From 9325e8bed23545b543aae3bb69c9e8cccb3b2bf1 Mon Sep 17 00:00:00 2001 From: Christoph Ostertag <37454333+christophostertag@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:38:05 +0200 Subject: [PATCH] fix: recreate non-view dependents around a function DROP + CREATE (#601) A function change that CREATE OR REPLACE cannot apply is planned as DROP FUNCTION + CREATE FUNCTION (#326). #619 moved the calling views through that cycle; the other objects PostgreSQL records a dependency for still made the DROP fail with SQLSTATE 2BP01. Column defaults, CHECK and EXCLUDE constraints, expression and partial indexes, policies, trigger WHEN conditions and domain defaults and CHECK constraints whose desired definition calls a recreated function are now held: the regular table and domain diff leaves them alone, their current version is dropped right before the function and the desired version is created right after it. Drops, recreation and restores share one transaction: index changes of kept materialized views, which the plan builds CONCURRENTLY, wait until the restores are done, and a test checks every diff case for a transaction boundary inside that span or a change to a held object outside it. A rebuilt index is created without CONCURRENTLY so the table is never without it, and a CHECK constraint on an existing table is added NOT VALID and validated in a transaction of its own afterwards. New tables and domains are created without the held objects, which follow the function. Objects naming a column the diff re-creates (#591) keep their early drop. The regular column diff leaves a held default as it is. Defaults on partitioned tables are changed with ALTER TABLE ONLY so partitions keep defaults of their own, and partitions leave the CHECK constraints and indexes they inherit to their parent. A materialized view whose index calls the function is recreated like one whose query does. Rejected at plan time, with the column and function named: a generated column calling a recreated function, a column added or re-created (#591) whose default - its own or its domain type's - calls one, since its existing rows would be filled before the function exists again, and a column whose type changes while its new default calls one. A CHECK constraint the desired state declares NOT VALID is now added NOT VALID without VALIDATE; the online rewrite validated it anyway, which failed with SQLSTATE 23514 when existing rows violate it and otherwise re-planned the constraint on every run. Co-Authored-By: Claude Opus 5.5 --- cmd/plan/plan.go | 5 + internal/diff/diff.go | 72 +- internal/diff/function_dependents.go | 863 ++++++++++++++++++ internal/diff/function_dependents_test.go | 140 +++ internal/diff/table.go | 6 + internal/diff/view.go | 6 +- internal/plan/held_dependents_test.go | 110 +++ internal/plan/plan.go | 31 +- internal/plan/rewrite.go | 22 +- .../diff.sql | 70 ++ .../new.sql | 33 + .../old.sql | 21 + .../plan.json | 162 ++++ .../plan.sql | 82 ++ .../plan.txt | 116 +++ .../diff.sql | 46 + .../new.sql | 16 + .../old.sql | 16 + .../plan.json | 98 ++ .../plan.sql | 46 + .../plan.txt | 67 ++ .../diff.sql | 33 + .../new.sql | 15 + .../old.sql | 15 + .../plan.json | 96 ++ .../plan.sql | 33 + .../plan.txt | 59 ++ .../diff.sql | 21 + .../new.sql | 20 + .../old.sql | 18 + .../plan.json | 82 ++ .../plan.sql | 35 + .../plan.txt | 60 ++ .../diff.sql | 109 +++ .../new.sql | 43 + .../old.sql | 43 + .../plan.json | 216 +++++ .../plan.sql | 109 +++ .../plan.txt | 153 ++++ .../diff.sql | 23 + .../new.sql | 13 + .../old.sql | 13 + .../plan.json | 76 ++ .../plan.sql | 23 + .../plan.txt | 49 + .../diff.sql | 18 + .../new.sql | 24 + .../old.sql | 23 + .../plan.json | 74 ++ .../plan.sql | 30 + .../plan.txt | 56 ++ .../diff.sql | 16 + .../new.sql | 19 + .../old.sql | 18 + .../plan.json | 82 ++ .../plan.sql | 30 + .../plan.txt | 54 ++ .../diff/online/add_check_not_valid/diff.sql | 2 + .../diff/online/add_check_not_valid/new.sql | 7 + .../diff/online/add_check_not_valid/old.sql | 7 + .../diff/online/add_check_not_valid/plan.json | 20 + .../diff/online/add_check_not_valid/plan.sql | 2 + .../diff/online/add_check_not_valid/plan.txt | 14 + 63 files changed, 3861 insertions(+), 20 deletions(-) create mode 100644 internal/diff/function_dependents.go create mode 100644 internal/diff/function_dependents_test.go create mode 100644 internal/plan/held_dependents_test.go create mode 100644 testdata/diff/dependency/issue_601_function_recreate_changed_dependents/diff.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_changed_dependents/new.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_changed_dependents/old.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_changed_dependents/plan.json create mode 100644 testdata/diff/dependency/issue_601_function_recreate_changed_dependents/plan.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_changed_dependents/plan.txt create mode 100644 testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/diff.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/new.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/old.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/plan.json create mode 100644 testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/plan.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/plan.txt create mode 100644 testdata/diff/dependency/issue_601_function_recreate_partitioned_table/diff.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_partitioned_table/new.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_partitioned_table/old.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_partitioned_table/plan.json create mode 100644 testdata/diff/dependency/issue_601_function_recreate_partitioned_table/plan.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_partitioned_table/plan.txt create mode 100644 testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/diff.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/new.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/old.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/plan.json create mode 100644 testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/plan.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/plan.txt create mode 100644 testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/diff.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/new.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/old.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/plan.json create mode 100644 testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/plan.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/plan.txt create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_enum_value/diff.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_enum_value/new.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_enum_value/old.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_enum_value/plan.json create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_enum_value/plan.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_enum_value/plan.txt create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/diff.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/new.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/old.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/plan.json create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/plan.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/plan.txt create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/diff.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/new.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/old.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/plan.json create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/plan.sql create mode 100644 testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/plan.txt create mode 100644 testdata/diff/online/add_check_not_valid/diff.sql create mode 100644 testdata/diff/online/add_check_not_valid/new.sql create mode 100644 testdata/diff/online/add_check_not_valid/old.sql create mode 100644 testdata/diff/online/add_check_not_valid/plan.json create mode 100644 testdata/diff/online/add_check_not_valid/plan.sql create mode 100644 testdata/diff/online/add_check_not_valid/plan.txt diff --git a/cmd/plan/plan.go b/cmd/plan/plan.go index ea4dceb3..1f25a237 100644 --- a/cmd/plan/plan.go +++ b/cmd/plan/plan.go @@ -391,6 +391,11 @@ func GeneratePlan(config *PlanConfig, provider postgres.DesiredStateProvider) (* fmt.Sscanf(v, "%d", &targetMajorVersion) } + // Refuse function recreations whose callers cannot survive them (#601) + if err := diff.ValidateFunctionRecreations(currentStateIR, desiredStateIR, targetMajorVersion); err != nil { + return nil, err + } + // Generate diff (current -> desired) using IR directly diffs := diff.GenerateMigrationForTarget(currentStateIR, desiredStateIR, config.Schema, targetMajorVersion) diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 64112bb4..09966778 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -267,6 +267,11 @@ type Diff struct { Operation DiffOperation `json:"operation"` // create, alter, drop, replace Path string `json:"path"` Source DiffSource `json:"-"` // interface; not JSON-serializable (see #305) + // HeldDependent marks the drop or in-transaction restore of an object + // held around a function that is dropped and created again (#601). The + // plan must not put a transaction boundary between the first and the + // last of them. + HeldDependent bool `json:"-"` } type ddlDiff struct { @@ -339,6 +344,11 @@ type ddlDiff struct { fkPreDrops []*ir.Constraint fkPostAdds []*deferredConstraint suppressedInlineFKs map[string]bool + + // Objects whose desired definition calls a function that is dropped and + // created again; see holdRecreatedFunctionDependents (#601). + heldTableDependents []*heldTableDependents + heldDomainDependents []*heldDomainDependents } // schemaDiff represents changes to a schema @@ -893,6 +903,10 @@ func generateMigration(oldIR, newIR *ir.IR, targetSchema string, qualifySchema b } } + // Objects other than views that call a function dropped and created again + // are dropped before it and created after it (#601). + diff.holdRecreatedFunctionDependents(oldTables, newTables, oldTypes, newTypes) + // Compare views across all schemas oldViews := make(map[string]*ir.View) newViews := make(map[string]*ir.View) @@ -948,7 +962,7 @@ func generateMigration(oldIR, newIR *ir.IR, targetSchema string, qualifySchema b // Likewise for a view that calls a function being dropped and // created again, e.g. for a return type change (#601). dependsOnRecreated := viewDependsOnRecreatedColumn(oldView, diff.modifiedTables) || - referencesNewFunction(oldView.Definition, oldView.Schema, recreatedFunctionLookup) + viewCallsRoutines(oldView, recreatedFunctionLookup) // 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. @@ -2303,6 +2317,11 @@ func (d *ddlDiff) generateModifySQL(targetSchema string, collector *diffCollecto // modifications would be processed (and correctly skipped). sortModifiedViewsForProcessing(d.modifiedViews) + // Drop the defaults, constraints, indexes, policies and triggers that call + // a function dropped and created again below; they are created again once + // it is back (#601). + d.generateDropHeldFunctionDependentsSQL(targetSchema, collector) + // Functions that are dropped and created again while views call them go // first: the calling views are dropped, the functions recreated, and the // modify-views phase below then creates the views again (#601). @@ -2311,7 +2330,7 @@ func (d *ddlDiff) generateModifySQL(targetSchema string, collector *diffCollecto lookup := buildRoutineLookup(recreatedFunctions(functionsUnderViews), nil) var callingViews []*ir.View for _, viewDiff := range d.modifiedViews { - if viewDiff.RequiresRecreate && referencesNewFunction(viewDiff.Old.Definition, viewDiff.Old.Schema, lookup) { + if viewDiff.RequiresRecreate && viewCallsRoutines(viewDiff.Old, lookup) { callingViews = append(callingViews, viewDiff.Old) } } @@ -2319,8 +2338,21 @@ func (d *ddlDiff) generateModifySQL(targetSchema string, collector *diffCollecto generateModifyFunctionsSQL(functionsUnderViews, targetSchema, collector) } + // Index changes on materialized views that are kept become CREATE INDEX + // CONCURRENTLY in the plan, which ends the transaction. While functions + // are dropped and created again, they wait until the objects held around + // that recreation are back, so everything from the first held drop to the + // last restore runs in one transaction (#601). + viewIndexCollector := collector + var deferredViewIndexes *diffCollector + if len(recreatedFunctions(d.modifiedFunctions)) > 0 { + deferredViewIndexes = newDiffCollector() + deferredViewIndexes.qualifySchema = collector.qualifySchema + viewIndexCollector = deferredViewIndexes + } + // Modify views - pass preDroppedViews to skip DROP for already-dropped views - generateModifyViewsSQL(d.modifiedViews, targetSchema, collector, preDroppedViews, dependentViewsCtx, recreatedViews) + generateModifyViewsSQL(d.modifiedViews, targetSchema, collector, viewIndexCollector, preDroppedViews, dependentViewsCtx, recreatedViews) // Create functions deferred from generateCreateSQL because their return/parameter // type references a view just recreated above. Emitting them now (rather than in @@ -2333,6 +2365,17 @@ func (d *ddlDiff) generateModifySQL(targetSchema string, collector *diffCollecto // Modify functions generateModifyFunctionsSQL(otherModifiedFunctions, targetSchema, collector) + // Now that every recreated function exists again, create the objects that + // call it: the ones dropped before it and the ones the create phase left + // out of new tables and domains (#601). Then the steps that run in + // transactions of their own: the deferred materialized view index changes, + // VALIDATE CONSTRAINT for re-added CHECK constraints, and new indexes. + d.generateRestoreHeldFunctionDependentsSQL(targetSchema, collector) + if deferredViewIndexes != nil { + collector.diffs = append(collector.diffs, deferredViewIndexes.diffs...) + } + d.generateCompleteHeldFunctionDependentsSQL(targetSchema, collector) + // Modify procedures generateModifyProceduresSQL(d.modifiedProcedures, targetSchema, collector) @@ -2470,6 +2513,21 @@ func viewDependsOnRecreatedColumn(view *ir.View, modifiedTables []*tableDiff) bo return false } +// viewCallsRoutines reports whether a view calls a routine in the lookup, in +// its query or, for a materialized view, in an index expression or predicate: +// either way the view is created after the routine and blocks its DROP. (#601) +func viewCallsRoutines(view *ir.View, routines map[string]struct{}) bool { + if referencesNewFunction(view.Definition, view.Schema, routines) { + return true + } + for _, name := range sortedKeys(view.Indexes) { + if indexCallsFunction(view.Indexes[name], routines) { + return true + } + } + return false +} + // recreatedFunctions returns the desired state of the modified functions that // are applied as DROP FUNCTION + CREATE FUNCTION (see functionRequiresRecreate). func recreatedFunctions(modifiedFunctions []*functionDiff) []*ir.Function { @@ -2505,15 +2563,15 @@ func (d *ddlDiff) splitFunctionsRecreatedUnderViews() (underViews, others []*fun func (d *ddlDiff) viewCallsFunction(fn *ir.Function) bool { lookup := buildRoutineLookup([]*ir.Function{fn}, nil) for _, viewDiff := range d.modifiedViews { - if viewDiff.RequiresRecreate && referencesNewFunction(viewDiff.Old.Definition, viewDiff.Old.Schema, lookup) { + if viewDiff.RequiresRecreate && viewCallsRoutines(viewDiff.Old, lookup) { return true } - if referencesNewFunction(viewDiff.New.Definition, viewDiff.New.Schema, lookup) { + if viewCallsRoutines(viewDiff.New, lookup) { return true } } for _, view := range d.addedViews { - if referencesNewFunction(view.Definition, view.Schema, lookup) { + if viewCallsRoutines(view, lookup) { return true } } @@ -3124,7 +3182,7 @@ func splitViewsReferencingRoutines(views []*ir.View, routines map[string]struct{ return views, nil } for _, v := range views { - if referencesNewFunction(v.Definition, v.Schema, routines) || viewReferencesAnyDeferredView(v, later) { + if viewCallsRoutines(v, routines) || viewReferencesAnyDeferredView(v, later) { later = append(later, v) } else { now = append(now, v) diff --git a/internal/diff/function_dependents.go b/internal/diff/function_dependents.go new file mode 100644 index 00000000..a597ae63 --- /dev/null +++ b/internal/diff/function_dependents.go @@ -0,0 +1,863 @@ +package diff + +import ( + "fmt" + "sort" + "strings" + + "github.com/pgplex/pgschema/ir" +) + +// A function change CREATE OR REPLACE cannot apply (return type, parameter +// names, OUT parameters) is planned as DROP FUNCTION + CREATE FUNCTION (#326). +// Calling views go through that cycle with the function (#601, see +// splitFunctionsRecreatedUnderViews). The other objects PostgreSQL records a +// dependency for are handled here: column defaults, CHECK and EXCLUDE +// constraints, expression and partial indexes, row-level security policies, +// trigger WHEN conditions, and domain defaults and CHECK constraints. +// +// Such an object is held when its desired definition calls a recreated +// function: its current version, if any, is dropped right before the +// function and the desired version is created once the function exists +// again, so both land in the transaction that drops and creates the function +// (only a later VALIDATE CONSTRAINT or a brand-new CONCURRENTLY index runs on +// its own). The regular table and domain diff leaves it alone, except for the +// early drop of an EXCLUDE constraint, policy or trigger naming a column it +// re-creates (#591). An object whose current definition calls the function +// but whose desired one does not is left to the regular diff, which drops or +// replaces it in the modify phase, still ahead of the function's DROP. +// +// Matching is by function name (see referencesNewFunction), so an overload of +// a recreated function causes a redundant drop and restore of its callers. +// +// Generated columns and columns added (or re-created) with a DEFAULT that +// calls a recreated function are not carried through the cycle; +// ValidateFunctionRecreations rejects those plans. + +// heldTableDependents are the objects of one table held around the recreation +// of the functions they call. +type heldTableDependents struct { + table *ir.Table // desired state + existing bool // the table exists in the current state + + droppedDefaults []*ir.Column // current columns whose default calls a recreated function + defaults []*ir.Column // desired columns whose default calls a recreated function + droppedConstraints []*ir.Constraint + constraints []*ir.Constraint + droppedIndexes []*ir.Index + indexes []*ir.Index + droppedPolicies []*ir.RLSPolicy + policies []*ir.RLSPolicy + droppedTriggers []*ir.Trigger + triggers []*ir.Trigger +} + +func (h *heldTableDependents) empty() bool { + return len(h.defaults) == 0 && len(h.constraints) == 0 && len(h.indexes) == 0 && + len(h.policies) == 0 && len(h.triggers) == 0 +} + +// heldDomainDependents are the default and CHECK constraints of one domain +// held around the recreation of the functions they call. +type heldDomainDependents struct { + domain *ir.Type // desired state + dropDefault bool // the current default calls a recreated function + setDefault bool // the desired default calls a recreated function + droppedConstraints []*ir.DomainConstraint + constraints []*ir.DomainConstraint +} + +// holdRecreatedFunctionDependents collects the objects whose desired +// definition calls a function this migration drops and creates again, takes +// them out of the regular table and domain diff, and strips them from the +// tables and domains the create phase adds. (#601) +func (d *ddlDiff) holdRecreatedFunctionDependents(oldTables, newTables map[string]*ir.Table, oldTypes, newTypes map[string]*ir.Type) { + recreated := buildRoutineLookup(recreatedFunctions(d.modifiedFunctions), nil) + if len(recreated) == 0 { + return + } + + modifiedTables := make(map[string]*tableDiff, len(d.modifiedTables)) + for _, td := range d.modifiedTables { + modifiedTables[td.Table.Schema+"."+td.Table.Name] = td + } + addedTables := make(map[string]int, len(d.addedTables)) + for i, table := range d.addedTables { + addedTables[table.Schema+"."+table.Name] = i + } + + for _, key := range sortedKeys(newTables) { + newTable := newTables[key] + oldTable := oldTables[key] + if newTable.IsExternal || (oldTable != nil && oldTable.IsExternal) { + continue + } + td := modifiedTables[key] + h := collectHeldTableDependents(oldTable, newTable, partitionParent(oldTables, oldTable), partitionParent(newTables, newTable), td, recreated) + if h == nil { + continue + } + if h.existing { + if td != nil { + td.releaseHeldDependents(h) + } + } else if i, ok := addedTables[key]; ok { + d.addedTables[i] = stripHeldDependents(newTable, h) + } + d.heldTableDependents = append(d.heldTableDependents, h) + } + + modifiedTypes := make(map[string]*typeDiff, len(d.modifiedTypes)) + for _, td := range d.modifiedTypes { + modifiedTypes[td.New.Schema+"."+td.New.Name] = td + } + addedTypes := make(map[string]int, len(d.addedTypes)) + for i, typ := range d.addedTypes { + addedTypes[typ.Schema+"."+typ.Name] = i + } + + for _, key := range sortedKeys(newTypes) { + newDomain := newTypes[key] + if newDomain.Kind != ir.TypeKindDomain { + continue + } + oldDomain := oldTypes[key] + if oldDomain != nil && oldDomain.Kind != ir.TypeKindDomain { + continue + } + h := collectHeldDomainDependents(oldDomain, newDomain, recreated) + if h == nil { + continue + } + if oldDomain != nil { + if td := modifiedTypes[key]; td != nil { + // A held default is left as it is by the regular diff. + newDefault := td.New.Default + if h.setDefault { + newDefault = td.Old.Default + } + td.Old = withoutHeldDomainDependents(td.Old, h, td.Old.Default) + td.New = withoutHeldDomainDependents(td.New, h, newDefault) + } + } else if i, ok := addedTypes[key]; ok { + newDefault := newDomain.Default + if h.setDefault { + newDefault = "" + } + d.addedTypes[i] = withoutHeldDomainDependents(newDomain, h, newDefault) + } + d.heldDomainDependents = append(d.heldDomainDependents, h) + } +} + +// partitionParent returns the partitioned table a partition belongs to, if it +// is part of the same state. +func partitionParent(tables map[string]*ir.Table, table *ir.Table) *ir.Table { + if table == nil || table.PartitionOf == "" { + return nil + } + schema := table.PartitionOfSchema + if schema == "" { + schema = table.Schema + } + return tables[schema+"."+table.PartitionOf] +} + +// inheritedConstraint and inheritedIndex report whether a partition's constraint or index is +// the copy PostgreSQL keeps for a CHECK constraint or index of its +// partitioned parent. Those go with the parent's: dropping them on their own +// fails, and adding the parent's creates them. +func inheritedConstraint(parent *ir.Table, constraint *ir.Constraint) bool { + return parent != nil && parent.Constraints[constraint.Name] != nil +} + +func inheritedIndex(parent *ir.Table, index *ir.Index) bool { + if parent == nil { + return false + } + for _, parentIndex := range parent.Indexes { + if indexesStructurallyEqual(parentIndex, index) { + return true + } + } + return false +} + +// collectHeldTableDependents returns the objects of newTable whose desired +// definition calls a recreated function, with the current versions to drop +// first, or nil if there are none. For a partition, the constraints and +// indexes it inherits from its parent are left to the parent's. +func collectHeldTableDependents(oldTable, newTable, oldParent, newParent *ir.Table, td *tableDiff, recreated map[string]struct{}) *heldTableDependents { + h := &heldTableDependents{table: newTable, existing: oldTable != nil} + calls := func(expr string) bool { return referencesNewFunction(expr, newTable.Schema, recreated) } + + var recreatedColumns map[string]bool + if td != nil { + recreatedColumns = td.RecreatedColumns + } + oldColumns := make(map[string]*ir.Column) + if oldTable != nil { + for _, col := range oldTable.Columns { + oldColumns[col.Name] = col + } + } + for _, col := range newTable.Columns { + if col.DefaultValue == nil || !calls(*col.DefaultValue) { + continue + } + if oldTable == nil { + h.defaults = append(h.defaults, col) + continue + } + oldCol := oldColumns[col.Name] + // A column added or re-created (#591) by this migration fills its + // existing rows from the default; ValidateFunctionRecreations refuses + // that. + if oldCol == nil || recreatedColumns[col.Name] { + continue + } + h.defaults = append(h.defaults, col) + if oldCol.DefaultValue != nil && calls(*oldCol.DefaultValue) { + h.droppedDefaults = append(h.droppedDefaults, oldCol) + } + } + + for _, name := range sortedKeys(newTable.Constraints) { + constraint := newTable.Constraints[name] + if !constraintCallsFunction(constraint, recreated) || inheritedConstraint(newParent, constraint) { + continue + } + h.constraints = append(h.constraints, constraint) + if oldTable != nil && oldTable.Constraints[name] != nil && !inheritedConstraint(oldParent, oldTable.Constraints[name]) { + h.droppedConstraints = append(h.droppedConstraints, oldTable.Constraints[name]) + } + } + + for _, name := range sortedKeys(newTable.Indexes) { + index := newTable.Indexes[name] + if !indexCallsFunction(index, recreated) || inheritedIndex(newParent, index) { + continue + } + h.indexes = append(h.indexes, index) + if oldTable != nil && oldTable.Indexes[name] != nil && !inheritedIndex(oldParent, oldTable.Indexes[name]) { + h.droppedIndexes = append(h.droppedIndexes, oldTable.Indexes[name]) + } + } + + for _, name := range sortedKeys(newTable.Policies) { + policy := newTable.Policies[name] + if !policyReferencesNewFunction(policy, recreated) { + continue + } + h.policies = append(h.policies, policy) + if oldTable != nil && oldTable.Policies[name] != nil { + h.droppedPolicies = append(h.droppedPolicies, oldTable.Policies[name]) + } + } + + for _, name := range sortedKeys(newTable.Triggers) { + trigger := newTable.Triggers[name] + if !calls(trigger.Condition) { + continue + } + h.triggers = append(h.triggers, trigger) + if oldTable != nil && oldTable.Triggers[name] != nil { + h.droppedTriggers = append(h.droppedTriggers, oldTable.Triggers[name]) + } + } + + if h.empty() { + return nil + } + sort.SliceStable(h.constraints, func(i, j int) bool { + return constraintTypeOrder(h.constraints[i]) < constraintTypeOrder(h.constraints[j]) + }) + return h +} + +// constraintCallsFunction reports whether a CHECK or EXCLUDE constraint calls +// a function in the lookup; other constraint types hold no expressions. +func constraintCallsFunction(constraint *ir.Constraint, functions map[string]struct{}) bool { + switch constraint.Type { + case ir.ConstraintTypeCheck: + return referencesNewFunction(constraint.CheckClause, constraint.Schema, functions) + case ir.ConstraintTypeExclusion: + return referencesNewFunction(constraint.ExclusionDefinition, constraint.Schema, functions) + } + return false +} + +// indexCallsFunction reports whether an index calls a function in the lookup +// in an expression column or its partial-index predicate. +func indexCallsFunction(index *ir.Index, functions map[string]struct{}) bool { + if index.IsExpression { + for _, col := range index.Columns { + if referencesNewFunction(col.Name, index.Schema, functions) { + return true + } + } + } + return index.IsPartial && referencesNewFunction(index.Where, index.Schema, functions) +} + +// releaseHeldDependents takes the held objects out of the regular table diff: +// they are dropped right before the functions they call and created again +// after them. An EXCLUDE constraint, policy or trigger naming a column the +// regular diff re-creates (#591) must be gone before that column's DROP, so +// the regular diff keeps dropping it. +func (td *tableDiff) releaseHeldDependents(h *heldTableDependents) { + constraints := make(map[string]bool) + for _, c := range h.constraints { + constraints[c.Name] = true + } + td.AddedConstraints = removeByName(td.AddedConstraints, constraints, func(c *ir.Constraint) string { return c.Name }) + td.ModifiedConstraints = removeByName(td.ModifiedConstraints, constraints, func(c *ConstraintDiff) string { return c.New.Name }) + earlyConstraints := make(map[string]bool) + for _, c := range td.DroppedConstraints { + if constraints[c.Name] && exclusionReferencesColumns(c, td.RecreatedColumns) { + earlyConstraints[c.Name] = true + } + } + td.DroppedConstraints = removeByName(td.DroppedConstraints, subtract(constraints, earlyConstraints), func(c *ir.Constraint) string { return c.Name }) + h.droppedConstraints = removeByName(h.droppedConstraints, earlyConstraints, func(c *ir.Constraint) string { return c.Name }) + + indexes := make(map[string]bool) + for _, index := range h.indexes { + indexes[index.Name] = true + } + td.AddedIndexes = removeByName(td.AddedIndexes, indexes, func(i *ir.Index) string { return i.Name }) + td.DroppedIndexes = removeByName(td.DroppedIndexes, indexes, func(i *ir.Index) string { return i.Name }) + td.ModifiedIndexes = removeByName(td.ModifiedIndexes, indexes, func(i *IndexDiff) string { return i.New.Name }) + + policies := make(map[string]bool) + for _, policy := range h.policies { + policies[policy.Name] = true + } + td.AddedPolicies = removeByName(td.AddedPolicies, policies, func(p *ir.RLSPolicy) string { return p.Name }) + td.ModifiedPolicies = removeByName(td.ModifiedPolicies, policies, func(p *policyDiff) string { return p.New.Name }) + earlyPolicies := make(map[string]bool) + for _, p := range td.DroppedPolicies { + if policies[p.Name] && policyReferencesColumns(p, td.RecreatedColumns) { + earlyPolicies[p.Name] = true + } + } + td.DroppedPolicies = removeByName(td.DroppedPolicies, subtract(policies, earlyPolicies), func(p *ir.RLSPolicy) string { return p.Name }) + h.droppedPolicies = removeByName(h.droppedPolicies, earlyPolicies, func(p *ir.RLSPolicy) string { return p.Name }) + + triggers := make(map[string]bool) + for _, trigger := range h.triggers { + triggers[trigger.Name] = true + } + td.AddedTriggers = removeByName(td.AddedTriggers, triggers, func(t *ir.Trigger) string { return t.Name }) + td.ModifiedTriggers = removeByName(td.ModifiedTriggers, triggers, func(t *triggerDiff) string { return t.New.Name }) + earlyTriggers := make(map[string]bool) + for _, t := range td.DroppedTriggers { + if triggers[t.Name] && triggerReferencesColumns(t, td.RecreatedColumns) { + earlyTriggers[t.Name] = true + } + } + td.DroppedTriggers = removeByName(td.DroppedTriggers, subtract(triggers, earlyTriggers), func(t *ir.Trigger) string { return t.Name }) + h.droppedTriggers = removeByName(h.droppedTriggers, earlyTriggers, func(t *ir.Trigger) string { return t.Name }) + + // The regular column diff leaves a held default as it is; it is dropped + // and set again around the function. (A held default on a column whose + // type changes is refused by ValidateFunctionRecreations.) + heldDefaults := make(map[string]bool) + for _, col := range h.defaults { + heldDefaults[col.Name] = true + } + for _, cd := range td.ModifiedColumns { + if heldDefaults[cd.New.Name] { + cd.New = columnWithDefault(cd.New, cd.Old.DefaultValue) + } + } +} + +// subtract returns the names in a that are not in b. +func subtract(a, b map[string]bool) map[string]bool { + if len(b) == 0 { + return a + } + rest := make(map[string]bool, len(a)) + for name := range a { + if !b[name] { + rest[name] = true + } + } + return rest +} + +// onlyTable returns the name to use in ALTER TABLE for a change that must not +// recurse to the partitions of a partitioned table. +func onlyTable(table *ir.Table, tableName string) string { + if table.IsPartitioned { + return "ONLY " + tableName + } + return tableName +} + +func removeByName[T any](items []T, names map[string]bool, name func(T) string) []T { + if len(names) == 0 { + return items + } + kept := items[:0:0] + for _, item := range items { + if !names[name(item)] { + kept = append(kept, item) + } + } + return kept +} + +// columnWithDefault returns a copy of col with the given default. +func columnWithDefault(col *ir.Column, value *string) *ir.Column { + c := *col + c.DefaultValue = value + return &c +} + +// stripHeldDependents returns a copy of a table the create phase adds, +// without the held objects: they are created after the functions they call. +// The table is empty then, so a default set afterwards fills no rows. +func stripHeldDependents(table *ir.Table, h *heldTableDependents) *ir.Table { + stripped := *table + heldDefaults := make(map[string]bool) + for _, col := range h.defaults { + heldDefaults[col.Name] = true + } + stripped.Columns = make([]*ir.Column, len(table.Columns)) + for i, col := range table.Columns { + if heldDefaults[col.Name] { + col = columnWithDefault(col, nil) + } + stripped.Columns[i] = col + } + stripped.Constraints = withoutKeys(table.Constraints, h.constraints, func(c *ir.Constraint) string { return c.Name }) + stripped.Indexes = withoutKeys(table.Indexes, h.indexes, func(i *ir.Index) string { return i.Name }) + stripped.Policies = withoutKeys(table.Policies, h.policies, func(p *ir.RLSPolicy) string { return p.Name }) + stripped.Triggers = withoutKeys(table.Triggers, h.triggers, func(t *ir.Trigger) string { return t.Name }) + return &stripped +} + +func withoutKeys[T any](m map[string]T, held []T, name func(T) string) map[string]T { + if len(held) == 0 { + return m + } + skip := make(map[string]bool, len(held)) + for _, item := range held { + skip[name(item)] = true + } + kept := make(map[string]T, len(m)) + for key, value := range m { + if !skip[key] { + kept[key] = value + } + } + return kept +} + +// collectHeldDomainDependents returns the default and CHECK constraints of +// newDomain that call a recreated function, or nil if there are none. Only +// named constraints can be dropped and added again. +func collectHeldDomainDependents(oldDomain, newDomain *ir.Type, recreated map[string]struct{}) *heldDomainDependents { + h := &heldDomainDependents{domain: newDomain} + calls := func(expr string) bool { return referencesNewFunction(expr, newDomain.Schema, recreated) } + + if calls(newDomain.Default) { + h.setDefault = true + h.dropDefault = oldDomain != nil && calls(oldDomain.Default) + } + for _, constraint := range newDomain.Constraints { + if constraint.Name == "" || !calls(constraint.Definition) { + continue + } + h.constraints = append(h.constraints, constraint) + if oldDomain == nil { + continue + } + for _, old := range oldDomain.Constraints { + if old.Name == constraint.Name { + h.droppedConstraints = append(h.droppedConstraints, old) + } + } + } + if !h.setDefault && len(h.constraints) == 0 { + return nil + } + sort.Slice(h.constraints, func(i, j int) bool { return h.constraints[i].Name < h.constraints[j].Name }) + sort.Slice(h.droppedConstraints, func(i, j int) bool { return h.droppedConstraints[i].Name < h.droppedConstraints[j].Name }) + return h +} + +// withoutHeldDomainDependents returns a copy of a domain without its held +// constraints and with the given default. +func withoutHeldDomainDependents(domain *ir.Type, h *heldDomainDependents, defaultValue string) *ir.Type { + stripped := *domain + held := make(map[string]bool, len(h.constraints)) + for _, c := range h.constraints { + held[c.Name] = true + } + stripped.Constraints = nil + for _, c := range domain.Constraints { + if !held[c.Name] { + stripped.Constraints = append(stripped.Constraints, c) + } + } + stripped.Default = defaultValue + return &stripped +} + +// generateDropHeldFunctionDependentsSQL drops the current version of the held +// objects right before the functions they call are dropped. A dropped +// constraint, index, policy or trigger is recorded as a recreate operation. +// The regular diff ran before and may have dropped a constraint or index +// already with a column it depends on, hence IF EXISTS. A default on a +// partitioned table is dropped and set with ONLY: without it PostgreSQL +// applies the change to every partition, overwriting partition defaults of +// their own. (#601) +func (d *ddlDiff) generateDropHeldFunctionDependentsSQL(targetSchema string, collector *diffCollector) { + defer markHeldDependents(collector, len(collector.diffs)) + for _, h := range d.heldDomainDependents { + domainName := qualifyEntityName(h.domain.Schema, h.domain.Name, targetSchema) + var statements []string + if h.dropDefault { + statements = append(statements, fmt.Sprintf("ALTER DOMAIN %s DROP DEFAULT;", domainName)) + } + for _, c := range h.droppedConstraints { + statements = append(statements, fmt.Sprintf("ALTER DOMAIN %s DROP CONSTRAINT IF EXISTS %s;", domainName, ir.QuoteIdentifier(c.Name))) + } + for _, stmt := range statements { + collector.collect(&diffContext{ + Type: DiffTypeDomain, + Operation: DiffOperationAlter, + Path: fmt.Sprintf("%s.%s", h.domain.Schema, h.domain.Name), + Source: h.domain, + CanRunInTransaction: true, + }, stmt) + } + } + + for _, h := range d.heldTableDependents { + table := h.table + tableName := getTableNameWithSchema(table.Schema, table.Name, targetSchema) + drop := func(diffType DiffType, operation DiffOperation, name string, source DiffSource, stmt string) { + collector.collect(&diffContext{ + Type: diffType, + Operation: operation, + Path: fmt.Sprintf("%s.%s.%s", table.Schema, table.Name, name), + Source: source, + CanRunInTransaction: true, + }, stmt) + } + for _, trigger := range h.droppedTriggers { + drop(DiffTypeTableTrigger, DiffOperationRecreate, trigger.Name, trigger, fmt.Sprintf("DROP TRIGGER IF EXISTS %s ON %s;", ir.QuoteIdentifier(trigger.Name), tableName)) + } + for _, policy := range h.droppedPolicies { + drop(DiffTypeTablePolicy, DiffOperationRecreate, policy.Name, policy, fmt.Sprintf("DROP POLICY IF EXISTS %s ON %s;", ir.QuoteIdentifier(policy.Name), tableName)) + } + for _, constraint := range h.droppedConstraints { + drop(DiffTypeTableConstraint, DiffOperationRecreate, constraint.Name, constraint, fmt.Sprintf("ALTER TABLE %s DROP CONSTRAINT IF EXISTS %s;", tableName, ir.QuoteIdentifier(constraint.Name))) + } + for _, index := range h.droppedIndexes { + drop(DiffTypeTableIndex, DiffOperationRecreate, index.Name, index, fmt.Sprintf("DROP INDEX IF EXISTS %s;", qualifyEntityName(index.Schema, index.Name, targetSchema))) + } + for _, col := range h.droppedDefaults { + drop(DiffTypeTableColumn, DiffOperationAlter, col.Name, col, fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP DEFAULT;", onlyTable(table, tableName), ir.QuoteIdentifier(col.Name))) + } + } +} + +// generateRestoreHeldFunctionDependentsSQL creates the desired version of +// every held object once the functions it calls have been created again, +// using the same statements as any other added default, constraint, index, +// policy or trigger. (#601) +// +// The objects replacing a version dropped for the recreation are created in +// the same transaction, so the table is never without them: a rebuilt index +// is created without CONCURRENTLY (see the plan's online rewrites), and a +// CHECK constraint on an existing table is added NOT VALID, which enforces it +// for new rows at once. The steps needing transactions of their own follow +// only after all of them are back (generateCompleteHeldFunctionDependentsSQL). +func (d *ddlDiff) generateRestoreHeldFunctionDependentsSQL(targetSchema string, collector *diffCollector) { + defer markHeldDependents(collector, len(collector.diffs)) + for _, h := range d.heldDomainDependents { + domainName := qualifyEntityName(h.domain.Schema, h.domain.Name, targetSchema) + var statements []string + if h.setDefault { + statements = append(statements, fmt.Sprintf("ALTER DOMAIN %s SET DEFAULT %s;", domainName, h.domain.Default)) + } + for _, c := range h.constraints { + statements = append(statements, fmt.Sprintf("ALTER DOMAIN %s ADD CONSTRAINT %s %s;", domainName, ir.QuoteIdentifier(c.Name), c.Definition)) + } + for _, stmt := range statements { + collector.collect(&diffContext{ + Type: DiffTypeDomain, + Operation: DiffOperationAlter, + Path: fmt.Sprintf("%s.%s", h.domain.Schema, h.domain.Name), + Source: h.domain, + CanRunInTransaction: true, + }, stmt) + } + } + + for _, h := range d.heldTableDependents { + tableName := getTableNameWithSchema(h.table.Schema, h.table.Name, targetSchema) + for _, col := range h.defaults { + collector.collect(&diffContext{ + Type: DiffTypeTableColumn, + Operation: DiffOperationAlter, + Path: fmt.Sprintf("%s.%s.%s", h.table.Schema, h.table.Name, col.Name), + Source: col, + CanRunInTransaction: true, + }, fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s;", onlyTable(h.table, tableName), ir.QuoteIdentifier(col.Name), *col.DefaultValue)) + } + restore := &tableDiff{ + Table: h.table, + AddedPolicies: h.policies, + AddedTriggers: h.triggers, + } + for _, constraint := range h.constraints { + if constraint.Type == ir.ConstraintTypeCheck && h.existing { + notValid := *constraint + notValid.IsValid = false + constraint = ¬Valid + } + restore.AddedConstraints = append(restore.AddedConstraints, constraint) + } + for _, index := range h.indexes { + if !h.newIndex(index) { + restore.AddedIndexes = append(restore.AddedIndexes, index) + } + } + restore.generateAlterTableStatements(targetSchema, collector, nil, nil, nil, nil) + } +} + +// generateCompleteHeldFunctionDependentsSQL emits the steps that follow the +// restore in transactions of their own: VALIDATE CONSTRAINT for the CHECK +// constraints re-added NOT VALID on existing tables, and the indexes that did +// not exist before, created CONCURRENTLY like any added index. (#601) +func (d *ddlDiff) generateCompleteHeldFunctionDependentsSQL(targetSchema string, collector *diffCollector) { + for _, h := range d.heldTableDependents { + for _, constraint := range h.constraints { + if constraint.Type != ir.ConstraintTypeCheck || !h.existing || !constraint.IsValid { + continue + } + collector.collect(&diffContext{ + Type: DiffTypeTableConstraint, + Operation: DiffOperationCreate, + Path: fmt.Sprintf("%s.%s.%s", constraint.Schema, constraint.Table, constraint.Name), + Source: constraint, + CanRunInTransaction: true, + }, fmt.Sprintf("ALTER TABLE %s VALIDATE CONSTRAINT %s;", + getTableNameWithSchema(constraint.Schema, constraint.Table, targetSchema), ir.QuoteIdentifier(constraint.Name))) + } + } + for _, h := range d.heldTableDependents { + added := &tableDiff{Table: h.table} + for _, index := range h.indexes { + if h.newIndex(index) { + added.AddedIndexes = append(added.AddedIndexes, index) + } + } + if len(added.AddedIndexes) > 0 { + added.generateAlterTableStatements(targetSchema, collector, nil, nil, nil, nil) + } + } +} + +// newIndex reports whether a held index is added to an existing table rather +// than replacing a version dropped for the recreation. +func (h *heldTableDependents) newIndex(index *ir.Index) bool { + if !h.existing { + return false + } + for _, dropped := range h.droppedIndexes { + if dropped.Name == index.Name { + return false + } + } + return true +} + +// markHeldDependents flags the diffs collected since from as held +// dependents (see Diff.HeldDependent). +func markHeldDependents(collector *diffCollector, from int) { + for i := from; i < len(collector.diffs); i++ { + collector.diffs[i].HeldDependent = true + } +} + +// ValidateFunctionRecreations returns an error when the migration from oldIR +// to newIR drops and creates again a function (a return type or parameter +// change, #326) that an object calls which pgschema does not carry through +// that cycle (#601): +// +// - a generated column: it would have to be dropped and added again around +// the function (a table rewrite that also drops what depends on the +// column), which is not done here; +// - a column the migration adds to an existing table, or re-creates with +// DROP + ADD COLUMN (#591), whose DEFAULT - its own, or else that of its +// domain type (following domains over domains) - calls the function: its +// existing rows are filled when the column is added, before the function +// exists again (by the old function, or with NULL where the default is +// held back); +// - an existing column whose type changes and whose new DEFAULT calls the +// function: ALTER COLUMN TYPE runs before the function, and the default +// cannot stay held across it. +// +// targetMajorVersion decides which generation-clause changes re-create a +// column, as in GenerateMigrationForTarget. Matching is by function name, as +// for the objects that are handled. +func ValidateFunctionRecreations(oldIR, newIR *ir.IR, targetMajorVersion int) error { + oldFunctions := functionsBySignature(oldIR) + newFunctions := functionsBySignature(newIR) + var recreated []*ir.Function + for _, key := range sortedKeys(newFunctions) { + if old, ok := oldFunctions[key]; ok && functionRequiresRecreate(old, newFunctions[key]) { + recreated = append(recreated, newFunctions[key]) + } + } + if len(recreated) == 0 { + return nil + } + lookup := buildRoutineLookup(recreated, nil) + // callee names the recreated function(s) an expression calls. + callee := func(expr, schema string) string { + var names []string + for _, fn := range recreated { + if referencesNewFunction(expr, schema, buildRoutineLookup([]*ir.Function{fn}, nil)) { + names = append(names, fmt.Sprintf("%s.%s(%s)", fn.Schema, fn.Name, fn.GetArguments())) + } + } + return strings.Join(names, ", ") + } + + oldTables := tablesByName(oldIR) + newTables := tablesByName(newIR) + domains := make(map[string]*ir.Type) + for _, dbSchema := range newIR.Schemas { + for _, typ := range dbSchema.Types { + if typ.Kind == ir.TypeKindDomain { + domains[strings.ToLower(typ.Schema+"."+typ.Name)] = typ + } + } + } + var problems []string + for _, key := range sortedKeys(newTables) { + table := newTables[key] + if table.IsExternal { + continue + } + oldTable := oldTables[key] + oldColumns := make(map[string]*ir.Column) + if oldTable != nil { + for _, col := range oldTable.Columns { + oldColumns[col.Name] = col + } + } + for _, col := range table.Columns { + name := fmt.Sprintf("%s.%s.%s", table.Schema, table.Name, col.Name) + if col.IsGenerated && col.GeneratedExpr != nil && referencesNewFunction(*col.GeneratedExpr, table.Schema, lookup) { + if oldTable == nil { + problems = append(problems, fmt.Sprintf("generated column %s of new table %s.%s calls %s, and the table would be created while the old function still exists; create the table in a separate step after the function change", + name, table.Schema, table.Name, callee(*col.GeneratedExpr, table.Schema))) + } else { + problems = append(problems, fmt.Sprintf("generated column %s calls %s; keeping it would require dropping and re-adding the column (a table rewrite, and its dependents with it), which is not done here; change or drop the column in a separate step first", + name, callee(*col.GeneratedExpr, table.Schema))) + } + continue + } + // The rows a column added to an existing table (or re-created) + // already has are filled from its default, or else from its + // domain's, when ADD COLUMN runs: before the function exists again. + if oldTable == nil { + continue + } + oldCol := oldColumns[col.Name] + var how string + switch { + case oldCol == nil: + how = "is added" + case generatedColumnNeedsRecreate(oldCol, col, targetMajorVersion): + how = "is re-created (DROP + ADD COLUMN)" + default: + // The default is held around the function, but ALTER COLUMN + // TYPE runs before it and would need the current default + // re-set on the new type (or the column left without one). + if col.DefaultValue != nil && referencesNewFunction(*col.DefaultValue, table.Schema, lookup) && columnTypeChanges(oldCol, col, table.Schema) { + problems = append(problems, fmt.Sprintf("column %s changes its type or collation and gets a DEFAULT that calls %s; change the column type or collation in a separate step, before or after the function change", + name, callee(*col.DefaultValue, table.Schema))) + } + continue + } + defaultValue, source := "", "a DEFAULT that" + if col.DefaultValue != nil { + defaultValue = *col.DefaultValue + } else if domain := domainWithDefault(col.DataType, table.Schema, domains); domain != nil { + defaultValue = domain.Default + source = fmt.Sprintf("type %s.%s, whose DEFAULT", domain.Schema, domain.Name) + } + if referencesNewFunction(defaultValue, table.Schema, lookup) { + problems = append(problems, fmt.Sprintf("column %s %s with %s calls %s, so its existing rows would be filled before the function is created again; add or change the column in a separate step after the function change", + name, how, source, callee(defaultValue, table.Schema))) + } + } + } + if len(problems) == 0 { + return nil + } + return fmt.Errorf("cannot drop and re-create functions whose return type or parameters change, because these objects call them:\n - %s", + strings.Join(problems, "\n - ")) +} + +// columnTypeChanges reports whether the column diff alters the column's type +// or collation (see ColumnDiff.generateColumnSQL). +func columnTypeChanges(old, new *ir.Column, schema string) bool { + return stripSchemaPrefix(comparableColumnType(old), schema) != stripSchemaPrefix(comparableColumnType(new), schema) || + stripSchemaPrefix(old.Collation, schema) != stripSchemaPrefix(new.Collation, schema) +} + +// domainWithDefault returns the domain whose default a column of the given +// type gets: the type itself if it is a domain with a default, or else the +// nearest domain with one it is (transitively) based on. +func domainWithDefault(typeName, schema string, domains map[string]*ir.Type) *ir.Type { + for depth := 0; depth < 16 && typeName != ""; depth++ { + if strings.HasSuffix(strings.TrimSpace(typeName), "]") { + return nil // an array of a domain does not get the domain's default + } + name := strings.ToLower(strings.ReplaceAll(extractBaseTypeName(typeName), `"`, "")) + domain := domains[name] + if domain == nil && !strings.Contains(name, ".") { + domain = domains[strings.ToLower(schema)+"."+name] + } + if domain == nil { + return nil + } + if domain.Default != "" { + return domain + } + typeName, schema = domain.BaseType, domain.Schema + } + return nil +} + +func functionsBySignature(schemaIR *ir.IR) map[string]*ir.Function { + functions := make(map[string]*ir.Function) + for _, dbSchema := range schemaIR.Schemas { + for signature, fn := range dbSchema.Functions { + functions[fn.Schema+"."+signature] = fn + } + } + return functions +} + +func tablesByName(schemaIR *ir.IR) map[string]*ir.Table { + tables := make(map[string]*ir.Table) + for _, dbSchema := range schemaIR.Schemas { + for _, table := range dbSchema.Tables { + tables[table.Schema+"."+table.Name] = table + } + } + return tables +} diff --git a/internal/diff/function_dependents_test.go b/internal/diff/function_dependents_test.go new file mode 100644 index 00000000..40275ec1 --- /dev/null +++ b/internal/diff/function_dependents_test.go @@ -0,0 +1,140 @@ +package diff + +import ( + "strings" + "testing" + + "github.com/pgplex/pgschema/ir" + "github.com/pgplex/pgschema/testutil" +) + +// A function whose return type changes is dropped and created again. Objects +// that call it and cannot be restored unchanged around that are rejected at +// plan time with the column and the function named; the ones that can are +// not (#601). +func TestValidateFunctionRecreations(t *testing.T) { + const oldFunction = "CREATE FUNCTION lim(x integer) RETURNS integer LANGUAGE sql IMMUTABLE AS 'select x';\n" + const newFunction = "CREATE FUNCTION lim(x integer) RETURNS bigint LANGUAGE sql IMMUTABLE AS 'select x';\n" + + tests := []struct { + name string + oldTables string + newTables string + wantErr []string + }{ + { + name: "generated column", + oldTables: "CREATE TABLE g (id integer, v integer, d integer GENERATED ALWAYS AS (lim(v)) STORED);", + newTables: "CREATE TABLE g (id integer, v integer, d integer GENERATED ALWAYS AS (lim(v)) STORED);", + wantErr: []string{"generated column public.g.d calls public.lim(integer); keeping it would require dropping and re-adding the column"}, + }, + { + name: "generated column on a new table", + newTables: "CREATE TABLE g (id integer, v integer, d integer GENERATED ALWAYS AS (lim(v)) STORED);", + wantErr: []string{"generated column public.g.d of new table public.g calls public.lim(integer)", "create the table in a separate step"}, + }, + { + name: "column added with a default", + oldTables: "CREATE TABLE a (id integer);", + newTables: "CREATE TABLE a (id integer, n integer DEFAULT lim(1));", + wantErr: []string{"column public.a.n is added with a DEFAULT that calls public.lim(integer), so its existing rows would be filled before the function is created again"}, + }, + { + name: "column added with a domain whose default calls it", + oldTables: "CREATE TABLE a (id integer);", + newTables: "CREATE DOMAIN amount AS integer DEFAULT lim(1); CREATE DOMAIN positive_amount AS amount CHECK (VALUE > 0); CREATE TABLE a (id integer, n amount, p positive_amount, q amount DEFAULT 0, r amount[]);", + wantErr: []string{ + "column public.a.n is added with type public.amount, whose DEFAULT calls public.lim(integer)", + "column public.a.p is added with type public.positive_amount, whose DEFAULT calls public.lim(integer)", + }, + }, + { + name: "column type change with a new default", + oldTables: "CREATE TABLE c (id integer, v text DEFAULT lower('X'));", + newTables: "CREATE TABLE c (id integer, v integer DEFAULT lim(1));", + wantErr: []string{"column public.c.v changes its type or collation and gets a DEFAULT that calls public.lim(integer); change the column type or collation in a separate step"}, + }, + { + name: "nullability change with a new default", + oldTables: "CREATE TABLE c (id integer, v integer DEFAULT 0);", + newTables: "CREATE TABLE c (id integer, v integer NOT NULL DEFAULT lim(1));", + }, + { + name: "default on an existing column", + oldTables: "CREATE TABLE c (id integer, n integer DEFAULT lim(1));", + newTables: "CREATE TABLE c (id integer, n integer DEFAULT lim(1));", + }, + { + name: "new table with a default", + newTables: "CREATE TABLE c (id integer, n integer DEFAULT lim(1));", + }, + { + name: "generated column that stops calling the function", + oldTables: "CREATE TABLE g (id integer, v integer, d integer GENERATED ALWAYS AS (lim(v)) STORED);", + newTables: "CREATE TABLE g (id integer, v integer, d integer GENERATED ALWAYS AS (v * 2) STORED);", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldIR := testutil.ParseSQLToIRWithSetup(t, sharedTestPostgres, oldFunction+tt.oldTables, "public", "") + newIR := testutil.ParseSQLToIRWithSetup(t, sharedTestPostgres, newFunction+tt.newTables, "public", "") + err := ValidateFunctionRecreations(oldIR, newIR, 0) + if len(tt.wantErr) == 0 { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected an error containing %q", tt.wantErr) + } + for _, want := range tt.wantErr { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not contain %q", err.Error(), want) + } + } + }) + } + + // No function is recreated: nothing to reject. + oldIR := testutil.ParseSQLToIRWithSetup(t, sharedTestPostgres, oldFunction+"CREATE TABLE a (id integer);", "public", "") + newIR := testutil.ParseSQLToIRWithSetup(t, sharedTestPostgres, oldFunction+"CREATE TABLE a (id integer, n integer DEFAULT lim(1));", "public", "") + if err := ValidateFunctionRecreations(oldIR, newIR, 0); err != nil { + t.Fatalf("unexpected error without a recreated function: %v", err) + } +} + +// A VIRTUAL generated column turned into a plain column is re-created (DROP + +// ADD COLUMN, #591); with a DEFAULT calling a recreated function its rows +// would be filled by the old function, so the plan is refused. A STORED one +// keeps its values (DROP EXPRESSION) and its default is held instead. The IR +// is built by hand because VIRTUAL columns need PostgreSQL 18. +func TestValidateFunctionRecreations_RecreatedColumnDefault(t *testing.T) { + schemaIR := func(returnType string, column *ir.Column) *ir.IR { + return &ir.IR{Schemas: map[string]*ir.Schema{"public": { + Name: "public", + Functions: map[string]*ir.Function{"lim()": { + Schema: "public", Name: "lim", ReturnType: returnType, Language: "sql", Definition: "select 1", + }}, + Tables: map[string]*ir.Table{"t": { + Schema: "public", Name: "t", Type: ir.TableTypeBase, + Columns: []*ir.Column{{Name: "id", Position: 1, DataType: "integer"}, column}, + }}, + }}} + } + generated := func(kind string) *ir.Column { + expr := "(id * 2)" + return &ir.Column{Name: "n", Position: 2, DataType: "integer", IsNullable: true, IsGenerated: true, GeneratedKind: kind, GeneratedExpr: &expr} + } + defaultValue := "lim()" + plain := &ir.Column{Name: "n", Position: 2, DataType: "integer", IsNullable: true, DefaultValue: &defaultValue} + + err := ValidateFunctionRecreations(schemaIR("integer", generated("v")), schemaIR("bigint", plain), 18) + if err == nil || !strings.Contains(err.Error(), "column public.t.n is re-created (DROP + ADD COLUMN) with a DEFAULT that calls public.lim()") { + t.Fatalf("VIRTUAL -> plain: got %v", err) + } + if err := ValidateFunctionRecreations(schemaIR("integer", generated("s")), schemaIR("bigint", plain), 18); err != nil { + t.Fatalf("STORED -> plain: unexpected error: %v", err) + } +} diff --git a/internal/diff/table.go b/internal/diff/table.go index c5571876..e7362932 100644 --- a/internal/diff/table.go +++ b/internal/diff/table.go @@ -1486,6 +1486,9 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector if constraint.NoInherit { suffix += " NO INHERIT" } + if !constraint.IsValid { + suffix += " NOT VALID" + } canonicalSQL := fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s %s%s;", tableName, ir.QuoteIdentifier(constraint.Name), clause, suffix) @@ -1613,6 +1616,9 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector if constraint.NoInherit { suffix += " NO INHERIT" } + if !constraint.IsValid { + suffix += " NOT VALID" + } addSQL = fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s %s%s;", tableName, ir.QuoteIdentifier(constraint.Name), ensureCheckClauseParens(constraint.CheckClause), suffix) diff --git a/internal/diff/view.go b/internal/diff/view.go index 49579b22..9e3e5037 100644 --- a/internal/diff/view.go +++ b/internal/diff/view.go @@ -92,7 +92,9 @@ func generateCreateViewsSQL(views []*ir.View, targetSchema string, collector *di // preDroppedViews contains views that were already dropped in the pre-drop phase // dependentViewsCtx contains views that depend on materialized views being recreated // recreatedViews tracks views that were recreated as dependencies (to avoid duplicate processing) -func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *diffCollector, preDroppedViews map[string]bool, dependentViewsCtx *dependentViewsContext, recreatedViews map[string]bool) { +// indexCollector receives the index changes of materialized views that are +// kept (not recreated); it is collector unless the caller defers them (#601). +func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector, indexCollector *diffCollector, preDroppedViews map[string]bool, dependentViewsCtx *dependentViewsContext, recreatedViews map[string]bool) { // Track dependent views that have already been dropped to avoid redundant operations // when a view depends on multiple materialized views being recreated droppedDependentViews := make(map[string]bool) @@ -355,7 +357,7 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d targetSchema, DiffTypeMaterializedViewIndex, DiffTypeMaterializedViewIndexComment, - collector, + indexCollector, ) } } else { diff --git a/internal/plan/held_dependents_test.go b/internal/plan/held_dependents_test.go new file mode 100644 index 00000000..9f51bb9b --- /dev/null +++ b/internal/plan/held_dependents_test.go @@ -0,0 +1,110 @@ +package plan + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pgplex/pgschema/internal/diff" + "github.com/pgplex/pgschema/testutil" +) + +// TestHeldDependentsShareOneTransaction checks, for every diff test case, that +// the plan puts no transaction boundary between the first and the last step +// that drops or restores an object held around a function that is dropped and +// created again: the drops, the recreation and the restores commit together, +// so the objects are never missing between transactions, even when the same +// plan has steps of other objects that need transactions of their own (#601). +func TestHeldDependentsShareOneTransaction(t *testing.T) { + root := "../../testdata/diff" + conn, _, _, _, _, _ := testutil.ConnectToPostgres(t, sharedTestPostgres) + majorVersion, err := testutil.GetMajorVersion(conn) + conn.Close() + if err != nil { + t.Fatalf("Failed to detect PostgreSQL version: %v", err) + } + + casesWithHeldDependents := 0 + err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || !info.IsDir() { + return err + } + oldSQL, errOld := os.ReadFile(filepath.Join(path, "old.sql")) + newSQL, errNew := os.ReadFile(filepath.Join(path, "new.sql")) + if errOld != nil || errNew != nil { + return nil + } + relPath, _ := filepath.Rel(root, path) + name := strings.ReplaceAll(relPath, string(os.PathSeparator), "_") + t.Run(name, func(t *testing.T) { + testutil.ShouldSkipTest(t, name, majorVersion) + setupSQL, _ := os.ReadFile(filepath.Join(path, "setup.sql")) + oldIR := testutil.ParseSQLToIRWithSetup(t, sharedTestPostgres, string(oldSQL), "public", string(setupSQL)) + newIR := testutil.ParseSQLToIRWithSetup(t, sharedTestPostgres, string(newSQL), "public", string(setupSQL)) + groups := groupDiffs(diff.GenerateMigrationForTarget(oldIR, newIR, "public", majorVersion), majorVersion, oldIR) + + first, last := -1, -1 + for i, group := range groups { + for _, step := range group.Steps { + if step.heldDependent { + if first < 0 { + first = i + } + last = i + } + } + } + if first < 0 { + return + } + casesWithHeldDependents++ + if first != last { + t.Errorf("held drops and restores span transaction groups %d to %d", first+1, last+1) + } + // Nor may any other statement change a held object outside that + // transaction, e.g. a DROP DEFAULT of a held column emitted by the + // regular column diff. Only the VALIDATE CONSTRAINT of a CHECK + // constraint re-added NOT VALID follows in a transaction of its own. + for i, group := range groups { + for _, step := range group.Steps { + if i > first && strings.Contains(step.SQL, " VALIDATE CONSTRAINT ") { + continue + } + if !step.heldDependent && i != first && touchesHeldObject(step, groups) { + t.Errorf("statement on a held object in transaction group %d, outside the held span (group %d): %s", i+1, first+1, step.SQL) + } + } + } + }) + return nil + }) + if err != nil { + t.Fatalf("Failed to walk %s: %v", root, err) + } + if casesWithHeldDependents == 0 && majorVersion == testutil.LatestPostgresVersion { + t.Errorf("no test case has held dependents; the check ran on nothing") + } +} + +// touchesHeldObject reports whether an unmarked step changes an object that +// held steps drop or restore: the same constraint, index, policy or trigger, +// or the default of the same column or domain. +func touchesHeldObject(step Step, groups []ExecutionGroup) bool { + for _, group := range groups { + for _, held := range group.Steps { + if !held.heldDependent || held.Type != step.Type || held.Path != step.Path { + continue + } + switch step.Type { + case "table.column", "domain": + if strings.Contains(step.SQL, " DEFAULT") && strings.Contains(held.SQL, " DEFAULT") { + return true + } + default: + return true + } + } + } + return false +} diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 096bc47c..2c795254 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -41,6 +41,9 @@ type Step struct { Type string `json:"type,omitempty"` // e.g., "table", "index" Operation string `json:"operation,omitempty"` // e.g., "create", "alter", "drop" Path string `json:"path,omitempty"` // e.g., "public.users" + + // heldDependent carries diff.Diff.HeldDependent (#601); not serialized. + heldDependent bool } // ExecutionGroup represents a group of steps that should be executed together @@ -161,6 +164,9 @@ func groupDiffs(diffs []diff.Diff, targetMajorVersion int, currentIR *ir.IR) []E // We build these maps incrementally as we process each diff. newlyCreatedTables := make(map[string]bool) newlyCreatedMaterializedViews := make(map[string]bool) + // Indexes dropped earlier in the plan as part of a drop + create cycle + // (around a function that is dropped and created again, #601). + recreatedIndexes := make(map[string]bool) // Convert diffs to steps for _, d := range diffs { @@ -171,18 +177,22 @@ func groupDiffs(diffs []diff.Diff, targetMajorVersion int, currentIR *ir.IR) []E if d.Type == diff.DiffTypeMaterializedView && d.Operation == diff.DiffOperationCreate { newlyCreatedMaterializedViews[d.Path] = true } + if d.Type == diff.DiffTypeTableIndex && d.Operation == diff.DiffOperationRecreate { + recreatedIndexes[d.Path] = true + } // Try to generate rewrites if online operations are enabled - rewriteSteps := generateRewrite(d, newlyCreatedTables, newlyCreatedMaterializedViews, targetMajorVersion, currentIR) + rewriteSteps := generateRewrite(d, newlyCreatedTables, newlyCreatedMaterializedViews, recreatedIndexes, targetMajorVersion, currentIR) if len(rewriteSteps) > 0 { // For operations with rewrites, create one step per rewrite statement for _, rewriteStep := range rewriteSteps { step := Step{ - SQL: rewriteStep.SQL, - Type: d.Type.String(), - Operation: d.Operation.String(), - Path: d.Path, - Directive: rewriteStep.Directive, + SQL: rewriteStep.SQL, + Type: d.Type.String(), + Operation: d.Operation.String(), + Path: d.Path, + Directive: rewriteStep.Directive, + heldDependent: d.HeldDependent, } // Check if this step needs isolation: it has a directive, cannot @@ -210,10 +220,11 @@ func groupDiffs(diffs []diff.Diff, targetMajorVersion int, currentIR *ir.IR) []E // For operations without rewrites, create one step per canonical statement for _, stmt := range d.Statements { step := Step{ - SQL: stmt.SQL, - Type: d.Type.String(), - Operation: d.Operation.String(), - Path: d.Path, + SQL: stmt.SQL, + Type: d.Type.String(), + Operation: d.Operation.String(), + Path: d.Path, + heldDependent: d.HeldDependent, } // Canonical statements don't have directives transactionalSteps = append(transactionalSteps, step) diff --git a/internal/plan/rewrite.go b/internal/plan/rewrite.go index ccbd5bd7..fd2fb364 100644 --- a/internal/plan/rewrite.go +++ b/internal/plan/rewrite.go @@ -27,7 +27,9 @@ type RewriteStep struct { // required version or unknown. currentIR is the target database's current state // (nil-safe); rewrites consult it to pick constraint names that don't collide // with existing constraints, including ones invisible to the IR. -func generateRewrite(d diff.Diff, newlyCreatedTables map[string]bool, newlyCreatedMaterializedViews map[string]bool, targetMajorVersion int, currentIR *ir.IR) []RewriteStep { +// recreatedIndexes holds the paths of indexes the plan dropped earlier as part +// of a drop + create cycle; they are rebuilt without CONCURRENTLY. +func generateRewrite(d diff.Diff, newlyCreatedTables map[string]bool, newlyCreatedMaterializedViews map[string]bool, recreatedIndexes map[string]bool, targetMajorVersion int, currentIR *ir.IR) []RewriteStep { // Dispatch to specific rewrite generators based on diff type and source switch d.Type { case diff.DiffTypeTableIndex: @@ -39,6 +41,13 @@ func generateRewrite(d diff.Diff, newlyCreatedTables map[string]bool, newlyCreat if newlyCreatedTables[tableKey] { return nil // No rewrite needed for indexes on new tables } + // An index dropped around a function that is dropped and + // created again is rebuilt in the same transaction, so the + // table is never without it (#601). CREATE INDEX CONCURRENTLY + // cannot run there. + if recreatedIndexes[d.Path] { + return nil + } return generateIndexRewrite(index) } case diff.DiffOperationAlter: @@ -71,6 +80,13 @@ func generateRewrite(d diff.Diff, newlyCreatedTables map[string]bool, newlyCreat } } case diff.DiffTypeTableConstraint: + // A standalone VALIDATE CONSTRAINT (completing a constraint added + // NOT VALID earlier in the plan, #601) scans the table; like the one + // following an ADD ... NOT VALID it runs in a transaction of its own. + if len(d.Statements) == 1 && strings.HasPrefix(d.Statements[0].SQL, "ALTER TABLE ") && + !strings.Contains(d.Statements[0].SQL, "ADD CONSTRAINT") && strings.Contains(d.Statements[0].SQL, " VALIDATE CONSTRAINT ") { + return []RewriteStep{{SQL: d.Statements[0].SQL, CanRunInTransaction: true, RequiresIsolation: true}} + } if d.Operation == diff.DiffOperationCreate { if constraint, ok := d.Source.(*ir.Constraint); ok { // Skip rewrite for constraints on newly created tables @@ -272,6 +288,10 @@ func generateConstraintRewrite(constraint *ir.Constraint) []RewriteStep { } notValidSQL := fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s %s%s NOT VALID;", tableName, ir.QuoteIdentifier(constraint.Name), constraint.CheckClause, noInheritSuffix) + // A constraint the desired state leaves NOT VALID is not validated. + if !constraint.IsValid { + return []RewriteStep{{SQL: notValidSQL, CanRunInTransaction: true}} + } validateSQL := fmt.Sprintf("ALTER TABLE %s VALIDATE CONSTRAINT %s;", tableName, ir.QuoteIdentifier(constraint.Name)) diff --git a/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/diff.sql b/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/diff.sql new file mode 100644 index 00000000..562e3709 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/diff.sql @@ -0,0 +1,70 @@ +CREATE TABLE IF NOT EXISTS refunds ( + id integer, + tenant_id integer, + qty integer, + CONSTRAINT refunds_pkey PRIMARY KEY (id) +); + +ALTER TABLE refunds ENABLE ROW LEVEL SECURITY; + +ALTER TABLE shipments DROP CONSTRAINT IF EXISTS shipments_qty_check; + +DROP INDEX IF EXISTS shipments_qty_idx; + +DROP FUNCTION IF EXISTS current_tenant(); + +CREATE OR REPLACE FUNCTION current_tenant() +RETURNS bigint +LANGUAGE sql +STABLE +AS $$ SELECT current_setting('app.tenant', true)::bigint +$$; + +DROP FUNCTION IF EXISTS default_priority(); + +CREATE OR REPLACE FUNCTION default_priority() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +DROP FUNCTION IF EXISTS max_qty(); + +CREATE OR REPLACE FUNCTION max_qty() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 100 +$$; + +DROP FUNCTION IF EXISTS normalize_code(text); + +CREATE OR REPLACE FUNCTION normalize_code( + input text +) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ SELECT lower(input) +$$; + +ALTER TABLE refunds ALTER COLUMN tenant_id SET DEFAULT current_tenant(); + +ALTER TABLE refunds +ADD CONSTRAINT refunds_qty_check CHECK (qty <= max_qty()); + +CREATE POLICY refunds_tenant ON refunds TO tenant_reader USING (tenant_id = current_tenant()); + +CREATE INDEX IF NOT EXISTS refunds_large_idx ON refunds (id) WHERE qty > (max_qty() / 2); + +ALTER TABLE shipments ALTER COLUMN priority SET DEFAULT default_priority(); + +ALTER TABLE shipments +ADD CONSTRAINT shipments_qty_check CHECK (qty > 0 AND qty <= max_qty()) NOT VALID; + +CREATE INDEX IF NOT EXISTS shipments_qty_idx ON shipments ((qty * max_qty())); + +ALTER TABLE shipments VALIDATE CONSTRAINT shipments_qty_check; + +CREATE INDEX IF NOT EXISTS shipments_code_idx ON shipments (normalize_code(id::text)); diff --git a/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/new.sql b/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/new.sql new file mode 100644 index 00000000..c9624a13 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/new.sql @@ -0,0 +1,33 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tenant_reader') THEN + CREATE ROLE tenant_reader; + END IF; +END $$; + +-- Functions whose return type or parameter names change (DROP + CREATE) +CREATE FUNCTION max_qty() RETURNS bigint LANGUAGE sql IMMUTABLE AS $$ SELECT 100 $$; +CREATE FUNCTION normalize_code(input text) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT lower(input) $$; +CREATE FUNCTION current_tenant() RETURNS bigint LANGUAGE sql STABLE AS $$ SELECT current_setting('app.tenant', true)::bigint $$; +CREATE FUNCTION default_priority() RETURNS bigint LANGUAGE sql IMMUTABLE AS $$ SELECT 5 $$; + +-- Dependents that start calling the functions, or are added, in the desired state +CREATE TABLE shipments ( + id integer PRIMARY KEY, + qty integer, + priority integer DEFAULT default_priority(), + CONSTRAINT shipments_qty_check CHECK (qty > 0 AND qty <= max_qty()) +); +CREATE INDEX shipments_qty_idx ON shipments ((qty * max_qty())); +CREATE INDEX shipments_code_idx ON shipments ((normalize_code(id::text))); + +-- New table whose default, CHECK constraint, index and policy call recreated functions +CREATE TABLE refunds ( + id integer PRIMARY KEY, + tenant_id integer DEFAULT current_tenant(), + qty integer, + CONSTRAINT refunds_qty_check CHECK (qty <= max_qty()) +); +CREATE INDEX refunds_large_idx ON refunds (id) WHERE qty > max_qty() / 2; +ALTER TABLE refunds ENABLE ROW LEVEL SECURITY; +CREATE POLICY refunds_tenant ON refunds TO tenant_reader USING (tenant_id = current_tenant()); diff --git a/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/old.sql b/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/old.sql new file mode 100644 index 00000000..05492861 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/old.sql @@ -0,0 +1,21 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tenant_reader') THEN + CREATE ROLE tenant_reader; + END IF; +END $$; + +-- Functions whose return type or parameter names change (DROP + CREATE) +CREATE FUNCTION max_qty() RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT 100 $$; +CREATE FUNCTION normalize_code(code text) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT lower(code) $$; +CREATE FUNCTION current_tenant() RETURNS integer LANGUAGE sql STABLE AS $$ SELECT current_setting('app.tenant', true)::integer $$; +CREATE FUNCTION default_priority() RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT 5 $$; + +-- Dependents that start calling the functions, or are added, in the desired state +CREATE TABLE shipments ( + id integer PRIMARY KEY, + qty integer, + priority integer DEFAULT 0, + CONSTRAINT shipments_qty_check CHECK (qty > 0) +); +CREATE INDEX shipments_qty_idx ON shipments (qty); diff --git a/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/plan.json b/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/plan.json new file mode 100644 index 00000000..d62e362f --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/plan.json @@ -0,0 +1,162 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.13.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "b937ed39410ca595a2e96ecccfba9a415b696601cb8719a4861f179317c6dc0a" + }, + "groups": [ + { + "steps": [ + { + "sql": "CREATE TABLE IF NOT EXISTS refunds (\n id integer,\n tenant_id integer,\n qty integer,\n CONSTRAINT refunds_pkey PRIMARY KEY (id)\n);", + "type": "table", + "operation": "create", + "path": "public.refunds" + }, + { + "sql": "ALTER TABLE refunds ENABLE ROW LEVEL SECURITY;", + "type": "table.rls", + "operation": "alter", + "path": "public.refunds" + }, + { + "sql": "ALTER TABLE shipments DROP CONSTRAINT IF EXISTS shipments_qty_check;", + "type": "table.constraint", + "operation": "recreate", + "path": "public.shipments.shipments_qty_check" + }, + { + "sql": "DROP INDEX IF EXISTS shipments_qty_idx;", + "type": "table.index", + "operation": "recreate", + "path": "public.shipments.shipments_qty_idx" + }, + { + "sql": "DROP FUNCTION IF EXISTS current_tenant();", + "type": "function", + "operation": "alter", + "path": "public.current_tenant" + }, + { + "sql": "CREATE OR REPLACE FUNCTION current_tenant()\nRETURNS bigint\nLANGUAGE sql\nSTABLE\nAS $$ SELECT current_setting('app.tenant', true)::bigint\n$$;", + "type": "function", + "operation": "alter", + "path": "public.current_tenant" + }, + { + "sql": "DROP FUNCTION IF EXISTS default_priority();", + "type": "function", + "operation": "alter", + "path": "public.default_priority" + }, + { + "sql": "CREATE OR REPLACE FUNCTION default_priority()\nRETURNS bigint\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT 5\n$$;", + "type": "function", + "operation": "alter", + "path": "public.default_priority" + }, + { + "sql": "DROP FUNCTION IF EXISTS max_qty();", + "type": "function", + "operation": "alter", + "path": "public.max_qty" + }, + { + "sql": "CREATE OR REPLACE FUNCTION max_qty()\nRETURNS bigint\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT 100\n$$;", + "type": "function", + "operation": "alter", + "path": "public.max_qty" + }, + { + "sql": "DROP FUNCTION IF EXISTS normalize_code(text);", + "type": "function", + "operation": "alter", + "path": "public.normalize_code" + }, + { + "sql": "CREATE OR REPLACE FUNCTION normalize_code(\n input text\n)\nRETURNS text\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT lower(input)\n$$;", + "type": "function", + "operation": "alter", + "path": "public.normalize_code" + }, + { + "sql": "ALTER TABLE refunds ALTER COLUMN tenant_id SET DEFAULT current_tenant();", + "type": "table.column", + "operation": "alter", + "path": "public.refunds.tenant_id" + }, + { + "sql": "ALTER TABLE refunds\nADD CONSTRAINT refunds_qty_check CHECK (qty <= max_qty());", + "type": "table.constraint", + "operation": "create", + "path": "public.refunds.refunds_qty_check" + }, + { + "sql": "CREATE POLICY refunds_tenant ON refunds TO tenant_reader USING (tenant_id = current_tenant());", + "type": "table.policy", + "operation": "create", + "path": "public.refunds.refunds_tenant" + }, + { + "sql": "CREATE INDEX IF NOT EXISTS refunds_large_idx ON refunds (id) WHERE qty > (max_qty() / 2);", + "type": "table.index", + "operation": "create", + "path": "public.refunds.refunds_large_idx" + }, + { + "sql": "ALTER TABLE shipments ALTER COLUMN priority SET DEFAULT default_priority();", + "type": "table.column", + "operation": "alter", + "path": "public.shipments.priority" + }, + { + "sql": "ALTER TABLE shipments\nADD CONSTRAINT shipments_qty_check CHECK (qty > 0 AND qty <= max_qty()) NOT VALID;", + "type": "table.constraint", + "operation": "create", + "path": "public.shipments.shipments_qty_check" + }, + { + "sql": "CREATE INDEX IF NOT EXISTS shipments_qty_idx ON shipments ((qty * max_qty()));", + "type": "table.index", + "operation": "create", + "path": "public.shipments.shipments_qty_idx" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE shipments VALIDATE CONSTRAINT shipments_qty_check;", + "type": "table.constraint", + "operation": "create", + "path": "public.shipments.shipments_qty_check" + } + ] + }, + { + "steps": [ + { + "sql": "CREATE INDEX CONCURRENTLY IF NOT EXISTS shipments_code_idx ON shipments (normalize_code(id::text));", + "type": "table.index", + "operation": "create", + "path": "public.shipments.shipments_code_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 = 'shipments_code_idx';", + "directive": { + "type": "wait", + "message": "Creating index shipments_code_idx" + }, + "type": "table.index", + "operation": "create", + "path": "public.shipments.shipments_code_idx" + } + ] + } + ] +} diff --git a/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/plan.sql b/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/plan.sql new file mode 100644 index 00000000..e0b25789 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/plan.sql @@ -0,0 +1,82 @@ +CREATE TABLE IF NOT EXISTS refunds ( + id integer, + tenant_id integer, + qty integer, + CONSTRAINT refunds_pkey PRIMARY KEY (id) +); + +ALTER TABLE refunds ENABLE ROW LEVEL SECURITY; + +ALTER TABLE shipments DROP CONSTRAINT IF EXISTS shipments_qty_check; + +DROP INDEX IF EXISTS shipments_qty_idx; + +DROP FUNCTION IF EXISTS current_tenant(); + +CREATE OR REPLACE FUNCTION current_tenant() +RETURNS bigint +LANGUAGE sql +STABLE +AS $$ SELECT current_setting('app.tenant', true)::bigint +$$; + +DROP FUNCTION IF EXISTS default_priority(); + +CREATE OR REPLACE FUNCTION default_priority() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +DROP FUNCTION IF EXISTS max_qty(); + +CREATE OR REPLACE FUNCTION max_qty() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 100 +$$; + +DROP FUNCTION IF EXISTS normalize_code(text); + +CREATE OR REPLACE FUNCTION normalize_code( + input text +) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ SELECT lower(input) +$$; + +ALTER TABLE refunds ALTER COLUMN tenant_id SET DEFAULT current_tenant(); + +ALTER TABLE refunds +ADD CONSTRAINT refunds_qty_check CHECK (qty <= max_qty()); + +CREATE POLICY refunds_tenant ON refunds TO tenant_reader USING (tenant_id = current_tenant()); + +CREATE INDEX IF NOT EXISTS refunds_large_idx ON refunds (id) WHERE qty > (max_qty() / 2); + +ALTER TABLE shipments ALTER COLUMN priority SET DEFAULT default_priority(); + +ALTER TABLE shipments +ADD CONSTRAINT shipments_qty_check CHECK (qty > 0 AND qty <= max_qty()) NOT VALID; + +CREATE INDEX IF NOT EXISTS shipments_qty_idx ON shipments ((qty * max_qty())); + +ALTER TABLE shipments VALIDATE CONSTRAINT shipments_qty_check; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS shipments_code_idx ON shipments (normalize_code(id::text)); + +-- 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 = 'shipments_code_idx'; diff --git a/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/plan.txt b/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/plan.txt new file mode 100644 index 00000000..50582a40 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_changed_dependents/plan.txt @@ -0,0 +1,116 @@ +Plan: 1 to add, 5 to modify. + +Summary by type: + functions: 4 to modify + tables: 1 to add, 1 to modify + +Functions: + ~ current_tenant + ~ default_priority + ~ max_qty + ~ normalize_code + +Tables: + + refunds + ~ tenant_id (column) + + refunds_qty_check (constraint) + + refunds_large_idx (index) + + refunds_tenant (policy) + ~ refunds (rls) + ~ shipments + ~ priority (column) + ~ shipments_qty_check (constraint) + + shipments_qty_check (constraint) + + shipments_code_idx (index) + ~ shipments_qty_idx (index) + + shipments_qty_idx (index) + +DDL to be executed: +-------------------------------------------------- + +-- Transaction Group #1 +CREATE TABLE IF NOT EXISTS refunds ( + id integer, + tenant_id integer, + qty integer, + CONSTRAINT refunds_pkey PRIMARY KEY (id) +); + +ALTER TABLE refunds ENABLE ROW LEVEL SECURITY; + +ALTER TABLE shipments DROP CONSTRAINT IF EXISTS shipments_qty_check; + +DROP INDEX IF EXISTS shipments_qty_idx; + +DROP FUNCTION IF EXISTS current_tenant(); + +CREATE OR REPLACE FUNCTION current_tenant() +RETURNS bigint +LANGUAGE sql +STABLE +AS $$ SELECT current_setting('app.tenant', true)::bigint +$$; + +DROP FUNCTION IF EXISTS default_priority(); + +CREATE OR REPLACE FUNCTION default_priority() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +DROP FUNCTION IF EXISTS max_qty(); + +CREATE OR REPLACE FUNCTION max_qty() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 100 +$$; + +DROP FUNCTION IF EXISTS normalize_code(text); + +CREATE OR REPLACE FUNCTION normalize_code( + input text +) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ SELECT lower(input) +$$; + +ALTER TABLE refunds ALTER COLUMN tenant_id SET DEFAULT current_tenant(); + +ALTER TABLE refunds +ADD CONSTRAINT refunds_qty_check CHECK (qty <= max_qty()); + +CREATE POLICY refunds_tenant ON refunds TO tenant_reader USING (tenant_id = current_tenant()); + +CREATE INDEX IF NOT EXISTS refunds_large_idx ON refunds (id) WHERE qty > (max_qty() / 2); + +ALTER TABLE shipments ALTER COLUMN priority SET DEFAULT default_priority(); + +ALTER TABLE shipments +ADD CONSTRAINT shipments_qty_check CHECK (qty > 0 AND qty <= max_qty()) NOT VALID; + +CREATE INDEX IF NOT EXISTS shipments_qty_idx ON shipments ((qty * max_qty())); + +-- Transaction Group #2 +ALTER TABLE shipments VALIDATE CONSTRAINT shipments_qty_check; + +-- Transaction Group #3 +CREATE INDEX CONCURRENTLY IF NOT EXISTS shipments_code_idx ON shipments (normalize_code(id::text)); + +-- 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 = 'shipments_code_idx'; diff --git a/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/diff.sql b/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/diff.sql new file mode 100644 index 00000000..6928674d --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/diff.sql @@ -0,0 +1,46 @@ +ALTER DOMAIN quantity DROP DEFAULT; + +ALTER DOMAIN quantity DROP CONSTRAINT IF EXISTS quantity_max; + +ALTER TABLE slots DROP CONSTRAINT IF EXISTS slots_code_excl; + +ALTER TABLE slots DROP CONSTRAINT IF EXISTS slots_id_check; + +DROP FUNCTION IF EXISTS default_qty(); + +CREATE OR REPLACE FUNCTION default_qty() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +DROP FUNCTION IF EXISTS max_qty(); + +CREATE OR REPLACE FUNCTION max_qty() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 100 +$$; + +DROP FUNCTION IF EXISTS normalize_code(text); + +CREATE OR REPLACE FUNCTION normalize_code( + input text +) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ SELECT lower(input) +$$; + +ALTER DOMAIN quantity SET DEFAULT default_qty(); + +ALTER DOMAIN quantity ADD CONSTRAINT quantity_max CHECK (VALUE <= max_qty()); + +ALTER TABLE slots +ADD CONSTRAINT slots_id_check CHECK (id <= max_qty()) NOT VALID; + +ALTER TABLE slots +ADD CONSTRAINT slots_code_excl EXCLUDE USING btree (normalize_code(code) WITH =); diff --git a/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/new.sql b/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/new.sql new file mode 100644 index 00000000..57a5a38c --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/new.sql @@ -0,0 +1,16 @@ +-- Functions whose return type or parameter names change (DROP + CREATE) +CREATE FUNCTION max_qty() RETURNS bigint LANGUAGE sql IMMUTABLE AS $$ SELECT 100 $$; +CREATE FUNCTION normalize_code(input text) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT lower(input) $$; +CREATE FUNCTION default_qty() RETURNS bigint LANGUAGE sql IMMUTABLE AS $$ SELECT 5 $$; + +-- EXCLUDE constraint and NOT VALID CHECK constraint calling a recreated function +CREATE TABLE slots ( + id integer PRIMARY KEY, + code text, + CONSTRAINT slots_code_excl EXCLUDE USING btree ((normalize_code(code)) WITH =) +); +ALTER TABLE slots ADD CONSTRAINT slots_id_check CHECK (id <= max_qty()) NOT VALID; + +-- Domain default and CHECK constraint calling recreated functions +CREATE DOMAIN quantity AS integer DEFAULT default_qty() CONSTRAINT quantity_max CHECK (VALUE <= max_qty()); +CREATE TABLE stock (id integer PRIMARY KEY, amount quantity); diff --git a/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/old.sql b/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/old.sql new file mode 100644 index 00000000..415a8da0 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/old.sql @@ -0,0 +1,16 @@ +-- Functions whose return type or parameter names change (DROP + CREATE) +CREATE FUNCTION max_qty() RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT 100 $$; +CREATE FUNCTION normalize_code(code text) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT lower(code) $$; +CREATE FUNCTION default_qty() RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT 5 $$; + +-- EXCLUDE constraint and NOT VALID CHECK constraint calling a recreated function +CREATE TABLE slots ( + id integer PRIMARY KEY, + code text, + CONSTRAINT slots_code_excl EXCLUDE USING btree ((normalize_code(code)) WITH =) +); +ALTER TABLE slots ADD CONSTRAINT slots_id_check CHECK (id <= max_qty()) NOT VALID; + +-- Domain default and CHECK constraint calling recreated functions +CREATE DOMAIN quantity AS integer DEFAULT default_qty() CONSTRAINT quantity_max CHECK (VALUE <= max_qty()); +CREATE TABLE stock (id integer PRIMARY KEY, amount quantity); diff --git a/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/plan.json b/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/plan.json new file mode 100644 index 00000000..8220d14c --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/plan.json @@ -0,0 +1,98 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.13.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "6c38fbdfa73d9d1793aa3966dc6ea4f689852c2cd82832e126c3b65c7c0cad2a" + }, + "groups": [ + { + "steps": [ + { + "sql": "ALTER DOMAIN quantity DROP DEFAULT;", + "type": "domain", + "operation": "alter", + "path": "public.quantity" + }, + { + "sql": "ALTER DOMAIN quantity DROP CONSTRAINT IF EXISTS quantity_max;", + "type": "domain", + "operation": "alter", + "path": "public.quantity" + }, + { + "sql": "ALTER TABLE slots DROP CONSTRAINT IF EXISTS slots_code_excl;", + "type": "table.constraint", + "operation": "recreate", + "path": "public.slots.slots_code_excl" + }, + { + "sql": "ALTER TABLE slots DROP CONSTRAINT IF EXISTS slots_id_check;", + "type": "table.constraint", + "operation": "recreate", + "path": "public.slots.slots_id_check" + }, + { + "sql": "DROP FUNCTION IF EXISTS default_qty();", + "type": "function", + "operation": "alter", + "path": "public.default_qty" + }, + { + "sql": "CREATE OR REPLACE FUNCTION default_qty()\nRETURNS bigint\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT 5\n$$;", + "type": "function", + "operation": "alter", + "path": "public.default_qty" + }, + { + "sql": "DROP FUNCTION IF EXISTS max_qty();", + "type": "function", + "operation": "alter", + "path": "public.max_qty" + }, + { + "sql": "CREATE OR REPLACE FUNCTION max_qty()\nRETURNS bigint\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT 100\n$$;", + "type": "function", + "operation": "alter", + "path": "public.max_qty" + }, + { + "sql": "DROP FUNCTION IF EXISTS normalize_code(text);", + "type": "function", + "operation": "alter", + "path": "public.normalize_code" + }, + { + "sql": "CREATE OR REPLACE FUNCTION normalize_code(\n input text\n)\nRETURNS text\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT lower(input)\n$$;", + "type": "function", + "operation": "alter", + "path": "public.normalize_code" + }, + { + "sql": "ALTER DOMAIN quantity SET DEFAULT default_qty();", + "type": "domain", + "operation": "alter", + "path": "public.quantity" + }, + { + "sql": "ALTER DOMAIN quantity ADD CONSTRAINT quantity_max CHECK (VALUE <= max_qty());", + "type": "domain", + "operation": "alter", + "path": "public.quantity" + }, + { + "sql": "ALTER TABLE slots\nADD CONSTRAINT slots_id_check CHECK (id <= max_qty()) NOT VALID;", + "type": "table.constraint", + "operation": "create", + "path": "public.slots.slots_id_check" + }, + { + "sql": "ALTER TABLE slots\nADD CONSTRAINT slots_code_excl EXCLUDE USING btree (normalize_code(code) WITH =);", + "type": "table.constraint", + "operation": "create", + "path": "public.slots.slots_code_excl" + } + ] + } + ] +} diff --git a/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/plan.sql b/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/plan.sql new file mode 100644 index 00000000..6928674d --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/plan.sql @@ -0,0 +1,46 @@ +ALTER DOMAIN quantity DROP DEFAULT; + +ALTER DOMAIN quantity DROP CONSTRAINT IF EXISTS quantity_max; + +ALTER TABLE slots DROP CONSTRAINT IF EXISTS slots_code_excl; + +ALTER TABLE slots DROP CONSTRAINT IF EXISTS slots_id_check; + +DROP FUNCTION IF EXISTS default_qty(); + +CREATE OR REPLACE FUNCTION default_qty() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +DROP FUNCTION IF EXISTS max_qty(); + +CREATE OR REPLACE FUNCTION max_qty() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 100 +$$; + +DROP FUNCTION IF EXISTS normalize_code(text); + +CREATE OR REPLACE FUNCTION normalize_code( + input text +) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ SELECT lower(input) +$$; + +ALTER DOMAIN quantity SET DEFAULT default_qty(); + +ALTER DOMAIN quantity ADD CONSTRAINT quantity_max CHECK (VALUE <= max_qty()); + +ALTER TABLE slots +ADD CONSTRAINT slots_id_check CHECK (id <= max_qty()) NOT VALID; + +ALTER TABLE slots +ADD CONSTRAINT slots_code_excl EXCLUDE USING btree (normalize_code(code) WITH =); diff --git a/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/plan.txt b/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/plan.txt new file mode 100644 index 00000000..c614df76 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_domain_exclude_not_valid/plan.txt @@ -0,0 +1,67 @@ +Plan: 8 to modify. + +Summary by type: + functions: 3 to modify + tables: 1 to modify + +Functions: + ~ default_qty + ~ max_qty + ~ normalize_code + +Tables: + ~ slots + ~ slots_code_excl (constraint) + + slots_code_excl (constraint) + ~ slots_id_check (constraint) + + slots_id_check (constraint) + +DDL to be executed: +-------------------------------------------------- + +ALTER DOMAIN quantity DROP DEFAULT; + +ALTER DOMAIN quantity DROP CONSTRAINT IF EXISTS quantity_max; + +ALTER TABLE slots DROP CONSTRAINT IF EXISTS slots_code_excl; + +ALTER TABLE slots DROP CONSTRAINT IF EXISTS slots_id_check; + +DROP FUNCTION IF EXISTS default_qty(); + +CREATE OR REPLACE FUNCTION default_qty() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +DROP FUNCTION IF EXISTS max_qty(); + +CREATE OR REPLACE FUNCTION max_qty() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 100 +$$; + +DROP FUNCTION IF EXISTS normalize_code(text); + +CREATE OR REPLACE FUNCTION normalize_code( + input text +) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ SELECT lower(input) +$$; + +ALTER DOMAIN quantity SET DEFAULT default_qty(); + +ALTER DOMAIN quantity ADD CONSTRAINT quantity_max CHECK (VALUE <= max_qty()); + +ALTER TABLE slots +ADD CONSTRAINT slots_id_check CHECK (id <= max_qty()) NOT VALID; + +ALTER TABLE slots +ADD CONSTRAINT slots_code_excl EXCLUDE USING btree (normalize_code(code) WITH =); diff --git a/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/diff.sql b/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/diff.sql new file mode 100644 index 00000000..ac837bdb --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/diff.sql @@ -0,0 +1,33 @@ +ALTER TABLE measurements DROP CONSTRAINT IF EXISTS measurements_qty_check; + +DROP INDEX IF EXISTS measurements_qty_idx; + +ALTER TABLE ONLY measurements ALTER COLUMN qty DROP DEFAULT; + +ALTER TABLE measurements_p2 ALTER COLUMN qty DROP DEFAULT; + +ALTER TABLE measurements_p3 ALTER COLUMN qty DROP DEFAULT; + +DROP FUNCTION IF EXISTS default_qty(integer); + +CREATE OR REPLACE FUNCTION default_qty( + x integer +) +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT x +$$; + +ALTER TABLE ONLY measurements ALTER COLUMN qty SET DEFAULT default_qty(1); + +ALTER TABLE measurements +ADD CONSTRAINT measurements_qty_check CHECK (qty <= default_qty(100)) NOT VALID; + +CREATE INDEX IF NOT EXISTS measurements_qty_idx ON measurements ((qty + default_qty(0))); + +ALTER TABLE measurements_p2 ALTER COLUMN qty SET DEFAULT default_qty(1); + +ALTER TABLE measurements_p3 ALTER COLUMN qty SET DEFAULT default_qty(1); + +ALTER TABLE measurements VALIDATE CONSTRAINT measurements_qty_check; diff --git a/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/new.sql b/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/new.sql new file mode 100644 index 00000000..d390efa8 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/new.sql @@ -0,0 +1,15 @@ +-- Function whose return type changes (DROP + CREATE) +CREATE FUNCTION default_qty(x integer) RETURNS bigint LANGUAGE sql IMMUTABLE AS $$ SELECT x $$; + +-- Partitioned table: the default, CHECK constraint and index of the parent +-- call the function. Partition p1 keeps a default of its own, p2 and p3 copy +-- the parent's; the partitions' copies of the CHECK and index follow the parent. +CREATE TABLE measurements ( + id integer NOT NULL, + qty integer DEFAULT default_qty(1), + CONSTRAINT measurements_qty_check CHECK (qty <= default_qty(100)) +) PARTITION BY RANGE (id); +CREATE TABLE measurements_p1 PARTITION OF measurements (qty DEFAULT 7) FOR VALUES FROM (0) TO (10); +CREATE TABLE measurements_p2 PARTITION OF measurements FOR VALUES FROM (10) TO (20); +CREATE TABLE measurements_p3 PARTITION OF measurements FOR VALUES FROM (20) TO (30); +CREATE INDEX measurements_qty_idx ON measurements ((qty + default_qty(0))); diff --git a/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/old.sql b/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/old.sql new file mode 100644 index 00000000..a3ddc006 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/old.sql @@ -0,0 +1,15 @@ +-- Function whose return type changes (DROP + CREATE) +CREATE FUNCTION default_qty(x integer) RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT x $$; + +-- Partitioned table: the default, CHECK constraint and index of the parent +-- call the function. Partition p1 keeps a default of its own, p2 and p3 copy +-- the parent's; the partitions' copies of the CHECK and index follow the parent. +CREATE TABLE measurements ( + id integer NOT NULL, + qty integer DEFAULT default_qty(1), + CONSTRAINT measurements_qty_check CHECK (qty <= default_qty(100)) +) PARTITION BY RANGE (id); +CREATE TABLE measurements_p1 PARTITION OF measurements (qty DEFAULT 7) FOR VALUES FROM (0) TO (10); +CREATE TABLE measurements_p2 PARTITION OF measurements FOR VALUES FROM (10) TO (20); +CREATE TABLE measurements_p3 PARTITION OF measurements FOR VALUES FROM (20) TO (30); +CREATE INDEX measurements_qty_idx ON measurements ((qty + default_qty(0))); diff --git a/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/plan.json b/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/plan.json new file mode 100644 index 00000000..1490df12 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/plan.json @@ -0,0 +1,96 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.13.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "29d912f084e43a71d3222e536f0a5ce25cc65258cf56241f9da79c2d70760581" + }, + "groups": [ + { + "steps": [ + { + "sql": "ALTER TABLE measurements DROP CONSTRAINT IF EXISTS measurements_qty_check;", + "type": "table.constraint", + "operation": "recreate", + "path": "public.measurements.measurements_qty_check" + }, + { + "sql": "DROP INDEX IF EXISTS measurements_qty_idx;", + "type": "table.index", + "operation": "recreate", + "path": "public.measurements.measurements_qty_idx" + }, + { + "sql": "ALTER TABLE ONLY measurements ALTER COLUMN qty DROP DEFAULT;", + "type": "table.column", + "operation": "alter", + "path": "public.measurements.qty" + }, + { + "sql": "ALTER TABLE measurements_p2 ALTER COLUMN qty DROP DEFAULT;", + "type": "table.column", + "operation": "alter", + "path": "public.measurements_p2.qty" + }, + { + "sql": "ALTER TABLE measurements_p3 ALTER COLUMN qty DROP DEFAULT;", + "type": "table.column", + "operation": "alter", + "path": "public.measurements_p3.qty" + }, + { + "sql": "DROP FUNCTION IF EXISTS default_qty(integer);", + "type": "function", + "operation": "alter", + "path": "public.default_qty" + }, + { + "sql": "CREATE OR REPLACE FUNCTION default_qty(\n x integer\n)\nRETURNS bigint\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT x\n$$;", + "type": "function", + "operation": "alter", + "path": "public.default_qty" + }, + { + "sql": "ALTER TABLE ONLY measurements ALTER COLUMN qty SET DEFAULT default_qty(1);", + "type": "table.column", + "operation": "alter", + "path": "public.measurements.qty" + }, + { + "sql": "ALTER TABLE measurements\nADD CONSTRAINT measurements_qty_check CHECK (qty <= default_qty(100)) NOT VALID;", + "type": "table.constraint", + "operation": "create", + "path": "public.measurements.measurements_qty_check" + }, + { + "sql": "CREATE INDEX IF NOT EXISTS measurements_qty_idx ON measurements ((qty + default_qty(0)));", + "type": "table.index", + "operation": "create", + "path": "public.measurements.measurements_qty_idx" + }, + { + "sql": "ALTER TABLE measurements_p2 ALTER COLUMN qty SET DEFAULT default_qty(1);", + "type": "table.column", + "operation": "alter", + "path": "public.measurements_p2.qty" + }, + { + "sql": "ALTER TABLE measurements_p3 ALTER COLUMN qty SET DEFAULT default_qty(1);", + "type": "table.column", + "operation": "alter", + "path": "public.measurements_p3.qty" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE measurements VALIDATE CONSTRAINT measurements_qty_check;", + "type": "table.constraint", + "operation": "create", + "path": "public.measurements.measurements_qty_check" + } + ] + } + ] +} diff --git a/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/plan.sql b/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/plan.sql new file mode 100644 index 00000000..ac837bdb --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/plan.sql @@ -0,0 +1,33 @@ +ALTER TABLE measurements DROP CONSTRAINT IF EXISTS measurements_qty_check; + +DROP INDEX IF EXISTS measurements_qty_idx; + +ALTER TABLE ONLY measurements ALTER COLUMN qty DROP DEFAULT; + +ALTER TABLE measurements_p2 ALTER COLUMN qty DROP DEFAULT; + +ALTER TABLE measurements_p3 ALTER COLUMN qty DROP DEFAULT; + +DROP FUNCTION IF EXISTS default_qty(integer); + +CREATE OR REPLACE FUNCTION default_qty( + x integer +) +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT x +$$; + +ALTER TABLE ONLY measurements ALTER COLUMN qty SET DEFAULT default_qty(1); + +ALTER TABLE measurements +ADD CONSTRAINT measurements_qty_check CHECK (qty <= default_qty(100)) NOT VALID; + +CREATE INDEX IF NOT EXISTS measurements_qty_idx ON measurements ((qty + default_qty(0))); + +ALTER TABLE measurements_p2 ALTER COLUMN qty SET DEFAULT default_qty(1); + +ALTER TABLE measurements_p3 ALTER COLUMN qty SET DEFAULT default_qty(1); + +ALTER TABLE measurements VALIDATE CONSTRAINT measurements_qty_check; diff --git a/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/plan.txt b/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/plan.txt new file mode 100644 index 00000000..41e2bbc2 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_partitioned_table/plan.txt @@ -0,0 +1,59 @@ +Plan: 4 to modify. + +Summary by type: + functions: 1 to modify + tables: 3 to modify + +Functions: + ~ default_qty + +Tables: + ~ measurements + ~ qty (column) + ~ measurements_qty_check (constraint) + + measurements_qty_check (constraint) + ~ measurements_qty_idx (index) + + measurements_qty_idx (index) + ~ measurements_p2 + ~ qty (column) + ~ measurements_p3 + ~ qty (column) + +DDL to be executed: +-------------------------------------------------- + +-- Transaction Group #1 +ALTER TABLE measurements DROP CONSTRAINT IF EXISTS measurements_qty_check; + +DROP INDEX IF EXISTS measurements_qty_idx; + +ALTER TABLE ONLY measurements ALTER COLUMN qty DROP DEFAULT; + +ALTER TABLE measurements_p2 ALTER COLUMN qty DROP DEFAULT; + +ALTER TABLE measurements_p3 ALTER COLUMN qty DROP DEFAULT; + +DROP FUNCTION IF EXISTS default_qty(integer); + +CREATE OR REPLACE FUNCTION default_qty( + x integer +) +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT x +$$; + +ALTER TABLE ONLY measurements ALTER COLUMN qty SET DEFAULT default_qty(1); + +ALTER TABLE measurements +ADD CONSTRAINT measurements_qty_check CHECK (qty <= default_qty(100)) NOT VALID; + +CREATE INDEX IF NOT EXISTS measurements_qty_idx ON measurements ((qty + default_qty(0))); + +ALTER TABLE measurements_p2 ALTER COLUMN qty SET DEFAULT default_qty(1); + +ALTER TABLE measurements_p3 ALTER COLUMN qty SET DEFAULT default_qty(1); + +-- Transaction Group #2 +ALTER TABLE measurements VALIDATE CONSTRAINT measurements_qty_check; diff --git a/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/diff.sql b/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/diff.sql new file mode 100644 index 00000000..3f39189b --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/diff.sql @@ -0,0 +1,21 @@ +ALTER TABLE ledger +ADD CONSTRAINT ledger_amount_check CHECK (amount >= 0); + +CREATE INDEX IF NOT EXISTS ledger_amount_idx ON ledger (amount); + +DROP TRIGGER IF EXISTS audited_mark ON audited; + +DROP FUNCTION IF EXISTS audit_threshold(); + +CREATE OR REPLACE FUNCTION audit_threshold() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 10 +$$; + +CREATE OR REPLACE TRIGGER audited_mark + BEFORE INSERT ON audited + FOR EACH ROW + WHEN (((NEW.qty > audit_threshold()))) + EXECUTE FUNCTION mark_audited(); diff --git a/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/new.sql b/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/new.sql new file mode 100644 index 00000000..e22ba97f --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/new.sql @@ -0,0 +1,20 @@ +-- Function whose return type changes (DROP + CREATE) +CREATE FUNCTION audit_threshold() RETURNS bigint LANGUAGE sql IMMUTABLE AS $$ SELECT 10 $$; +CREATE FUNCTION mark_audited() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.audited := true; RETURN NEW; END $$; + +-- Trigger WHEN calling it; the plan also changes another table with online +-- steps (a CHECK constraint validated on its own, an index built concurrently), +-- which must not separate the trigger's DROP from its re-creation +CREATE TABLE audited ( + id integer PRIMARY KEY, + qty integer, + audited boolean DEFAULT false +); +CREATE TRIGGER audited_mark BEFORE INSERT ON audited FOR EACH ROW WHEN (NEW.qty > audit_threshold()) EXECUTE FUNCTION mark_audited(); + +CREATE TABLE ledger ( + id integer PRIMARY KEY, + amount integer, + CONSTRAINT ledger_amount_check CHECK (amount >= 0) +); +CREATE INDEX ledger_amount_idx ON ledger (amount); diff --git a/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/old.sql b/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/old.sql new file mode 100644 index 00000000..19eadf14 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/old.sql @@ -0,0 +1,18 @@ +-- Function whose return type changes (DROP + CREATE) +CREATE FUNCTION audit_threshold() RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT 10 $$; +CREATE FUNCTION mark_audited() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.audited := true; RETURN NEW; END $$; + +-- Trigger WHEN calling it; the plan also changes another table with online +-- steps (a CHECK constraint validated on its own, an index built concurrently), +-- which must not separate the trigger's DROP from its re-creation +CREATE TABLE audited ( + id integer PRIMARY KEY, + qty integer, + audited boolean DEFAULT false +); +CREATE TRIGGER audited_mark BEFORE INSERT ON audited FOR EACH ROW WHEN (NEW.qty > audit_threshold()) EXECUTE FUNCTION mark_audited(); + +CREATE TABLE ledger ( + id integer PRIMARY KEY, + amount integer +); diff --git a/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/plan.json b/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/plan.json new file mode 100644 index 00000000..01b456aa --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/plan.json @@ -0,0 +1,82 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.13.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "b08312b318569ac126ca0f5c33438430d26061fce659026089a034ead40dc856" + }, + "groups": [ + { + "steps": [ + { + "sql": "ALTER TABLE ledger\nADD CONSTRAINT ledger_amount_check CHECK (amount >= 0) NOT VALID;", + "type": "table.constraint", + "operation": "create", + "path": "public.ledger.ledger_amount_check" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE ledger VALIDATE CONSTRAINT ledger_amount_check;", + "type": "table.constraint", + "operation": "create", + "path": "public.ledger.ledger_amount_check" + } + ] + }, + { + "steps": [ + { + "sql": "CREATE INDEX CONCURRENTLY IF NOT EXISTS ledger_amount_idx ON ledger (amount);", + "type": "table.index", + "operation": "create", + "path": "public.ledger.ledger_amount_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 = 'ledger_amount_idx';", + "directive": { + "type": "wait", + "message": "Creating index ledger_amount_idx" + }, + "type": "table.index", + "operation": "create", + "path": "public.ledger.ledger_amount_idx" + } + ] + }, + { + "steps": [ + { + "sql": "DROP TRIGGER IF EXISTS audited_mark ON audited;", + "type": "table.trigger", + "operation": "recreate", + "path": "public.audited.audited_mark" + }, + { + "sql": "DROP FUNCTION IF EXISTS audit_threshold();", + "type": "function", + "operation": "alter", + "path": "public.audit_threshold" + }, + { + "sql": "CREATE OR REPLACE FUNCTION audit_threshold()\nRETURNS bigint\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT 10\n$$;", + "type": "function", + "operation": "alter", + "path": "public.audit_threshold" + }, + { + "sql": "CREATE OR REPLACE TRIGGER audited_mark\n BEFORE INSERT ON audited\n FOR EACH ROW\n WHEN (((NEW.qty > audit_threshold())))\n EXECUTE FUNCTION mark_audited();", + "type": "table.trigger", + "operation": "create", + "path": "public.audited.audited_mark" + } + ] + } + ] +} diff --git a/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/plan.sql b/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/plan.sql new file mode 100644 index 00000000..1e1f2204 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/plan.sql @@ -0,0 +1,35 @@ +ALTER TABLE ledger +ADD CONSTRAINT ledger_amount_check CHECK (amount >= 0) NOT VALID; + +ALTER TABLE ledger VALIDATE CONSTRAINT ledger_amount_check; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS ledger_amount_idx ON ledger (amount); + +-- 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 = 'ledger_amount_idx'; + +DROP TRIGGER IF EXISTS audited_mark ON audited; + +DROP FUNCTION IF EXISTS audit_threshold(); + +CREATE OR REPLACE FUNCTION audit_threshold() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 10 +$$; + +CREATE OR REPLACE TRIGGER audited_mark + BEFORE INSERT ON audited + FOR EACH ROW + WHEN (((NEW.qty > audit_threshold()))) + EXECUTE FUNCTION mark_audited(); diff --git a/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/plan.txt b/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/plan.txt new file mode 100644 index 00000000..1f5f2179 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_trigger_with_online_steps/plan.txt @@ -0,0 +1,60 @@ +Plan: 3 to modify. + +Summary by type: + functions: 1 to modify + tables: 2 to modify + +Functions: + ~ audit_threshold + +Tables: + ~ audited + ~ audited_mark (trigger) + + audited_mark (trigger) + ~ ledger + + ledger_amount_check (constraint) + + ledger_amount_idx (index) + +DDL to be executed: +-------------------------------------------------- + +-- Transaction Group #1 +ALTER TABLE ledger +ADD CONSTRAINT ledger_amount_check CHECK (amount >= 0) NOT VALID; + +-- Transaction Group #2 +ALTER TABLE ledger VALIDATE CONSTRAINT ledger_amount_check; + +-- Transaction Group #3 +CREATE INDEX CONCURRENTLY IF NOT EXISTS ledger_amount_idx ON ledger (amount); + +-- 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 = 'ledger_amount_idx'; + +-- Transaction Group #5 +DROP TRIGGER IF EXISTS audited_mark ON audited; + +DROP FUNCTION IF EXISTS audit_threshold(); + +CREATE OR REPLACE FUNCTION audit_threshold() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 10 +$$; + +CREATE OR REPLACE TRIGGER audited_mark + BEFORE INSERT ON audited + FOR EACH ROW + WHEN (((NEW.qty > audit_threshold()))) + EXECUTE FUNCTION mark_audited(); diff --git a/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/diff.sql b/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/diff.sql new file mode 100644 index 00000000..13449eab --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/diff.sql @@ -0,0 +1,109 @@ +DROP TRIGGER IF EXISTS orders_audit ON orders; + +DROP POLICY IF EXISTS orders_tenant ON orders; + +ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_qty_check; + +DROP INDEX IF EXISTS orders_code_key; + +DROP INDEX IF EXISTS orders_large_idx; + +ALTER TABLE orders ALTER COLUMN tenant_id DROP DEFAULT; + +ALTER TABLE orders ALTER COLUMN priority DROP DEFAULT; + +DROP MATERIALIZED VIEW IF EXISTS order_codes RESTRICT; + +DROP VIEW IF EXISTS my_orders RESTRICT; + +DROP FUNCTION IF EXISTS current_tenant(); + +CREATE OR REPLACE FUNCTION current_tenant() +RETURNS bigint +LANGUAGE sql +STABLE +AS $$ SELECT current_setting('app.tenant', true)::bigint +$$; + +DROP FUNCTION IF EXISTS normalize_code(text); + +CREATE OR REPLACE FUNCTION normalize_code( + input text +) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ SELECT lower(input) +$$; + +CREATE OR REPLACE VIEW my_orders AS + SELECT id, + code + FROM orders + WHERE tenant_id = current_tenant(); + +CREATE MATERIALIZED VIEW IF NOT EXISTS order_codes AS + SELECT id, + code + FROM orders; + +CREATE INDEX IF NOT EXISTS order_codes_normalized_idx ON order_codes (normalize_code(code)); + +DROP FUNCTION IF EXISTS audit_threshold(); + +CREATE OR REPLACE FUNCTION audit_threshold() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 10 +$$; + +DROP FUNCTION IF EXISTS default_priority(); + +CREATE OR REPLACE FUNCTION default_priority() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +DROP FUNCTION IF EXISTS max_qty(); + +CREATE OR REPLACE FUNCTION max_qty() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 100 +$$; + +DROP FUNCTION IF EXISTS standalone(); + +CREATE OR REPLACE FUNCTION standalone() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 1 +$$; + +ALTER TABLE orders ALTER COLUMN tenant_id SET DEFAULT current_tenant(); + +ALTER TABLE orders ALTER COLUMN priority SET DEFAULT default_priority(); + +ALTER TABLE orders +ADD CONSTRAINT orders_qty_check CHECK (qty <= max_qty()) NOT VALID; + +CREATE OR REPLACE TRIGGER orders_audit + BEFORE INSERT ON orders + FOR EACH ROW + WHEN (((NEW.qty > audit_threshold()))) + EXECUTE FUNCTION mark_audited(); + +CREATE POLICY orders_tenant ON orders TO tenant_reader USING (tenant_id = current_tenant()); + +CREATE UNIQUE INDEX IF NOT EXISTS orders_code_key ON orders (normalize_code(code)); + +COMMENT ON INDEX orders_code_key IS 'Case-insensitive order code'; + +CREATE INDEX IF NOT EXISTS orders_large_idx ON orders (id) WHERE qty > (max_qty() / 2); + +ALTER TABLE orders VALIDATE CONSTRAINT orders_qty_check; diff --git a/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/new.sql b/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/new.sql new file mode 100644 index 00000000..d240d181 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/new.sql @@ -0,0 +1,43 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tenant_reader') THEN + CREATE ROLE tenant_reader; + END IF; +END $$; + +-- Functions whose return type or parameter names change (DROP + CREATE) +CREATE FUNCTION max_qty() RETURNS bigint LANGUAGE sql IMMUTABLE AS $$ SELECT 100 $$; +CREATE FUNCTION normalize_code(input text) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT lower(input) $$; +CREATE FUNCTION current_tenant() RETURNS bigint LANGUAGE sql STABLE AS $$ SELECT current_setting('app.tenant', true)::bigint $$; +CREATE FUNCTION audit_threshold() RETURNS bigint LANGUAGE sql IMMUTABLE AS $$ SELECT 10 $$; +CREATE FUNCTION default_priority() RETURNS bigint LANGUAGE sql IMMUTABLE AS $$ SELECT 5 $$; + +CREATE FUNCTION mark_audited() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.audited := true; RETURN NEW; END $$; + +-- Unchanged table: column defaults, CHECK constraint, unique expression index +-- with a comment, partial index, policy and trigger WHEN calling them +CREATE TABLE orders ( + id integer PRIMARY KEY, + tenant_id integer NOT NULL DEFAULT current_tenant(), + code text NOT NULL, + qty integer NOT NULL, + priority integer DEFAULT default_priority(), + audited boolean DEFAULT false, + CONSTRAINT orders_qty_check CHECK (qty <= max_qty()) +); +CREATE UNIQUE INDEX orders_code_key ON orders ((normalize_code(code))); +COMMENT ON INDEX orders_code_key IS 'Case-insensitive order code'; +CREATE INDEX orders_large_idx ON orders (id) WHERE qty > max_qty() / 2; +ALTER TABLE orders ENABLE ROW LEVEL SECURITY; +CREATE POLICY orders_tenant ON orders TO tenant_reader USING (tenant_id = current_tenant()); +CREATE TRIGGER orders_audit BEFORE INSERT ON orders FOR EACH ROW WHEN (NEW.qty > audit_threshold()) EXECUTE FUNCTION mark_audited(); + +-- View calling a recreated function that table defaults call as well +CREATE VIEW my_orders AS SELECT id, code FROM orders WHERE tenant_id = current_tenant(); + +-- Materialized view whose index, not its query, calls a recreated function +CREATE MATERIALIZED VIEW order_codes AS SELECT id, code FROM orders; +CREATE INDEX order_codes_normalized_idx ON order_codes ((normalize_code(code))); + +-- Function with a return type change but no dependents +CREATE FUNCTION standalone() RETURNS bigint LANGUAGE sql IMMUTABLE AS $$ SELECT 1 $$; diff --git a/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/old.sql b/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/old.sql new file mode 100644 index 00000000..5a5d23e4 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/old.sql @@ -0,0 +1,43 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tenant_reader') THEN + CREATE ROLE tenant_reader; + END IF; +END $$; + +-- Functions whose return type or parameter names change (DROP + CREATE) +CREATE FUNCTION max_qty() RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT 100 $$; +CREATE FUNCTION normalize_code(code text) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT lower(code) $$; +CREATE FUNCTION current_tenant() RETURNS integer LANGUAGE sql STABLE AS $$ SELECT current_setting('app.tenant', true)::integer $$; +CREATE FUNCTION audit_threshold() RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT 10 $$; +CREATE FUNCTION default_priority() RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT 5 $$; + +CREATE FUNCTION mark_audited() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.audited := true; RETURN NEW; END $$; + +-- Unchanged table: column defaults, CHECK constraint, unique expression index +-- with a comment, partial index, policy and trigger WHEN calling them +CREATE TABLE orders ( + id integer PRIMARY KEY, + tenant_id integer NOT NULL DEFAULT current_tenant(), + code text NOT NULL, + qty integer NOT NULL, + priority integer DEFAULT default_priority(), + audited boolean DEFAULT false, + CONSTRAINT orders_qty_check CHECK (qty <= max_qty()) +); +CREATE UNIQUE INDEX orders_code_key ON orders ((normalize_code(code))); +COMMENT ON INDEX orders_code_key IS 'Case-insensitive order code'; +CREATE INDEX orders_large_idx ON orders (id) WHERE qty > max_qty() / 2; +ALTER TABLE orders ENABLE ROW LEVEL SECURITY; +CREATE POLICY orders_tenant ON orders TO tenant_reader USING (tenant_id = current_tenant()); +CREATE TRIGGER orders_audit BEFORE INSERT ON orders FOR EACH ROW WHEN (NEW.qty > audit_threshold()) EXECUTE FUNCTION mark_audited(); + +-- View calling a recreated function that table defaults call as well +CREATE VIEW my_orders AS SELECT id, code FROM orders WHERE tenant_id = current_tenant(); + +-- Materialized view whose index, not its query, calls a recreated function +CREATE MATERIALIZED VIEW order_codes AS SELECT id, code FROM orders; +CREATE INDEX order_codes_normalized_idx ON order_codes ((normalize_code(code))); + +-- Function with a return type change but no dependents +CREATE FUNCTION standalone() RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT 1 $$; diff --git a/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/plan.json b/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/plan.json new file mode 100644 index 00000000..75a189ba --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/plan.json @@ -0,0 +1,216 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.13.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "8792fb022bb6a32d3821a378a9070c708cc40ae435fe6a55ec2b7a7bec3c39c6" + }, + "groups": [ + { + "steps": [ + { + "sql": "DROP TRIGGER IF EXISTS orders_audit ON orders;", + "type": "table.trigger", + "operation": "recreate", + "path": "public.orders.orders_audit" + }, + { + "sql": "DROP POLICY IF EXISTS orders_tenant ON orders;", + "type": "table.policy", + "operation": "recreate", + "path": "public.orders.orders_tenant" + }, + { + "sql": "ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_qty_check;", + "type": "table.constraint", + "operation": "recreate", + "path": "public.orders.orders_qty_check" + }, + { + "sql": "DROP INDEX IF EXISTS orders_code_key;", + "type": "table.index", + "operation": "recreate", + "path": "public.orders.orders_code_key" + }, + { + "sql": "DROP INDEX IF EXISTS orders_large_idx;", + "type": "table.index", + "operation": "recreate", + "path": "public.orders.orders_large_idx" + }, + { + "sql": "ALTER TABLE orders ALTER COLUMN tenant_id DROP DEFAULT;", + "type": "table.column", + "operation": "alter", + "path": "public.orders.tenant_id" + }, + { + "sql": "ALTER TABLE orders ALTER COLUMN priority DROP DEFAULT;", + "type": "table.column", + "operation": "alter", + "path": "public.orders.priority" + }, + { + "sql": "DROP MATERIALIZED VIEW IF EXISTS order_codes RESTRICT;", + "type": "materialized_view", + "operation": "recreate", + "path": "public.order_codes" + }, + { + "sql": "DROP VIEW IF EXISTS my_orders RESTRICT;", + "type": "view", + "operation": "recreate", + "path": "public.my_orders" + }, + { + "sql": "DROP FUNCTION IF EXISTS current_tenant();", + "type": "function", + "operation": "alter", + "path": "public.current_tenant" + }, + { + "sql": "CREATE OR REPLACE FUNCTION current_tenant()\nRETURNS bigint\nLANGUAGE sql\nSTABLE\nAS $$ SELECT current_setting('app.tenant', true)::bigint\n$$;", + "type": "function", + "operation": "alter", + "path": "public.current_tenant" + }, + { + "sql": "DROP FUNCTION IF EXISTS normalize_code(text);", + "type": "function", + "operation": "alter", + "path": "public.normalize_code" + }, + { + "sql": "CREATE OR REPLACE FUNCTION normalize_code(\n input text\n)\nRETURNS text\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT lower(input)\n$$;", + "type": "function", + "operation": "alter", + "path": "public.normalize_code" + }, + { + "sql": "CREATE OR REPLACE VIEW my_orders AS\n SELECT id,\n code\n FROM orders\n WHERE tenant_id = current_tenant();", + "type": "view", + "operation": "create", + "path": "public.my_orders" + }, + { + "sql": "CREATE MATERIALIZED VIEW IF NOT EXISTS order_codes AS\n SELECT id,\n code\n FROM orders;", + "type": "materialized_view", + "operation": "create", + "path": "public.order_codes" + }, + { + "sql": "CREATE INDEX IF NOT EXISTS order_codes_normalized_idx ON order_codes (normalize_code(code));", + "type": "materialized_view.index", + "operation": "create", + "path": "public.order_codes.order_codes_normalized_idx" + }, + { + "sql": "DROP FUNCTION IF EXISTS audit_threshold();", + "type": "function", + "operation": "alter", + "path": "public.audit_threshold" + }, + { + "sql": "CREATE OR REPLACE FUNCTION audit_threshold()\nRETURNS bigint\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT 10\n$$;", + "type": "function", + "operation": "alter", + "path": "public.audit_threshold" + }, + { + "sql": "DROP FUNCTION IF EXISTS default_priority();", + "type": "function", + "operation": "alter", + "path": "public.default_priority" + }, + { + "sql": "CREATE OR REPLACE FUNCTION default_priority()\nRETURNS bigint\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT 5\n$$;", + "type": "function", + "operation": "alter", + "path": "public.default_priority" + }, + { + "sql": "DROP FUNCTION IF EXISTS max_qty();", + "type": "function", + "operation": "alter", + "path": "public.max_qty" + }, + { + "sql": "CREATE OR REPLACE FUNCTION max_qty()\nRETURNS bigint\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT 100\n$$;", + "type": "function", + "operation": "alter", + "path": "public.max_qty" + }, + { + "sql": "DROP FUNCTION IF EXISTS standalone();", + "type": "function", + "operation": "alter", + "path": "public.standalone" + }, + { + "sql": "CREATE OR REPLACE FUNCTION standalone()\nRETURNS bigint\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT 1\n$$;", + "type": "function", + "operation": "alter", + "path": "public.standalone" + }, + { + "sql": "ALTER TABLE orders ALTER COLUMN tenant_id SET DEFAULT current_tenant();", + "type": "table.column", + "operation": "alter", + "path": "public.orders.tenant_id" + }, + { + "sql": "ALTER TABLE orders ALTER COLUMN priority SET DEFAULT default_priority();", + "type": "table.column", + "operation": "alter", + "path": "public.orders.priority" + }, + { + "sql": "ALTER TABLE orders\nADD CONSTRAINT orders_qty_check CHECK (qty <= max_qty()) NOT VALID;", + "type": "table.constraint", + "operation": "create", + "path": "public.orders.orders_qty_check" + }, + { + "sql": "CREATE OR REPLACE TRIGGER orders_audit\n BEFORE INSERT ON orders\n FOR EACH ROW\n WHEN (((NEW.qty > audit_threshold())))\n EXECUTE FUNCTION mark_audited();", + "type": "table.trigger", + "operation": "create", + "path": "public.orders.orders_audit" + }, + { + "sql": "CREATE POLICY orders_tenant ON orders TO tenant_reader USING (tenant_id = current_tenant());", + "type": "table.policy", + "operation": "create", + "path": "public.orders.orders_tenant" + }, + { + "sql": "CREATE UNIQUE INDEX IF NOT EXISTS orders_code_key ON orders (normalize_code(code));", + "type": "table.index", + "operation": "create", + "path": "public.orders.orders_code_key" + }, + { + "sql": "COMMENT ON INDEX orders_code_key IS 'Case-insensitive order code';", + "type": "table.index.comment", + "operation": "create", + "path": "public.orders.orders_code_key" + }, + { + "sql": "CREATE INDEX IF NOT EXISTS orders_large_idx ON orders (id) WHERE qty > (max_qty() / 2);", + "type": "table.index", + "operation": "create", + "path": "public.orders.orders_large_idx" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE orders VALIDATE CONSTRAINT orders_qty_check;", + "type": "table.constraint", + "operation": "create", + "path": "public.orders.orders_qty_check" + } + ] + } + ] +} diff --git a/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/plan.sql b/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/plan.sql new file mode 100644 index 00000000..13449eab --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/plan.sql @@ -0,0 +1,109 @@ +DROP TRIGGER IF EXISTS orders_audit ON orders; + +DROP POLICY IF EXISTS orders_tenant ON orders; + +ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_qty_check; + +DROP INDEX IF EXISTS orders_code_key; + +DROP INDEX IF EXISTS orders_large_idx; + +ALTER TABLE orders ALTER COLUMN tenant_id DROP DEFAULT; + +ALTER TABLE orders ALTER COLUMN priority DROP DEFAULT; + +DROP MATERIALIZED VIEW IF EXISTS order_codes RESTRICT; + +DROP VIEW IF EXISTS my_orders RESTRICT; + +DROP FUNCTION IF EXISTS current_tenant(); + +CREATE OR REPLACE FUNCTION current_tenant() +RETURNS bigint +LANGUAGE sql +STABLE +AS $$ SELECT current_setting('app.tenant', true)::bigint +$$; + +DROP FUNCTION IF EXISTS normalize_code(text); + +CREATE OR REPLACE FUNCTION normalize_code( + input text +) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ SELECT lower(input) +$$; + +CREATE OR REPLACE VIEW my_orders AS + SELECT id, + code + FROM orders + WHERE tenant_id = current_tenant(); + +CREATE MATERIALIZED VIEW IF NOT EXISTS order_codes AS + SELECT id, + code + FROM orders; + +CREATE INDEX IF NOT EXISTS order_codes_normalized_idx ON order_codes (normalize_code(code)); + +DROP FUNCTION IF EXISTS audit_threshold(); + +CREATE OR REPLACE FUNCTION audit_threshold() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 10 +$$; + +DROP FUNCTION IF EXISTS default_priority(); + +CREATE OR REPLACE FUNCTION default_priority() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +DROP FUNCTION IF EXISTS max_qty(); + +CREATE OR REPLACE FUNCTION max_qty() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 100 +$$; + +DROP FUNCTION IF EXISTS standalone(); + +CREATE OR REPLACE FUNCTION standalone() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 1 +$$; + +ALTER TABLE orders ALTER COLUMN tenant_id SET DEFAULT current_tenant(); + +ALTER TABLE orders ALTER COLUMN priority SET DEFAULT default_priority(); + +ALTER TABLE orders +ADD CONSTRAINT orders_qty_check CHECK (qty <= max_qty()) NOT VALID; + +CREATE OR REPLACE TRIGGER orders_audit + BEFORE INSERT ON orders + FOR EACH ROW + WHEN (((NEW.qty > audit_threshold()))) + EXECUTE FUNCTION mark_audited(); + +CREATE POLICY orders_tenant ON orders TO tenant_reader USING (tenant_id = current_tenant()); + +CREATE UNIQUE INDEX IF NOT EXISTS orders_code_key ON orders (normalize_code(code)); + +COMMENT ON INDEX orders_code_key IS 'Case-insensitive order code'; + +CREATE INDEX IF NOT EXISTS orders_large_idx ON orders (id) WHERE qty > (max_qty() / 2); + +ALTER TABLE orders VALIDATE CONSTRAINT orders_qty_check; diff --git a/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/plan.txt b/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/plan.txt new file mode 100644 index 00000000..5d3dacfc --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_unchanged_dependents/plan.txt @@ -0,0 +1,153 @@ +Plan: 9 to modify. + +Summary by type: + functions: 6 to modify + tables: 1 to modify + views: 1 to modify + materialized views: 1 to modify + +Functions: + ~ audit_threshold + ~ current_tenant + ~ default_priority + ~ max_qty + ~ normalize_code + ~ standalone + +Tables: + ~ orders + ~ priority (column) + ~ tenant_id (column) + + orders_qty_check (constraint) + ~ orders_qty_check (constraint) + ~ orders_code_key (index) + + orders_code_key (index) + ~ orders_large_idx (index) + + orders_large_idx (index) + + orders_code_key (index.comment) + ~ orders_tenant (policy) + + orders_tenant (policy) + ~ orders_audit (trigger) + + orders_audit (trigger) + +Views: + ~ my_orders + +Materialized views: + ~ order_codes + + order_codes_normalized_idx (index) + +DDL to be executed: +-------------------------------------------------- + +-- Transaction Group #1 +DROP TRIGGER IF EXISTS orders_audit ON orders; + +DROP POLICY IF EXISTS orders_tenant ON orders; + +ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_qty_check; + +DROP INDEX IF EXISTS orders_code_key; + +DROP INDEX IF EXISTS orders_large_idx; + +ALTER TABLE orders ALTER COLUMN tenant_id DROP DEFAULT; + +ALTER TABLE orders ALTER COLUMN priority DROP DEFAULT; + +DROP MATERIALIZED VIEW IF EXISTS order_codes RESTRICT; + +DROP VIEW IF EXISTS my_orders RESTRICT; + +DROP FUNCTION IF EXISTS current_tenant(); + +CREATE OR REPLACE FUNCTION current_tenant() +RETURNS bigint +LANGUAGE sql +STABLE +AS $$ SELECT current_setting('app.tenant', true)::bigint +$$; + +DROP FUNCTION IF EXISTS normalize_code(text); + +CREATE OR REPLACE FUNCTION normalize_code( + input text +) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ SELECT lower(input) +$$; + +CREATE OR REPLACE VIEW my_orders AS + SELECT id, + code + FROM orders + WHERE tenant_id = current_tenant(); + +CREATE MATERIALIZED VIEW IF NOT EXISTS order_codes AS + SELECT id, + code + FROM orders; + +CREATE INDEX IF NOT EXISTS order_codes_normalized_idx ON order_codes (normalize_code(code)); + +DROP FUNCTION IF EXISTS audit_threshold(); + +CREATE OR REPLACE FUNCTION audit_threshold() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 10 +$$; + +DROP FUNCTION IF EXISTS default_priority(); + +CREATE OR REPLACE FUNCTION default_priority() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +DROP FUNCTION IF EXISTS max_qty(); + +CREATE OR REPLACE FUNCTION max_qty() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 100 +$$; + +DROP FUNCTION IF EXISTS standalone(); + +CREATE OR REPLACE FUNCTION standalone() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 1 +$$; + +ALTER TABLE orders ALTER COLUMN tenant_id SET DEFAULT current_tenant(); + +ALTER TABLE orders ALTER COLUMN priority SET DEFAULT default_priority(); + +ALTER TABLE orders +ADD CONSTRAINT orders_qty_check CHECK (qty <= max_qty()) NOT VALID; + +CREATE OR REPLACE TRIGGER orders_audit + BEFORE INSERT ON orders + FOR EACH ROW + WHEN (((NEW.qty > audit_threshold()))) + EXECUTE FUNCTION mark_audited(); + +CREATE POLICY orders_tenant ON orders TO tenant_reader USING (tenant_id = current_tenant()); + +CREATE UNIQUE INDEX IF NOT EXISTS orders_code_key ON orders (normalize_code(code)); + +COMMENT ON INDEX orders_code_key IS 'Case-insensitive order code'; + +CREATE INDEX IF NOT EXISTS orders_large_idx ON orders (id) WHERE qty > (max_qty() / 2); + +-- Transaction Group #2 +ALTER TABLE orders VALIDATE CONSTRAINT orders_qty_check; diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/diff.sql b/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/diff.sql new file mode 100644 index 00000000..b3ed15e4 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/diff.sql @@ -0,0 +1,23 @@ +ALTER TYPE ticket_state ADD VALUE 'pending' AFTER 'open'; + +ALTER TABLE tickets ALTER COLUMN state SET DEFAULT 'pending'::ticket_state; + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_priority_check; + +ALTER TABLE tickets ALTER COLUMN priority DROP DEFAULT; + +DROP FUNCTION IF EXISTS default_priority(); + +CREATE OR REPLACE FUNCTION default_priority() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +ALTER TABLE tickets ALTER COLUMN priority SET DEFAULT default_priority(); + +ALTER TABLE tickets +ADD CONSTRAINT tickets_priority_check CHECK (priority <= (default_priority() * 10)) NOT VALID; + +ALTER TABLE tickets VALIDATE CONSTRAINT tickets_priority_check; diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/new.sql b/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/new.sql new file mode 100644 index 00000000..f006ef1f --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/new.sql @@ -0,0 +1,13 @@ +-- Function whose return type changes (DROP + CREATE) +CREATE FUNCTION default_priority() RETURNS bigint LANGUAGE sql IMMUTABLE AS $$ SELECT 5 $$; + +-- Enum gaining a label: ADD VALUE commits right away, which must come before +-- the held drops, not between them and the restores +CREATE TYPE ticket_state AS ENUM ('open', 'pending', 'closed'); + +CREATE TABLE tickets ( + id integer PRIMARY KEY, + state ticket_state DEFAULT 'pending', + priority integer DEFAULT default_priority(), + CONSTRAINT tickets_priority_check CHECK (priority <= default_priority() * 10) +); diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/old.sql b/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/old.sql new file mode 100644 index 00000000..0c46c1a3 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/old.sql @@ -0,0 +1,13 @@ +-- Function whose return type changes (DROP + CREATE) +CREATE FUNCTION default_priority() RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT 5 $$; + +-- Enum gaining a label: ADD VALUE commits right away, which must come before +-- the held drops, not between them and the restores +CREATE TYPE ticket_state AS ENUM ('open', 'closed'); + +CREATE TABLE tickets ( + id integer PRIMARY KEY, + state ticket_state DEFAULT 'open', + priority integer DEFAULT default_priority(), + CONSTRAINT tickets_priority_check CHECK (priority <= default_priority() * 10) +); diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/plan.json b/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/plan.json new file mode 100644 index 00000000..f8d7e2df --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/plan.json @@ -0,0 +1,76 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.13.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "7ac709786f11de355879832efce9da01bc0732ab369ef694db3c8c882eaa2272" + }, + "groups": [ + { + "steps": [ + { + "sql": "ALTER TYPE ticket_state ADD VALUE 'pending' AFTER 'open';", + "type": "type", + "operation": "alter", + "path": "public.ticket_state" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE tickets ALTER COLUMN state SET DEFAULT 'pending'::ticket_state;", + "type": "table.column", + "operation": "alter", + "path": "public.tickets.state" + }, + { + "sql": "ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_priority_check;", + "type": "table.constraint", + "operation": "recreate", + "path": "public.tickets.tickets_priority_check" + }, + { + "sql": "ALTER TABLE tickets ALTER COLUMN priority DROP DEFAULT;", + "type": "table.column", + "operation": "alter", + "path": "public.tickets.priority" + }, + { + "sql": "DROP FUNCTION IF EXISTS default_priority();", + "type": "function", + "operation": "alter", + "path": "public.default_priority" + }, + { + "sql": "CREATE OR REPLACE FUNCTION default_priority()\nRETURNS bigint\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT 5\n$$;", + "type": "function", + "operation": "alter", + "path": "public.default_priority" + }, + { + "sql": "ALTER TABLE tickets ALTER COLUMN priority SET DEFAULT default_priority();", + "type": "table.column", + "operation": "alter", + "path": "public.tickets.priority" + }, + { + "sql": "ALTER TABLE tickets\nADD CONSTRAINT tickets_priority_check CHECK (priority <= (default_priority() * 10)) NOT VALID;", + "type": "table.constraint", + "operation": "create", + "path": "public.tickets.tickets_priority_check" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE tickets VALIDATE CONSTRAINT tickets_priority_check;", + "type": "table.constraint", + "operation": "create", + "path": "public.tickets.tickets_priority_check" + } + ] + } + ] +} diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/plan.sql b/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/plan.sql new file mode 100644 index 00000000..b3ed15e4 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/plan.sql @@ -0,0 +1,23 @@ +ALTER TYPE ticket_state ADD VALUE 'pending' AFTER 'open'; + +ALTER TABLE tickets ALTER COLUMN state SET DEFAULT 'pending'::ticket_state; + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_priority_check; + +ALTER TABLE tickets ALTER COLUMN priority DROP DEFAULT; + +DROP FUNCTION IF EXISTS default_priority(); + +CREATE OR REPLACE FUNCTION default_priority() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +ALTER TABLE tickets ALTER COLUMN priority SET DEFAULT default_priority(); + +ALTER TABLE tickets +ADD CONSTRAINT tickets_priority_check CHECK (priority <= (default_priority() * 10)) NOT VALID; + +ALTER TABLE tickets VALIDATE CONSTRAINT tickets_priority_check; diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/plan.txt b/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/plan.txt new file mode 100644 index 00000000..6adb293d --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_enum_value/plan.txt @@ -0,0 +1,49 @@ +Plan: 3 to modify. + +Summary by type: + types: 1 to modify + functions: 1 to modify + tables: 1 to modify + +Types: + ~ ticket_state + +Functions: + ~ default_priority + +Tables: + ~ tickets + ~ priority (column) + ~ state (column) + ~ tickets_priority_check (constraint) + + tickets_priority_check (constraint) + +DDL to be executed: +-------------------------------------------------- + +-- Transaction Group #1 +ALTER TYPE ticket_state ADD VALUE 'pending' AFTER 'open'; + +-- Transaction Group #2 +ALTER TABLE tickets ALTER COLUMN state SET DEFAULT 'pending'::ticket_state; + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_priority_check; + +ALTER TABLE tickets ALTER COLUMN priority DROP DEFAULT; + +DROP FUNCTION IF EXISTS default_priority(); + +CREATE OR REPLACE FUNCTION default_priority() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +ALTER TABLE tickets ALTER COLUMN priority SET DEFAULT default_priority(); + +ALTER TABLE tickets +ADD CONSTRAINT tickets_priority_check CHECK (priority <= (default_priority() * 10)) NOT VALID; + +-- Transaction Group #3 +ALTER TABLE tickets VALIDATE CONSTRAINT tickets_priority_check; diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/diff.sql b/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/diff.sql new file mode 100644 index 00000000..390034d3 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/diff.sql @@ -0,0 +1,18 @@ +DROP POLICY IF EXISTS documents_tenant ON documents; + +ALTER TABLE documents ALTER COLUMN tenant_id DROP DEFAULT; + +DROP FUNCTION IF EXISTS current_tenant(); + +CREATE OR REPLACE FUNCTION current_tenant() +RETURNS bigint +LANGUAGE sql +STABLE +AS $$ SELECT current_setting('app.tenant', true)::bigint +$$; + +ALTER TABLE documents ALTER COLUMN tenant_id SET DEFAULT current_tenant(); + +CREATE POLICY documents_tenant ON documents AS RESTRICTIVE TO tenant_reader USING (tenant_id = current_tenant()); + +CREATE INDEX IF NOT EXISTS document_titles_title_idx ON document_titles (title); diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/new.sql b/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/new.sql new file mode 100644 index 00000000..d8a52344 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/new.sql @@ -0,0 +1,24 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tenant_reader') THEN + CREATE ROLE tenant_reader; + END IF; +END $$; + +-- Function whose return type changes (DROP + CREATE) +CREATE FUNCTION current_tenant() RETURNS bigint LANGUAGE sql STABLE AS $$ SELECT current_setting('app.tenant', true)::bigint $$; + +-- Policy and default held around the recreation +CREATE TABLE documents ( + id integer PRIMARY KEY, + tenant_id integer NOT NULL DEFAULT current_tenant(), + title text +); +ALTER TABLE documents ENABLE ROW LEVEL SECURITY; +CREATE POLICY documents_tenant ON documents AS RESTRICTIVE TO tenant_reader USING (tenant_id = current_tenant()); +CREATE POLICY documents_read ON documents TO tenant_reader USING (true); + +-- Unrelated materialized view that gains an index (built CONCURRENTLY in its +-- own transaction): it must not split the drops from the restores +CREATE MATERIALIZED VIEW document_titles AS SELECT id, title FROM documents; +CREATE INDEX document_titles_title_idx ON document_titles (title); diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/old.sql b/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/old.sql new file mode 100644 index 00000000..ff021773 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/old.sql @@ -0,0 +1,23 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tenant_reader') THEN + CREATE ROLE tenant_reader; + END IF; +END $$; + +-- Function whose return type changes (DROP + CREATE) +CREATE FUNCTION current_tenant() RETURNS integer LANGUAGE sql STABLE AS $$ SELECT current_setting('app.tenant', true)::integer $$; + +-- Policy and default held around the recreation +CREATE TABLE documents ( + id integer PRIMARY KEY, + tenant_id integer NOT NULL DEFAULT current_tenant(), + title text +); +ALTER TABLE documents ENABLE ROW LEVEL SECURITY; +CREATE POLICY documents_tenant ON documents AS RESTRICTIVE TO tenant_reader USING (tenant_id = current_tenant()); +CREATE POLICY documents_read ON documents TO tenant_reader USING (true); + +-- Unrelated materialized view that gains an index (built CONCURRENTLY in its +-- own transaction): it must not split the drops from the restores +CREATE MATERIALIZED VIEW document_titles AS SELECT id, title FROM documents; diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/plan.json b/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/plan.json new file mode 100644 index 00000000..138d4044 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/plan.json @@ -0,0 +1,74 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.13.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "9c3572e76427259ba43e76dddfae381322763f26ebdc42eb53e67cc2943dfa18" + }, + "groups": [ + { + "steps": [ + { + "sql": "DROP POLICY IF EXISTS documents_tenant ON documents;", + "type": "table.policy", + "operation": "recreate", + "path": "public.documents.documents_tenant" + }, + { + "sql": "ALTER TABLE documents ALTER COLUMN tenant_id DROP DEFAULT;", + "type": "table.column", + "operation": "alter", + "path": "public.documents.tenant_id" + }, + { + "sql": "DROP FUNCTION IF EXISTS current_tenant();", + "type": "function", + "operation": "alter", + "path": "public.current_tenant" + }, + { + "sql": "CREATE OR REPLACE FUNCTION current_tenant()\nRETURNS bigint\nLANGUAGE sql\nSTABLE\nAS $$ SELECT current_setting('app.tenant', true)::bigint\n$$;", + "type": "function", + "operation": "alter", + "path": "public.current_tenant" + }, + { + "sql": "ALTER TABLE documents ALTER COLUMN tenant_id SET DEFAULT current_tenant();", + "type": "table.column", + "operation": "alter", + "path": "public.documents.tenant_id" + }, + { + "sql": "CREATE POLICY documents_tenant ON documents AS RESTRICTIVE TO tenant_reader USING (tenant_id = current_tenant());", + "type": "table.policy", + "operation": "create", + "path": "public.documents.documents_tenant" + } + ] + }, + { + "steps": [ + { + "sql": "CREATE INDEX CONCURRENTLY IF NOT EXISTS document_titles_title_idx ON document_titles (title);", + "type": "materialized_view.index", + "operation": "create", + "path": "public.document_titles.document_titles_title_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 = 'document_titles_title_idx';", + "directive": { + "type": "wait", + "message": "Creating index document_titles_title_idx" + }, + "type": "materialized_view.index", + "operation": "create", + "path": "public.document_titles.document_titles_title_idx" + } + ] + } + ] +} diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/plan.sql b/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/plan.sql new file mode 100644 index 00000000..815631b5 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/plan.sql @@ -0,0 +1,30 @@ +DROP POLICY IF EXISTS documents_tenant ON documents; + +ALTER TABLE documents ALTER COLUMN tenant_id DROP DEFAULT; + +DROP FUNCTION IF EXISTS current_tenant(); + +CREATE OR REPLACE FUNCTION current_tenant() +RETURNS bigint +LANGUAGE sql +STABLE +AS $$ SELECT current_setting('app.tenant', true)::bigint +$$; + +ALTER TABLE documents ALTER COLUMN tenant_id SET DEFAULT current_tenant(); + +CREATE POLICY documents_tenant ON documents AS RESTRICTIVE TO tenant_reader USING (tenant_id = current_tenant()); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS document_titles_title_idx ON document_titles (title); + +-- 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 = 'document_titles_title_idx'; diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/plan.txt b/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/plan.txt new file mode 100644 index 00000000..3be041b6 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_matview_index_change/plan.txt @@ -0,0 +1,56 @@ +Plan: 3 to modify. + +Summary by type: + functions: 1 to modify + tables: 1 to modify + materialized views: 1 to modify + +Functions: + ~ current_tenant + +Tables: + ~ documents + ~ tenant_id (column) + ~ documents_tenant (policy) + + documents_tenant (policy) + +Materialized views: + ~ document_titles + + document_titles_title_idx (index) + +DDL to be executed: +-------------------------------------------------- + +-- Transaction Group #1 +DROP POLICY IF EXISTS documents_tenant ON documents; + +ALTER TABLE documents ALTER COLUMN tenant_id DROP DEFAULT; + +DROP FUNCTION IF EXISTS current_tenant(); + +CREATE OR REPLACE FUNCTION current_tenant() +RETURNS bigint +LANGUAGE sql +STABLE +AS $$ SELECT current_setting('app.tenant', true)::bigint +$$; + +ALTER TABLE documents ALTER COLUMN tenant_id SET DEFAULT current_tenant(); + +CREATE POLICY documents_tenant ON documents AS RESTRICTIVE TO tenant_reader USING (tenant_id = current_tenant()); + +-- Transaction Group #2 +CREATE INDEX CONCURRENTLY IF NOT EXISTS document_titles_title_idx ON document_titles (title); + +-- Transaction Group #3 +-- 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 = 'document_titles_title_idx'; diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/diff.sql b/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/diff.sql new file mode 100644 index 00000000..de8aa4d6 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/diff.sql @@ -0,0 +1,16 @@ +CREATE INDEX IF NOT EXISTS notes_body_idx ON notes (body); + +ALTER TABLE tasks ALTER COLUMN weight SET NOT NULL; + +DROP FUNCTION IF EXISTS default_priority(); + +CREATE OR REPLACE FUNCTION default_priority() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +ALTER TABLE tasks ALTER COLUMN priority SET DEFAULT default_priority(); + +ALTER TABLE tasks ALTER COLUMN weight SET DEFAULT default_priority(); diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/new.sql b/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/new.sql new file mode 100644 index 00000000..3c667f92 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/new.sql @@ -0,0 +1,19 @@ +-- Function whose return type changes (DROP + CREATE) +CREATE FUNCTION default_priority() RETURNS bigint LANGUAGE sql IMMUTABLE AS $$ SELECT 5 $$; + +-- Columns whose new default calls the function: a default-only change on a +-- NOT NULL column, and a column that also becomes NOT NULL (an online +-- rewrite with a VALIDATE of its own). Another table gains an index built +-- CONCURRENTLY. The defaults must change only inside the function's +-- transaction, never before those online steps commit. +CREATE TABLE tasks ( + id integer PRIMARY KEY, + priority integer NOT NULL DEFAULT default_priority(), + weight integer NOT NULL DEFAULT default_priority() +); + +CREATE TABLE notes ( + id integer PRIMARY KEY, + body text +); +CREATE INDEX notes_body_idx ON notes (body); diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/old.sql b/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/old.sql new file mode 100644 index 00000000..fa4ca380 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/old.sql @@ -0,0 +1,18 @@ +-- Function whose return type changes (DROP + CREATE) +CREATE FUNCTION default_priority() RETURNS integer LANGUAGE sql IMMUTABLE AS $$ SELECT 5 $$; + +-- Columns whose new default calls the function: a default-only change on a +-- NOT NULL column, and a column that also becomes NOT NULL (an online +-- rewrite with a VALIDATE of its own). Another table gains an index built +-- CONCURRENTLY. The defaults must change only inside the function's +-- transaction, never before those online steps commit. +CREATE TABLE tasks ( + id integer PRIMARY KEY, + priority integer NOT NULL DEFAULT 0, + weight integer DEFAULT 1 +); + +CREATE TABLE notes ( + id integer PRIMARY KEY, + body text +); diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/plan.json b/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/plan.json new file mode 100644 index 00000000..8aa0f12d --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/plan.json @@ -0,0 +1,82 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.13.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "8f6ce19c9317e25b16dcead60288b519224ed9919d208ccce998b7c2f77f9521" + }, + "groups": [ + { + "steps": [ + { + "sql": "CREATE INDEX CONCURRENTLY IF NOT EXISTS notes_body_idx ON notes (body);", + "type": "table.index", + "operation": "create", + "path": "public.notes.notes_body_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 = 'notes_body_idx';", + "directive": { + "type": "wait", + "message": "Creating index notes_body_idx" + }, + "type": "table.index", + "operation": "create", + "path": "public.notes.notes_body_idx" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE tasks ADD CONSTRAINT tasks_weight_not_null NOT NULL weight NOT VALID;", + "type": "table.column", + "operation": "alter", + "path": "public.tasks.weight" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE tasks VALIDATE CONSTRAINT tasks_weight_not_null;", + "type": "table.column", + "operation": "alter", + "path": "public.tasks.weight" + } + ] + }, + { + "steps": [ + { + "sql": "DROP FUNCTION IF EXISTS default_priority();", + "type": "function", + "operation": "alter", + "path": "public.default_priority" + }, + { + "sql": "CREATE OR REPLACE FUNCTION default_priority()\nRETURNS bigint\nLANGUAGE sql\nIMMUTABLE\nAS $$ SELECT 5\n$$;", + "type": "function", + "operation": "alter", + "path": "public.default_priority" + }, + { + "sql": "ALTER TABLE tasks ALTER COLUMN priority SET DEFAULT default_priority();", + "type": "table.column", + "operation": "alter", + "path": "public.tasks.priority" + }, + { + "sql": "ALTER TABLE tasks ALTER COLUMN weight SET DEFAULT default_priority();", + "type": "table.column", + "operation": "alter", + "path": "public.tasks.weight" + } + ] + } + ] +} diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/plan.sql b/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/plan.sql new file mode 100644 index 00000000..bbf00ca2 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/plan.sql @@ -0,0 +1,30 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS notes_body_idx ON notes (body); + +-- 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 = 'notes_body_idx'; + +ALTER TABLE tasks ADD CONSTRAINT tasks_weight_not_null NOT NULL weight NOT VALID; + +ALTER TABLE tasks VALIDATE CONSTRAINT tasks_weight_not_null; + +DROP FUNCTION IF EXISTS default_priority(); + +CREATE OR REPLACE FUNCTION default_priority() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +ALTER TABLE tasks ALTER COLUMN priority SET DEFAULT default_priority(); + +ALTER TABLE tasks ALTER COLUMN weight SET DEFAULT default_priority(); diff --git a/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/plan.txt b/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/plan.txt new file mode 100644 index 00000000..fa5b06f1 --- /dev/null +++ b/testdata/diff/dependency/issue_601_function_recreate_with_table_online_steps/plan.txt @@ -0,0 +1,54 @@ +Plan: 3 to modify. + +Summary by type: + functions: 1 to modify + tables: 2 to modify + +Functions: + ~ default_priority + +Tables: + ~ notes + + notes_body_idx (index) + ~ tasks + ~ priority (column) + ~ weight (column) + +DDL to be executed: +-------------------------------------------------- + +-- Transaction Group #1 +CREATE INDEX CONCURRENTLY IF NOT EXISTS notes_body_idx ON notes (body); + +-- Transaction Group #2 +-- 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 = 'notes_body_idx'; + +-- Transaction Group #3 +ALTER TABLE tasks ADD CONSTRAINT tasks_weight_not_null NOT NULL weight NOT VALID; + +-- Transaction Group #4 +ALTER TABLE tasks VALIDATE CONSTRAINT tasks_weight_not_null; + +-- Transaction Group #5 +DROP FUNCTION IF EXISTS default_priority(); + +CREATE OR REPLACE FUNCTION default_priority() +RETURNS bigint +LANGUAGE sql +IMMUTABLE +AS $$ SELECT 5 +$$; + +ALTER TABLE tasks ALTER COLUMN priority SET DEFAULT default_priority(); + +ALTER TABLE tasks ALTER COLUMN weight SET DEFAULT default_priority(); diff --git a/testdata/diff/online/add_check_not_valid/diff.sql b/testdata/diff/online/add_check_not_valid/diff.sql new file mode 100644 index 00000000..5c526789 --- /dev/null +++ b/testdata/diff/online/add_check_not_valid/diff.sql @@ -0,0 +1,2 @@ +ALTER TABLE orders +ADD CONSTRAINT orders_amount_check CHECK (amount >= 0) NOT VALID; diff --git a/testdata/diff/online/add_check_not_valid/new.sql b/testdata/diff/online/add_check_not_valid/new.sql new file mode 100644 index 00000000..ab6bb959 --- /dev/null +++ b/testdata/diff/online/add_check_not_valid/new.sql @@ -0,0 +1,7 @@ +CREATE TABLE public.orders ( + id integer PRIMARY KEY, + amount integer +); + +-- Enforced for new rows only; existing rows stay unchecked +ALTER TABLE public.orders ADD CONSTRAINT orders_amount_check CHECK (amount >= 0) NOT VALID; diff --git a/testdata/diff/online/add_check_not_valid/old.sql b/testdata/diff/online/add_check_not_valid/old.sql new file mode 100644 index 00000000..d4a22b31 --- /dev/null +++ b/testdata/diff/online/add_check_not_valid/old.sql @@ -0,0 +1,7 @@ +CREATE TABLE public.orders ( + id integer PRIMARY KEY, + amount integer +); + +-- A legacy row the new constraint must not be validated against +INSERT INTO public.orders (id, amount) VALUES (1, -5); diff --git a/testdata/diff/online/add_check_not_valid/plan.json b/testdata/diff/online/add_check_not_valid/plan.json new file mode 100644 index 00000000..ec0f0292 --- /dev/null +++ b/testdata/diff/online/add_check_not_valid/plan.json @@ -0,0 +1,20 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.13.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "dbdb86e54e0cd7cab91492b48159e7049e9c045141ab050c705b1213a6c0e018" + }, + "groups": [ + { + "steps": [ + { + "sql": "ALTER TABLE orders\nADD CONSTRAINT orders_amount_check CHECK (amount >= 0) NOT VALID;", + "type": "table.constraint", + "operation": "create", + "path": "public.orders.orders_amount_check" + } + ] + } + ] +} diff --git a/testdata/diff/online/add_check_not_valid/plan.sql b/testdata/diff/online/add_check_not_valid/plan.sql new file mode 100644 index 00000000..5c526789 --- /dev/null +++ b/testdata/diff/online/add_check_not_valid/plan.sql @@ -0,0 +1,2 @@ +ALTER TABLE orders +ADD CONSTRAINT orders_amount_check CHECK (amount >= 0) NOT VALID; diff --git a/testdata/diff/online/add_check_not_valid/plan.txt b/testdata/diff/online/add_check_not_valid/plan.txt new file mode 100644 index 00000000..bc0660b0 --- /dev/null +++ b/testdata/diff/online/add_check_not_valid/plan.txt @@ -0,0 +1,14 @@ +Plan: 1 to modify. + +Summary by type: + tables: 1 to modify + +Tables: + ~ orders + + orders_amount_check (constraint) + +DDL to be executed: +-------------------------------------------------- + +ALTER TABLE orders +ADD CONSTRAINT orders_amount_check CHECK (amount >= 0) NOT VALID;