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;