diff --git a/.changeset/fix-partition-generated-expression.md b/.changeset/fix-partition-generated-expression.md new file mode 100644 index 000000000..c8410824f --- /dev/null +++ b/.changeset/fix-partition-generated-expression.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Preserve child-specific generated partition expressions during routine replacement. diff --git a/.changeset/green-pandas-repair.md b/.changeset/green-pandas-repair.md new file mode 100644 index 000000000..b6a0d68b7 --- /dev/null +++ b/.changeset/green-pandas-repair.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Refresh generated-column expression dependents more precisely during routine replacement, including covered column recreations, child-specific partition expressions, and publication row filters without explicit column lists. diff --git a/.changeset/yummy-jokes-sing.md b/.changeset/yummy-jokes-sing.md new file mode 100644 index 000000000..ab8de2c11 --- /dev/null +++ b/.changeset/yummy-jokes-sing.md @@ -0,0 +1,9 @@ +--- +"@supabase/pg-delta": patch +--- + +Recreate expression dependents directly during procedure replacement instead of replacing their owning table or domain. + +Also preserve retained column metadata for column recreations already covered by the original diff, restore defaults on the branch-side owner of replayed owned sequences, and keep domain/table owner restores after expression and metadata replay. + +Handle quoted-dot table stable IDs while restoring column defaults, and recognize truncated PostgreSQL 18 generated NOT NULL constraint names from catalog dependency edges. diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts index 6c1a09393..6d68ccbdd 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -2,10 +2,52 @@ import { describe, expect, test } from "bun:test"; import { Catalog, createEmptyCatalog } from "./catalog.model.ts"; import type { Change } from "./change.types.ts"; import { expandReplaceDependencies } from "./expand-replace-dependencies.ts"; +import { AlterAggregateChangeOwner } from "./objects/aggregate/changes/aggregate.alter.ts"; +import { CreateCommentOnAggregate } from "./objects/aggregate/changes/aggregate.comment.ts"; +import { CreateAggregate } from "./objects/aggregate/changes/aggregate.create.ts"; +import { DropAggregate } from "./objects/aggregate/changes/aggregate.drop.ts"; +import { GrantAggregatePrivileges } from "./objects/aggregate/changes/aggregate.privilege.ts"; +import { CreateSecurityLabelOnAggregate } from "./objects/aggregate/changes/aggregate.security-label.ts"; +import { Aggregate } from "./objects/aggregate/aggregate.model.ts"; import { DefaultPrivilegeState } from "./objects/base.default-privileges.ts"; +import { + AlterDomainAddConstraint, + AlterDomainDropConstraint, + AlterDomainDropDefault, + AlterDomainSetDefault, + AlterDomainValidateConstraint, +} from "./objects/domain/changes/domain.alter.ts"; +import { CreateDomain } from "./objects/domain/changes/domain.create.ts"; +import { DropDomain } from "./objects/domain/changes/domain.drop.ts"; +import { Domain } from "./objects/domain/domain.model.ts"; import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts"; import { DropProcedure } from "./objects/procedure/changes/procedure.drop.ts"; +import { AlterProcedureChangeOwner } from "./objects/procedure/changes/procedure.alter.ts"; +import { GrantProcedurePrivileges } from "./objects/procedure/changes/procedure.privilege.ts"; import { Procedure } from "./objects/procedure/procedure.model.ts"; +import { AlterIndexSetStatistics } from "./objects/index/changes/index.alter.ts"; +import { CreateCommentOnIndex } from "./objects/index/changes/index.comment.ts"; +import { CreateIndex } from "./objects/index/changes/index.create.ts"; +import { DropIndex } from "./objects/index/changes/index.drop.ts"; +import { Index, type IndexProps } from "./objects/index/index.model.ts"; +import { + AlterMaterializedViewChangeOwner, + AlterMaterializedViewClusterOn, +} from "./objects/materialized-view/changes/materialized-view.alter.ts"; +import { CreateMaterializedView } from "./objects/materialized-view/changes/materialized-view.create.ts"; +import { DropMaterializedView } from "./objects/materialized-view/changes/materialized-view.drop.ts"; +import { + MaterializedView, + type MaterializedViewProps, +} from "./objects/materialized-view/materialized-view.model.ts"; +import { + AlterPublicationAddTables, + AlterPublicationDropTables, + AlterPublicationSetOwner, +} from "./objects/publication/changes/publication.alter.ts"; +import { CreatePublication } from "./objects/publication/changes/publication.create.ts"; +import { DropPublication } from "./objects/publication/changes/publication.drop.ts"; +import { Publication } from "./objects/publication/publication.model.ts"; import { AlterRlsPolicySetUsingExpression, AlterRlsPolicySetWithCheckExpression, @@ -14,28 +56,54 @@ import { CreateCommentOnRlsPolicy } from "./objects/rls-policy/changes/rls-polic import { CreateRlsPolicy } from "./objects/rls-policy/changes/rls-policy.create.ts"; import { DropRlsPolicy } from "./objects/rls-policy/changes/rls-policy.drop.ts"; import { RlsPolicy } from "./objects/rls-policy/rls-policy.model.ts"; +import { CreateRule } from "./objects/rule/changes/rule.create.ts"; +import { DropRule } from "./objects/rule/changes/rule.drop.ts"; +import { Rule } from "./objects/rule/rule.model.ts"; +import { + AlterSequenceChangeOwner, + AlterSequenceSetOwnedBy, +} from "./objects/sequence/changes/sequence.alter.ts"; import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts"; import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts"; import { diffSequences } from "./objects/sequence/sequence.diff.ts"; import { Sequence } from "./objects/sequence/sequence.model.ts"; import { + AlterTableAddColumn, + AlterTableAddConstraint, + AlterTableAlterColumnDropDefault, AlterTableAlterColumnSetDefault, AlterTableChangeOwner, + AlterTableClusterOn, AlterTableDropColumn, AlterTableDropConstraint, AlterTableEnableRowLevelSecurity, + AlterTableForceRowLevelSecurity, AlterTableSetReplicaIdentity, + AlterTableValidateConstraint, } from "./objects/table/changes/table.alter.ts"; +import { + CreateCommentOnColumn, + CreateCommentOnTable, +} from "./objects/table/changes/table.comment.ts"; import { CreateTable } from "./objects/table/changes/table.create.ts"; import { DropTable } from "./objects/table/changes/table.drop.ts"; import { GrantTablePrivileges } from "./objects/table/changes/table.privilege.ts"; -import { Table } from "./objects/table/table.model.ts"; +import { + CreateSecurityLabelOnColumn, + CreateSecurityLabelOnTable, +} from "./objects/table/changes/table.security-label.ts"; +import { Table, type TableProps } from "./objects/table/table.model.ts"; +import { ReplaceTrigger } from "./objects/trigger/changes/trigger.alter.ts"; +import { CreateTrigger } from "./objects/trigger/changes/trigger.create.ts"; +import { DropTrigger } from "./objects/trigger/changes/trigger.drop.ts"; +import { Trigger } from "./objects/trigger/trigger.model.ts"; import { CreateEnum } from "./objects/type/enum/changes/enum.create.ts"; import { DropEnum } from "./objects/type/enum/changes/enum.drop.ts"; import { Enum } from "./objects/type/enum/enum.model.ts"; import { CreateView } from "./objects/view/changes/view.create.ts"; import { DropView } from "./objects/view/changes/view.drop.ts"; import { View } from "./objects/view/view.model.ts"; +import { sortChanges } from "./sort/sort-changes.ts"; function mockChange(overrides: { creates?: string[]; @@ -72,6 +140,568 @@ function mockInvalidatingChange(invalidates: string[]): Change { } as unknown as Change; } +function procedureWithArgs( + argumentTypes: string[], + name = "normalize_value", +): Procedure { + return new Procedure({ + schema: "public", + name, + kind: "f", + return_type: "integer", + return_type_schema: "pg_catalog", + language: "sql", + security_definer: false, + volatility: "i", + parallel_safety: "u", + execution_cost: 100, + result_rows: 0, + is_strict: false, + leakproof: false, + returns_set: false, + argument_count: argumentTypes.length, + argument_default_count: 0, + argument_names: argumentTypes.map((_, index) => `arg${index + 1}`), + argument_types: argumentTypes, + all_argument_types: null, + argument_modes: null, + argument_defaults: null, + source_code: "SELECT 1", + binary_path: null, + sql_body: null, + config: null, + definition: "CREATE FUNCTION public.normalize_value(...) RETURNS integer", + owner: "postgres", + comment: null, + privileges: [], + }); +} + +function tableWithDefault( + columnDefault: string | null, + columnOverrides: Partial = {}, +): Table { + return new Table({ + schema: "public", + name: "items", + persistence: "p", + row_security: false, + force_row_security: false, + has_indexes: false, + has_rules: false, + has_triggers: false, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + options: null, + partition_bound: null, + partition_by: null, + owner: "postgres", + comment: null, + parent_schema: null, + parent_name: null, + columns: [ + { + name: "value", + position: 1, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: columnDefault, + comment: null, + ...columnOverrides, + }, + ], + privileges: [], + }); +} + +function tableNamedWithDefault( + name: string, + columnDefault: string | null, + columnOverrides: Partial = {}, +): Table { + return new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault(columnDefault, columnOverrides), + name, + }); +} + +function partitionTableWithDefault( + columnDefault: string | null, + columnOverrides: Partial = {}, +): Table { + const table = tableWithDefault(columnDefault, columnOverrides); + return new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...table, + name: "items_2026", + is_partition: true, + partition_bound: "FOR VALUES FROM (2026) TO (2027)", + parent_schema: "public", + parent_name: "items", + }); +} + +function domainWithDefault(defaultValue: string | null): Domain { + return new Domain({ + schema: "public", + name: "item_value", + base_type: "int4", + base_type_schema: "pg_catalog", + base_type_str: "integer", + not_null: false, + type_modifier: null, + array_dimensions: null, + collation: null, + default_bin: defaultValue, + default_value: defaultValue, + owner: "postgres", + comment: null, + constraints: [], + privileges: [], + }); +} + +function domainWithConstraint( + checkExpression: string, + constraintOverrides: Partial = {}, +): Domain { + return new Domain({ + schema: "public", + name: "item_value", + base_type: "int4", + base_type_schema: "pg_catalog", + base_type_str: "integer", + not_null: false, + type_modifier: null, + array_dimensions: null, + collation: null, + default_bin: null, + default_value: null, + owner: "postgres", + comment: null, + constraints: [ + { + name: "item_value_check", + validated: true, + is_local: true, + no_inherit: false, + check_expression: checkExpression, + ...constraintOverrides, + }, + ], + privileges: [], + }); +} + +function tableWithCheckConstraint( + checkExpression: string, + constraintOverrides: Partial< + NonNullable[number] + > = {}, +): Table { + const table = tableWithDefault(null); + return new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...table, + constraints: [ + { + name: "items_value_check", + constraint_type: "c", + deferrable: false, + initially_deferred: false, + validated: true, + is_local: true, + no_inherit: false, + is_temporal: false, + is_partition_clone: false, + parent_constraint_schema: null, + parent_constraint_name: null, + parent_table_schema: null, + parent_table_name: null, + key_columns: ["value"], + foreign_key_columns: null, + foreign_key_table: null, + foreign_key_schema: null, + foreign_key_table_is_partition: null, + foreign_key_parent_schema: null, + foreign_key_parent_table: null, + foreign_key_effective_schema: null, + foreign_key_effective_table: null, + on_update: null, + on_delete: null, + match_type: null, + check_expression: checkExpression, + owner: "postgres", + definition: `CHECK (${checkExpression})${ + constraintOverrides.validated === false ? " NOT VALID" : "" + }`, + comment: null, + ...constraintOverrides, + }, + ], + }); +} + +function tableWithUniqueConstraint( + tableOverrides: Partial = {}, + constraintOverrides: Partial< + NonNullable[number] + > = {}, +): Table { + const table = tableWithDefault("public.normalize_value(value)"); + return new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...table, + constraints: [ + { + name: "items_value_key", + constraint_type: "u", + deferrable: false, + initially_deferred: false, + validated: true, + is_local: true, + no_inherit: false, + is_temporal: false, + is_partition_clone: false, + parent_constraint_schema: null, + parent_constraint_name: null, + parent_table_schema: null, + parent_table_name: null, + key_columns: ["value"], + foreign_key_columns: null, + foreign_key_table: null, + foreign_key_schema: null, + foreign_key_table_is_partition: null, + foreign_key_parent_schema: null, + foreign_key_parent_table: null, + foreign_key_effective_schema: null, + foreign_key_effective_table: null, + on_update: null, + on_delete: null, + match_type: null, + check_expression: null, + owner: "postgres", + definition: "UNIQUE (value)", + comment: null, + ...constraintOverrides, + }, + ], + ...tableOverrides, + }); +} + +function indexOnItemsValue(overrides: Partial = {}): Index { + return new Index({ + schema: "public", + table_name: "items", + name: "items_value_idx", + storage_params: [], + statistics_target: [], + index_type: "btree", + tablespace: null, + is_unique: false, + is_primary: false, + is_exclusion: false, + nulls_not_distinct: false, + immediate: true, + is_clustered: false, + is_replica_identity: false, + key_columns: [], + column_collations: [], + operator_classes: [], + column_options: [], + index_expressions: "value", + partial_predicate: null, + is_owned_by_constraint: false, + table_relkind: "r", + is_partitioned_index: false, + is_index_partition: false, + parent_index_name: null, + definition: "CREATE INDEX items_value_idx ON public.items (value)", + comment: null, + owner: "postgres", + ...overrides, + }); +} + +function constraintBackedIndexOnItemsValue( + overrides: Partial = {}, +): Index { + return indexOnItemsValue({ + name: "items_value_key", + is_unique: true, + is_owned_by_constraint: true, + key_columns: [1], + index_expressions: null, + definition: "CREATE UNIQUE INDEX items_value_key ON public.items (value)", + ...overrides, + }); +} + +function sequenceOwnedByItemsValue(): Sequence { + return new Sequence({ + schema: "public", + name: "items_value_seq", + data_type: "bigint", + start_value: 1, + minimum_value: BigInt(1), + maximum_value: BigInt("9223372036854775807"), + increment: 1, + cycle_option: false, + cache_size: 1, + persistence: "p", + owned_by_schema: "public", + owned_by_table: "items", + owned_by_column: "value", + comment: null, + privileges: [], + owner: "postgres", + security_labels: [], + }); +} + +function viewOnItemsValue(): View { + return new View({ + schema: "public", + name: "items_value_view", + definition: " SELECT value FROM public.items;", + row_security: false, + force_row_security: false, + has_indexes: false, + has_rules: false, + has_triggers: false, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + partition_bound: null, + options: null, + owner: "postgres", + comment: null, + columns: [ + { + name: "value", + position: 1, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ], + privileges: [], + }); +} + +function materializedViewOnItemsValue( + overrides: Partial = {}, +): MaterializedView { + return new MaterializedView({ + schema: "public", + name: "items_value_mv", + definition: " SELECT value FROM public.items;", + row_security: false, + force_row_security: false, + has_indexes: false, + has_rules: false, + has_triggers: false, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + partition_bound: null, + options: null, + owner: "postgres", + comment: null, + columns: [ + { + name: "value", + position: 1, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ], + privileges: [], + ...overrides, + }); +} + +function aggregateWithArgs( + argumentTypes: string[], + overrides: Partial[0]> = {}, +): Aggregate { + return new Aggregate({ + schema: "public", + name: "total_value", + identity_arguments: argumentTypes.join(", "), + kind: "a", + aggkind: "n", + num_direct_args: 0, + return_type: "integer", + return_type_schema: "pg_catalog", + parallel_safety: "u", + is_strict: false, + transition_function: "public.sum_state", + state_data_type: "integer", + state_data_type_schema: "pg_catalog", + state_data_space: 0, + final_function: null, + final_function_extra_args: false, + final_function_modify: null, + combine_function: null, + serial_function: null, + deserial_function: null, + initial_condition: null, + moving_transition_function: null, + moving_inverse_function: null, + moving_state_data_type: null, + moving_state_data_type_schema: null, + moving_state_data_space: null, + moving_final_function: null, + moving_final_function_extra_args: false, + moving_final_function_modify: null, + moving_initial_condition: null, + sort_operator: null, + argument_count: argumentTypes.length, + argument_default_count: 0, + argument_names: null, + argument_types: argumentTypes, + all_argument_types: null, + argument_modes: null, + argument_defaults: null, + owner: "postgres", + comment: null, + privileges: [], + ...overrides, + }); +} + +function triggerOnItemsValue(overrides: Partial = {}): Trigger { + return new Trigger({ + schema: "public", + name: "items_value_trigger", + table_name: "items", + table_relkind: "r", + function_schema: "public", + function_name: "touch_value", + trigger_type: 16, + enabled: "O", + is_internal: false, + deferrable: false, + initially_deferred: false, + argument_count: 0, + column_numbers: [1], + arguments: [], + when_condition: null, + old_table: null, + new_table: null, + is_partition_clone: false, + parent_trigger_name: null, + parent_table_schema: null, + parent_table_name: null, + is_on_partitioned_table: false, + owner: "postgres", + definition: + "CREATE TRIGGER items_value_trigger AFTER UPDATE OF value ON public.items FOR EACH ROW EXECUTE FUNCTION public.touch_value()", + comment: null, + ...overrides, + }); +} + +function ruleOnItemsValue(overrides: Partial = {}): Rule { + return new Rule({ + schema: "public", + name: "items_value_rule", + table_name: "items", + relation_kind: "r", + event: "UPDATE", + enabled: "O", + is_instead: false, + owner: "postgres", + definition: + "CREATE RULE items_value_rule AS ON UPDATE TO public.items DO ALSO SELECT new.value", + comment: null, + columns: ["value"], + ...overrides, + }); +} + +function publicationOnItemsValue(rowFilter: string | null): Publication { + return new Publication({ + name: "items_pub", + owner: "postgres", + comment: null, + all_tables: false, + publish_insert: true, + publish_update: true, + publish_delete: true, + publish_truncate: true, + publish_via_partition_root: false, + tables: [ + { + schema: "public", + name: "items", + columns: null, + row_filter: rowFilter, + }, + ], + schemas: [], + }); +} + +function publicationOnTables( + tables: Publication["tables"], + name = "items_pub", +): Publication { + return new Publication({ + name, + owner: "postgres", + comment: null, + all_tables: false, + publish_insert: true, + publish_update: true, + publish_delete: true, + publish_truncate: true, + publish_via_partition_root: false, + tables, + schemas: [], + }); +} + describe("expandReplaceDependencies", () => { test("returns changes unchanged when there are no replace roots", async () => { const catalog = await createEmptyCatalog(160004, "u"); @@ -977,4 +1607,6464 @@ describe("expandReplaceDependencies", () => { expect(expanded.changes).not.toContain(alterUsing); expect(expanded.changes).not.toContain(alterWithCheck); }); + + test("does not replace a table when a procedure signature replacement is covered by a column default alter", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithDefault("public.normalize_value(1)"); + const branchTable = tableWithDefault("public.normalize_value((1)::bigint)"); + const setDefault = new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchTable.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + setDefault, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(setDefault); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toBe(true); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + + test("synthesizes a column default replacement for an unchanged expression that depends on a replaced procedure", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(1)"); + const branchTable = tableWithDefault("public.normalize_value(1)"); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toBe(true); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("synthesizes column default replacement for quoted-dot table stable ids", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableNamedWithDefault( + '"a.b"', + "public.normalize_value(1)", + ); + const branchTable = tableNamedWithDefault( + '"a.b"', + "public.normalize_value(1)", + ); + const columnId = 'column:public."a.b".value'; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("does not replace a table when a replaced procedure only drops a dependent column default", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(1)"); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainTable, + columns: [], + }); + const dropColumn = new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0] as TableProps["columns"][number], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + dropColumn, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(dropColumn); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toBe(false); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + + test("does not count unrelated constraint column requirements as column default restore coverage", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(1)"); + const branchTable = tableWithDefault("public.normalize_value(1)"); + const referencedTable = tableNamedWithDefault("parents", null, { + name: "id", + not_null: true, + }); + const addForeignKey = new AlterTableAddConstraint({ + table: branchTable, + constraint: { + name: "items_value_fkey", + constraint_type: "f", + deferrable: false, + initially_deferred: false, + validated: true, + is_local: true, + no_inherit: false, + is_temporal: false, + is_partition_clone: false, + parent_constraint_schema: null, + parent_constraint_name: null, + parent_table_schema: null, + parent_table_name: null, + key_columns: ["value"], + foreign_key_columns: ["id"], + foreign_key_table: "parents", + foreign_key_schema: "public", + foreign_key_table_is_partition: false, + foreign_key_parent_schema: null, + foreign_key_parent_table: null, + foreign_key_effective_schema: "public", + foreign_key_effective_table: "parents", + on_update: "a", + on_delete: "a", + match_type: "s", + check_expression: null, + owner: "postgres", + definition: "FOREIGN KEY (value) REFERENCES public.parents(id)", + comment: null, + }, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + addForeignKey, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { + [mainTable.stableId]: mainTable, + [referencedTable.stableId]: referencedTable, + }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { + [branchTable.stableId]: branchTable, + [referencedTable.stableId]: referencedTable, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(addForeignKey); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("synthesizes a generated column fallback before PostgreSQL 17 for an unchanged expression that depends on a same-signature procedure replacement", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("skips child generated column fallback when parent recreation covers inherited partition expressions", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainParentTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchParentTable = tableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const mainPartition = partitionTableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const branchPartition = partitionTableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const columnId = "column:public.items_2026.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainParentTable, + column: mainParentTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchParentTable, + column: branchParentTable.columns[0], + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { + [mainParentTable.stableId]: mainParentTable, + [mainPartition.stableId]: mainPartition, + }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { + [branchParentTable.stableId]: branchParentTable, + [branchPartition.stableId]: branchPartition, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableDropColumn && + change.table.name === "items_2026", + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAddColumn && + change.table.name === "items_2026", + ), + ).toHaveLength(0); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("restores child-specific generated partition expression despite parent dependency coverage", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainParentTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchParentTable = tableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const mainPartition = partitionTableWithDefault( + "public.normalize_value(value + 1)", + { + is_generated: true, + }, + ); + const branchPartition = partitionTableWithDefault( + "public.normalize_value(value + 1)", + { + is_generated: true, + }, + ); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainParentTable, + column: mainParentTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchParentTable, + column: branchParentTable.columns[0], + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { + [mainParentTable.stableId]: mainParentTable, + [mainPartition.stableId]: mainPartition, + }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items_2026.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { + [branchParentTable.stableId]: branchParentTable, + [branchPartition.stableId]: branchPartition, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableDropColumn && + change.table.name === "items_2026", + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAddColumn && + change.table.name === "items_2026", + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAlterColumnSetDefault && + change.table.name === "items_2026", + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("skips child column DDL when partition-propagated drops remove inherited columns", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = partitionTableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...partitionTableWithDefault(null), + columns: [], + }); + const columnId = "column:public.items_2026.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(0); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("releases a local partition column default that depends on a replaced procedure", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = partitionTableWithDefault("public.normalize_value(1)"); + const branchTable = partitionTableWithDefault("public.normalize_value(1)"); + const columnId = "column:public.items_2026.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + + test("does not count child partition default changes as parent default restore coverage", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainParentTable = tableWithDefault("public.normalize_value(1)"); + const branchParentTable = tableWithDefault("public.normalize_value(1)"); + const mainPartition = partitionTableWithDefault( + "public.normalize_value(2)", + ); + const branchPartition = partitionTableWithDefault( + "public.normalize_value(3)", + ); + const childSetDefault = new AlterTableAlterColumnSetDefault({ + table: branchPartition, + column: branchPartition.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + childSetDefault, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { + [mainParentTable.stableId]: mainParentTable, + [mainPartition.stableId]: mainPartition, + }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items_2026.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { + [branchParentTable.stableId]: branchParentTable, + [branchPartition.stableId]: branchPartition, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(childSetDefault); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAlterColumnDropDefault && + change.table.name === "items", + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAlterColumnSetDefault && + change.table.name === "items", + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAlterColumnSetDefault && + change.table.name === "items_2026", + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + + test("does not count generated SET EXPRESSION as release coverage for same-signature procedure replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value + 1)", { + is_generated: true, + }); + const setExpression = new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchTable.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + setExpression, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect(expanded.changes).not.toContain(setExpression); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("traverses retained dependents when generated column recreation is already covered", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainIndex = indexOnItemsValue(); + const branchIndex = indexOnItemsValue(); + const columnId = "column:public.items.value"; + const preExistingDropColumn = new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }); + const preExistingAddColumn = new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + preExistingDropColumn, + preExistingAddColumn, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainIndex.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes).toContain(preExistingDropColumn); + expect(expanded.changes).toContain(preExistingAddColumn); + expect( + expanded.changes.filter((change) => change instanceof DropIndex), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateIndex), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + + test("traverses retained dependents when a regular column is recreated as generated", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)"); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainIndex = indexOnItemsValue(); + const branchIndex = indexOnItemsValue(); + const columnId = "column:public.items.value"; + const preExistingDropColumn = new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }); + const preExistingAddColumn = new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + preExistingDropColumn, + preExistingAddColumn, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainIndex.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes).toContain(preExistingDropColumn); + expect(expanded.changes).toContain(preExistingAddColumn); + expect( + expanded.changes.filter((change) => change instanceof DropIndex), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateIndex), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + + test("traverses retained view, trigger, and rule dependents when a generated column is recreated as regular", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)"); + const mainView = viewOnItemsValue(); + const branchView = viewOnItemsValue(); + const mainTrigger = triggerOnItemsValue(); + const branchTrigger = triggerOnItemsValue(); + const mainRule = ruleOnItemsValue(); + const branchRule = ruleOnItemsValue(); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + views: { [mainView.stableId]: mainView }, + triggers: { [mainTrigger.stableId]: mainTrigger }, + rules: { [mainRule.stableId]: mainRule }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainView.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainTrigger.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainRule.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + views: { [branchView.stableId]: branchView }, + triggers: { [branchTrigger.stableId]: branchTrigger }, + rules: { [branchRule.stableId]: branchRule }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter((change) => change instanceof DropView), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateView), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof DropTrigger), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateTrigger), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof DropRule), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateRule), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + + test("traverses retained view, trigger, and rule dependents when generated column recreation is already covered", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainView = viewOnItemsValue(); + const branchView = viewOnItemsValue(); + const mainTrigger = triggerOnItemsValue(); + const branchTrigger = triggerOnItemsValue(); + const mainRule = ruleOnItemsValue(); + const branchRule = ruleOnItemsValue(); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + views: { [mainView.stableId]: mainView }, + triggers: { [mainTrigger.stableId]: mainTrigger }, + rules: { [mainRule.stableId]: mainRule }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainView.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainTrigger.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainRule.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + views: { [branchView.stableId]: branchView }, + triggers: { [branchTrigger.stableId]: branchTrigger }, + rules: { [branchRule.stableId]: branchRule }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter((change) => change instanceof DropView), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateView), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof DropTrigger), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateTrigger), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof DropRule), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateRule), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + + test("does not promote the owning table while traversing covered generated column recreation", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainIndex = indexOnItemsValue(); + const branchIndex = indexOnItemsValue(); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainIndex.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: columnId, + deptype: "a", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter((change) => change instanceof DropIndex), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateIndex), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("does not promote the owning table for truncated generated not null constraints", async () => { + const baseline = await createEmptyCatalog(180000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + not_null: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + not_null: true, + }); + const columnId = "column:public.items.value"; + const truncatedNotNullConstraintId = + "constraint:public.items.items_value_not_nul"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: truncatedNotNullConstraintId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 180000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + + test("promotes retained dependents of a recreated generated column", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithCheckConstraint("value >= 0", { + name: "items_value_nonnegative", + definition: "CHECK (value >= 0)", + }); + const branchTable = tableWithCheckConstraint("value >= 0", { + name: "items_value_nonnegative", + definition: "CHECK (value >= 0)", + }); + const generatedColumn = { + is_generated: true, + default: "public.normalize_value(value)", + }; + mainTable.columns[0] = { + ...mainTable.columns[0], + ...generatedColumn, + }; + branchTable.columns[0] = { + ...branchTable.columns[0], + ...generatedColumn, + }; + const retainedExpressionIndex = { + index_expressions: "value + 1", + definition: "CREATE INDEX items_value_idx ON public.items ((value + 1))", + statistics_target: [100], + }; + const mainIndex = indexOnItemsValue({ + comment: "retained index comment", + ...retainedExpressionIndex, + }); + const branchIndex = indexOnItemsValue({ + comment: "retained index comment", + ...retainedExpressionIndex, + }); + const mainView = viewOnItemsValue(); + const branchView = viewOnItemsValue(); + const columnId = "column:public.items.value"; + const constraintId = "constraint:public.items.items_value_nonnegative"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + views: { [mainView.stableId]: mainView }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainIndex.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainView.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: constraintId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + views: { [branchView.stableId]: branchView }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof DropIndex), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateIndex), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof CreateCommentOnIndex, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterIndexSetStatistics, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof DropView), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateView), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddConstraint, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("restores retained comments without replacing the table when walking a recreated generated column", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + comment: "computed value", + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + comment: "computed value", + }); + const columnId = "column:public.items.value"; + const commentId = `comment:${columnId}`; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: commentId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof CreateCommentOnColumn, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + + test("restores retained column grants without replacing the table when recreating a generated column", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const retainedPrivileges = [ + { + grantee: "value_reader", + privilege: "SELECT", + grantable: false, + columns: ["value"], + }, + ]; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }), + privileges: retainedPrivileges, + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }), + privileges: retainedPrivileges, + }); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof GrantTablePrivileges, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + + test("replays retained grants only for covered generated column recreation", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const retainedPrivileges = [ + { + grantee: "value_reader", + privilege: "SELECT", + grantable: false, + columns: ["value"], + }, + { + grantee: "other_reader", + privilege: "SELECT", + grantable: false, + columns: ["other_value"], + }, + ]; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }), + columns: [ + ...tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }).columns, + { + name: "other_value", + position: 2, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ], + privileges: retainedPrivileges, + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainTable, + privileges: retainedPrivileges, + }); + const valueColumn = mainTable.columns[0]; + const branchValueColumn = branchTable.columns[0]; + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainTable, + column: valueColumn, + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchValueColumn, + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const grants = expanded.changes.filter( + (change): change is GrantTablePrivileges => + change instanceof GrantTablePrivileges, + ); + + expect(grants).toHaveLength(1); + expect(grants[0]?.grantee).toBe("value_reader"); + expect(grants[0]?.columns).toEqual(["value"]); + }); + + test("replays retained grants for covered regular-to-generated column recreation", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const retainedPrivileges = [ + { + grantee: "value_reader", + privilege: "SELECT", + grantable: false, + columns: ["value"], + }, + ]; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault("public.normalize_value(value)"), + privileges: retainedPrivileges, + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }), + privileges: retainedPrivileges, + }); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const grants = expanded.changes.filter( + (change): change is GrantTablePrivileges => + change instanceof GrantTablePrivileges, + ); + + expect(grants).toHaveLength(1); + expect(grants[0]?.grantee).toBe("value_reader"); + expect(grants[0]?.columns).toEqual(["value"]); + }); + + test("restores retained comments for covered regular-to-generated column recreation", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + comment: "computed value", + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + comment: "computed value", + }); + const columnId = "column:public.items.value"; + const commentId = `comment:${columnId}`; + const preExistingDropColumn = new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }); + const preExistingAddColumn = new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + preExistingDropColumn, + preExistingAddColumn, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: commentId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes).toContain(preExistingDropColumn); + expect(expanded.changes).toContain(preExistingAddColumn); + expect( + expanded.changes.filter( + (change) => change instanceof CreateCommentOnColumn, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + + test("restores retained security labels without replacing the table when recreating a generated column", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const securityLabels = [{ provider: "dummy", label: "classified" }]; + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + security_labels: securityLabels, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + security_labels: securityLabels, + }); + const columnId = "column:public.items.value"; + const securityLabelId = + "securityLabel:column:public.items.value::provider:dummy"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: securityLabelId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof CreateSecurityLabelOnColumn, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + + test("restores retained metadata when generated column recreation is already covered", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const securityLabels = [{ provider: "dummy", label: "classified" }]; + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + comment: "computed value", + security_labels: securityLabels, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + comment: "computed value", + security_labels: securityLabels, + }); + const columnId = "column:public.items.value"; + const commentId = `comment:${columnId}`; + const securityLabelId = + "securityLabel:column:public.items.value::provider:dummy"; + const preExistingDropColumn = new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }); + const preExistingAddColumn = new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + preExistingDropColumn, + preExistingAddColumn, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: commentId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: securityLabelId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes).toContain(preExistingDropColumn); + expect(expanded.changes).toContain(preExistingAddColumn); + expect( + expanded.changes.filter( + (change) => change instanceof CreateCommentOnColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof CreateSecurityLabelOnColumn, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + + test("replays retained index, trigger, and rule metadata after generated column fallback", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainIndex = indexOnItemsValue({ is_clustered: true }); + const branchIndex = indexOnItemsValue({ is_clustered: true }); + const mainTrigger = triggerOnItemsValue({ enabled: "D" }); + const branchTrigger = triggerOnItemsValue({ enabled: "D" }); + const mainRule = ruleOnItemsValue({ enabled: "R" }); + const branchRule = ruleOnItemsValue({ enabled: "R" }); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + triggers: { [mainTrigger.stableId]: mainTrigger }, + rules: { [mainRule.stableId]: mainRule }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainIndex.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainTrigger.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainRule.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + triggers: { [branchTrigger.stableId]: branchTrigger }, + rules: { [branchRule.stableId]: branchRule }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect(serialized).toContain( + "ALTER TABLE public.items CLUSTER ON items_value_idx", + ); + expect(serialized).toContain( + "ALTER TABLE public.items DISABLE TRIGGER items_value_trigger", + ); + expect(serialized).toContain( + "ALTER TABLE public.items ENABLE REPLICA RULE items_value_rule", + ); + }); + + test("restores clustered indexes on promoted materialized views", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainMaterializedView = materializedViewOnItemsValue({ + definition: + " SELECT public.normalize_value(items.value) AS value FROM public.items;", + }); + const branchMaterializedView = materializedViewOnItemsValue({ + definition: + " SELECT public.normalize_value(items.value) AS value FROM public.items;", + }); + const mainIndex = indexOnItemsValue({ + table_name: "items_value_mv", + name: "items_value_mv_value_idx", + table_relkind: "m", + is_clustered: true, + definition: + "CREATE INDEX items_value_mv_value_idx ON public.items_value_mv (value)", + }); + const branchIndex = indexOnItemsValue({ + table_name: "items_value_mv", + name: "items_value_mv_value_idx", + table_relkind: "m", + is_clustered: true, + definition: + "CREATE INDEX items_value_mv_value_idx ON public.items_value_mv (value)", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + materializedViews: { + [mainMaterializedView.stableId]: mainMaterializedView, + }, + indexableObjects: { + [mainMaterializedView.stableId]: mainMaterializedView, + }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: mainMaterializedView.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + materializedViews: { + [branchMaterializedView.stableId]: branchMaterializedView, + }, + indexableObjects: { + [branchMaterializedView.stableId]: branchMaterializedView, + }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterMaterializedViewClusterOn, + ), + ).toHaveLength(1); + expect(serialized).toContain( + "ALTER MATERIALIZED VIEW public.items_value_mv CLUSTER ON items_value_mv_value_idx", + ); + }); + + test("orders promoted materialized view clustering before owner restore", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainMaterializedView = materializedViewOnItemsValue({ + definition: + " SELECT public.normalize_value(items.value) AS value FROM public.items;", + owner: "app_owner", + }); + const branchMaterializedView = materializedViewOnItemsValue({ + definition: + " SELECT public.normalize_value(items.value) AS value FROM public.items;", + owner: "app_owner", + }); + const mainIndex = indexOnItemsValue({ + table_name: "items_value_mv", + name: "items_value_mv_value_idx", + table_relkind: "m", + is_clustered: true, + definition: + "CREATE INDEX items_value_mv_value_idx ON public.items_value_mv (value)", + }); + const branchIndex = indexOnItemsValue({ + table_name: "items_value_mv", + name: "items_value_mv_value_idx", + table_relkind: "m", + is_clustered: true, + definition: + "CREATE INDEX items_value_mv_value_idx ON public.items_value_mv (value)", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + materializedViews: { + [mainMaterializedView.stableId]: mainMaterializedView, + }, + indexableObjects: { + [mainMaterializedView.stableId]: mainMaterializedView, + }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: mainMaterializedView.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + materializedViews: { + [branchMaterializedView.stableId]: branchMaterializedView, + }, + indexableObjects: { + [branchMaterializedView.stableId]: branchMaterializedView, + }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const clusterIndex = sorted.findIndex( + (change) => change instanceof AlterMaterializedViewClusterOn, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterMaterializedViewChangeOwner, + ); + + expect(clusterIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(clusterIndex); + }); + + test("restores retained indexes when materialized views are already replace roots", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainMaterializedView = materializedViewOnItemsValue({ + definition: " SELECT items.value AS value FROM public.items;", + }); + const branchMaterializedView = materializedViewOnItemsValue({ + definition: " SELECT items.value + 1 AS value FROM public.items;", + }); + const mainIndex = indexOnItemsValue({ + table_name: "items_value_mv", + name: "items_value_mv_value_idx", + table_relkind: "m", + is_clustered: true, + definition: + "CREATE INDEX items_value_mv_value_idx ON public.items_value_mv (value)", + }); + const branchIndex = indexOnItemsValue({ + table_name: "items_value_mv", + name: "items_value_mv_value_idx", + table_relkind: "m", + is_clustered: true, + definition: + "CREATE INDEX items_value_mv_value_idx ON public.items_value_mv (value)", + }); + + const changes: Change[] = [ + new DropMaterializedView({ materializedView: mainMaterializedView }), + new CreateMaterializedView({ + materializedView: branchMaterializedView, + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + materializedViews: { + [mainMaterializedView.stableId]: mainMaterializedView, + }, + indexableObjects: { + [mainMaterializedView.stableId]: mainMaterializedView, + }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + materializedViews: { + [branchMaterializedView.stableId]: branchMaterializedView, + }, + indexableObjects: { + [branchMaterializedView.stableId]: branchMaterializedView, + }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect( + expanded.changes.filter((change) => change instanceof DropIndex), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateIndex), + ).toHaveLength(1); + expect(serialized).toContain( + "ALTER MATERIALIZED VIEW public.items_value_mv CLUSTER ON items_value_mv_value_idx", + ); + }); + + test("skips partition-cloned trigger dependents during routine replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const partitionClone = { + schema: "public", + table_name: "items_2026", + is_partition_clone: true, + parent_trigger_name: "items_value_trigger", + parent_table_schema: "public", + parent_table_name: "items", + }; + const mainTrigger = triggerOnItemsValue(partitionClone); + const branchTrigger = triggerOnItemsValue(partitionClone); + const mainTable = partitionTableWithDefault(null); + const branchTable = partitionTableWithDefault(null); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + triggers: { [mainTrigger.stableId]: mainTrigger }, + depends: [ + { + dependent_stable_id: mainTrigger.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + triggers: { [branchTrigger.stableId]: branchTrigger }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.some((change) => change.objectType === "trigger"), + ).toBe(false); + }); + + test("prunes superseded trigger alters for promoted trigger replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault(null); + const branchTable = tableWithDefault(null); + const mainTrigger = triggerOnItemsValue({ + function_name: mainProcedure.name, + definition: + "CREATE TRIGGER items_value_trigger AFTER UPDATE OF value ON public.items FOR EACH ROW EXECUTE FUNCTION public.normalize_value()", + }); + const branchTrigger = triggerOnItemsValue({ + function_name: branchProcedure.name, + definition: + "CREATE TRIGGER items_value_trigger BEFORE UPDATE OF value ON public.items FOR EACH ROW EXECUTE FUNCTION public.normalize_value()", + }); + const originalReplaceTrigger = new ReplaceTrigger({ + trigger: branchTrigger, + indexableObject: branchTable, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + originalReplaceTrigger, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexableObjects: { [mainTable.stableId]: mainTable }, + triggers: { [mainTrigger.stableId]: mainTrigger }, + depends: [ + { + dependent_stable_id: mainTrigger.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexableObjects: { [branchTable.stableId]: branchTable }, + triggers: { [branchTrigger.stableId]: branchTrigger }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).not.toContain(originalReplaceTrigger); + expect( + expanded.changes.filter((change) => change instanceof ReplaceTrigger), + ).toHaveLength(0); + expect( + expanded.changes.filter((change) => change instanceof DropTrigger), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateTrigger), + ).toHaveLength(1); + }); + + test("replaces child partitions instead of dropping generated columns on them", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = partitionTableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const branchTable = partitionTableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const columnId = "column:public.items_2026.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableDropColumn && + change.table.name === "items_2026", + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAddColumn && + change.table.name === "items_2026", + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => + change instanceof DropTable && change.table.name === "items_2026", + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => + change instanceof CreateTable && change.table.name === "items_2026", + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toHaveLength(0); + expect(serialized).toContain( + "CREATE TABLE public.items_2026 PARTITION OF public.items (value GENERATED ALWAYS AS (public.normalize_value(value)) STORED) FOR VALUES FROM (2026) TO (2027)", + ); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + true, + ); + }); + + test("refreshes each publication table entry when recreating generated columns", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainItems = tableNamedWithDefault( + "items", + "public.normalize_value(value)", + { is_generated: true }, + ); + const branchItems = tableNamedWithDefault( + "items", + "public.normalize_value(value)", + { is_generated: true }, + ); + const mainWidgets = tableNamedWithDefault( + "widgets", + "public.normalize_value(value)", + { is_generated: true }, + ); + const branchWidgets = tableNamedWithDefault( + "widgets", + "public.normalize_value(value)", + { is_generated: true }, + ); + const mainPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: ["value"], + row_filter: null, + }, + { + schema: "public", + name: "widgets", + columns: ["value"], + row_filter: null, + }, + ]); + const branchPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: ["value"], + row_filter: null, + }, + { + schema: "public", + name: "widgets", + columns: ["value"], + row_filter: null, + }, + ]); + const itemColumnId = "column:public.items.value"; + const widgetColumnId = "column:public.widgets.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { + [mainItems.stableId]: mainItems, + [mainWidgets.stableId]: mainWidgets, + }, + depends: [ + { + dependent_stable_id: itemColumnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: widgetColumnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: itemColumnId, + deptype: "n", + }, + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: widgetColumnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { + [branchItems.stableId]: branchItems, + [branchWidgets.stableId]: branchWidgets, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationDropTables, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toHaveLength(1); + expect(serialized).toContain( + "ALTER PUBLICATION items_pub DROP TABLE public.items, public.widgets", + ); + expect(serialized).toContain( + "ALTER PUBLICATION items_pub ADD TABLE public.items (value), TABLE public.widgets (value)", + ); + }); + + test("releases retained publication row filters before routine replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainPublication = publicationOnItemsValue( + "(public.normalize_value(value) > 0)", + ); + const branchPublication = publicationOnItemsValue( + "(public.normalize_value(value) > 0)", + ); + const table = tableWithDefault(null); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { [table.stableId]: table }, + depends: [ + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { [table.stableId]: table }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationDropTables, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toHaveLength(1); + }); + + test("orders replayed publication row filters before publication owner restore", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainPublication = publicationOnItemsValue( + "(public.normalize_value(value) > 0)", + ); + const branchPublication = new Publication({ + // oxlint-disable-next-line typescript/no-misused-spread + ...publicationOnItemsValue("(public.normalize_value(value) > 0)"), + owner: "app_owner", + }); + const table = tableWithDefault(null); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterPublicationSetOwner({ + publication: branchPublication, + owner: "app_owner", + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { [table.stableId]: table }, + depends: [ + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { [table.stableId]: table }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addTablesIndex = sorted.findIndex( + (change) => change instanceof AlterPublicationAddTables, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterPublicationSetOwner, + ); + + expect(addTablesIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(addTablesIndex); + }); + + test("refreshes publication row filters that depend on recreated generated columns without column lists", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainPublication = publicationOnItemsValue("(value > 0)"); + const branchPublication = publicationOnItemsValue("(value > 0)"); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect(serialized).toContain( + "ALTER PUBLICATION items_pub DROP TABLE public.items", + ); + expect(serialized).toContain( + "ALTER PUBLICATION items_pub ADD TABLE public.items WHERE (value > 0)", + ); + }); + + test("does not duplicate existing publication row filter replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainPublication = publicationOnItemsValue( + "(public.normalize_value(value) > 0)", + ); + const branchPublication = publicationOnItemsValue( + "(public.normalize_value(value) >= 0)", + ); + const table = tableWithDefault(null); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterPublicationDropTables({ + publication: mainPublication, + tables: mainPublication.tables, + }), + new AlterPublicationAddTables({ + publication: branchPublication, + tables: branchPublication.tables, + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { [table.stableId]: table }, + depends: [ + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { [table.stableId]: table }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationDropTables, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toHaveLength(1); + }); + + test("does not duplicate existing publication column list replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: ["value"], + row_filter: null, + }, + ]); + const branchPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: ["value"], + row_filter: null, + }, + ]); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterPublicationDropTables({ + publication: mainPublication, + tables: mainPublication.tables, + }), + new AlterPublicationAddTables({ + publication: branchPublication, + tables: branchPublication.tables, + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationDropTables, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toHaveLength(1); + }); + + test("does not duplicate publication table entries when row filter and column list both need refresh", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: ["value"], + row_filter: "(public.normalize_value(value) > 0)", + }, + ]); + const branchPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: ["value"], + row_filter: "(public.normalize_value(value) > 0)", + }, + ]); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect( + serialized.filter((statement) => + statement.startsWith("ALTER PUBLICATION items_pub DROP TABLE"), + ), + ).toEqual(["ALTER PUBLICATION items_pub DROP TABLE public.items"]); + expect( + serialized.filter((statement) => + statement.startsWith("ALTER PUBLICATION items_pub ADD TABLE"), + ), + ).toEqual([ + "ALTER PUBLICATION items_pub ADD TABLE public.items (value) WHERE (public.normalize_value(value) > 0)", + ]); + }); + + test("handles aggregate replacements as expression roots", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainAggregate = aggregateWithArgs(["integer"]); + const branchAggregate = aggregateWithArgs(["bigint"]); + const mainTable = tableWithDefault("public.total_value(value)"); + const branchTable = tableWithDefault("public.total_value(value::bigint)"); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropAggregate({ aggregate: mainAggregate }), + new CreateAggregate({ aggregate: branchAggregate }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + aggregates: { [mainAggregate.stableId]: mainAggregate }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainAggregate.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + aggregates: { [branchAggregate.stableId]: branchAggregate }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + + test("recreates retained aggregates that depend on replaced routines", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"], "sum_state"); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.sum_state(renamed integer) RETURNS integer", + }); + const mainAggregate = aggregateWithArgs(["integer"]); + const branchAggregate = aggregateWithArgs(["integer"]); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + aggregates: { [mainAggregate.stableId]: mainAggregate }, + depends: [ + { + dependent_stable_id: mainAggregate.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + aggregates: { [branchAggregate.stableId]: branchAggregate }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter((change) => change instanceof DropAggregate), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateAggregate), + ).toHaveLength(1); + }); + + test("restores aggregate metadata when an existing aggregate create is converted from orReplace", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"], "sum_state"); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.sum_state(renamed integer) RETURNS integer", + }); + const mainAggregate = aggregateWithArgs(["integer"]); + const branchAggregate = aggregateWithArgs(["integer"], { + owner: "aggregate_owner", + comment: "aggregate comment", + security_labels: [{ provider: "dummy", label: "aggregate label" }], + privileges: [ + { + grantee: "aggregate_executor", + privilege: "EXECUTE", + grantable: false, + }, + ], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new CreateAggregate({ aggregate: branchAggregate, orReplace: true }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + aggregates: { [mainAggregate.stableId]: mainAggregate }, + depends: [ + { + dependent_stable_id: mainAggregate.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + aggregates: { [branchAggregate.stableId]: branchAggregate }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect( + expanded.changes.filter((change) => change instanceof DropAggregate), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterAggregateChangeOwner, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof CreateCommentOnAggregate, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof CreateSecurityLabelOnAggregate, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof GrantAggregatePrivileges, + ), + ).toHaveLength(1); + expect(serialized).toContain( + "ALTER AGGREGATE public.total_value(integer) OWNER TO aggregate_owner", + ); + expect(serialized).toContain( + "COMMENT ON AGGREGATE public.total_value(integer) IS 'aggregate comment'", + ); + expect(serialized).toContain( + "SECURITY LABEL FOR dummy ON AGGREGATE public.total_value(integer) IS 'aggregate label'", + ); + expect(serialized).toContain( + "GRANT ALL ON FUNCTION public.total_value(integer) TO aggregate_executor", + ); + }); + + test("restores promoted routine metadata after dependent routine replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDependent = procedureWithArgs(["integer"], "uses_normalize"); + const branchDependent = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainDependent, + owner: "routine_owner", + comment: "dependent routine comment", + security_labels: [{ provider: "dummy", label: "routine label" }], + privileges: [ + { + grantee: "routine_executor", + privilege: "EXECUTE", + grantable: false, + }, + ], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [mainProcedure.stableId]: mainProcedure, + [mainDependent.stableId]: mainDependent, + }, + depends: [ + { + dependent_stable_id: mainDependent.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [branchProcedure.stableId]: branchProcedure, + [branchDependent.stableId]: branchDependent, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect(serialized).toContain( + "ALTER FUNCTION public.uses_normalize(integer) OWNER TO routine_owner", + ); + expect(serialized).toContain( + "COMMENT ON FUNCTION public.uses_normalize(integer) IS 'dependent routine comment'", + ); + expect(serialized).toContain( + "SECURITY LABEL FOR dummy ON FUNCTION public.uses_normalize(integer) IS 'routine label'", + ); + expect(serialized).toContain( + "GRANT ALL ON FUNCTION public.uses_normalize(integer) TO routine_executor", + ); + }); + + test("de-duplicates superseded owner alters for promoted routine replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDependent = procedureWithArgs(["integer"], "uses_normalize"); + const branchDependent = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainDependent, + owner: "routine_owner", + }); + const ownerAlter = new AlterProcedureChangeOwner({ + procedure: mainDependent, + owner: branchDependent.owner, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ownerAlter, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [mainProcedure.stableId]: mainProcedure, + [mainDependent.stableId]: mainDependent, + }, + depends: [ + { + dependent_stable_id: mainDependent.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [branchProcedure.stableId]: branchProcedure, + [branchDependent.stableId]: branchDependent, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterProcedureChangeOwner, + ), + ).toHaveLength(1); + }); + + test("preserves new grants when injecting the drop side for an existing routine create", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDependent = procedureWithArgs(["integer"], "uses_normalize"); + const branchDependent = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainDependent, + source_code: "SELECT public.normalize_value(arg1::bigint)", + definition: + "CREATE FUNCTION public.uses_normalize(arg1 integer) RETURNS integer", + privileges: [ + { + grantee: "routine_executor", + privilege: "EXECUTE", + grantable: false, + }, + ], + }); + const originalGrant = new GrantProcedurePrivileges({ + procedure: branchDependent, + grantee: "routine_executor", + privileges: [{ privilege: "EXECUTE", grantable: false }], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new CreateProcedure({ procedure: branchDependent, orReplace: true }), + originalGrant, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [mainProcedure.stableId]: mainProcedure, + [mainDependent.stableId]: mainDependent, + }, + depends: [ + { + dependent_stable_id: mainDependent.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [branchProcedure.stableId]: branchProcedure, + [branchDependent.stableId]: branchDependent, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes).not.toContain(originalGrant); + expect( + expanded.changes.filter( + (change) => change instanceof GrantProcedurePrivileges, + ), + ).toHaveLength(1); + expect(expanded.changes.map((change) => change.serialize())).toContain( + "GRANT ALL ON FUNCTION public.uses_normalize(integer) TO routine_executor", + ); + }); + + test("defers promoted routine owner restore until after routine metadata replay", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDependent = procedureWithArgs(["integer"], "uses_normalize"); + const branchDependent = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainDependent, + owner: "routine_owner", + comment: "dependent routine comment", + security_labels: [{ provider: "dummy", label: "routine label" }], + privileges: [ + { + grantee: "routine_executor", + privilege: "EXECUTE", + grantable: false, + }, + ], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [mainProcedure.stableId]: mainProcedure, + [mainDependent.stableId]: mainDependent, + }, + depends: [ + { + dependent_stable_id: mainDependent.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [branchProcedure.stableId]: branchProcedure, + [branchDependent.stableId]: branchDependent, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ).map((change) => change.serialize()); + + const ownerIndex = sorted.indexOf( + "ALTER FUNCTION public.uses_normalize(integer) OWNER TO routine_owner", + ); + const commentIndex = sorted.indexOf( + "COMMENT ON FUNCTION public.uses_normalize(integer) IS 'dependent routine comment'", + ); + const securityLabelIndex = sorted.indexOf( + "SECURITY LABEL FOR dummy ON FUNCTION public.uses_normalize(integer) IS 'routine label'", + ); + const grantIndex = sorted.indexOf( + "GRANT ALL ON FUNCTION public.uses_normalize(integer) TO routine_executor", + ); + + expect(ownerIndex).toBeGreaterThan(commentIndex); + expect(ownerIndex).toBeGreaterThan(securityLabelIndex); + expect(ownerIndex).toBeGreaterThan(grantIndex); + }); + + test("restores procedure metadata when an existing procedure create is converted from orReplace", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDependent = procedureWithArgs(["integer"], "uses_normalize"); + const branchDependent = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainDependent, + source_code: "SELECT public.normalize_value(arg1::bigint)", + definition: + "CREATE FUNCTION public.uses_normalize(arg1 integer) RETURNS integer", + owner: "routine_owner", + comment: "dependent routine comment", + security_labels: [{ provider: "dummy", label: "routine label" }], + privileges: [ + { + grantee: "routine_executor", + privilege: "EXECUTE", + grantable: false, + }, + ], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new CreateProcedure({ procedure: branchDependent, orReplace: true }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [mainProcedure.stableId]: mainProcedure, + [mainDependent.stableId]: mainDependent, + }, + depends: [ + { + dependent_stable_id: mainDependent.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [branchProcedure.stableId]: branchProcedure, + [branchDependent.stableId]: branchDependent, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect(serialized).toContain( + "DROP FUNCTION public.uses_normalize(arg1 integer)", + ); + expect(serialized).toContain( + "CREATE OR REPLACE FUNCTION public.uses_normalize(arg1 integer) RETURNS integer", + ); + expect(serialized).toContain( + "ALTER FUNCTION public.uses_normalize(integer) OWNER TO routine_owner", + ); + expect(serialized).toContain( + "COMMENT ON FUNCTION public.uses_normalize(integer) IS 'dependent routine comment'", + ); + expect(serialized).toContain( + "SECURITY LABEL FOR dummy ON FUNCTION public.uses_normalize(integer) IS 'routine label'", + ); + expect(serialized).toContain( + "GRANT ALL ON FUNCTION public.uses_normalize(integer) TO routine_executor", + ); + }); + + test("replays retained table metadata for promoted table replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const tableSecurityLabels = [{ provider: "dummy", label: "table label" }]; + const columnSecurityLabels = [{ provider: "dummy", label: "column label" }]; + const retainedPrivileges = [ + { + grantee: "table_reader", + privilege: "SELECT", + grantable: false, + }, + ]; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault("public.normalize_value(value)", { + comment: "value column", + security_labels: columnSecurityLabels, + }), + owner: "app_owner", + row_security: true, + force_row_security: true, + replica_identity: "f", + comment: "retained table comment", + privileges: retainedPrivileges, + security_labels: tableSecurityLabels, + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainTable, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + true, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableChangeOwner, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableEnableRowLevelSecurity, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableForceRowLevelSecurity, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableSetReplicaIdentity, + ), + ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof CreateCommentOnTable), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof CreateCommentOnColumn, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof CreateSecurityLabelOnTable, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof CreateSecurityLabelOnColumn, + ), + ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof GrantTablePrivileges), + ).toBe(true); + }); + + test("replays retained owned sequences for promoted table replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = tableWithDefault(columnDefault); + const branchTable = tableWithDefault(columnDefault); + const mainSequence = sequenceOwnedByItemsValue(); + const branchSequence = sequenceOwnedByItemsValue(); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + true, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof CreateSequence), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterSequenceSetOwnedBy, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toBe(true); + }); + + test("restores retained owned sequence defaults on the branch owning table", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = tableWithDefault(columnDefault); + const branchTable = tableWithDefault(columnDefault); + const mainArchiveTable = tableNamedWithDefault( + "archived_items", + columnDefault, + { name: "archived_value" }, + ); + const branchArchiveTable = tableNamedWithDefault( + "archived_items", + columnDefault, + { name: "archived_value" }, + ); + const mainSequence = sequenceOwnedByItemsValue(); + const branchSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainSequence, + owned_by_table: "archived_items", + owned_by_column: "archived_value", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { + [mainTable.stableId]: mainTable, + [mainArchiveTable.stableId]: mainArchiveTable, + }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { + [branchTable.stableId]: branchTable, + [branchArchiveTable.stableId]: branchArchiveTable, + }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const setDefaultChanges = expanded.changes.filter( + (change): change is AlterTableAlterColumnSetDefault => + change instanceof AlterTableAlterColumnSetDefault, + ); + + expect( + setDefaultChanges.map( + (change) => `${change.table.name}.${change.column.name}`, + ), + ).toContain("archived_items.archived_value"); + }); + + test("replays sequences cascaded by main-side promoted table ownership", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = tableWithDefault(columnDefault); + const branchTable = tableWithDefault(columnDefault); + const mainSequence = sequenceOwnedByItemsValue(); + const branchSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainSequence, + owned_by_schema: null, + owned_by_table: null, + owned_by_column: null, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + const createSequences = expanded.changes.filter( + (change) => change instanceof CreateSequence, + ); + const ownedByChanges = expanded.changes.filter( + (change) => change instanceof AlterSequenceSetOwnedBy, + ); + + expect(createSequences).toHaveLength(1); + expect(ownedByChanges).toHaveLength(0); + }); + + test("defers promoted table owner restore until after table-local replay", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithUniqueConstraint({ owner: "app_owner" }); + const branchTable = tableWithUniqueConstraint({ owner: "app_owner" }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addConstraintIndex = sorted.findIndex( + (change) => change instanceof AlterTableAddConstraint, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterTableChangeOwner, + ); + + expect(addConstraintIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(addConstraintIndex); + }); + + test("restores retained owned sequence owners before reattaching ownership on promoted tables", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault(columnDefault), + owner: "app_owner", + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainTable, + }); + const mainSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...sequenceOwnedByItemsValue(), + owner: "app_owner", + }); + const branchSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainSequence, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sortedSql = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ).map((change) => change.serialize()); + const ownerIndex = sortedSql.indexOf( + "ALTER SEQUENCE public.items_value_seq OWNER TO app_owner", + ); + const ownedByIndex = sortedSql.indexOf( + "ALTER SEQUENCE public.items_value_seq OWNED BY public.items.value", + ); + + expect(ownerIndex).toBeGreaterThan(-1); + expect(ownedByIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeLessThan(ownedByIndex); + }); + + test("prunes original owned sequence owner alters after promoted table replay", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = tableWithDefault(columnDefault); + const branchTable = tableWithDefault(columnDefault); + const mainSequence = sequenceOwnedByItemsValue(); + const branchSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainSequence, + owner: "app_owner", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterSequenceChangeOwner({ + sequence: branchSequence, + owner: "app_owner", + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sequenceOwnerChanges = expanded.changes.filter( + (change) => change instanceof AlterSequenceChangeOwner, + ); + + expect(sequenceOwnerChanges).toHaveLength(1); + expect(sequenceOwnerChanges[0].sequence.stableId).toBe( + branchSequence.stableId, + ); + }); + + test("orders promoted table and owned sequence owner restores before OWNED BY", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithUniqueConstraint({ + columns: [ + { + ...tableWithDefault(columnDefault).columns[0], + not_null: true, + }, + ], + owner: "app_owner", + }), + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainTable, + }); + const mainSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...sequenceOwnedByItemsValue(), + owner: "app_owner", + }); + const branchSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainSequence, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addConstraintIndex = sorted.findIndex( + (change) => change instanceof AlterTableAddConstraint, + ); + const tableOwnerIndex = sorted.findIndex( + (change) => change instanceof AlterTableChangeOwner, + ); + const sequenceOwnerIndex = sorted.findIndex( + (change) => change instanceof AlterSequenceChangeOwner, + ); + const ownedByIndex = sorted.findIndex( + (change) => change instanceof AlterSequenceSetOwnedBy, + ); + + expect(addConstraintIndex).toBeGreaterThan(-1); + expect(tableOwnerIndex).toBeGreaterThan(addConstraintIndex); + expect(sequenceOwnerIndex).toBeGreaterThan(-1); + expect(ownedByIndex).toBeGreaterThan(sequenceOwnerIndex); + expect(ownedByIndex).toBeGreaterThan(tableOwnerIndex); + }); + + test("traverses foreign keys that reference recreated column constraints", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const generatedColumnTable = tableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const mainTable = tableWithUniqueConstraint({ + columns: generatedColumnTable.columns, + }); + const branchTable = tableWithUniqueConstraint({ + columns: generatedColumnTable.columns, + }); + const mainReferencingTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableNamedWithDefault("item_refs", null, { + name: "item_value", + }), + constraints: [ + { + name: "item_refs_item_value_fkey", + constraint_type: "f", + deferrable: false, + initially_deferred: false, + validated: true, + is_local: true, + no_inherit: false, + is_temporal: false, + is_partition_clone: false, + parent_constraint_schema: null, + parent_constraint_name: null, + parent_table_schema: null, + parent_table_name: null, + key_columns: ["item_value"], + foreign_key_columns: ["value"], + foreign_key_table: "items", + foreign_key_schema: "public", + foreign_key_table_is_partition: false, + foreign_key_parent_schema: null, + foreign_key_parent_table: null, + foreign_key_effective_schema: "public", + foreign_key_effective_table: "items", + on_update: "a", + on_delete: "a", + match_type: "s", + check_expression: null, + owner: "postgres", + definition: "FOREIGN KEY (item_value) REFERENCES public.items(value)", + comment: null, + }, + ], + }); + const branchReferencingTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainReferencingTable, + }); + const columnId = "column:public.items.value"; + const uniqueConstraintId = "constraint:public.items.items_value_key"; + const foreignKeyId = + "constraint:public.item_refs.item_refs_item_value_fkey"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { + [mainTable.stableId]: mainTable, + [mainReferencingTable.stableId]: mainReferencingTable, + }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: uniqueConstraintId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: foreignKeyId, + referenced_stable_id: uniqueConstraintId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { + [branchTable.stableId]: branchTable, + [branchReferencingTable.stableId]: branchReferencingTable, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const dropConstraints = expanded.changes.filter( + (change): change is AlterTableDropConstraint => + change instanceof AlterTableDropConstraint, + ); + const addConstraints = expanded.changes.filter( + (change): change is AlterTableAddConstraint => + change instanceof AlterTableAddConstraint, + ); + + expect( + dropConstraints.filter( + (change) => change.constraint.name === "items_value_key", + ), + ).toHaveLength(1); + expect( + addConstraints.filter( + (change) => change.constraint.name === "items_value_key", + ), + ).toHaveLength(1); + expect( + dropConstraints.filter( + (change) => change.constraint.name === "item_refs_item_value_fkey", + ), + ).toHaveLength(1); + expect( + addConstraints.filter( + (change) => change.constraint.name === "item_refs_item_value_fkey", + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + + test("does not count domain constraints as domain default restores", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainDomain = domainWithDefault("public.normalize_value(1)"); + const branchDomain = new Domain({ + // oxlint-disable-next-line typescript/no-misused-spread + ...domainWithConstraint("VALUE > 0"), + default_bin: mainDomain.default_bin, + default_value: mainDomain.default_value, + }); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + const addConstraint = new AlterDomainAddConstraint({ + domain: branchDomain, + constraint: branchDomain.constraints[0] as Domain["constraints"][number], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + addConstraint, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: mainDomain.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(addConstraint); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainDropDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainSetDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + expect( + expanded.changes.some((change) => change instanceof CreateDomain), + ).toBe(false); + }); + + test("does not count domain constraint drops as domain default releases", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainDomain = new Domain({ + // oxlint-disable-next-line typescript/no-misused-spread + ...domainWithConstraint("VALUE > 0"), + default_bin: "public.normalize_value(1)", + default_value: "public.normalize_value(1)", + }); + const branchDomain = domainWithDefault("public.normalize_value(1)"); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + const dropConstraint = new AlterDomainDropConstraint({ + domain: mainDomain, + constraint: mainDomain.constraints[0] as Domain["constraints"][number], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + dropConstraint, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: mainDomain.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(dropConstraint); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainDropDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainSetDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + }); + + test("replays retained publication membership for promoted table replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithDefault("public.normalize_value(value)"); + const branchTable = tableWithDefault("public.normalize_value(value)"); + const mainPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: null, + row_filter: null, + }, + ]); + const branchPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: null, + row_filter: null, + }, + ]); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + publications: { [mainPublication.stableId]: mainPublication }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + publications: { [branchPublication.stableId]: branchPublication }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + true, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toBe(true); + }); + + test("orders constraint-backed replica identity after promoted table constraints", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithUniqueConstraint({ + replica_identity: "i", + replica_identity_index: "items_value_key", + }); + const branchTable = tableWithUniqueConstraint({ + replica_identity: "i", + replica_identity_index: "items_value_key", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const addConstraint = expanded.changes.find( + (change) => change instanceof AlterTableAddConstraint, + ); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addConstraintIndex = sorted.findIndex( + (change) => change instanceof AlterTableAddConstraint, + ); + const replicaIdentityIndex = sorted.findIndex( + (change) => change instanceof AlterTableSetReplicaIdentity, + ); + + expect(addConstraint).toBeInstanceOf(AlterTableAddConstraint); + expect(addConstraint?.creates).toContain( + "index:public.items.items_value_key", + ); + expect(replicaIdentityIndex).toBeGreaterThan(addConstraintIndex); + }); + + test("treats publication recreation as row-filter replacement coverage", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithDefault("public.normalize_value(value)"); + const branchTable = tableWithDefault("public.normalize_value(value)"); + const mainPublication = publicationOnItemsValue( + "(public.normalize_value(value) > 0)", + ); + const branchPublication = publicationOnItemsValue( + "(public.normalize_value(value) > 0)", + ); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new DropPublication({ publication: mainPublication }), + new CreatePublication({ publication: branchPublication }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + publications: { [mainPublication.stableId]: mainPublication }, + depends: [ + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + publications: { [branchPublication.stableId]: branchPublication }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationDropTables, + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toHaveLength(0); + }); + + test("keeps owned sequence metadata for promoted table cycle filtering", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = tableWithDefault(columnDefault); + const branchTable = tableWithDefault(columnDefault); + const mainSequence = sequenceOwnedByItemsValue(); + const branchSequence = sequenceOwnedByItemsValue(); + const sequenceId = "sequence:public.items_value_seq"; + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: sequenceId, + deptype: "n", + }, + { + dependent_stable_id: sequenceId, + referenced_stable_id: columnId, + deptype: "a", + }, + ], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const createSequence = expanded.changes.find( + (change): change is CreateSequence => change instanceof CreateSequence, + ); + + expect(createSequence?.sequence.owned_by_schema).toBe("public"); + expect(createSequence?.sequence.owned_by_table).toBe("items"); + expect(createSequence?.sequence.owned_by_column).toBe("value"); + expect(() => + sortChanges({ mainCatalog, branchCatalog }, expanded.changes), + ).not.toThrow(); + }); + + test("replays metadata for retained constraint-backed indexes on promoted tables", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithUniqueConstraint(); + const branchTable = tableWithUniqueConstraint(); + const mainIndex = constraintBackedIndexOnItemsValue({ is_clustered: true }); + const branchIndex = constraintBackedIndexOnItemsValue({ + is_clustered: true, + comment: "retained unique index", + statistics_target: [100], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.some((change) => change instanceof AlterTableClusterOn), + ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof CreateCommentOnIndex), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterIndexSetStatistics, + ), + ).toBe(true); + + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addConstraintIndex = sorted.findIndex( + (change) => change instanceof AlterTableAddConstraint, + ); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnIndex, + ); + const statisticsIndex = sorted.findIndex( + (change) => change instanceof AlterIndexSetStatistics, + ); + const clusterIndex = sorted.findIndex( + (change) => change instanceof AlterTableClusterOn, + ); + + expect(commentIndex).toBeGreaterThan(addConstraintIndex); + expect(statisticsIndex).toBeGreaterThan(addConstraintIndex); + expect(clusterIndex).toBeGreaterThan(addConstraintIndex); + }); + + test("replays metadata for retained constraint-backed indexes after targeted constraint replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const generatedColumnTable = tableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const mainTable = tableWithUniqueConstraint({ + columns: generatedColumnTable.columns, + replica_identity: "i", + replica_identity_index: "items_value_key", + }); + const branchTable = tableWithUniqueConstraint({ + columns: generatedColumnTable.columns, + replica_identity: "i", + replica_identity_index: "items_value_key", + }); + const mainIndex = constraintBackedIndexOnItemsValue({ is_clustered: true }); + const branchIndex = constraintBackedIndexOnItemsValue({ + is_clustered: true, + is_replica_identity: true, + comment: "retained unique index", + statistics_target: [100], + }); + const columnId = "column:public.items.value"; + const constraintId = "constraint:public.items.items_value_key"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: constraintId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addConstraintIndex = sorted.findIndex( + (change) => change instanceof AlterTableAddConstraint, + ); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnIndex, + ); + const statisticsIndex = sorted.findIndex( + (change) => change instanceof AlterIndexSetStatistics, + ); + const clusterIndex = sorted.findIndex( + (change) => change instanceof AlterTableClusterOn, + ); + const replicaIdentityIndex = sorted.findIndex( + (change) => change instanceof AlterTableSetReplicaIdentity, + ); + + expect(addConstraintIndex).toBeGreaterThan(-1); + expect(commentIndex).toBeGreaterThan(addConstraintIndex); + expect(statisticsIndex).toBeGreaterThan(addConstraintIndex); + expect(clusterIndex).toBeGreaterThan(addConstraintIndex); + expect(replicaIdentityIndex).toBeGreaterThan(addConstraintIndex); + }); + + test("synthesizes a table check constraint replacement for an unchanged expression that depends on a replaced procedure", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + ); + const branchTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + ); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.some( + (change) => change instanceof AlterTableDropConstraint, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAddConstraint, + ), + ).toBe(true); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("preserves NOT VALID table check state when synthesizing a replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + { validated: false }, + ); + const branchTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + { validated: false }, + ); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const addConstraint = expanded.changes.find( + (change) => change instanceof AlterTableAddConstraint, + ); + + expect(addConstraint).toBeInstanceOf(AlterTableAddConstraint); + expect(addConstraint?.serialize()).toContain("NOT VALID"); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableValidateConstraint, + ), + ).toBe(false); + }); + + test("adds a missing release when a table check restore is already covered", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + ); + const branchTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + ); + const addConstraint = new AlterTableAddConstraint({ + table: branchTable, + constraint: branchTable.constraints[0] as NonNullable< + (typeof branchTable.constraints)[number] + >, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + addConstraint, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(addConstraint); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddConstraint, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + + test("does not replace a table when a replaced procedure only drops a dependent table check constraint", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + ); + const branchTable = tableWithDefault(null); + const dropConstraint = new AlterTableDropConstraint({ + table: mainTable, + constraint: mainTable.constraints[0] as NonNullable< + (typeof mainTable.constraints)[number] + >, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + dropConstraint, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(dropConstraint); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAddConstraint, + ), + ).toBe(false); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + + test("skips partition-cloned table check constraints during expression replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const cloneConstraint = { + is_partition_clone: true, + parent_constraint_schema: "public", + parent_constraint_name: "items_value_check", + parent_table_schema: "public", + parent_table_name: "items", + }; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithCheckConstraint("public.normalize_value(value) > 0", { + ...cloneConstraint, + }), + name: "items_2026", + is_partition: true, + partition_bound: "FOR VALUES FROM (2026) TO (2027)", + parent_schema: "public", + parent_name: "items", + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithCheckConstraint("public.normalize_value(value) > 0", { + ...cloneConstraint, + }), + name: "items_2026", + is_partition: true, + partition_bound: "FOR VALUES FROM (2026) TO (2027)", + parent_schema: "public", + parent_name: "items", + }); + const constraintId = "constraint:public.items_2026.items_value_check"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: constraintId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropConstraint, + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddConstraint, + ), + ).toHaveLength(0); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("synthesizes one table check constraint replacement when the expression depends on two replaced procedures", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainNormalize = procedureWithArgs(["integer"], "normalize_value"); + const branchNormalize = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainNormalize, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainShift = procedureWithArgs(["integer"], "shift_value"); + const branchShift = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainShift, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.shift_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithCheckConstraint( + "public.normalize_value(value) > public.shift_value(value)", + ); + const branchTable = tableWithCheckConstraint( + "public.normalize_value(value) > public.shift_value(value)", + ); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainNormalize }), + new CreateProcedure({ procedure: branchNormalize }), + new DropProcedure({ procedure: mainShift }), + new CreateProcedure({ procedure: branchShift }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [mainNormalize.stableId]: mainNormalize, + [mainShift.stableId]: mainShift, + }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: mainNormalize.stableId, + deptype: "n", + }, + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: mainShift.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [branchNormalize.stableId]: branchNormalize, + [branchShift.stableId]: branchShift, + }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddConstraint, + ), + ).toHaveLength(1); + }); + + test("synthesizes a domain check constraint replacement for an unchanged expression that depends on a replaced procedure", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + ); + const branchDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + ); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: "constraint:public.item_value.item_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.some( + (change) => change instanceof AlterDomainDropConstraint, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterDomainAddConstraint, + ), + ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + expect( + expanded.changes.some((change) => change instanceof CreateDomain), + ).toBe(false); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("does not duplicate a covered domain check constraint release", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + ); + const branchDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + ); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + const dropConstraint = new AlterDomainDropConstraint({ + domain: mainDomain, + constraint: mainDomain.constraints[0] as Domain["constraints"][number], + }); + const addConstraint = new AlterDomainAddConstraint({ + domain: branchDomain, + constraint: branchDomain.constraints[0] as Domain["constraints"][number], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + dropConstraint, + addConstraint, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: "constraint:public.item_value.item_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(dropConstraint); + expect(expanded.changes).toContain(addConstraint); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainAddConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + }); + + test("does not duplicate a covered removed domain check constraint release", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + ); + const branchDomain = domainWithDefault(null); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + const dropConstraint = new AlterDomainDropConstraint({ + domain: mainDomain, + constraint: mainDomain.constraints[0] as Domain["constraints"][number], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + dropConstraint, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: "constraint:public.item_value.item_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(dropConstraint); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + }); + + test("preserves NOT VALID domain check state when synthesizing a replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + { validated: false }, + ); + const branchDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + { validated: false }, + ); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: "constraint:public.item_value.item_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const addConstraint = expanded.changes.find( + (change) => change instanceof AlterDomainAddConstraint, + ); + + expect(addConstraint).toBeInstanceOf(AlterDomainAddConstraint); + expect(addConstraint?.serialize()).toContain("NOT VALID"); + expect( + expanded.changes.some( + (change) => change instanceof AlterDomainValidateConstraint, + ), + ).toBe(false); + }); + + test("does not replace a domain or table when a procedure signature replacement is covered by a domain default alter", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDomain = domainWithDefault("public.normalize_value(1)"); + const branchDomain = domainWithDefault( + "public.normalize_value((1)::bigint)", + ); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + const setDomainDefault = new AlterDomainSetDefault({ + domain: mainDomain, + defaultValue: branchDomain.default_value ?? "NULL", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + setDomainDefault, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: mainDomain.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(setDomainDefault); + expect( + expanded.changes.some( + (change) => change instanceof AlterDomainDropDefault, + ), + ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + expect( + expanded.changes.some((change) => change instanceof CreateDomain), + ).toBe(false); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(tableUsingDomain.stableId)).toBe( + false, + ); + }); + + test("does not replace a domain or table when a procedure signature replacement is covered by dropping a domain default", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDomain = domainWithDefault("public.normalize_value(1)"); + const branchDomain = domainWithDefault(null); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + const dropDomainDefault = new AlterDomainDropDefault({ + domain: mainDomain, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + dropDomainDefault, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: mainDomain.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(dropDomainDefault); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + expect( + expanded.changes.some((change) => change instanceof CreateDomain), + ).toBe(false); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(tableUsingDomain.stableId)).toBe( + false, + ); + }); }); diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index c1a464418..fb09aa9fd 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -1,22 +1,89 @@ import type { Catalog } from "./catalog.model.ts"; import type { Change } from "./change.types.ts"; +import { + diffPrivileges, + emitObjectPrivilegeChanges, + filterPublicBuiltInDefaults, +} from "./objects/base.privilege-diff.ts"; import type { ObjectDiffContext } from "./objects/diff-context.ts"; +import { AlterAggregateChangeOwner } from "./objects/aggregate/changes/aggregate.alter.ts"; +import { CreateCommentOnAggregate } from "./objects/aggregate/changes/aggregate.comment.ts"; +import { CreateAggregate } from "./objects/aggregate/changes/aggregate.create.ts"; +import { DropAggregate } from "./objects/aggregate/changes/aggregate.drop.ts"; +import { + GrantAggregatePrivileges, + RevokeAggregatePrivileges, + RevokeGrantOptionAggregatePrivileges, +} from "./objects/aggregate/changes/aggregate.privilege.ts"; +import { CreateSecurityLabelOnAggregate } from "./objects/aggregate/changes/aggregate.security-label.ts"; +import { + AlterDomainAddConstraint, + AlterDomainDropConstraint, + AlterDomainDropDefault, + AlterDomainSetDefault, +} from "./objects/domain/changes/domain.alter.ts"; import { CreateDomain } from "./objects/domain/changes/domain.create.ts"; import { DropDomain } from "./objects/domain/changes/domain.drop.ts"; +import { AlterIndexSetStatistics } from "./objects/index/changes/index.alter.ts"; +import { CreateCommentOnIndex } from "./objects/index/changes/index.comment.ts"; import { CreateIndex } from "./objects/index/changes/index.create.ts"; import { DropIndex } from "./objects/index/changes/index.drop.ts"; +import { AlterMaterializedViewClusterOn } from "./objects/materialized-view/changes/materialized-view.alter.ts"; import { CreateMaterializedView } from "./objects/materialized-view/changes/materialized-view.create.ts"; import { DropMaterializedView } from "./objects/materialized-view/changes/materialized-view.drop.ts"; import { buildCreateMaterializedViewChanges } from "./objects/materialized-view/materialized-view.diff.ts"; +import { AlterProcedureChangeOwner } from "./objects/procedure/changes/procedure.alter.ts"; +import { CreateCommentOnProcedure } from "./objects/procedure/changes/procedure.comment.ts"; import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts"; import { DropProcedure } from "./objects/procedure/changes/procedure.drop.ts"; +import { + GrantProcedurePrivileges, + RevokeGrantOptionProcedurePrivileges, + RevokeProcedurePrivileges, +} from "./objects/procedure/changes/procedure.privilege.ts"; +import { CreateSecurityLabelOnProcedure } from "./objects/procedure/changes/procedure.security-label.ts"; +import { + AlterPublicationAddTables, + AlterPublicationDropTables, +} from "./objects/publication/changes/publication.alter.ts"; +import { CreatePublication } from "./objects/publication/changes/publication.create.ts"; +import { DropPublication } from "./objects/publication/changes/publication.drop.ts"; +import type { PublicationTableProps } from "./objects/publication/publication.model.ts"; import { CreateCommentOnRlsPolicy } from "./objects/rls-policy/changes/rls-policy.comment.ts"; import { CreateRlsPolicy } from "./objects/rls-policy/changes/rls-policy.create.ts"; import { DropRlsPolicy } from "./objects/rls-policy/changes/rls-policy.drop.ts"; -import { AlterTableAddConstraint } from "./objects/table/changes/table.alter.ts"; -import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.ts"; +import { CreateCommentOnRule } from "./objects/rule/changes/rule.comment.ts"; +import { CreateRule } from "./objects/rule/changes/rule.create.ts"; +import { DropRule } from "./objects/rule/changes/rule.drop.ts"; +import { SetRuleEnabledState } from "./objects/rule/changes/rule.alter.ts"; +import { AlterSequenceChangeOwner } from "./objects/sequence/changes/sequence.alter.ts"; +import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts"; +import { diffSequences } from "./objects/sequence/sequence.diff.ts"; +import { + AlterTableAddColumn, + AlterTableAddConstraint, + AlterTableAlterColumnDropDefault, + AlterTableAlterColumnSetDefault, + AlterTableChangeOwner, + AlterTableClusterOn, + AlterTableDropColumn, + AlterTableDropConstraint, + AlterTableSetReplicaIdentity, +} from "./objects/table/changes/table.alter.ts"; +import { + CreateCommentOnColumn, + CreateCommentOnConstraint, +} from "./objects/table/changes/table.comment.ts"; import { CreateTable } from "./objects/table/changes/table.create.ts"; import { DropTable } from "./objects/table/changes/table.drop.ts"; +import { GrantTablePrivileges } from "./objects/table/changes/table.privilege.ts"; +import { CreateSecurityLabelOnColumn } from "./objects/table/changes/table.security-label.ts"; +import { diffTables } from "./objects/table/table.diff.ts"; +import type { TableProps } from "./objects/table/table.model.ts"; +import { SetTriggerEnabledState } from "./objects/trigger/changes/trigger.alter.ts"; +import { CreateCommentOnTrigger } from "./objects/trigger/changes/trigger.comment.ts"; +import { CreateTrigger } from "./objects/trigger/changes/trigger.create.ts"; +import { DropTrigger } from "./objects/trigger/changes/trigger.drop.ts"; import { CreateCompositeType } from "./objects/type/composite-type/changes/composite-type.create.ts"; import { DropCompositeType } from "./objects/type/composite-type/changes/composite-type.drop.ts"; import { CreateEnum } from "./objects/type/enum/changes/enum.create.ts"; @@ -44,6 +111,19 @@ type ResolvedObject = main: Catalog["indexes"][string]; branch: Catalog["indexes"][string]; branchIndexableObject: Catalog["indexableObjects"][string] | undefined; + branchMaterializedView: Catalog["materializedViews"][string] | undefined; + branchTable: Catalog["tables"][string] | undefined; + } + | { + kind: "trigger"; + main: Catalog["triggers"][string]; + branch: Catalog["triggers"][string]; + branchIndexableObject: Catalog["indexableObjects"][string] | undefined; + } + | { + kind: "rule"; + main: Catalog["rules"][string]; + branch: Catalog["rules"][string]; } | { kind: "materialized_view"; @@ -55,6 +135,11 @@ type ResolvedObject = main: Catalog["procedures"][string]; branch: Catalog["procedures"][string]; } + | { + kind: "aggregate"; + main: Catalog["aggregates"][string]; + branch: Catalog["aggregates"][string]; + } | { kind: "rls_policy"; main: Catalog["rlsPolicies"][string]; @@ -117,9 +202,13 @@ export function expandReplaceDependencies({ } const replaceRoots = new Set(); + const routineExpressionReplacementRoots = new Set(); for (const id of createdIds) { if (droppedIds.has(id)) { replaceRoots.add(id); + if (isRoutineExpressionReplacementRoot(id)) { + routineExpressionReplacementRoots.add(id); + } } } @@ -133,22 +222,22 @@ export function expandReplaceDependencies({ promotedRlsPolicyIds, }); - // Procedure stableIds are signature-qualified - // (`procedure:schema.name(argtypes)`), so a function whose parameter types - // change has different ids in `createdIds` and `droppedIds` and would not - // appear in the intersection above. Treat any dropped procedure whose - // `(schema, name)` matches a created procedure as a replace root so - // dependents referencing the old signature via pg_depend get promoted to - // DROP+CREATE. - const createdProcedureNames = new Set(); + // Procedure and aggregate stableIds are signature-qualified, so a routine + // whose parameter types change has different ids in `createdIds` and + // `droppedIds` and would not appear in the intersection above. Treat any + // dropped routine whose `(kind, schema, name)` matches a created routine as a + // replace root so dependents referencing the old signature via pg_depend get + // promoted or temporarily released. + const createdRoutineNames = new Set(); for (const id of createdIds) { - const key = parseProcedureSchemaName(id); - if (key) createdProcedureNames.add(key); + const key = parseRoutineSchemaName(id); + if (key) createdRoutineNames.add(key); } for (const id of droppedIds) { - const key = parseProcedureSchemaName(id); - if (key && createdProcedureNames.has(key)) { + const key = parseRoutineSchemaName(id); + if (key && createdRoutineNames.has(key)) { replaceRoots.add(id); + routineExpressionReplacementRoots.add(id); } } @@ -197,15 +286,60 @@ export function expandReplaceDependencies({ const visitedTargets = new Set(); const visitedRefs = new Set(replaceRoots); const queue: string[] = [...replaceRoots]; + const expressionDependentCoverage = + collectExpressionDependentCoverage(changes); // Tables being replaced by an expansion-added DropTable+CreateTable pair. // Any pre-existing targeted AlterTable*(T) object-scope change is superseded // by the replacement and must be removed to avoid contradictions (e.g. an // AlterTableDropColumn on a table that is about to be dropped) and the // associated drop-phase cycle with the catalog constraint→column edge. const tablesReplacedByExpansion = new Set(); + const routinesReplacedByExpansion = new Set(); + const triggersReplacedByExpansion = new Set(); + const sequencesRecreatedByPromotedTableReplay = new Set(); + const generatedColumnsRecreatedByExpressionFallback = + collectCoveredGeneratedColumnRecreations(changes); + const columnsRecreatedByOriginalDiff = + collectCoveredColumnRecreations(changes); + const columnsEligibleForGrantRestores = new Set([ + ...generatedColumnsRecreatedByExpressionFallback, + ...columnsRecreatedByOriginalDiff, + ]); + const generatedColumnGrantRestoresAdded = new Set(); + for (const columnId of generatedColumnsRecreatedByExpressionFallback) { + maybeAddCoveredGeneratedColumnGrantRestores({ + columnId, + columnsEligibleForGrantRestores, + generatedColumnGrantRestoresAdded, + branchCatalog, + additions, + existingChanges: [...changes, ...additions], + diffContext, + }); + } + const rootRetainedIndexChanges = buildRootRetainedIndexReplacementChanges({ + replaceRoots, + mainCatalog, + branchCatalog, + createdIds, + droppedIds, + visitedTargets, + diffContext, + }); + additions.push(...rootRetainedIndexChanges); + recordChangeIds(rootRetainedIndexChanges, createdIds, droppedIds); while (queue.length > 0) { const refId = queue.shift() as string; + maybeAddCoveredGeneratedColumnGrantRestores({ + columnId: refId, + columnsEligibleForGrantRestores, + generatedColumnGrantRestoresAdded, + branchCatalog, + additions, + existingChanges: [...changes, ...additions], + diffContext, + }); const dependents = dependentsByReferenced.get(refId); if (!dependents) continue; @@ -220,6 +354,186 @@ export function expandReplaceDependencies({ ) { continue; } + const columnRecreationCovered = + generatedColumnsRecreatedByExpressionFallback.has(refId) || + columnsRecreatedByOriginalDiff.has(refId); + if ( + columnRecreationCovered && + isMetadataDependentStableId(dependentRaw) + ) { + // Column comments and security labels are metadata for the recreated + // column itself. The drop/add fallback restores them directly; when the + // drop/add pair came from the original diff, restore retained metadata + // here instead of promoting metadata into object replacement. + maybeAddRecreatedColumnMetadataRestore({ + columnId: refId, + dependentRaw, + branchCatalog, + additions, + createdIds, + }); + continue; + } + if ( + columnRecreationCovered && + isOwnerTableDependentForColumn(refId, dependentRaw) + ) { + // A table has catalog bookkeeping dependencies on its own columns. The + // generated-column fallback already drops and recreates the column, so + // that internal table->column edge must not promote the whole table. + continue; + } + if ( + columnRecreationCovered && + isGeneratedColumnNotNullConstraintDependent({ + columnId: refId, + dependentId: dependentRaw, + mainCatalog, + branchCatalog, + }) + ) { + // PostgreSQL 18 stores NOT NULL as a pg_constraint dependency, but + // pg-delta models it on the column. Dropping/re-adding the generated + // column already releases and restores this edge. + continue; + } + + if ( + shouldHandleRoutineExpressionDependent({ + refId, + dependentRaw, + routineExpressionReplacementRoots, + }) + ) { + const releaseCovered = + expressionDependentCoverage.release.has(dependentRaw); + const restoreCovered = + expressionDependentCoverage.restore.has(dependentRaw); + if (releaseCovered && restoreCovered) { + if (columnsEligibleForGrantRestores.has(dependentRaw)) { + maybeAddCoveredGeneratedColumnGrantRestores({ + columnId: dependentRaw, + columnsEligibleForGrantRestores, + generatedColumnGrantRestoresAdded, + branchCatalog, + additions, + existingChanges: [...changes, ...additions], + diffContext, + }); + } + if (columnsRecreatedByOriginalDiff.has(dependentRaw)) { + queueRefForTraversal(dependentRaw, visitedRefs, queue); + } + continue; + } + + const expressionReplacementChanges = + buildExpressionDependentReplacementChanges({ + refId, + dependentRaw, + mainCatalog, + branchCatalog, + diffContext, + createdIds, + existingChanges: changes, + addRelease: !releaseCovered, + addRestore: !restoreCovered, + }); + + if (expressionReplacementChanges !== null) { + additions.push(...expressionReplacementChanges); + if ( + recreatesExpressionDependent({ + dependentRaw, + expressionReplacementChanges, + }) + ) { + generatedColumnsRecreatedByExpressionFallback.add(dependentRaw); + } + if (!releaseCovered) { + expressionDependentCoverage.release.add(dependentRaw); + } + if (!restoreCovered) { + expressionDependentCoverage.restore.add(dependentRaw); + } + for (const change of expressionReplacementChanges) { + for (const id of change.creates ?? []) createdIds.add(id); + for (const id of change.drops ?? []) droppedIds.add(id); + } + if ( + shouldTraverseExpressionReplacementDependent({ + dependentRaw, + expressionReplacementChanges, + }) + ) { + queueRefForTraversal(dependentRaw, visitedRefs, queue); + } + continue; + } + } + + if ( + maybeAddRoutineDependentPublicationReplacement({ + refId, + dependentRaw, + routineExpressionReplacementRoots, + mainCatalog, + branchCatalog, + additions, + existingChanges: [...changes, ...additions], + visitedTargets, + }) + ) { + continue; + } + + if ( + maybeAddColumnDependentPublicationReplacement({ + refId, + dependentRaw, + mainCatalog, + branchCatalog, + additions, + existingChanges: [...changes, ...additions], + visitedTargets, + }) + ) { + continue; + } + + if ( + maybeAddColumnDependentConstraintReplacement({ + refId, + dependentRaw, + mainCatalog, + branchCatalog, + additions, + createdIds, + droppedIds, + visitedRefs, + queue, + visitedTargets, + }) + ) { + continue; + } + + const targetId = normalizeDependentId( + dependentRaw, + mainCatalog, + branchCatalog, + ); + if ( + targetId && + shouldSuppressCoveredExpressionDependent({ + refId, + dependentRaw, + expressionDependentCoverage, + routineExpressionReplacementRoots, + }) + ) { + continue; + } // Continue traversing the dependency graph from the raw dependent id. if (!visitedRefs.has(dependentRaw)) { @@ -227,7 +541,6 @@ export function expandReplaceDependencies({ queue.push(dependentRaw); } - const targetId = normalizeDependentId(dependentRaw); if (!targetId) continue; // Also traverse using the normalized owning object id (e.g., table for a column). @@ -265,10 +578,85 @@ export function expandReplaceDependencies({ }); if (!replacementChanges) continue; - additions.push(...replacementChanges); + const retainedIndexChanges = + resolved.kind === "table" || resolved.kind === "materialized_view" + ? buildRetainedIndexReplacementChanges({ + relationStableId: targetId, + mainCatalog, + branchCatalog, + createdIds, + droppedIds, + visitedTargets, + diffContext, + }) + : []; + const retainedOwnedSequenceChanges = + resolved.kind === "table" && addDrop && addCreate + ? buildRetainedOwnedSequenceReplacementChanges({ + table: resolved.branch, + mainCatalog, + branchCatalog, + createdIds, + diffContext, + }) + : []; + for (const change of retainedOwnedSequenceChanges) { + if (change instanceof CreateSequence) { + sequencesRecreatedByPromotedTableReplay.add(change.sequence.stableId); + } + } + const retainedPublicationMembershipChanges = + resolved.kind === "table" && addDrop && addCreate + ? buildRetainedPublicationMembershipChanges({ + table: resolved.branch, + mainCatalog, + branchCatalog, + existingChanges: [...changes, ...additions], + }) + : []; + const procedureMetadataRestoreChanges = + resolved.kind === "procedure" && addDrop && !addCreate + ? buildRetainedProcedureMetadataChanges({ + procedure: resolved.branch, + diffContext, + }).filter( + (change) => + !isCreateAlreadyCovered(change, createdIds) || + isRoutinePrivilegeReplay(change), + ) + : []; + const aggregateMetadataRestoreChanges = + resolved.kind === "aggregate" && addDrop && !addCreate + ? buildRetainedAggregateMetadataChanges({ + aggregate: resolved.branch, + diffContext, + }).filter( + (change) => + !isCreateAlreadyCovered(change, createdIds) || + isRoutinePrivilegeReplay(change), + ) + : []; + + additions.push( + ...replacementChanges, + ...retainedIndexChanges, + ...retainedOwnedSequenceChanges, + ...retainedPublicationMembershipChanges, + ...procedureMetadataRestoreChanges, + ...aggregateMetadataRestoreChanges, + ); if (resolved.kind === "rls_policy") { promotedRlsPolicyIds.add(targetId); } + if ( + (resolved.kind === "procedure" || resolved.kind === "aggregate") && + addDrop + ) { + routinesReplacedByExpansion.add(targetId); + } + if (resolved.kind === "trigger" && addDrop) { + triggersReplacedByExpansion.add(targetId); + } // If we added a DropTable(T) for an existing table, mark T so any // pre-existing object-scope AlterTable*(T) changes get dropped below — @@ -278,7 +666,14 @@ export function expandReplaceDependencies({ } // Track new creates/drops so we don't duplicate work for downstream dependents. - for (const change of replacementChanges) { + for (const change of [ + ...replacementChanges, + ...retainedIndexChanges, + ...retainedOwnedSequenceChanges, + ...retainedPublicationMembershipChanges, + ...procedureMetadataRestoreChanges, + ...aggregateMetadataRestoreChanges, + ]) { for (const id of change.creates ?? []) createdIds.add(id); for (const id of change.drops ?? []) droppedIds.add(id); } @@ -294,13 +689,35 @@ export function expandReplaceDependencies({ return { changes: [ - ...removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds), + ...removeSupersededGeneratedColumnSetExpressions( + removeSupersededSequenceAlters( + removeSupersededTriggerAlters( + removeSupersededRoutineAlters( + removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds), + routinesReplacedByExpansion, + ), + triggersReplacedByExpansion, + ), + sequencesRecreatedByPromotedTableReplay, + ), + generatedColumnsRecreatedByExpressionFallback, + ), ...additions, ], replacedTableIds: tablesReplacedByExpansion, }; } +function queueRefForTraversal( + refId: string, + visitedRefs: Set, + queue: string[], +) { + if (visitedRefs.has(refId)) return; + visitedRefs.add(refId); + queue.push(refId); +} + function collectInvalidatedRlsPolicyReplacements({ changes, mainCatalog, @@ -333,7 +750,11 @@ function collectInvalidatedRlsPolicyReplacements({ for (const dep of mainCatalog.depends) { if (!invalidatedIds.has(dep.referenced_stable_id)) continue; - const targetId = normalizeDependentId(dep.dependent_stable_id); + const targetId = normalizeDependentId( + dep.dependent_stable_id, + mainCatalog, + branchCatalog, + ); if (!targetId?.startsWith("rlsPolicy:")) continue; if (promotedRlsPolicyIds.has(targetId)) continue; if (createdIds.has(targetId) && droppedIds.has(targetId)) continue; @@ -377,149 +798,1868 @@ function removeSupersededRlsPolicyAlters( }); } -function isOwnedSequenceColumnDependency( - referencedId: string, - dependentId: string, - mainCatalog: Catalog, - branchCatalog: Catalog, -): boolean { - // When a sequence replace root is still OWNED BY the same column, the - // sequence->column pg_depend edge is bookkeeping for ownership, not a signal - // that the whole owning table needs to be replaced. Skipping that edge keeps - // expandReplaceDependencies focused on recreating the sequence itself. - if ( - !referencedId.startsWith("sequence:") || - !dependentId.startsWith("column:") - ) { - return false; - } +function removeSupersededRoutineAlters( + changes: Change[], + promotedRoutineIds: ReadonlySet, +): Change[] { + if (promotedRoutineIds.size === 0) return changes; + return changes.filter((change) => { + if (change.operation !== "alter") return true; + if (change.objectType === "procedure") { + return !promotedRoutineIds.has(change.procedure.stableId); + } + if (change.objectType === "aggregate") { + return !promotedRoutineIds.has(change.aggregate.stableId); + } + return true; + }); +} - const sequence = - branchCatalog.sequences[referencedId] ?? - mainCatalog.sequences[referencedId]; - if ( - !sequence?.owned_by_schema || - !sequence.owned_by_table || - !sequence.owned_by_column - ) { - return false; - } +function removeSupersededTriggerAlters( + changes: Change[], + promotedTriggerIds: ReadonlySet, +): Change[] { + if (promotedTriggerIds.size === 0) return changes; + return changes.filter((change) => { + if (change.objectType !== "trigger" || change.operation !== "alter") { + return true; + } + return !promotedTriggerIds.has(change.trigger.stableId); + }); +} + +function removeSupersededSequenceAlters( + changes: Change[], + recreatedSequenceIds: ReadonlySet, +): Change[] { + if (recreatedSequenceIds.size === 0) return changes; + return changes.filter((change) => { + if (!(change instanceof AlterSequenceChangeOwner)) return true; + return !recreatedSequenceIds.has(change.sequence.stableId); + }); +} +function isRoutinePrivilegeReplay(change: Change): boolean { return ( - dependentId === - stableId.column( - sequence.owned_by_schema, - sequence.owned_by_table, - sequence.owned_by_column, - ) + change.operation === "alter" && + change.scope === "privilege" && + (change.objectType === "procedure" || change.objectType === "aggregate") ); } -function parseProcedureSchemaName(stableId: string): string | null { - if (!stableId.startsWith("procedure:")) return null; - const paren = stableId.indexOf("("); - if (paren === -1) return null; - return stableId.slice("procedure:".length, paren); +interface ExpressionDependentCoverage { + release: Set; + restore: Set; } -function normalizeDependentId(dependentId: string): string | null { - let id = dependentId; +function collectExpressionDependentCoverage( + changes: Change[], +): ExpressionDependentCoverage { + const release = new Set(); + const restore = new Set(); - while (id.startsWith("comment:")) { - id = id.slice("comment:".length); - } + for (const change of changes) { + for (const id of change.creates ?? []) { + if (isExpressionContainerStableId(id)) { + restore.add(id); + } + } - if ( - id.startsWith("acl:") || - id.startsWith("defacl:") || - id.startsWith("membership:") || - id.startsWith("role:") || - id.startsWith("schema:") - ) { - return null; - } + for (const id of change.drops ?? []) { + if (isExpressionContainerStableId(id)) { + release.add(id); + } + } - if (id.startsWith("column:")) { - const parts = id.slice("column:".length).split("."); - if (parts.length >= 2) { - const [schema, table] = parts; - return `table:${schema}.${table}`; + if ( + change instanceof AlterTableAlterColumnDropDefault || + change instanceof AlterTableDropConstraint || + change instanceof AlterDomainDropDefault + ) { + for (const id of change.requires ?? []) { + if (isExpressionContainerStableId(id)) { + release.add(id); + } + } } - return null; - } - if (id.startsWith("constraint:")) { - const parts = id.slice("constraint:".length).split("."); - if (parts.length >= 2) { - const [schema, table] = parts; - return `table:${schema}.${table}`; + if (change instanceof AlterDomainSetDefault) { + for (const id of change.requires ?? []) { + if (isExpressionContainerStableId(id)) { + restore.add(id); + } + } + } + + if (change instanceof AlterTableAlterColumnSetDefault) { + // SET DEFAULT / SET EXPRESSION restores only the column being altered. + // Partition child changes may require the parent column for ordering, but + // that parent requirement is not itself parent expression restore coverage. + restore.add( + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ), + ); } - return null; } - return id; + return { release, restore }; } -function resolveObjectForStableId( - stableId: string, - mainCatalog: Catalog, - branchCatalog: Catalog, -): ResolvedObject | null { - if (stableId.startsWith("table:")) { - const main = mainCatalog.tables[stableId]; - const branch = branchCatalog.tables[stableId]; - return main && branch ? { kind: "table", main, branch } : null; - } +function collectCoveredGeneratedColumnRecreations( + changes: readonly Change[], +): Set { + const release = new Set(); + const restore = new Set(); - if (stableId.startsWith("view:")) { - const main = mainCatalog.views[stableId]; - const branch = branchCatalog.views[stableId]; - return main && branch ? { kind: "view", main, branch } : null; + for (const change of changes) { + if (change instanceof AlterTableDropColumn && change.column.is_generated) { + release.add( + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ), + ); + } + if (change instanceof AlterTableAddColumn && change.column.is_generated) { + restore.add( + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ), + ); + } } - if (stableId.startsWith("materializedView:")) { - const main = mainCatalog.materializedViews[stableId]; - const branch = branchCatalog.materializedViews[stableId]; - return main && branch ? { kind: "materialized_view", main, branch } : null; - } + return new Set([...release].filter((columnId) => restore.has(columnId))); +} - if (stableId.startsWith("index:")) { - const main = mainCatalog.indexes[stableId]; - const branch = branchCatalog.indexes[stableId]; - return main && branch - ? { - kind: "index", - main, - branch, - branchIndexableObject: - branchCatalog.indexableObjects[branch.tableStableId], - } - : null; - } +function collectCoveredColumnRecreations( + changes: readonly Change[], +): Set { + const release = new Set(); + const restore = new Set(); - if (stableId.startsWith("procedure:")) { - const main = mainCatalog.procedures[stableId]; - const branch = branchCatalog.procedures[stableId]; - return main && branch ? { kind: "procedure", main, branch } : null; + for (const change of changes) { + if (change instanceof AlterTableDropColumn) { + release.add( + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ), + ); + } + if (change instanceof AlterTableAddColumn) { + restore.add( + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ), + ); + } } - if (stableId.startsWith("rlsPolicy:")) { - const main = mainCatalog.rlsPolicies[stableId]; - const branch = branchCatalog.rlsPolicies[stableId]; - return main && branch ? { kind: "rls_policy", main, branch } : null; - } + return new Set([...release].filter((columnId) => restore.has(columnId))); +} - if (stableId.startsWith("domain:")) { - const main = mainCatalog.domains[stableId]; - const branch = branchCatalog.domains[stableId]; - return main && branch ? { kind: "domain", main, branch } : null; - } +function shouldSuppressCoveredExpressionDependent({ + refId, + dependentRaw, + expressionDependentCoverage, + routineExpressionReplacementRoots, +}: { + refId: string; + dependentRaw: string; + expressionDependentCoverage: { + release: ReadonlySet; + restore: ReadonlySet; + }; + routineExpressionReplacementRoots: ReadonlySet; +}): boolean { + // Routine replacement can require expression containers to release their + // pg_depend edge before the old routine is dropped. When the original diff + // already emitted that targeted expression change, promoting the normalized + // table/domain owner to DROP+CREATE would be redundant and destructive. + return ( + routineExpressionReplacementRoots.has(refId) && + isExpressionContainerStableId(dependentRaw) && + expressionDependentCoverage.release.has(dependentRaw) && + expressionDependentCoverage.restore.has(dependentRaw) + ); +} - if (stableId.startsWith("type:")) { - const enumMain = mainCatalog.enums[stableId]; - const enumBranch = branchCatalog.enums[stableId]; - if (enumMain && enumBranch) { - return { kind: "enum", main: enumMain, branch: enumBranch }; +function shouldHandleRoutineExpressionDependent({ + refId, + dependentRaw, + routineExpressionReplacementRoots, +}: { + refId: string; + dependentRaw: string; + routineExpressionReplacementRoots: ReadonlySet; +}): boolean { + return ( + routineExpressionReplacementRoots.has(refId) && + isExpressionContainerStableId(dependentRaw) + ); +} + +function isExpressionContainerStableId(stableId: string): boolean { + return ( + stableId.startsWith("column:") || + stableId.startsWith("constraint:") || + stableId.startsWith("domain:") + ); +} + +function isMetadataDependentStableId(stableId: string): boolean { + return ( + stableId.startsWith("comment:") || stableId.startsWith("securityLabel:") + ); +} + +function isOwnerTableDependentForColumn( + columnId: string, + dependentId: string, +): boolean { + const columnRef = parseColumnStableId(columnId); + if (!columnRef) return false; + + return dependentId === stableId.table(columnRef.schema, columnRef.table); +} + +function isGeneratedColumnNotNullConstraintDependent({ + columnId, + dependentId, + mainCatalog, + branchCatalog, +}: { + columnId: string; + dependentId: string; + mainCatalog: Catalog; + branchCatalog: Catalog; +}): boolean { + const columnRef = parseColumnStableId(columnId); + const constraintRef = parseConstraintStableId(dependentId); + if ( + !columnRef || + !constraintRef || + constraintRef.schema !== columnRef.schema || + constraintRef.owner !== columnRef.table + ) { + return false; + } + + const tableId = stableId.table(columnRef.schema, columnRef.table); + const mainTable = mainCatalog.tables[tableId]; + const branchTable = branchCatalog.tables[tableId]; + const mainColumn = mainTable?.columns.find( + (column) => column.name === columnRef.column, + ); + const branchColumn = branchTable?.columns.find( + (column) => column.name === columnRef.column, + ); + const modeledConstraint = Boolean( + mainTable?.constraints.some( + (constraint) => constraint.name === constraintRef.constraint, + ) || + branchTable?.constraints.some( + (constraint) => constraint.name === constraintRef.constraint, + ), + ); + const generatedColumnNotNull = Boolean( + (mainColumn?.is_generated && mainColumn.not_null) || + (branchColumn?.is_generated && branchColumn.not_null), + ); + + return !modeledConstraint && generatedColumnNotNull; +} + +function maybeAddRecreatedColumnMetadataRestore({ + columnId, + dependentRaw, + branchCatalog, + additions, + createdIds, +}: { + columnId: string; + dependentRaw: string; + branchCatalog: Catalog; + additions: Change[]; + createdIds: Set; +}): void { + if (createdIds.has(dependentRaw)) { + return; + } + + const columnRef = parseColumnStableId(columnId); + if (!columnRef) { + return; + } + + const branchTable = + branchCatalog.tables[stableId.table(columnRef.schema, columnRef.table)]; + const branchColumn = branchTable?.columns.find( + (column) => column.name === columnRef.column, + ); + if (!branchTable || !branchColumn) { + return; + } + + if (dependentRaw === stableId.comment(columnId)) { + if (branchColumn.comment !== null) { + const change = new CreateCommentOnColumn({ + table: branchTable, + column: branchColumn, + }); + additions.push(change); + for (const id of change.creates ?? []) createdIds.add(id); + } + return; + } + + const provider = parseSecurityLabelProvider(dependentRaw, columnId); + if (!provider) { + return; + } + + const securityLabel = branchColumn.security_labels?.find( + (label) => label.provider === provider, + ); + if (!securityLabel) { + return; + } + + const change = new CreateSecurityLabelOnColumn({ + table: branchTable, + column: branchColumn, + securityLabel, + }); + additions.push(change); + for (const id of change.creates ?? []) createdIds.add(id); +} + +function maybeAddCoveredGeneratedColumnGrantRestores({ + columnId, + columnsEligibleForGrantRestores, + generatedColumnGrantRestoresAdded, + branchCatalog, + additions, + existingChanges, + diffContext, +}: { + columnId: string; + columnsEligibleForGrantRestores: ReadonlySet; + generatedColumnGrantRestoresAdded: Set; + branchCatalog: Catalog; + additions: Change[]; + existingChanges: readonly Change[]; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): void { + if ( + !columnsEligibleForGrantRestores.has(columnId) || + generatedColumnGrantRestoresAdded.has(columnId) + ) { + return; + } + + const columnRef = parseColumnStableId(columnId); + if (!columnRef) { + return; + } + + const branchTable = + branchCatalog.tables[stableId.table(columnRef.schema, columnRef.table)]; + if (!branchTable) { + return; + } + + generatedColumnGrantRestoresAdded.add(columnId); + additions.push( + ...buildRetainedColumnGrantChanges({ + table: branchTable, + columnName: columnRef.column, + existingChanges, + diffContext, + }), + ); +} + +function parseSecurityLabelProvider( + dependentRaw: string, + columnId: string, +): string | null { + const prefix = stableId.securityLabel(columnId, ""); + if (!dependentRaw.startsWith(prefix)) { + return null; + } + + const provider = dependentRaw.slice(prefix.length); + return provider.length > 0 ? provider : null; +} + +function shouldTraverseExpressionReplacementDependent({ + dependentRaw, + expressionReplacementChanges, +}: { + dependentRaw: string; + expressionReplacementChanges: Change[]; +}): boolean { + // Some targeted expression fallbacks are themselves destructive. Recreating a + // generated column releases the procedure dependency but also removes or + // blocks objects that depend on that column, so keep walking from the raw id. + return expressionReplacementChanges.some((change) => + change.drops?.some((id) => id === dependentRaw), + ); +} + +function recreatesExpressionDependent({ + dependentRaw, + expressionReplacementChanges, +}: { + dependentRaw: string; + expressionReplacementChanges: Change[]; +}): boolean { + let dropsDependent = false; + let createsDependent = false; + for (const change of expressionReplacementChanges) { + dropsDependent ||= change.drops?.some((id) => id === dependentRaw) ?? false; + createsDependent ||= + change.creates?.some((id) => id === dependentRaw) ?? false; + } + return dropsDependent && createsDependent; +} + +function removeSupersededGeneratedColumnSetExpressions( + changes: Change[], + recreatedColumnIds: ReadonlySet, +): Change[] { + if (recreatedColumnIds.size === 0) return changes; + + return changes.filter((change) => { + if (!(change instanceof AlterTableAlterColumnSetDefault)) return true; + if (!change.column.is_generated) return true; + + const columnId = stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ); + return !recreatedColumnIds.has(columnId); + }); +} + +function buildExpressionDependentReplacementChanges({ + refId, + dependentRaw, + mainCatalog, + branchCatalog, + diffContext, + createdIds, + existingChanges, + addRelease, + addRestore, +}: { + refId: string; + dependentRaw: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; + createdIds: ReadonlySet; + existingChanges: readonly Change[]; + addRelease: boolean; + addRestore: boolean; +}): Change[] | null { + if (dependentRaw.startsWith("column:")) { + return buildColumnExpressionReplacementChanges({ + refId, + dependentRaw, + mainCatalog, + branchCatalog, + diffContext, + createdIds, + existingChanges, + addRelease, + addRestore, + }); + } + + if (dependentRaw.startsWith("domain:")) { + return buildDomainDefaultReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + addRelease, + addRestore, + }); + } + + if (dependentRaw.startsWith("constraint:")) { + return buildConstraintExpressionReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + addRelease, + addRestore, + }); + } + + return null; +} + +function maybeAddRoutineDependentPublicationReplacement({ + refId, + dependentRaw, + routineExpressionReplacementRoots, + mainCatalog, + branchCatalog, + additions, + existingChanges, + visitedTargets, +}: { + refId: string; + dependentRaw: string; + routineExpressionReplacementRoots: ReadonlySet; + mainCatalog: Catalog; + branchCatalog: Catalog; + additions: Change[]; + existingChanges: readonly Change[]; + visitedTargets: Set; +}): boolean { + if ( + !routineExpressionReplacementRoots.has(refId) || + !dependentRaw.startsWith("publication:") + ) { + return false; + } + const visitKey = publicationRowFilterReplacementVisitKey(dependentRaw); + if (visitedTargets.has(visitKey)) return true; + + const replacementChanges = buildPublicationRowFilterReplacementChanges({ + publicationId: dependentRaw, + mainCatalog, + branchCatalog, + existingChanges, + }); + if (!replacementChanges) return false; + + additions.push(...replacementChanges); + visitedTargets.add(visitKey); + return true; +} + +function maybeAddColumnDependentPublicationReplacement({ + refId, + dependentRaw, + mainCatalog, + branchCatalog, + additions, + existingChanges, + visitedTargets, +}: { + refId: string; + dependentRaw: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + additions: Change[]; + existingChanges: readonly Change[]; + visitedTargets: Set; +}): boolean { + if ( + !refId.startsWith("column:") || + !dependentRaw.startsWith("publication:") + ) { + return false; + } + + const replacement = buildPublicationColumnListReplacement({ + columnId: refId, + publicationId: dependentRaw, + mainCatalog, + branchCatalog, + }); + if (!replacement) return false; + + if (visitedTargets.has(replacement.visitKey)) return true; + + const addRelease = !publicationTableChangeCovered({ + changes: existingChanges, + publicationId: replacement.mainPublication.stableId, + table: replacement.mainTable, + changeType: "drop", + }); + const addRestore = !publicationTableChangeCovered({ + changes: existingChanges, + publicationId: replacement.branchPublication.stableId, + table: replacement.branchTable, + changeType: "add", + }); + + if (addRelease || addRestore) { + appendPublicationColumnListReplacement(additions, replacement, { + addRelease, + addRestore, + }); + } + visitedTargets.add(replacement.visitKey); + return true; +} + +function maybeAddColumnDependentConstraintReplacement({ + refId, + dependentRaw, + mainCatalog, + branchCatalog, + additions, + createdIds, + droppedIds, + visitedRefs, + queue, + visitedTargets, +}: { + refId: string; + dependentRaw: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + additions: Change[]; + createdIds: Set; + droppedIds: Set; + visitedRefs: Set; + queue: string[]; + visitedTargets: Set; +}): boolean { + if ( + !(refId.startsWith("column:") || refId.startsWith("constraint:")) || + !dependentRaw.startsWith("constraint:") + ) { + return false; + } + if (visitedTargets.has(dependentRaw)) { + queueRefForTraversal(dependentRaw, visitedRefs, queue); + return true; + } + + const addRelease = !droppedIds.has(dependentRaw); + const addRestore = !createdIds.has(dependentRaw); + if (!addRelease && !addRestore) { + queueRefForTraversal(dependentRaw, visitedRefs, queue); + return true; + } + + const constraintReplacementChanges = + buildConstraintExpressionReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + addRelease, + addRestore, + }); + if (!constraintReplacementChanges) return false; + + additions.push(...constraintReplacementChanges); + visitedTargets.add(dependentRaw); + queueRefForTraversal(dependentRaw, visitedRefs, queue); + for (const change of constraintReplacementChanges) { + for (const id of change.creates ?? []) createdIds.add(id); + for (const id of change.drops ?? []) droppedIds.add(id); + } + return true; +} + +function buildColumnExpressionReplacementChanges({ + refId, + dependentRaw, + mainCatalog, + branchCatalog, + diffContext, + createdIds, + existingChanges, + addRelease, + addRestore, +}: { + refId: string; + dependentRaw: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; + createdIds: ReadonlySet; + existingChanges: readonly Change[]; + addRelease: boolean; + addRestore: boolean; +}): Change[] | null { + const columnRef = parseColumnStableId(dependentRaw); + if (!columnRef) return null; + + const tableId = stableId.table(columnRef.schema, columnRef.table); + const mainTable = mainCatalog.tables[tableId]; + const branchTable = branchCatalog.tables[tableId]; + if (!mainTable || !branchTable) return null; + + const mainColumn = mainTable.columns.find( + (column) => column.name === columnRef.column, + ); + if (!mainColumn || mainColumn.default === null) return null; + + const branchColumn = branchTable.columns.find( + (column) => column.name === columnRef.column, + ); + if (!branchColumn) { + // Partition child column drops are propagated from the parent table. + // Treating the child pg_depend row as covered avoids duplicate child DDL. + if (mainTable.is_partition || branchTable.is_partition) { + return []; + } + // The branch removed this column. When the original diff already drops it, + // that drop releases the pg_depend edge and there is no expression to + // restore, so this dependent is handled without owner table replacement. + return addRelease + ? [new AlterTableDropColumn({ table: mainTable, column: mainColumn })] + : []; + } + + const generatedColumnInvolved = + mainColumn.is_generated || branchColumn.is_generated; + // Generated partition child columns are parent-managed only when the parent + // column is also being recreated and the child expression matches the parent. + // Child-specific generation expressions need their own restore after the + // parent recreation propagates the parent expression. + if ( + (mainTable.is_partition || branchTable.is_partition) && + generatedColumnInvolved && + isPartitionGeneratedColumnInheritedFromParent({ + mainTable, + branchTable, + columnName: columnRef.column, + refId, + mainCatalog, + branchCatalog, + existingChanges, + }) + ) { + return []; + } + if (generatedColumnInvolved) { + const parentRecreationCoverage = + mainTable.is_partition || branchTable.is_partition + ? getPartitionGeneratedColumnParentRecreationCoverage({ + mainTable, + branchTable, + columnName: columnRef.column, + refId, + mainCatalog, + existingChanges, + }) + : null; + if (parentRecreationCoverage?.release && parentRecreationCoverage.restore) { + if ((diffContext?.version ?? 0) >= 170000) { + return addRestore + ? [ + new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchColumn, + }), + ] + : []; + } + + return null; + } + + if (mainTable.is_partition || branchTable.is_partition) { + return null; + } + + const canRecreateGeneratedColumn = + mainColumn.is_generated && + branchColumn.is_generated && + branchColumn.default !== null; + + if (addRelease && canRecreateGeneratedColumn) { + const commentRestoreCovered = createdIds.has( + stableId.comment(dependentRaw), + ); + // DROP DEFAULT is invalid for generated columns, while DROP EXPRESSION + // turns the column into a regular column that SET EXPRESSION cannot + // restore. Recreating the column releases the dependency without relying + // on PostgreSQL 17's SET EXPRESSION support. + return [ + new AlterTableDropColumn({ + table: mainTable, + column: mainColumn, + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchColumn, + }), + ...(branchColumn.comment !== null && !commentRestoreCovered + ? [ + new CreateCommentOnColumn({ + table: branchTable, + column: branchColumn, + }), + ] + : []), + ...(branchColumn.security_labels ?? []) + .filter( + (securityLabel) => + !createdIds.has( + stableId.securityLabel(dependentRaw, securityLabel.provider), + ), + ) + .map( + (securityLabel) => + new CreateSecurityLabelOnColumn({ + table: branchTable, + column: branchColumn, + securityLabel, + }), + ), + // Column ACLs are stored on the dropped attribute. Unchanged grants do + // not appear in the normal diff, so the fallback must replay them. + ...buildRetainedColumnGrantChanges({ + table: branchTable, + columnName: branchColumn.name, + existingChanges, + diffContext, + }), + ]; + } + + // PostgreSQL only gained ALTER COLUMN ... SET EXPRESSION for generated + // columns in v17. Switching generated status still needs the existing + // destructive column/table fallback because SET EXPRESSION applies only + // to an already-generated column. + if ( + (diffContext?.version ?? 0) < 170000 || + mainColumn.is_generated !== branchColumn.is_generated || + !branchColumn.is_generated || + branchColumn.default === null + ) { + return null; + } + + return addRestore + ? [ + new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchColumn, + }), + ] + : []; + } + + const changes: Change[] = []; + + if (addRelease) { + changes.push( + new AlterTableAlterColumnDropDefault({ + table: mainTable, + column: mainColumn, + }), + ); + } + + if (addRestore && branchColumn.default !== null) { + changes.push( + new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchColumn, + }), + ); + } + + return changes; +} + +function buildPublicationColumnListReplacement({ + columnId, + publicationId, + mainCatalog, + branchCatalog, +}: { + columnId: string; + publicationId: string; + mainCatalog: Catalog; + branchCatalog: Catalog; +}): PublicationColumnListReplacement | null { + const columnRef = parseColumnStableId(columnId); + if (!columnRef) return null; + + const mainPublication = mainCatalog.publications[publicationId]; + const branchPublication = branchCatalog.publications[publicationId]; + if (!mainPublication || !branchPublication) return null; + + const mainTable = findPublicationTableForColumn( + mainPublication.tables, + columnRef, + ); + const branchTable = findPublicationTableForColumn( + branchPublication.tables, + columnRef, + ); + if (!mainTable || !branchTable) return null; + + return { + mainPublication, + branchPublication, + mainTable, + branchTable, + visitKey: publicationTableReplacementVisitKey(publicationId, mainTable), + }; +} + +type PublicationColumnListReplacement = { + mainPublication: Catalog["publications"][string]; + branchPublication: Catalog["publications"][string]; + mainTable: PublicationTableProps; + branchTable: PublicationTableProps; + visitKey: string; +}; + +function appendPublicationColumnListReplacement( + additions: Change[], + replacement: PublicationColumnListReplacement, + options: { addRelease: boolean; addRestore: boolean }, +): void { + if (options.addRelease) { + const existingDrop = additions.find( + (change): change is AlterPublicationDropTables => + change instanceof AlterPublicationDropTables && + change.publication.stableId === replacement.mainPublication.stableId, + ); + if (existingDrop) { + if ( + !publicationTableChangeCovered({ + changes: [existingDrop], + publicationId: replacement.mainPublication.stableId, + table: replacement.mainTable, + changeType: "drop", + }) + ) { + existingDrop.tables.push(replacement.mainTable); + } + } else { + additions.push( + new AlterPublicationDropTables({ + publication: replacement.mainPublication, + tables: [replacement.mainTable], + }), + ); + } + } + + if (options.addRestore) { + const existingAdd = additions.find( + (change): change is AlterPublicationAddTables => + change instanceof AlterPublicationAddTables && + change.publication.stableId === replacement.branchPublication.stableId, + ); + if (existingAdd) { + if ( + !publicationTableChangeCovered({ + changes: [existingAdd], + publicationId: replacement.branchPublication.stableId, + table: replacement.branchTable, + changeType: "add", + }) + ) { + existingAdd.tables.push(replacement.branchTable); + } + } else { + additions.push( + new AlterPublicationAddTables({ + publication: replacement.branchPublication, + tables: [replacement.branchTable], + }), + ); + } + } +} + +function buildPublicationRowFilterReplacementChanges({ + publicationId, + mainCatalog, + branchCatalog, + existingChanges, +}: { + publicationId: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + existingChanges: readonly Change[]; +}): Change[] | null { + const mainPublication = mainCatalog.publications[publicationId]; + const branchPublication = branchCatalog.publications[publicationId]; + if (!mainPublication || !branchPublication) return null; + + const mainTables = mainPublication.tables.filter( + (table) => table.row_filter !== null, + ); + if (mainTables.length === 0) return null; + + const mainTableKeys = new Set( + mainTables.map((table) => publicationTableKey(table)), + ); + const branchTables = branchPublication.tables.filter((table) => + mainTableKeys.has(publicationTableKey(table)), + ); + const branchTablesByKey = new Map( + branchTables.map((table) => [publicationTableKey(table), table]), + ); + const releaseTables = mainTables.filter( + (table) => + !publicationTableChangeCovered({ + changes: existingChanges, + publicationId, + table, + changeType: "drop", + }), + ); + const restoreTables = mainTables.flatMap((table) => { + const tableKey = publicationTableKey(table); + const branchTable = branchTablesByKey.get(tableKey); + if (!branchTable) return []; + return publicationTableChangeCovered({ + changes: existingChanges, + publicationId, + table: branchTable, + changeType: "add", + }) + ? [] + : [branchTable]; + }); + + if (releaseTables.length === 0 && restoreTables.length === 0) return []; + + return [ + ...(releaseTables.length > 0 + ? [ + new AlterPublicationDropTables({ + publication: mainPublication, + tables: releaseTables, + }), + ] + : []), + ...(restoreTables.length > 0 + ? [ + new AlterPublicationAddTables({ + publication: branchPublication, + tables: restoreTables, + }), + ] + : []), + ]; +} + +function publicationRowFilterReplacementVisitKey( + publicationId: string, +): string { + return `publicationRowFilters:${publicationId}`; +} + +function publicationTableReplacementVisitKey( + publicationId: string, + table: PublicationTableProps, +): string { + return `publicationTable:${publicationId}:${publicationTableKey(table)}`; +} + +function publicationTableKey(table: PublicationTableProps): string { + return `${table.schema}.${table.name}`; +} + +function publicationTableChangeCovered({ + changes, + publicationId, + table, + changeType, +}: { + changes: readonly Change[]; + publicationId: string; + table: PublicationTableProps; + changeType: "add" | "drop"; +}): boolean { + const changeClass = + changeType === "add" + ? AlterPublicationAddTables + : AlterPublicationDropTables; + const tableStableId = stableId.table(table.schema, table.name); + + return changes.some((change) => { + if ( + changeType === "add" && + change instanceof CreatePublication && + change.publication.stableId === publicationId + ) { + return change.requires.includes(tableStableId); + } + if ( + changeType === "drop" && + change instanceof DropPublication && + change.publication.stableId === publicationId + ) { + return true; + } + if (!(change instanceof changeClass)) return false; + if (change.publication.stableId !== publicationId) return false; + + return changeType === "add" + ? change.requires.includes(tableStableId) + : change.drops.includes(tableStableId); + }); +} + +function findPublicationTableForColumn( + tables: readonly PublicationTableProps[], + columnRef: ColumnStableIdParts, +): PublicationTableProps | null { + return ( + tables.find( + (table) => + table.schema === columnRef.schema && + table.name === columnRef.table && + (table.columns?.includes(columnRef.column) || + table.row_filter !== null), + ) ?? null + ); +} + +function isPartitionGeneratedColumnInheritedFromParent({ + mainTable, + branchTable, + columnName, + refId, + mainCatalog, + branchCatalog, + existingChanges, +}: { + mainTable: Catalog["tables"][string]; + branchTable: Catalog["tables"][string]; + columnName: string; + refId: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + existingChanges: readonly Change[]; +}): boolean { + if ( + !partitionGeneratedColumnExpressionsMatchParent({ + mainTable, + branchTable, + columnName, + mainCatalog, + branchCatalog, + }) + ) { + return false; + } + + const coverage = getPartitionGeneratedColumnParentRecreationCoverage({ + mainTable, + branchTable, + columnName, + refId, + mainCatalog, + existingChanges, + }); + + return Boolean(coverage?.release && coverage.restore); +} + +function getPartitionGeneratedColumnParentRecreationCoverage({ + mainTable, + branchTable, + columnName, + refId, + mainCatalog, + existingChanges, +}: { + mainTable: Catalog["tables"][string]; + branchTable: Catalog["tables"][string]; + columnName: string; + refId: string; + mainCatalog: Catalog; + existingChanges: readonly Change[]; +}): { release: boolean; restore: boolean } | null { + const mainParentColumnId = + mainTable.parent_schema && mainTable.parent_name + ? stableId.column( + mainTable.parent_schema, + mainTable.parent_name, + columnName, + ) + : null; + const branchParentColumnId = + branchTable.parent_schema && branchTable.parent_name + ? stableId.column( + branchTable.parent_schema, + branchTable.parent_name, + columnName, + ) + : null; + if (!mainParentColumnId || !branchParentColumnId) return null; + + const parentHasRoutineDependency = mainCatalog.depends.some( + (dep) => + dep.dependent_stable_id === mainParentColumnId && + dep.referenced_stable_id === refId, + ); + + let releaseCovered = false; + let restoreCovered = false; + for (const change of existingChanges) { + if (change instanceof AlterTableDropColumn) { + releaseCovered ||= + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ) === mainParentColumnId; + } + if (change instanceof AlterTableAddColumn) { + restoreCovered ||= + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ) === branchParentColumnId; + } + } + + return { + release: parentHasRoutineDependency || releaseCovered, + restore: parentHasRoutineDependency || restoreCovered, + }; +} + +function partitionGeneratedColumnExpressionsMatchParent({ + mainTable, + branchTable, + columnName, + mainCatalog, + branchCatalog, +}: { + mainTable: Catalog["tables"][string]; + branchTable: Catalog["tables"][string]; + columnName: string; + mainCatalog: Catalog; + branchCatalog: Catalog; +}): boolean { + const mainColumn = findColumn(mainTable, columnName); + const branchColumn = findColumn(branchTable, columnName); + const mainParentColumn = findParentColumn(mainTable, columnName, mainCatalog); + const branchParentColumn = findParentColumn( + branchTable, + columnName, + branchCatalog, + ); + + return ( + Boolean(mainColumn?.is_generated) && + Boolean(branchColumn?.is_generated) && + Boolean(mainParentColumn?.is_generated) && + Boolean(branchParentColumn?.is_generated) && + mainColumn?.default === mainParentColumn?.default && + branchColumn?.default === branchParentColumn?.default + ); +} + +function findParentColumn( + table: Catalog["tables"][string], + columnName: string, + catalog: Catalog, +): TableProps["columns"][number] | undefined { + if (!table.parent_schema || !table.parent_name) return undefined; + const parentTable = + catalog.tables[stableId.table(table.parent_schema, table.parent_name)]; + return parentTable ? findColumn(parentTable, columnName) : undefined; +} + +function findColumn( + table: Catalog["tables"][string], + columnName: string, +): TableProps["columns"][number] | undefined { + return table.columns.find((column) => column.name === columnName); +} + +function buildDomainDefaultReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + addRelease, + addRestore, +}: { + dependentRaw: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + addRelease: boolean; + addRestore: boolean; +}): Change[] | null { + const mainDomain = mainCatalog.domains[dependentRaw]; + const branchDomain = branchCatalog.domains[dependentRaw]; + if (!mainDomain || !branchDomain || mainDomain.default_value === null) { + return null; + } + + const changes: Change[] = []; + if (addRelease) { + changes.push(new AlterDomainDropDefault({ domain: mainDomain })); + } + if (addRestore && branchDomain.default_value !== null) { + changes.push( + new AlterDomainSetDefault({ + domain: branchDomain, + defaultValue: branchDomain.default_value, + }), + ); + } + + return changes; +} + +function buildRetainedColumnGrantChanges({ + table, + columnName, + existingChanges, + diffContext, +}: { + table: Catalog["tables"][string]; + columnName: string; + existingChanges: readonly Change[]; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): Change[] { + const grantsByKey = new Map< + string, + { + grantee: string; + grantable: boolean; + privileges: Set; + } + >(); + + for (const privilege of table.privileges) { + if (!privilege.columns?.includes(columnName)) continue; + if (privilege.grantee === table.owner) continue; + if ( + isColumnGrantCovered({ + existingChanges, + tableStableId: table.stableId, + grantee: privilege.grantee, + columnName, + privilege: privilege.privilege, + grantable: privilege.grantable, + }) + ) { + continue; + } + + const key = `${privilege.grantee}\0${privilege.grantable}`; + const group = grantsByKey.get(key) ?? { + grantee: privilege.grantee, + grantable: privilege.grantable, + privileges: new Set(), + }; + group.privileges.add(privilege.privilege); + grantsByKey.set(key, group); + } + + return [...grantsByKey.values()].map( + (group) => + new GrantTablePrivileges({ + table, + grantee: group.grantee, + privileges: [...group.privileges].map((privilege) => ({ + privilege, + grantable: group.grantable, + })), + columns: [columnName], + version: diffContext?.version, + }), + ); +} + +function isColumnGrantCovered({ + existingChanges, + tableStableId, + grantee, + columnName, + privilege, + grantable, +}: { + existingChanges: readonly Change[]; + tableStableId: string; + grantee: string; + columnName: string; + privilege: string; + grantable: boolean; +}): boolean { + return existingChanges.some( + (change) => + change instanceof GrantTablePrivileges && + change.table.stableId === tableStableId && + change.grantee === grantee && + change.columns?.includes(columnName) === true && + change.privileges.some( + (grantedPrivilege) => + grantedPrivilege.privilege === privilege && + grantedPrivilege.grantable === grantable, + ), + ); +} + +function buildConstraintExpressionReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + addRelease, + addRestore, +}: { + dependentRaw: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + addRelease: boolean; + addRestore: boolean; +}): Change[] | null { + const constraintRef = parseConstraintStableId(dependentRaw); + if (!constraintRef) return null; + + const domainChanges = buildDomainConstraintReplacementChanges({ + constraintRef, + mainCatalog, + branchCatalog, + addRelease, + addRestore, + }); + if (domainChanges) return domainChanges; + + return buildTableConstraintReplacementChanges({ + constraintRef, + mainCatalog, + branchCatalog, + addRelease, + addRestore, + }); +} + +function buildDomainConstraintReplacementChanges({ + constraintRef, + mainCatalog, + branchCatalog, + addRelease, + addRestore, +}: { + constraintRef: ConstraintStableIdParts; + mainCatalog: Catalog; + branchCatalog: Catalog; + addRelease: boolean; + addRestore: boolean; +}): Change[] | null { + const domainId = `domain:${constraintRef.schema}.${constraintRef.owner}`; + const mainDomain = mainCatalog.domains[domainId]; + const branchDomain = branchCatalog.domains[domainId]; + + if (!mainDomain || !branchDomain) { + return null; + } + + const mainConstraint = mainDomain.constraints.find( + (constraint) => constraint.name === constraintRef.constraint, + ); + const branchConstraint = branchDomain.constraints.find( + (constraint) => constraint.name === constraintRef.constraint, + ); + if (!mainConstraint) return null; + + const changes: Change[] = []; + if (addRelease) { + changes.push( + new AlterDomainDropConstraint({ + domain: mainDomain, + constraint: mainConstraint, + }), + ); + } + + if (addRestore) { + if (!branchConstraint) return changes; + changes.push( + new AlterDomainAddConstraint({ + domain: branchDomain, + constraint: branchConstraint, + }), + ); + } + + return changes; +} + +function buildTableConstraintReplacementChanges({ + constraintRef, + mainCatalog, + branchCatalog, + addRelease, + addRestore, +}: { + constraintRef: ConstraintStableIdParts; + mainCatalog: Catalog; + branchCatalog: Catalog; + addRelease: boolean; + addRestore: boolean; +}): Change[] | null { + const tableId = stableId.table(constraintRef.schema, constraintRef.owner); + const mainTable = mainCatalog.tables[tableId]; + const branchTable = branchCatalog.tables[tableId]; + if (!mainTable || !branchTable) return null; + + const mainConstraint = mainTable.constraints.find( + (constraint) => constraint.name === constraintRef.constraint, + ); + const branchConstraint = branchTable.constraints.find( + (constraint) => constraint.name === constraintRef.constraint, + ); + if (!mainConstraint) return null; + // PostgreSQL clones parent CHECK constraints onto partitions. The parent + // constraint replacement releases/restores the dependency for all children. + if ( + mainConstraint.is_partition_clone || + branchConstraint?.is_partition_clone + ) { + return []; + } + + const changes: Change[] = []; + if (addRelease) { + changes.push( + new AlterTableDropConstraint({ + table: mainTable, + constraint: mainConstraint, + }), + ); + } + + if (addRestore) { + if (!branchConstraint) return changes; + changes.push( + new AlterTableAddConstraint({ + table: branchTable, + constraint: branchConstraint, + }), + ); + if (branchConstraint.comment !== null) { + changes.push( + new CreateCommentOnConstraint({ + table: branchTable, + constraint: branchConstraint, + }), + ); + } + changes.push( + ...buildRetainedConstraintBackedIndexMetadataChangesForConstraint({ + constraintRef, + mainCatalog, + branchCatalog, + }), + ); + } + + return changes; +} + +function buildRetainedConstraintBackedIndexMetadataChangesForConstraint({ + constraintRef, + mainCatalog, + branchCatalog, +}: { + constraintRef: ConstraintStableIdParts; + mainCatalog: Catalog; + branchCatalog: Catalog; +}): Change[] { + const indexId = stableId.index( + constraintRef.schema, + constraintRef.owner, + constraintRef.constraint, + ); + const resolved = resolveObjectForStableId( + indexId, + mainCatalog, + branchCatalog, + ); + if (!resolved || resolved.kind !== "index") return []; + return buildRetainedConstraintBackedIndexMetadataChanges(resolved); +} + +function isOwnedSequenceColumnDependency( + referencedId: string, + dependentId: string, + mainCatalog: Catalog, + branchCatalog: Catalog, +): boolean { + // When a sequence replace root is still OWNED BY the same column, the + // sequence->column pg_depend edge is bookkeeping for ownership, not a signal + // that the whole owning table needs to be replaced. Skipping that edge keeps + // expandReplaceDependencies focused on recreating the sequence itself. + if ( + !referencedId.startsWith("sequence:") || + !dependentId.startsWith("column:") + ) { + return false; + } + + const sequence = + branchCatalog.sequences[referencedId] ?? + mainCatalog.sequences[referencedId]; + if ( + !sequence?.owned_by_schema || + !sequence.owned_by_table || + !sequence.owned_by_column + ) { + return false; + } + + return ( + dependentId === + stableId.column( + sequence.owned_by_schema, + sequence.owned_by_table, + sequence.owned_by_column, + ) + ); +} + +function isRoutineExpressionReplacementRoot(stableId: string): boolean { + return stableId.startsWith("procedure:") || stableId.startsWith("aggregate:"); +} + +function parseRoutineSchemaName(stableId: string): string | null { + const prefix = stableId.startsWith("procedure:") + ? "procedure:" + : stableId.startsWith("aggregate:") + ? "aggregate:" + : null; + if (prefix === null) return null; + const paren = stableId.indexOf("("); + if (paren === -1) return null; + return `${prefix}${stableId.slice(prefix.length, paren)}`; +} + +function normalizeDependentId( + dependentId: string, + mainCatalog: Catalog, + branchCatalog: Catalog, +): string | null { + let id = dependentId; + + while (id.startsWith("comment:")) { + id = id.slice("comment:".length); + } + + if ( + id.startsWith("acl:") || + id.startsWith("defacl:") || + id.startsWith("membership:") || + id.startsWith("role:") || + id.startsWith("schema:") + ) { + return null; + } + + if (id.startsWith("column:")) { + const parts = splitStableIdParts(id.slice("column:".length)); + if (parts.length >= 2) { + const [schema, table] = parts; + return `table:${schema}.${table}`; + } + return null; + } + + if (id.startsWith("constraint:")) { + const constraintRef = parseConstraintStableId(id); + if (constraintRef) { + const domainId = `domain:${constraintRef.schema}.${constraintRef.owner}`; + if ( + mainCatalog.domains[domainId] !== undefined || + branchCatalog.domains[domainId] !== undefined + ) { + return domainId; + } + return stableId.table(constraintRef.schema, constraintRef.owner); + } + return null; + } + + return id; +} + +interface ColumnStableIdParts { + schema: string; + table: string; + column: string; +} + +interface ConstraintStableIdParts { + schema: string; + owner: string; + constraint: string; +} + +function splitStableIdParts(value: string): string[] { + const parts: string[] = []; + let partStart = 0; + let inQuotedIdentifier = false; + + for (let index = 0; index < value.length; index++) { + const char = value[index]; + + if (char === '"') { + if (inQuotedIdentifier && value[index + 1] === '"') { + index++; + continue; + } + inQuotedIdentifier = !inQuotedIdentifier; + continue; + } + + if (char === "." && !inQuotedIdentifier) { + parts.push(value.slice(partStart, index)); + partStart = index + 1; + } + } + + parts.push(value.slice(partStart)); + return parts; +} + +function parseColumnStableId(stableId: string): ColumnStableIdParts | null { + if (!stableId.startsWith("column:")) return null; + const parts = splitStableIdParts(stableId.slice("column:".length)); + if (parts.length < 3) return null; + const [schema, table, ...columnParts] = parts; + return { + schema, + table, + column: columnParts.join("."), + }; +} + +function parseConstraintStableId( + stableId: string, +): ConstraintStableIdParts | null { + if (!stableId.startsWith("constraint:")) return null; + const parts = splitStableIdParts(stableId.slice("constraint:".length)); + if (parts.length < 3) return null; + const [schema, owner, ...constraintParts] = parts; + return { + schema, + owner, + constraint: constraintParts.join("."), + }; +} + +function resolveObjectForStableId( + stableId: string, + mainCatalog: Catalog, + branchCatalog: Catalog, +): ResolvedObject | null { + if (stableId.startsWith("table:")) { + const main = mainCatalog.tables[stableId]; + const branch = branchCatalog.tables[stableId]; + return main && branch ? { kind: "table", main, branch } : null; + } + + if (stableId.startsWith("view:")) { + const main = mainCatalog.views[stableId]; + const branch = branchCatalog.views[stableId]; + return main && branch ? { kind: "view", main, branch } : null; + } + + if (stableId.startsWith("materializedView:")) { + const main = mainCatalog.materializedViews[stableId]; + const branch = branchCatalog.materializedViews[stableId]; + return main && branch ? { kind: "materialized_view", main, branch } : null; + } + + if (stableId.startsWith("index:")) { + const main = mainCatalog.indexes[stableId]; + const branch = branchCatalog.indexes[stableId]; + return main && branch + ? { + kind: "index", + main, + branch, + branchIndexableObject: + branchCatalog.indexableObjects[branch.tableStableId], + branchMaterializedView: + branchCatalog.materializedViews[branch.tableStableId], + branchTable: branchCatalog.tables[branch.tableStableId], + } + : null; + } + + if (stableId.startsWith("trigger:")) { + const main = mainCatalog.triggers[stableId]; + const branch = branchCatalog.triggers[stableId]; + if (main && branch) { + const branchTableId = `table:${branch.schema}.${branch.table_name}`; + return { + kind: "trigger", + main, + branch, + branchIndexableObject: branchCatalog.indexableObjects[branchTableId], + }; + } + return null; + } + + if (stableId.startsWith("rule:")) { + const main = mainCatalog.rules[stableId]; + const branch = branchCatalog.rules[stableId]; + return main && branch ? { kind: "rule", main, branch } : null; + } + + if (stableId.startsWith("procedure:")) { + const main = mainCatalog.procedures[stableId]; + const branch = branchCatalog.procedures[stableId]; + return main && branch ? { kind: "procedure", main, branch } : null; + } + + if (stableId.startsWith("aggregate:")) { + const main = mainCatalog.aggregates[stableId]; + const branch = branchCatalog.aggregates[stableId]; + return main && branch ? { kind: "aggregate", main, branch } : null; + } + + if (stableId.startsWith("rlsPolicy:")) { + const main = mainCatalog.rlsPolicies[stableId]; + const branch = branchCatalog.rlsPolicies[stableId]; + return main && branch ? { kind: "rls_policy", main, branch } : null; + } + + if (stableId.startsWith("domain:")) { + const main = mainCatalog.domains[stableId]; + const branch = branchCatalog.domains[stableId]; + return main && branch ? { kind: "domain", main, branch } : null; + } + + if (stableId.startsWith("type:")) { + const enumMain = mainCatalog.enums[stableId]; + const enumBranch = branchCatalog.enums[stableId]; + if (enumMain && enumBranch) { + return { kind: "enum", main: enumMain, branch: enumBranch }; } const rangeMain = mainCatalog.ranges[stableId]; @@ -542,6 +2682,316 @@ function resolveObjectForStableId( return null; } +function buildRetainedIndexReplacementChanges({ + relationStableId, + mainCatalog, + branchCatalog, + createdIds, + droppedIds, + visitedTargets, + diffContext, +}: { + relationStableId: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + createdIds: ReadonlySet; + droppedIds: ReadonlySet; + visitedTargets: Set; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): Change[] { + const changes: Change[] = []; + // Dropping a table or materialized view also drops its indexes. A retained + // relation replacement therefore has to recreate retained indexes from the + // branch catalog, even when pg_depend does not expose a standalone + // index -> relation edge for the graph walk. + const branchIndexes = Object.values(branchCatalog.indexes) + .filter((index) => index.tableStableId === relationStableId) + .sort((a, b) => a.stableId.localeCompare(b.stableId)); + + for (const branchIndex of branchIndexes) { + const indexId = branchIndex.stableId; + if (visitedTargets.has(indexId)) continue; + if (!mainCatalog.indexes[indexId]) continue; + if (createdIds.has(indexId) && droppedIds.has(indexId)) continue; + + const resolved = resolveObjectForStableId( + indexId, + mainCatalog, + branchCatalog, + ); + if (!resolved || resolved.kind !== "index") continue; + + const addDrop = !droppedIds.has(indexId); + const addCreate = !createdIds.has(indexId); + const replacementChanges = buildReplaceChanges(resolved, { + addDrop, + addCreate, + diffContext, + }); + if (!replacementChanges) { + const retainedConstraintBackedIndexChanges = + buildRetainedConstraintBackedIndexMetadataChanges(resolved); + if (retainedConstraintBackedIndexChanges.length > 0) { + changes.push(...retainedConstraintBackedIndexChanges); + visitedTargets.add(indexId); + } + continue; + } + + changes.push(...replacementChanges); + visitedTargets.add(indexId); + } + + return changes; +} + +function buildRootRetainedIndexReplacementChanges({ + replaceRoots, + mainCatalog, + branchCatalog, + createdIds, + droppedIds, + visitedTargets, + diffContext, +}: { + replaceRoots: ReadonlySet; + mainCatalog: Catalog; + branchCatalog: Catalog; + createdIds: ReadonlySet; + droppedIds: ReadonlySet; + visitedTargets: Set; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): Change[] { + const changes: Change[] = []; + for (const relationStableId of [...replaceRoots].sort()) { + if ( + !( + mainCatalog.tables[relationStableId] && + branchCatalog.tables[relationStableId] + ) && + !( + mainCatalog.materializedViews[relationStableId] && + branchCatalog.materializedViews[relationStableId] + ) + ) { + continue; + } + + changes.push( + ...buildRetainedIndexReplacementChanges({ + relationStableId, + mainCatalog, + branchCatalog, + createdIds, + droppedIds, + visitedTargets, + diffContext, + }), + ); + } + return changes; +} + +function buildRetainedOwnedSequenceReplacementChanges({ + table, + mainCatalog, + branchCatalog, + createdIds, + diffContext, +}: { + table: Catalog["tables"][string]; + mainCatalog: Catalog; + branchCatalog: Catalog; + createdIds: ReadonlySet; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): Change[] { + if (!diffContext) return []; + + const changes: Change[] = []; + const mainSequences = Object.values(mainCatalog.sequences) + .filter( + (sequence) => + sequence.owned_by_schema === table.schema && + sequence.owned_by_table === table.name && + sequence.owned_by_column !== null, + ) + .sort((a, b) => a.stableId.localeCompare(b.stableId)); + + for (const mainSequence of mainSequences) { + const branchSequence = branchCatalog.sequences[mainSequence.stableId]; + if (!branchSequence) continue; + if (createdIds.has(branchSequence.stableId)) continue; + + changes.push( + ...(diffSequences( + diffContext, + {}, + { [branchSequence.stableId]: branchSequence }, + branchCatalog.tables, + mainCatalog.tables, + ) as Change[]), + ); + + const ownedByColumn = branchSequence.owned_by_column; + if (ownedByColumn === null) continue; + + const ownedBySchema = branchSequence.owned_by_schema; + const ownedByTable = branchSequence.owned_by_table; + if (ownedBySchema === null || ownedByTable === null) continue; + + const branchOwnedTable = + branchCatalog.tables[stableId.table(ownedBySchema, ownedByTable)]; + if (!branchOwnedTable) continue; + + const ownedColumn = branchOwnedTable.columns.find( + (column) => column.name === ownedByColumn, + ); + if (ownedColumn && ownedColumn.default !== null) { + changes.push( + new AlterTableAlterColumnSetDefault({ + table: branchOwnedTable, + column: ownedColumn, + }), + ); + } + } + + return changes; +} + +function buildRetainedPublicationMembershipChanges({ + table, + mainCatalog, + branchCatalog, + existingChanges, +}: { + table: Catalog["tables"][string]; + mainCatalog: Catalog; + branchCatalog: Catalog; + existingChanges: readonly Change[]; +}): Change[] { + const changes: Change[] = []; + + for (const branchPublication of Object.values( + branchCatalog.publications, + ).sort((a, b) => a.stableId.localeCompare(b.stableId))) { + const mainPublication = + mainCatalog.publications[branchPublication.stableId]; + if (!mainPublication) continue; + + const branchTable = branchPublication.tables.find( + (publicationTable) => + publicationTable.schema === table.schema && + publicationTable.name === table.name, + ); + if (!branchTable) continue; + + const retainedInMain = mainPublication.tables.some( + (publicationTable) => + publicationTable.schema === branchTable.schema && + publicationTable.name === branchTable.name, + ); + if (!retainedInMain) continue; + + if ( + publicationTableChangeCovered({ + changes: [...existingChanges, ...changes], + publicationId: branchPublication.stableId, + table: branchTable, + changeType: "add", + }) + ) { + continue; + } + + changes.push( + new AlterPublicationAddTables({ + publication: branchPublication, + tables: [branchTable], + }), + ); + } + + return changes; +} + +function recordChangeIds( + changes: readonly Change[], + createdIds: Set, + droppedIds: Set, +): void { + for (const change of changes) { + for (const id of change.creates ?? []) createdIds.add(id); + for (const id of change.drops ?? []) droppedIds.add(id); + } +} + +function isCreateAlreadyCovered( + change: Change, + createdIds: ReadonlySet, +): boolean { + const creates = change.creates ?? []; + return creates.length > 0 && creates.every((id) => createdIds.has(id)); +} + +function buildRetainedClusterChange( + resolved: Extract, +): Change[] { + if (!resolved.branch.is_clustered) return []; + if (resolved.branch.table_relkind === "m") { + return resolved.branchMaterializedView + ? [ + new AlterMaterializedViewClusterOn({ + materializedView: resolved.branchMaterializedView, + indexName: resolved.branch.name, + }), + ] + : []; + } + + return resolved.branchTable + ? [ + new AlterTableClusterOn({ + table: resolved.branchTable, + indexName: resolved.branch.name, + }), + ] + : []; +} + +function buildRetainedConstraintBackedIndexMetadataChanges( + resolved: Extract, +): Change[] { + if (!(resolved.branch.is_owned_by_constraint || resolved.branch.is_primary)) { + return []; + } + return [ + ...(resolved.branch.comment !== null + ? [new CreateCommentOnIndex({ index: resolved.branch })] + : []), + ...buildRetainedIndexStatisticsChanges(resolved.branch), + ...buildRetainedClusterChange(resolved), + ...(resolved.branch.is_replica_identity && resolved.branchTable + ? [ + new AlterTableSetReplicaIdentity({ + table: resolved.branchTable, + mode: "i", + indexName: resolved.branch.name, + }), + ] + : []), + ]; +} + function buildReplaceChanges( resolved: ResolvedObject, options: { @@ -562,31 +3012,7 @@ function buildReplaceChanges( return [ ...(addDrop ? [new DropTable({ table: resolved.main })] : []), ...(addCreate - ? [ - new CreateTable({ table: resolved.branch }), - ...((resolved.branch.constraints ?? []) - .filter((c) => !c.is_partition_clone) - .flatMap((constraint) => { - const items: Change[] = [ - new AlterTableAddConstraint({ - table: resolved.branch, - constraint, - }), - ]; - if ( - constraint.comment !== null && - constraint.comment !== undefined - ) { - items.push( - new CreateCommentOnConstraint({ - table: resolved.branch, - constraint, - }), - ); - } - return items; - }) as Change[]), - ] + ? buildCreateTableReplacementChanges(resolved.branch, diffContext) : []), ]; case "view": @@ -638,6 +3064,61 @@ function buildReplaceChanges( index: resolved.branch, indexableObject: resolved.branchIndexableObject, }), + ...(resolved.branch.comment !== null + ? [new CreateCommentOnIndex({ index: resolved.branch })] + : []), + // CREATE INDEX does not carry expression-index statistics, so + // replay retained targets after the replacement index exists. + ...buildRetainedIndexStatisticsChanges(resolved.branch), + ...buildRetainedClusterChange(resolved), + ...(resolved.branch.is_replica_identity && resolved.branchTable + ? [ + new AlterTableSetReplicaIdentity({ + table: resolved.branchTable, + mode: "i", + indexName: resolved.branch.name, + }), + ] + : []), + ] + : []), + ]; + case "trigger": + if ( + resolved.main.is_partition_clone || + resolved.branch.is_partition_clone + ) { + return null; + } + return [ + ...(addDrop ? [new DropTrigger({ trigger: resolved.main })] : []), + ...(addCreate + ? [ + new CreateTrigger({ + trigger: resolved.branch, + indexableObject: resolved.branchIndexableObject, + }), + ...(resolved.branch.comment !== null + ? [new CreateCommentOnTrigger({ trigger: resolved.branch })] + : []), + ...(resolved.branch.enabled !== "O" + ? [new SetTriggerEnabledState({ trigger: resolved.branch })] + : []), + ] + : []), + ]; + case "rule": + return [ + ...(addDrop ? [new DropRule({ rule: resolved.main })] : []), + ...(addCreate + ? [ + new CreateRule({ rule: resolved.branch }), + ...(resolved.branch.comment !== null + ? [new CreateCommentOnRule({ rule: resolved.branch })] + : []), + ...(resolved.branch.enabled !== "O" + ? [new SetRuleEnabledState({ rule: resolved.branch })] + : []), ] : []), ]; @@ -645,7 +3126,26 @@ function buildReplaceChanges( return [ ...(addDrop ? [new DropProcedure({ procedure: resolved.main })] : []), ...(addCreate - ? [new CreateProcedure({ procedure: resolved.branch })] + ? [ + new CreateProcedure({ procedure: resolved.branch }), + ...buildRetainedProcedureMetadataChanges({ + procedure: resolved.branch, + diffContext, + }), + ] + : []), + ]; + case "aggregate": + return [ + ...(addDrop ? [new DropAggregate({ aggregate: resolved.main })] : []), + ...(addCreate + ? [ + new CreateAggregate({ aggregate: resolved.branch }), + ...buildRetainedAggregateMetadataChanges({ + aggregate: resolved.branch, + diffContext, + }), + ] : []), ]; case "rls_policy": @@ -689,6 +3189,221 @@ function buildReplaceChanges( } } +function buildRetainedIndexStatisticsChanges( + index: Catalog["indexes"][string], +): Change[] { + const columnTargets = index.statistics_target + .map((statistics, index) => ({ + columnNumber: index + 1, + statistics, + })) + .filter(({ statistics }) => statistics >= 0); + + return columnTargets.length > 0 + ? [new AlterIndexSetStatistics({ index, columnTargets })] + : []; +} + +function buildRetainedProcedureMetadataChanges({ + procedure, + diffContext, +}: { + procedure: Catalog["procedures"][string]; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): Change[] { + const changes: Change[] = []; + const ownerRestore = + diffContext && procedure.owner !== diffContext.currentUser + ? new AlterProcedureChangeOwner({ + procedure, + owner: procedure.owner, + }) + : null; + + if (procedure.comment !== null) { + changes.push(new CreateCommentOnProcedure({ procedure })); + } + + for (const securityLabel of procedure.security_labels) { + changes.push( + new CreateSecurityLabelOnProcedure({ + procedure, + securityLabel, + }), + ); + } + + if (!diffContext) return changes; + + const effectiveDefaults = + diffContext.defaultPrivilegeState.getEffectiveDefaults( + diffContext.currentUser, + "procedure", + procedure.schema ?? "", + ); + const creatorFilteredDefaults = + procedure.owner !== diffContext.currentUser + ? effectiveDefaults.filter((p) => p.grantee !== diffContext.currentUser) + : effectiveDefaults; + const desiredPrivileges = filterPublicBuiltInDefaults( + "procedure", + procedure.privileges, + ); + const privilegeResults = diffPrivileges( + filterPublicBuiltInDefaults("procedure", creatorFilteredDefaults), + desiredPrivileges, + procedure.owner, + ); + + changes.push( + ...(emitObjectPrivilegeChanges( + privilegeResults, + procedure, + procedure, + "procedure", + { + Grant: GrantProcedurePrivileges, + Revoke: RevokeProcedurePrivileges, + RevokeGrantOption: RevokeGrantOptionProcedurePrivileges, + }, + diffContext.version, + ) as Change[]), + ); + if (ownerRestore) { + changes.push(ownerRestore); + } + + return changes; +} + +function buildRetainedAggregateMetadataChanges({ + aggregate, + diffContext, +}: { + aggregate: Catalog["aggregates"][string]; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): Change[] { + const changes: Change[] = []; + const ownerRestore = + diffContext && aggregate.owner !== diffContext.currentUser + ? new AlterAggregateChangeOwner({ + aggregate, + owner: aggregate.owner, + }) + : null; + + if (aggregate.comment !== null) { + changes.push(new CreateCommentOnAggregate({ aggregate })); + } + + for (const securityLabel of aggregate.security_labels) { + changes.push( + new CreateSecurityLabelOnAggregate({ + aggregate, + securityLabel, + }), + ); + } + + if (!diffContext) return changes; + + const effectiveDefaults = + diffContext.defaultPrivilegeState.getEffectiveDefaults( + diffContext.currentUser, + "aggregate", + aggregate.schema ?? "", + ); + const creatorFilteredDefaults = + aggregate.owner !== diffContext.currentUser + ? effectiveDefaults.filter((p) => p.grantee !== diffContext.currentUser) + : effectiveDefaults; + const desiredPrivileges = filterPublicBuiltInDefaults( + "aggregate", + aggregate.privileges, + ); + const privilegeResults = diffPrivileges( + filterPublicBuiltInDefaults("aggregate", creatorFilteredDefaults), + desiredPrivileges, + aggregate.owner, + ); + + changes.push( + ...(emitObjectPrivilegeChanges( + privilegeResults, + aggregate, + aggregate, + "aggregate", + { + Grant: GrantAggregatePrivileges, + Revoke: RevokeAggregatePrivileges, + RevokeGrantOption: RevokeGrantOptionAggregatePrivileges, + }, + diffContext.version, + ) as Change[]), + ); + if (ownerRestore) { + changes.push(ownerRestore); + } + + return changes; +} + +function buildCreateTableReplacementChanges( + table: Catalog["tables"][string], + diffContext: + | Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + > + | undefined, +): Change[] { + if (diffContext) { + return moveTableOwnerRestoresAfterReplay( + diffTables(diffContext, {}, { [table.stableId]: table }) as Change[], + ); + } + + return [ + new CreateTable({ table }), + ...((table.constraints ?? []) + .filter((constraint) => !constraint.is_partition_clone) + .flatMap((constraint) => { + const items: Change[] = [ + new AlterTableAddConstraint({ + table, + constraint, + }), + ]; + if (constraint.comment !== null && constraint.comment !== undefined) { + items.push( + new CreateCommentOnConstraint({ + table, + constraint, + }), + ); + } + return items; + }) as Change[]), + ]; +} + +function moveTableOwnerRestoresAfterReplay(changes: Change[]): Change[] { + const ownerRestores = changes.filter( + (change) => change instanceof AlterTableChangeOwner, + ); + if (ownerRestores.length === 0) return changes; + return [ + ...changes.filter((change) => !(change instanceof AlterTableChangeOwner)), + ...ownerRestores, + ]; +} + function buildCreateViewReplacementChanges( view: Catalog["views"][string], diffContext: diff --git a/packages/pg-delta/src/core/objects/index/changes/index.create.test.ts b/packages/pg-delta/src/core/objects/index/changes/index.create.test.ts index 17a36fd91..836ac3519 100644 --- a/packages/pg-delta/src/core/objects/index/changes/index.create.test.ts +++ b/packages/pg-delta/src/core/objects/index/changes/index.create.test.ts @@ -66,4 +66,62 @@ describe("index", () => { "CREATE INDEX test_index ON public.test_table (id)", ); }); + + test("requires materialized view stable id for materialized view indexes", () => { + const index = new Index({ + schema: "public", + table_name: "test_matview", + name: "test_matview_idx", + storage_params: [], + statistics_target: [0], + index_type: "btree", + tablespace: null, + is_unique: false, + is_primary: false, + is_exclusion: false, + is_owned_by_constraint: false, + nulls_not_distinct: false, + immediate: true, + is_clustered: false, + is_replica_identity: false, + key_columns: [1], + column_collations: [], + operator_classes: [], + column_options: [], + index_expressions: null, + partial_predicate: null, + table_relkind: "m", + is_partitioned_index: false, + is_index_partition: false, + parent_index_name: null, + definition: "CREATE INDEX test_matview_idx ON public.test_matview (id)", + comment: null, + owner: "test", + }); + + const columns: ColumnProps[] = [ + { + name: "id", + position: 1, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ]; + const change = new CreateIndex({ index, indexableObject: { columns } }); + + expect(change.requires).toContain("materializedView:public.test_matview"); + expect(change.requires).not.toContain("table:public.test_matview"); + }); }); diff --git a/packages/pg-delta/src/core/objects/index/changes/index.create.ts b/packages/pg-delta/src/core/objects/index/changes/index.create.ts index ab1efbafc..2702a023c 100644 --- a/packages/pg-delta/src/core/objects/index/changes/index.create.ts +++ b/packages/pg-delta/src/core/objects/index/changes/index.create.ts @@ -43,8 +43,8 @@ export class CreateIndex extends CreateIndexChange { // Schema dependency dependencies.add(stableId.schema(this.index.schema)); - // Table dependency - dependencies.add(stableId.table(this.index.schema, this.index.table_name)); + // Relation dependency + dependencies.add(this.index.tableStableId); // Owner dependency dependencies.add(stableId.role(this.index.owner)); diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.ts index 53fd1cd8e..ae0ab4c0c 100644 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.ts +++ b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.ts @@ -1,4 +1,5 @@ import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; +import { stableId } from "../../utils.ts"; import type { MaterializedView } from "../materialized-view.model.ts"; import { AlterMaterializedViewChange } from "./materialized-view.base.ts"; @@ -34,6 +35,7 @@ import { AlterMaterializedViewChange } from "./materialized-view.base.ts"; export type AlterMaterializedView = | AlterMaterializedViewChangeOwner + | AlterMaterializedViewClusterOn | AlterMaterializedViewSetStorageParams; /** @@ -64,6 +66,44 @@ export class AlterMaterializedViewChangeOwner extends AlterMaterializedViewChang } } +/** + * ALTER MATERIALIZED VIEW ... CLUSTER ON ... + */ +export class AlterMaterializedViewClusterOn extends AlterMaterializedViewChange { + public readonly materializedView: MaterializedView; + public readonly indexName: string; + public readonly scope = "object" as const; + + constructor(props: { + materializedView: MaterializedView; + indexName: string; + }) { + super(); + this.materializedView = props.materializedView; + this.indexName = props.indexName; + } + + get requires() { + return [ + this.materializedView.stableId, + stableId.index( + this.materializedView.schema, + this.materializedView.name, + this.indexName, + ), + ]; + } + + serialize(_options?: SerializeOptions): string { + return [ + "ALTER MATERIALIZED VIEW", + `${this.materializedView.schema}.${this.materializedView.name}`, + "CLUSTER ON", + this.indexName, + ].join(" "); + } +} + /** * ALTER MATERIALIZED VIEW ... SET/RESET ( storage_parameter ... ) * Accepts main and branch, computes differences, and emits RESET then SET statements. diff --git a/packages/pg-delta/src/core/objects/publication/changes/publication.alter.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.alter.ts index a5649d138..57195c352 100644 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.alter.ts +++ b/packages/pg-delta/src/core/objects/publication/changes/publication.alter.ts @@ -92,7 +92,7 @@ export class AlterPublicationSetList extends AlterPublicationChange { export class AlterPublicationAddTables extends AlterPublicationChange { public readonly publication: Publication; public readonly scope = "object" as const; - private readonly tables: PublicationTableProps[]; + public readonly tables: PublicationTableProps[]; constructor(props: { publication: Publication; diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.ts index b18c2e5a1..5a1ae7e1d 100644 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.ts +++ b/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.ts @@ -17,7 +17,42 @@ import { AlterSequenceChange } from "./sequence.base.ts"; * ``` */ -export type AlterSequence = AlterSequenceSetOptions | AlterSequenceSetOwnedBy; +export type AlterSequence = + | AlterSequenceChangeOwner + | AlterSequenceSetOptions + | AlterSequenceSetOwnedBy; + +/** + * ALTER SEQUENCE ... OWNER TO ... + */ +export class AlterSequenceChangeOwner extends AlterSequenceChange { + public readonly sequence: Sequence; + public readonly owner: string; + public readonly scope = "object" as const; + + constructor(props: { sequence: Sequence; owner: string }) { + super(); + this.sequence = props.sequence; + this.owner = props.owner; + } + + get creates() { + return []; + } + + get requires() { + return [this.sequence.stableId, stableId.role(this.owner)]; + } + + serialize(_options?: SerializeOptions): string { + return [ + "ALTER SEQUENCE", + `${this.sequence.schema}.${this.sequence.name}`, + "OWNER TO", + this.owner, + ].join(" "); + } +} /** * ALTER SEQUENCE ... OWNED BY ... | OWNED BY NONE diff --git a/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts b/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts index 071067b86..0585d2f3f 100644 --- a/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts +++ b/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts @@ -3,6 +3,7 @@ import { DefaultPrivilegeState } from "../base.default-privileges.ts"; import { AlterTableAlterColumnSetDefault } from "../table/changes/table.alter.ts"; import { Table } from "../table/table.model.ts"; import { + AlterSequenceChangeOwner, AlterSequenceSetOptions, AlterSequenceSetOwnedBy, } from "./changes/sequence.alter.ts"; @@ -71,6 +72,39 @@ describe.concurrent("sequence.diff", () => { expect(changes[0]).toBeInstanceOf(AlterSequenceSetOwnedBy); }); + test("detaches existing owned sequence before changing owner", () => { + const main = new Sequence({ + ...base, + owner: "old_owner", + owned_by_schema: "public", + owned_by_table: "old_items", + owned_by_column: "id", + }); + const branch = new Sequence({ + ...base, + owner: "new_owner", + owned_by_schema: null, + owned_by_table: null, + owned_by_column: null, + }); + + const changes = diffSequences( + testContext, + { [main.stableId]: main }, + { [branch.stableId]: branch }, + ); + const detachIndex = changes.findIndex( + (change) => + change instanceof AlterSequenceSetOwnedBy && change.ownedBy === null, + ); + const ownerIndex = changes.findIndex( + (change) => change instanceof AlterSequenceChangeOwner, + ); + + expect(detachIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(detachIndex); + }); + test("alter options via diff", () => { const main = new Sequence(base); const branch = new Sequence({ @@ -108,6 +142,35 @@ describe.concurrent("sequence.diff", () => { expect(changes[1]).toBeInstanceOf(CreateSequence); }); + test("restores owner after replacing a sequence", () => { + const main = new Sequence({ + ...base, + persistence: "u", + owner: "old_owner", + }); + const branch = new Sequence({ + ...base, + persistence: "p", + owner: "app_owner", + }); + + const changes = diffSequences( + testContext, + { [main.stableId]: main }, + { [branch.stableId]: branch }, + ); + + const createIndex = changes.findIndex( + (change) => change instanceof CreateSequence, + ); + const ownerIndex = changes.findIndex( + (change) => change instanceof AlterSequenceChangeOwner, + ); + + expect(createIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(createIndex); + }); + test("replacing an owned sequence re-emits the owning column default", () => { // Use `persistence` (UNLOGGED → LOGGED) to trigger the // non-alterable replace path: it's the only field still in @@ -177,11 +240,12 @@ describe.concurrent("sequence.diff", () => { { [branchTable.stableId]: branchTable }, ); - expect(changes).toHaveLength(4); + expect(changes).toHaveLength(5); expect(changes[0]).toBeInstanceOf(DropSequence); expect(changes[1]).toBeInstanceOf(CreateSequence); - expect(changes[2]).toBeInstanceOf(AlterSequenceSetOwnedBy); - expect(changes[3]).toBeInstanceOf(AlterTableAlterColumnSetDefault); + expect(changes[2]).toBeInstanceOf(AlterSequenceChangeOwner); + expect(changes[3]).toBeInstanceOf(AlterSequenceSetOwnedBy); + expect(changes[4]).toBeInstanceOf(AlterTableAlterColumnSetDefault); }); test("skip DROP SEQUENCE when owned by table being dropped", () => { diff --git a/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts b/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts index f8f6b4d97..075e9c466 100644 --- a/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts +++ b/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts @@ -9,6 +9,7 @@ import { AlterTableAlterColumnSetDefault } from "../table/changes/table.alter.ts import type { Table } from "../table/table.model.ts"; import { hasNonAlterableChanges } from "../utils.ts"; import { + AlterSequenceChangeOwner, AlterSequenceSetOptions, AlterSequenceSetOwnedBy, } from "./changes/sequence.alter.ts"; @@ -61,6 +62,14 @@ export function diffSequences( for (const sequenceId of created) { const createdSeq = branch[sequenceId]; changes.push(new CreateSequence({ sequence: createdSeq })); + if (createdSeq.owner !== ctx.currentUser) { + changes.push( + new AlterSequenceChangeOwner({ + sequence: createdSeq, + owner: createdSeq.owner, + }), + ); + } if (createdSeq.comment !== null) { changes.push(new CreateCommentOnSequence({ sequence: createdSeq })); } @@ -201,6 +210,14 @@ export function diffSequences( changes.push(new DropSequence({ sequence: mainSequence })); } changes.push(new CreateSequence({ sequence: branchSequence })); + if (branchSequence.owner !== ctx.currentUser) { + changes.push( + new AlterSequenceChangeOwner({ + sequence: branchSequence, + owner: branchSequence.owner, + }), + ); + } // Re-apply OWNED BY if present on branch if ( branchSequence.owned_by_schema !== null && @@ -310,6 +327,31 @@ export function diffSequences( mainSequence.owned_by_schema !== branchSequence.owned_by_schema || mainSequence.owned_by_table !== branchSequence.owned_by_table || mainSequence.owned_by_column !== branchSequence.owned_by_column; + const ownerChanged = mainSequence.owner !== branchSequence.owner; + const mainOwnedBy = + mainSequence.owned_by_schema !== null || + mainSequence.owned_by_table !== null || + mainSequence.owned_by_column !== null; + const detachBeforeOwnerChange = + ownerChanged && ownedByChanged && mainOwnedBy; + + if (detachBeforeOwnerChange) { + changes.push( + new AlterSequenceSetOwnedBy({ + sequence: mainSequence, + ownedBy: null, + }), + ); + } + + if (ownerChanged) { + changes.push( + new AlterSequenceChangeOwner({ + sequence: mainSequence, + owner: branchSequence.owner, + }), + ); + } if (ownedByChanged) { const ownedBy = @@ -322,9 +364,11 @@ export function diffSequences( column: branchSequence.owned_by_column, } : null; - changes.push( - new AlterSequenceSetOwnedBy({ sequence: mainSequence, ownedBy }), - ); + if (!(detachBeforeOwnerChange && ownedBy === null)) { + changes.push( + new AlterSequenceSetOwnedBy({ sequence: mainSequence, ownedBy }), + ); + } } // COMMENT diff --git a/packages/pg-delta/src/core/objects/table/changes/table.alter.ts b/packages/pg-delta/src/core/objects/table/changes/table.alter.ts index 369d9efe5..909c3f075 100644 --- a/packages/pg-delta/src/core/objects/table/changes/table.alter.ts +++ b/packages/pg-delta/src/core/objects/table/changes/table.alter.ts @@ -73,6 +73,7 @@ export type AlterTable = | AlterTableAlterColumnType | AlterTableAttachPartition | AlterTableChangeOwner + | AlterTableClusterOn | AlterTableDetachPartition | AlterTableDisableRowLevelSecurity | AlterTableDropColumn @@ -115,6 +116,37 @@ export class AlterTableChangeOwner extends AlterTableChange { } } +/** + * ALTER TABLE ... CLUSTER ON ... + */ +export class AlterTableClusterOn extends AlterTableChange { + public readonly table: Table; + public readonly indexName: string; + public readonly scope = "object" as const; + + constructor(props: { table: Table; indexName: string }) { + super(); + this.table = props.table; + this.indexName = props.indexName; + } + + get requires() { + return [ + this.table.stableId, + stableId.index(this.table.schema, this.table.name, this.indexName), + ]; + } + + serialize(_options?: SerializeOptions): string { + return [ + "ALTER TABLE", + `${this.table.schema}.${this.table.name}`, + "CLUSTER ON", + this.indexName, + ].join(" "); + } +} + /** * ALTER TABLE ... SET LOGGED */ @@ -338,13 +370,27 @@ export class AlterTableAddConstraint extends AlterTableChange { } get creates() { - return [ + const creates: string[] = [ stableId.constraint( this.table.schema, this.table.name, this.constraint.name, ), ]; + if ( + this.constraint.constraint_type === "p" || + this.constraint.constraint_type === "u" || + this.constraint.constraint_type === "x" + ) { + creates.push( + stableId.index( + this.table.schema, + this.table.name, + this.constraint.name, + ), + ); + } + return creates; } get requires() { @@ -702,9 +748,21 @@ export class AlterTableAlterColumnSetDefault extends AlterTableChange { } get requires() { - return [ + const requirements = [ stableId.column(this.table.schema, this.table.name, this.column.name), ]; + + if (this.table.parent_schema && this.table.parent_name) { + requirements.push( + stableId.column( + this.table.parent_schema, + this.table.parent_name, + this.column.name, + ), + ); + } + + return requirements; } serialize(_options?: SerializeOptions): string { diff --git a/packages/pg-delta/src/core/objects/table/changes/table.create.test.ts b/packages/pg-delta/src/core/objects/table/changes/table.create.test.ts index b7e60e8bd..feb79009d 100644 --- a/packages/pg-delta/src/core/objects/table/changes/table.create.test.ts +++ b/packages/pg-delta/src/core/objects/table/changes/table.create.test.ts @@ -34,6 +34,41 @@ describe.concurrent("table.create", () => { expect(change.serialize()).toBe("CREATE TABLE public.t ()"); }); + test("partition create preserves child-specific generated expressions", async () => { + const t = new Table({ + ...base, + name: "t_2026", + is_partition: true, + parent_schema: "public", + parent_name: "parent", + partition_bound: "FOR VALUES FROM (2026) TO (2027)", + columns: [ + { + name: "total", + position: 1, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: true, + collation: null, + default: "public.compute_child_total(subtotal)", + comment: null, + }, + ], + }); + const change = new CreateTable({ table: t }); + expect(change.serialize()).toBe( + "CREATE TABLE public.t_2026 PARTITION OF public.parent (total GENERATED ALWAYS AS (public.compute_child_total(subtotal)) STORED) FOR VALUES FROM (2026) TO (2027)", + ); + }); + test("TEMPORARY with columns, inherits and options", async () => { const t = new Table({ ...base, diff --git a/packages/pg-delta/src/core/objects/table/changes/table.create.ts b/packages/pg-delta/src/core/objects/table/changes/table.create.ts index 4f2260d59..1caa12efd 100644 --- a/packages/pg-delta/src/core/objects/table/changes/table.create.ts +++ b/packages/pg-delta/src/core/objects/table/changes/table.create.ts @@ -115,10 +115,18 @@ export class CreateTable extends CreateTableChange { this.table.parent_name && this.table.partition_bound ) { + const columnOverrides = this.table.columns + .filter((col) => col.is_generated && col.default) + .map( + (col) => `${col.name} GENERATED ALWAYS AS (${col.default}) STORED`, + ); return [ ...parts, "PARTITION OF", `${this.table.parent_schema}.${this.table.parent_name}`, + ...(columnOverrides.length > 0 + ? [`(${columnOverrides.join(", ")})`] + : []), this.table.partition_bound, ].join(" "); } diff --git a/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.test.ts b/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.test.ts index f50673090..19469c6b5 100644 --- a/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.test.ts +++ b/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; +import { stableId } from "../../utils.ts"; import { Trigger, type TriggerProps } from "../trigger.model.ts"; -import { ReplaceTrigger } from "./trigger.alter.ts"; +import { ReplaceTrigger, SetTriggerEnabledState } from "./trigger.alter.ts"; describe.concurrent("trigger", () => { describe("alter", () => { @@ -46,5 +47,41 @@ describe.concurrent("trigger", () => { "CREATE OR REPLACE TRIGGER test_trigger AFTER UPDATE ON public.test_table EXECUTE FUNCTION public.test_function()", ); }); + + test("enabled-state restore requires the owning table", () => { + const trigger = new Trigger({ + schema: "public", + name: "test_trigger", + table_name: "test_table", + table_relkind: "r", + function_schema: "public", + function_name: "test_function", + trigger_type: 1 << 4, + enabled: "D", + is_internal: false, + deferrable: false, + initially_deferred: false, + argument_count: 0, + column_numbers: null, + arguments: [], + when_condition: null, + old_table: null, + new_table: null, + is_partition_clone: false, + parent_trigger_name: null, + parent_table_schema: null, + parent_table_name: null, + is_on_partitioned_table: false, + owner: "test", + definition: + "CREATE TRIGGER test_trigger AFTER UPDATE ON public.test_table EXECUTE FUNCTION public.test_function()", + comment: null, + }); + + const change = new SetTriggerEnabledState({ trigger }); + + expect(change.requires).toContain(trigger.stableId); + expect(change.requires).toContain(stableId.table("public", "test_table")); + }); }); }); diff --git a/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.ts b/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.ts index 1552f4c8d..7800a8a82 100644 --- a/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.ts +++ b/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.ts @@ -1,6 +1,7 @@ import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; import { quoteLiteral } from "../../base.change.ts"; import type { TableLikeObject } from "../../base.model.ts"; +import { stableId } from "../../utils.ts"; import type { Trigger } from "../trigger.model.ts"; import { AlterTriggerChange } from "./trigger.base.ts"; import { CreateTrigger } from "./trigger.create.ts"; @@ -17,7 +18,7 @@ import { DropTrigger } from "./trigger.drop.ts"; * ``` */ -export type AlterTrigger = ReplaceTrigger; +export type AlterTrigger = ReplaceTrigger | SetTriggerEnabledState; /** * Replace a trigger by dropping and recreating it. @@ -72,3 +73,44 @@ export class ReplaceTrigger extends AlterTriggerChange { return createChange.serialize(); } } + +export class SetTriggerEnabledState extends AlterTriggerChange { + public readonly trigger: Trigger; + public readonly enabled: Trigger["enabled"]; + public readonly scope = "object" as const; + + constructor(props: { trigger: Trigger; enabled?: Trigger["enabled"] }) { + super(); + this.trigger = props.trigger; + this.enabled = props.enabled ?? props.trigger.enabled; + } + + get requires() { + return [ + this.trigger.stableId, + stableId.table(this.trigger.schema, this.trigger.table_name), + ]; + } + + serialize(_options?: SerializeOptions): string { + const clause = clauseForState(this.enabled); + return `ALTER TABLE ${this.trigger.schema}.${this.trigger.table_name} ${clause} ${this.trigger.name}`; + } +} + +function clauseForState(state: Trigger["enabled"]) { + switch (state) { + case "O": + return "ENABLE TRIGGER"; + case "D": + return "DISABLE TRIGGER"; + case "R": + return "ENABLE REPLICA TRIGGER"; + case "A": + return "ENABLE ALWAYS TRIGGER"; + default: { + const _exhaustive: never = state; + return _exhaustive; + } + } +} diff --git a/packages/pg-delta/src/core/post-diff-normalization.test.ts b/packages/pg-delta/src/core/post-diff-normalization.test.ts index 906c084fa..3f9b2049b 100644 --- a/packages/pg-delta/src/core/post-diff-normalization.test.ts +++ b/packages/pg-delta/src/core/post-diff-normalization.test.ts @@ -10,7 +10,10 @@ import { type SequenceProps, } from "./objects/sequence/sequence.model.ts"; import { + AlterTableAddColumn, AlterTableAddConstraint, + AlterTableAlterColumnAddIdentity, + AlterTableAlterColumnDropIdentity, AlterTableChangeOwner, AlterTableDropColumn, AlterTableDropConstraint, @@ -181,6 +184,202 @@ describe("normalizePostDiffChanges", () => { expect(normalized).toContain(preExistingGrant); }); + test("dedupes table owner restores for replaced tables with last replacement replay winning", () => { + const mainTable = new Table({ + ...baseTableProps, + name: "items", + columns: [integerColumn("id", 1)], + owner: "postgres", + }); + const branchTable = new Table({ + ...baseTableProps, + name: "items", + columns: [integerColumn("id", 1)], + owner: "app_owner", + }); + + const preExistingOwnerRestore = new AlterTableChangeOwner({ + table: mainTable, + owner: "app_owner", + }); + const replacementOwnerRestore = new AlterTableChangeOwner({ + table: branchTable, + owner: "app_owner", + }); + const normalized = normalizePostDiffChanges({ + changes: [ + preExistingOwnerRestore, + new DropTable({ table: mainTable }), + new CreateTable({ table: branchTable }), + replacementOwnerRestore, + ], + replacedTableIds: new Set([mainTable.stableId]), + }); + const ownerRestores = normalized.filter( + (change): change is AlterTableChangeOwner => + change instanceof AlterTableChangeOwner, + ); + + expect(ownerRestores).toEqual([replacementOwnerRestore]); + }); + + test("prunes same-table add-column ALTERs for replaced tables only", () => { + const mainTable = new Table({ + ...baseTableProps, + name: "items", + columns: [integerColumn("id", 1)], + }); + const branchTable = new Table({ + ...baseTableProps, + name: "items", + columns: [integerColumn("id", 1), integerColumn("slug", 2)], + }); + const otherTable = new Table({ + ...baseTableProps, + name: "other_items", + columns: [integerColumn("id", 1), integerColumn("slug", 2)], + }); + + const replacedTableAddColumn = new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[1], + }); + const unrelatedAddColumn = new AlterTableAddColumn({ + table: otherTable, + column: otherTable.columns[1], + }); + + const normalized = normalizePostDiffChanges({ + changes: [ + new DropTable({ table: mainTable }), + new CreateTable({ table: branchTable }), + replacedTableAddColumn, + unrelatedAddColumn, + ], + replacedTableIds: new Set([mainTable.stableId]), + }); + + expect(normalized).not.toContain(replacedTableAddColumn); + expect(normalized).toContain(unrelatedAddColumn); + expect( + normalized.filter((change) => change instanceof AlterTableAddColumn), + ).toEqual([unrelatedAddColumn]); + }); + + test("prunes same-table identity-add ALTERs for replaced tables only", () => { + const mainTable = new Table({ + ...baseTableProps, + name: "items", + columns: [integerColumn("id", 1)], + }); + const branchTable = new Table({ + ...baseTableProps, + name: "items", + columns: [ + { + ...integerColumn("id", 1), + is_identity: true, + is_identity_always: true, + }, + ], + }); + const otherTable = new Table({ + ...baseTableProps, + name: "other_items", + columns: [ + { + ...integerColumn("id", 1), + is_identity: true, + is_identity_always: true, + }, + ], + }); + + const replacedTableAddIdentity = new AlterTableAlterColumnAddIdentity({ + table: branchTable, + column: branchTable.columns[0], + }); + const unrelatedAddIdentity = new AlterTableAlterColumnAddIdentity({ + table: otherTable, + column: otherTable.columns[0], + }); + + const normalized = normalizePostDiffChanges({ + changes: [ + new DropTable({ table: mainTable }), + new CreateTable({ table: branchTable }), + replacedTableAddIdentity, + unrelatedAddIdentity, + ], + replacedTableIds: new Set([mainTable.stableId]), + }); + + expect(normalized).not.toContain(replacedTableAddIdentity); + expect(normalized).toContain(unrelatedAddIdentity); + expect( + normalized.filter( + (change) => change instanceof AlterTableAlterColumnAddIdentity, + ), + ).toEqual([unrelatedAddIdentity]); + }); + + test("prunes same-table identity-drop ALTERs for replaced tables only", () => { + const mainTable = new Table({ + ...baseTableProps, + name: "items", + columns: [ + { + ...integerColumn("id", 1), + is_identity: true, + is_identity_always: true, + }, + ], + }); + const branchTable = new Table({ + ...baseTableProps, + name: "items", + columns: [integerColumn("id", 1)], + }); + const otherTable = new Table({ + ...baseTableProps, + name: "other_items", + columns: [ + { + ...integerColumn("id", 1), + is_identity: true, + is_identity_always: true, + }, + ], + }); + + const replacedTableDropIdentity = new AlterTableAlterColumnDropIdentity({ + table: mainTable, + column: mainTable.columns[0], + }); + const unrelatedDropIdentity = new AlterTableAlterColumnDropIdentity({ + table: otherTable, + column: otherTable.columns[0], + }); + + const normalized = normalizePostDiffChanges({ + changes: [ + new DropTable({ table: mainTable }), + new CreateTable({ table: branchTable }), + replacedTableDropIdentity, + unrelatedDropIdentity, + ], + replacedTableIds: new Set([mainTable.stableId]), + }); + + expect(normalized).not.toContain(replacedTableDropIdentity); + expect(normalized).toContain(unrelatedDropIdentity); + expect( + normalized.filter( + (change) => change instanceof AlterTableAlterColumnDropIdentity, + ), + ).toEqual([unrelatedDropIdentity]); + }); + test("dedupes duplicate constraint Add/Validate/Comment on replaced tables keeping last occurrence", async () => { const branchChildren = new Table({ ...baseTableProps, diff --git a/packages/pg-delta/src/core/post-diff-normalization.ts b/packages/pg-delta/src/core/post-diff-normalization.ts index 5854041d6..92f100053 100644 --- a/packages/pg-delta/src/core/post-diff-normalization.ts +++ b/packages/pg-delta/src/core/post-diff-normalization.ts @@ -3,7 +3,11 @@ import { CreateIndex } from "./objects/index/changes/index.create.ts"; import { DropIndex } from "./objects/index/changes/index.drop.ts"; import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts"; import { + AlterTableAddColumn, AlterTableAddConstraint, + AlterTableAlterColumnAddIdentity, + AlterTableAlterColumnDropIdentity, + AlterTableChangeOwner, AlterTableDropColumn, AlterTableDropConstraint, AlterTableSetReplicaIdentity, @@ -25,6 +29,9 @@ function isSupersededByTableReplacement( replacedTableIds: ReadonlySet, ): boolean { if ( + change instanceof AlterTableAddColumn || + change instanceof AlterTableAlterColumnAddIdentity || + change instanceof AlterTableAlterColumnDropIdentity || change instanceof AlterTableDropColumn || change instanceof AlterTableDropConstraint ) { @@ -127,6 +134,34 @@ function dropReplacedTableDuplicateConstraintChanges( return mutated ? reversedKept.reverse() : changes; } +function dropReplacedTableDuplicateOwnerChanges( + changes: Change[], + replacedTableIds: ReadonlySet, +): Change[] { + if (replacedTableIds.size === 0) return changes; + + const seen = new Set(); + const reversedKept: Change[] = []; + let mutated = false; + + for (let i = changes.length - 1; i >= 0; i--) { + const change = changes[i] as Change; + if ( + change instanceof AlterTableChangeOwner && + replacedTableIds.has(change.table.stableId) + ) { + if (seen.has(change.table.stableId)) { + mutated = true; + continue; + } + seen.add(change.table.stableId); + } + reversedKept.push(change); + } + + return mutated ? reversedKept.reverse() : changes; +} + /** * Re-emit `ALTER TABLE ... REPLICA IDENTITY USING INDEX ` after any * `DropIndex(idx) + CreateIndex(idx)` pair where `idx` is the replica-identity @@ -243,11 +278,12 @@ function restoreReplicaIdentityAfterIndexReplace( * * Concretely, this pass: * - * - Prunes `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)` + * - Prunes structural table alters (`AlterTableAddColumn(T.*)`, + * `AlterTableDropColumn(T.*)`, `AlterTableDropConstraint(T.*)`) * changes that are made redundant by an expansion-emitted * `DropTable(T) + CreateTable(T)` pair. Without this, the apply phase - * would try to drop a column that no longer exists in the freshly - * recreated table. + * would try to add/drop a column or drop a constraint that is already + * represented by the freshly recreated table. * - Prunes `DropSequence(S)` changes when `S` is `OWNED BY` a column on a * table promoted to `DropTable + CreateTable` by the expander. The * `DROP TABLE` cascade drops the sequence at apply time; emitting an @@ -283,10 +319,14 @@ export function normalizePostDiffChanges({ branchTables, ); - const dedupedChanges = dropReplacedTableDuplicateConstraintChanges( + const dedupedConstraintChanges = dropReplacedTableDuplicateConstraintChanges( restoredChanges, replacedTableIds, ); + const dedupedChanges = dropReplacedTableDuplicateOwnerChanges( + dedupedConstraintChanges, + replacedTableIds, + ); if (replacedTableIds.size === 0) return dedupedChanges; diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index c1c5ecdc8..e550bc8a2 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -1,15 +1,26 @@ import type { Change } from "../change.types.ts"; import { getSchema } from "../change-utils.ts"; +import { AlterAggregateChangeOwner } from "../objects/aggregate/changes/aggregate.alter.ts"; +import { AlterDomainChangeOwner } from "../objects/domain/changes/domain.alter.ts"; +import { AlterProcedureChangeOwner } from "../objects/procedure/changes/procedure.alter.ts"; +import { AlterPublicationSetOwner } from "../objects/publication/changes/publication.alter.ts"; import { GrantRoleDefaultPrivileges, RevokeRoleDefaultPrivileges, } from "../objects/role/changes/role.privilege.ts"; +import { AlterMaterializedViewChangeOwner } from "../objects/materialized-view/changes/materialized-view.alter.ts"; +import { + AlterSequenceChangeOwner, + AlterSequenceSetOwnedBy, +} from "../objects/sequence/changes/sequence.alter.ts"; import { AlterTableAlterColumnAddIdentity, AlterTableAlterColumnDropDefault, AlterTableAlterColumnDropIdentity, AlterTableAlterColumnSetDefault, + AlterTableChangeOwner, } from "../objects/table/changes/table.alter.ts"; +import { AlterViewChangeOwner } from "../objects/view/changes/view.alter.ts"; import type { Constraint } from "./types.ts"; /** @@ -157,6 +168,10 @@ function generateIdentityTransitionConstraints( const dropIdentityByColumn = new Map(); const addIdentityByColumn = new Map(); const setDefaultByColumn = new Map(); + const setDefaultChanges: Array<{ + index: number; + change: AlterTableAlterColumnSetDefault; + }> = []; for (let i = 0; i < changes.length; i++) { const change = changes[i]; @@ -182,6 +197,7 @@ function generateIdentityTransitionConstraints( const entries = setDefaultByColumn.get(columnKey) ?? []; entries.push(i); setDefaultByColumn.set(columnKey, entries); + setDefaultChanges.push({ index: i, change }); } } @@ -213,6 +229,678 @@ function generateIdentityTransitionConstraints( } } + for (const { index: targetIndex, change } of setDefaultChanges) { + if (!change.table.parent_schema || !change.table.parent_name) continue; + + const parentColumnKey = `${change.table.parent_schema}.${change.table.parent_name}.${change.column.name}`; + const parentSetDefaultIndexes = + setDefaultByColumn.get(parentColumnKey) ?? []; + for (const sourceIndex of parentSetDefaultIndexes) { + if (sourceIndex === targetIndex) continue; + constraints.push({ + sourceChangeIndex: sourceIndex, + targetChangeIndex: targetIndex, + source: "custom", + }); + } + } + + return constraints; +} + +function parseTableStableIdFromStableId(id: string): string | null { + if (id.startsWith("comment:")) { + return parseTableStableIdFromStableId(id.slice("comment:".length)); + } + if (id.startsWith("securityLabel:")) { + const objectId = id.slice("securityLabel:".length).split("::provider:")[0]; + return parseTableStableIdFromStableId(objectId); + } + if (id.startsWith("table:")) { + return id; + } + const parseSubEntity = (prefix: string) => { + if (!id.startsWith(prefix)) return null; + const parts = splitStableIdParts(id.slice(prefix.length)); + if (parts.length < 2) return null; + return `table:${parts[0]}.${parts[1]}`; + }; + return ( + parseSubEntity("column:") ?? + parseSubEntity("constraint:") ?? + parseSubEntity("index:") ?? + parseSubEntity("trigger:") ?? + parseSubEntity("rule:") ?? + parseSubEntity("rlsPolicy:") + ); +} + +function splitStableIdParts(value: string): string[] { + const parts: string[] = []; + let start = 0; + let inQuotedIdentifier = false; + + for (let index = 0; index < value.length; index++) { + const char = value[index]; + if (char === '"') { + if (inQuotedIdentifier && value[index + 1] === '"') { + index++; + continue; + } + inQuotedIdentifier = !inQuotedIdentifier; + continue; + } + if (char === "." && !inQuotedIdentifier) { + parts.push(value.slice(start, index)); + start = index + 1; + } + } + + parts.push(value.slice(start)); + return parts; +} + +function getRelatedTableStableIds(change: Change): Set { + const tableIds = new Set(); + if ( + "table" in change && + change.table && + typeof change.table === "object" && + "stableId" in change.table && + typeof change.table.stableId === "string" + ) { + tableIds.add(change.table.stableId); + } + if ( + "index" in change && + change.index && + typeof change.index === "object" && + "tableStableId" in change.index && + typeof change.index.tableStableId === "string" + ) { + tableIds.add(change.index.tableStableId); + } + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + const tableId = parseTableStableIdFromStableId(id); + if (tableId) tableIds.add(tableId); + } + return tableIds; +} + +function generateTableOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; tableId: string }> = []; + const changesByTable = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if ( + change.objectType !== "sequence" && + !(change instanceof AlterTableChangeOwner) + ) { + const tableIds = getRelatedTableStableIds(change); + for (const tableId of tableIds) { + const indexes = changesByTable.get(tableId) ?? []; + indexes.push(index); + changesByTable.set(tableId, indexes); + } + } + if (change instanceof AlterTableChangeOwner) { + ownerChanges.push({ index, tableId: change.table.stableId }); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = changesByTable.get(ownerChange.tableId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + +function parseMaterializedViewStableIdFromStableId(id: string): string | null { + if (id.startsWith("materializedView:")) { + return id; + } + return null; +} + +function getRelatedMaterializedViewStableIds(change: Change): Set { + const materializedViewIds = new Set(); + if ( + "materializedView" in change && + change.materializedView && + typeof change.materializedView === "object" && + "stableId" in change.materializedView && + typeof change.materializedView.stableId === "string" + ) { + materializedViewIds.add(change.materializedView.stableId); + } + if ( + "index" in change && + change.index && + typeof change.index === "object" && + "tableStableId" in change.index && + typeof change.index.tableStableId === "string" && + change.index.tableStableId.startsWith("materializedView:") + ) { + materializedViewIds.add(change.index.tableStableId); + } + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + const materializedViewId = parseMaterializedViewStableIdFromStableId(id); + if (materializedViewId) materializedViewIds.add(materializedViewId); + } + return materializedViewIds; +} + +function generateMaterializedViewOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; materializedViewId: string }> = []; + const changesByMaterializedView = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (!(change instanceof AlterMaterializedViewChangeOwner)) { + const materializedViewIds = getRelatedMaterializedViewStableIds(change); + for (const materializedViewId of materializedViewIds) { + const indexes = changesByMaterializedView.get(materializedViewId) ?? []; + indexes.push(index); + changesByMaterializedView.set(materializedViewId, indexes); + } + } else { + ownerChanges.push({ + index, + materializedViewId: change.materializedView.stableId, + }); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = + changesByMaterializedView.get(ownerChange.materializedViewId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + +function getRelatedViewStableIds(change: Change): Set { + const viewIds = new Set(); + if ( + "view" in change && + change.view && + typeof change.view === "object" && + "stableId" in change.view && + typeof change.view.stableId === "string" + ) { + viewIds.add(change.view.stableId); + } + if ( + "trigger" in change && + change.trigger && + typeof change.trigger === "object" && + "table_relkind" in change.trigger && + change.trigger.table_relkind === "v" && + "schema" in change.trigger && + typeof change.trigger.schema === "string" && + "table_name" in change.trigger && + typeof change.trigger.table_name === "string" + ) { + viewIds.add(`view:${change.trigger.schema}.${change.trigger.table_name}`); + } + if ( + "rule" in change && + change.rule && + typeof change.rule === "object" && + "relationStableId" in change.rule && + typeof change.rule.relationStableId === "string" && + change.rule.relationStableId.startsWith("view:") + ) { + viewIds.add(change.rule.relationStableId); + } + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + if (id.startsWith("view:")) { + viewIds.add(id); + } + if (id.startsWith("comment:")) { + const viewId = parseViewStableIdFromStableId(id.slice("comment:".length)); + if (viewId) viewIds.add(viewId); + } + if (id.startsWith("securityLabel:")) { + const objectId = id + .slice("securityLabel:".length) + .split("::provider:")[0]; + const viewId = parseViewStableIdFromStableId(objectId); + if (viewId) viewIds.add(viewId); + } + } + return viewIds; +} + +function parseViewStableIdFromStableId(id: string): string | null { + if (id.startsWith("view:")) { + return id; + } + return null; +} + +function generateViewOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; viewId: string }> = []; + const changesByView = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (change instanceof AlterViewChangeOwner) { + ownerChanges.push({ index, viewId: change.view.stableId }); + continue; + } + + const viewIds = getRelatedViewStableIds(change); + for (const viewId of viewIds) { + const indexes = changesByView.get(viewId) ?? []; + indexes.push(index); + changesByView.set(viewId, indexes); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = changesByView.get(ownerChange.viewId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + +function parseSequenceStableIdFromStableId(id: string): string | null { + if (id.startsWith("sequence:")) { + return id; + } + return null; +} + +function getRelatedSequenceStableIds(change: Change): Set { + const sequenceIds = new Set(); + if ( + "sequence" in change && + change.sequence && + typeof change.sequence === "object" && + "stableId" in change.sequence && + typeof change.sequence.stableId === "string" + ) { + sequenceIds.add(change.sequence.stableId); + } + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + const sequenceId = parseSequenceStableIdFromStableId(id); + if (sequenceId) sequenceIds.add(sequenceId); + } + return sequenceIds; +} + +function generateSequenceOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; sequenceId: string }> = []; + const changesBySequence = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (change instanceof AlterSequenceChangeOwner) { + ownerChanges.push({ + index, + sequenceId: change.sequence.stableId, + }); + continue; + } + if (change instanceof AlterSequenceSetOwnedBy && change.ownedBy !== null) { + continue; + } + const sequenceIds = getRelatedSequenceStableIds(change); + for (const sequenceId of sequenceIds) { + const indexes = changesBySequence.get(sequenceId) ?? []; + indexes.push(index); + changesBySequence.set(sequenceId, indexes); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = changesBySequence.get(ownerChange.sequenceId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + +function getRelatedRoutineStableIds(change: Change): Set { + const routineIds = new Set(); + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + if (id.startsWith("procedure:") || id.startsWith("aggregate:")) { + routineIds.add(id); + } + } + return routineIds; +} + +function generateRoutineOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; routineId: string }> = []; + const changesByRoutine = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (change instanceof AlterProcedureChangeOwner) { + ownerChanges.push({ + index, + routineId: change.procedure.stableId, + }); + continue; + } + if (change instanceof AlterAggregateChangeOwner) { + ownerChanges.push({ + index, + routineId: change.aggregate.stableId, + }); + continue; + } + + const routineIds = getRelatedRoutineStableIds(change); + for (const routineId of routineIds) { + const indexes = changesByRoutine.get(routineId) ?? []; + indexes.push(index); + changesByRoutine.set(routineId, indexes); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = changesByRoutine.get(ownerChange.routineId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + +function parseDomainStableIdFromStableId(id: string): string | null { + if (id.startsWith("comment:")) { + return parseDomainStableIdFromStableId(id.slice("comment:".length)); + } + if (id.startsWith("securityLabel:")) { + const objectId = id.slice("securityLabel:".length).split("::provider:")[0]; + return parseDomainStableIdFromStableId(objectId); + } + if (id.startsWith("domain:")) { + return id; + } + return null; +} + +function getRelatedDomainStableIds(change: Change): Set { + const domainIds = new Set(); + if ( + "domain" in change && + change.domain && + typeof change.domain === "object" && + "stableId" in change.domain && + typeof change.domain.stableId === "string" + ) { + domainIds.add(change.domain.stableId); + } + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + const domainId = parseDomainStableIdFromStableId(id); + if (domainId) domainIds.add(domainId); + } + return domainIds; +} + +function generateDomainOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; domainId: string }> = []; + const changesByDomain = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (change instanceof AlterDomainChangeOwner) { + ownerChanges.push({ + index, + domainId: change.domain.stableId, + }); + continue; + } + + const domainIds = getRelatedDomainStableIds(change); + for (const domainId of domainIds) { + const indexes = changesByDomain.get(domainId) ?? []; + indexes.push(index); + changesByDomain.set(domainId, indexes); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = changesByDomain.get(ownerChange.domainId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + +function getRelatedPublicationStableIds(change: Change): Set { + const publicationIds = new Set(); + if ( + "publication" in change && + change.publication && + typeof change.publication === "object" && + "stableId" in change.publication && + typeof change.publication.stableId === "string" + ) { + publicationIds.add(change.publication.stableId); + } + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + if (id.startsWith("publication:")) { + publicationIds.add(id); + } + } + return publicationIds; +} + +function generatePublicationOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; publicationId: string }> = []; + const changesByPublication = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (change instanceof AlterPublicationSetOwner) { + ownerChanges.push({ + index, + publicationId: change.publication.stableId, + }); + continue; + } + + const publicationIds = getRelatedPublicationStableIds(change); + for (const publicationId of publicationIds) { + const indexes = changesByPublication.get(publicationId) ?? []; + indexes.push(index); + changesByPublication.set(publicationId, indexes); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = + changesByPublication.get(ownerChange.publicationId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + +function generateOwnedSequenceAttachmentConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const tableOwnerChanges = new Map(); + const sequenceOwnerChanges = new Map(); + const ownedSequenceOwnerChanges: Array<{ + index: number; + tableId: string; + }> = []; + const ownedByChanges: Array<{ + index: number; + sequenceId: string; + tableId: string; + }> = []; + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (change instanceof AlterTableChangeOwner) { + const entries = tableOwnerChanges.get(change.table.stableId) ?? []; + entries.push(index); + tableOwnerChanges.set(change.table.stableId, entries); + } else if (change instanceof AlterSequenceChangeOwner) { + const entries = sequenceOwnerChanges.get(change.sequence.stableId) ?? []; + entries.push(index); + sequenceOwnerChanges.set(change.sequence.stableId, entries); + if (change.sequence.owned_by_schema && change.sequence.owned_by_table) { + ownedSequenceOwnerChanges.push({ + index, + tableId: `table:${change.sequence.owned_by_schema}.${change.sequence.owned_by_table}`, + }); + } + } else if ( + change instanceof AlterSequenceSetOwnedBy && + change.ownedBy !== null + ) { + ownedByChanges.push({ + index, + sequenceId: change.sequence.stableId, + tableId: `table:${change.ownedBy.schema}.${change.ownedBy.table}`, + }); + } + } + + for (const sequenceOwnerChange of ownedSequenceOwnerChanges) { + for (const sourceChangeIndex of tableOwnerChanges.get( + sequenceOwnerChange.tableId, + ) ?? []) { + if (sourceChangeIndex === sequenceOwnerChange.index) continue; + constraints.push({ + sourceChangeIndex, + targetChangeIndex: sequenceOwnerChange.index, + source: "custom", + }); + } + } + + for (const ownedByChange of ownedByChanges) { + for (const sourceChangeIndex of [ + ...(tableOwnerChanges.get(ownedByChange.tableId) ?? []), + ...(sequenceOwnerChanges.get(ownedByChange.sequenceId) ?? []), + ]) { + if (sourceChangeIndex === ownedByChange.index) continue; + constraints.push({ + sourceChangeIndex, + targetChangeIndex: ownedByChange.index, + source: "custom", + }); + } + } + return constraints; } @@ -222,6 +910,14 @@ function generateIdentityTransitionConstraints( const customConstraintGenerators: ConstraintGenerator[] = [ generateDefaultPrivilegeConstraints, generateIdentityTransitionConstraints, + generateTableOwnerRestoreLastConstraints, + generateMaterializedViewOwnerRestoreLastConstraints, + generateViewOwnerRestoreLastConstraints, + generateSequenceOwnerRestoreLastConstraints, + generateRoutineOwnerRestoreLastConstraints, + generateDomainOwnerRestoreLastConstraints, + generatePublicationOwnerRestoreLastConstraints, + generateOwnedSequenceAttachmentConstraints, ]; /** diff --git a/packages/pg-delta/src/core/sort/sort-changes.test.ts b/packages/pg-delta/src/core/sort/sort-changes.test.ts index 2305516e4..b28a96023 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -2,16 +2,55 @@ import { describe, expect, test } from "bun:test"; import { Catalog, createEmptyCatalog } from "../catalog.model.ts"; import type { Change } from "../change.types.ts"; import type { PgDepend } from "../depend.ts"; -import { AlterPublicationDropTables } from "../objects/publication/changes/publication.alter.ts"; +import { + AlterDomainAddConstraint, + AlterDomainChangeOwner, + AlterDomainDropConstraint, + AlterDomainDropDefault, + AlterDomainSetDefault, +} from "../objects/domain/changes/domain.alter.ts"; +import { DropDomain } from "../objects/domain/changes/domain.drop.ts"; +import { Domain } from "../objects/domain/domain.model.ts"; +import { CreateIndex } from "../objects/index/changes/index.create.ts"; +import { Index } from "../objects/index/index.model.ts"; +import { CreateMaterializedView } from "../objects/materialized-view/changes/materialized-view.create.ts"; +import { MaterializedView } from "../objects/materialized-view/materialized-view.model.ts"; +import { CreateProcedure } from "../objects/procedure/changes/procedure.create.ts"; +import { DropProcedure } from "../objects/procedure/changes/procedure.drop.ts"; +import { Procedure } from "../objects/procedure/procedure.model.ts"; +import { + AlterPublicationAddTables, + AlterPublicationDropTables, + AlterPublicationSetOwner, +} from "../objects/publication/changes/publication.alter.ts"; import { Publication } from "../objects/publication/publication.model.ts"; +import { SetRuleEnabledState } from "../objects/rule/changes/rule.alter.ts"; +import { CreateCommentOnRule } from "../objects/rule/changes/rule.comment.ts"; +import { CreateRule } from "../objects/rule/changes/rule.create.ts"; +import { Rule } from "../objects/rule/rule.model.ts"; +import { AlterSequenceChangeOwner } from "../objects/sequence/changes/sequence.alter.ts"; +import { CreateSequence } from "../objects/sequence/changes/sequence.create.ts"; +import { GrantSequencePrivileges } from "../objects/sequence/changes/sequence.privilege.ts"; +import { Sequence } from "../objects/sequence/sequence.model.ts"; import { + AlterTableAddConstraint, + AlterTableAlterColumnDropDefault, + AlterTableAlterColumnSetDefault, AlterTableAlterColumnType, + AlterTableChangeOwner, AlterTableDropConstraint, } from "../objects/table/changes/table.alter.ts"; import { DropTable } from "../objects/table/changes/table.drop.ts"; import { Table } from "../objects/table/table.model.ts"; +import { SetTriggerEnabledState } from "../objects/trigger/changes/trigger.alter.ts"; +import { CreateCommentOnTrigger } from "../objects/trigger/changes/trigger.comment.ts"; +import { CreateTrigger } from "../objects/trigger/changes/trigger.create.ts"; +import { Trigger } from "../objects/trigger/trigger.model.ts"; +import { AlterViewChangeOwner } from "../objects/view/changes/view.alter.ts"; +import { CreateCommentOnView } from "../objects/view/changes/view.comment.ts"; import { CreateView } from "../objects/view/changes/view.create.ts"; import { DropView } from "../objects/view/changes/view.drop.ts"; +import { GrantViewPrivileges } from "../objects/view/changes/view.privilege.ts"; import { View } from "../objects/view/view.model.ts"; import { sortChanges } from "./sort-changes.ts"; @@ -132,6 +171,40 @@ function uniqueConstraint(name: string, column: string) { }; } +function checkConstraint(name: string, expression: string) { + return { + name, + constraint_type: "c" as const, + deferrable: false, + initially_deferred: false, + validated: true, + is_local: true, + no_inherit: false, + is_temporal: false, + is_partition_clone: false, + parent_constraint_schema: null, + parent_constraint_name: null, + parent_table_schema: null, + parent_table_name: null, + key_columns: ["id"], + foreign_key_columns: null, + foreign_key_table: null, + foreign_key_schema: null, + foreign_key_table_is_partition: null, + foreign_key_parent_schema: null, + foreign_key_parent_table: null, + foreign_key_effective_schema: null, + foreign_key_effective_table: null, + on_update: null, + on_delete: null, + match_type: null, + check_expression: expression, + owner: "postgres", + definition: `CHECK (${expression})`, + comment: null, + }; +} + function table( name: string, constraints: ConstructorParameters[0]["constraints"] = [], @@ -171,6 +244,216 @@ function view(name: string, columns = [integerColumn("id", 1)]) { }); } +function materializedView(name: string) { + return new MaterializedView({ + schema: "public", + name, + definition: "SELECT id FROM public.users", + row_security: false, + force_row_security: false, + has_indexes: true, + has_rules: false, + has_triggers: false, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + options: null, + partition_bound: null, + owner: "postgres", + comment: null, + columns: [integerColumn("id", 1)], + privileges: [], + }); +} + +function indexOnMaterializedView(viewName: string, indexName: string) { + return new Index({ + schema: "public", + table_name: viewName, + name: indexName, + storage_params: [], + statistics_target: [0], + index_type: "btree", + tablespace: null, + is_unique: false, + is_primary: false, + is_exclusion: false, + nulls_not_distinct: false, + immediate: true, + is_clustered: false, + is_replica_identity: false, + key_columns: [1], + column_collations: [], + operator_classes: [], + column_options: [], + index_expressions: null, + partial_predicate: null, + is_owned_by_constraint: false, + table_relkind: "m", + is_partitioned_index: false, + is_index_partition: false, + parent_index_name: null, + definition: `CREATE INDEX ${indexName} ON public.${viewName} (id)`, + comment: null, + owner: "postgres", + }); +} + +function sequenceOwnedBy(tableName: string, columnName: string) { + return new Sequence({ + schema: "public", + name: `${tableName}_${columnName}_seq`, + data_type: "bigint", + start_value: 1, + minimum_value: BigInt(1), + maximum_value: BigInt("9223372036854775807"), + increment: 1, + cycle_option: false, + cache_size: 1, + persistence: "p", + owned_by_schema: "public", + owned_by_table: tableName, + owned_by_column: columnName, + comment: null, + privileges: [], + owner: "old_owner", + security_labels: [], + }); +} + +function triggerOnTable( + tableName: string, + overrides: Partial[0]> = {}, +) { + return new Trigger({ + schema: "public", + name: `${tableName}_audit_trigger`, + table_name: tableName, + table_relkind: "r", + function_schema: "public", + function_name: "audit_row", + trigger_type: 16, + enabled: "O", + is_internal: false, + deferrable: false, + initially_deferred: false, + argument_count: 0, + column_numbers: [], + arguments: [], + when_condition: null, + old_table: null, + new_table: null, + is_partition_clone: false, + parent_trigger_name: null, + parent_table_schema: null, + parent_table_name: null, + is_on_partitioned_table: false, + owner: "postgres", + definition: `CREATE TRIGGER ${tableName}_audit_trigger AFTER UPDATE ON public.${tableName} FOR EACH ROW EXECUTE FUNCTION public.audit_row()`, + comment: "retained trigger comment", + ...overrides, + }); +} + +function ruleOnView(viewName: string) { + return new Rule({ + schema: "public", + name: `${viewName}_update_rule`, + table_name: viewName, + relation_kind: "v", + event: "UPDATE", + enabled: "R", + is_instead: true, + owner: "postgres", + definition: `CREATE RULE ${viewName}_update_rule AS ON UPDATE TO public.${viewName} DO INSTEAD NOTHING`, + comment: "retained rule comment", + columns: [], + }); +} + +function domain(defaultValue: string | null, name = "score") { + return new Domain({ + schema: "public", + name, + base_type: "int4", + base_type_schema: "pg_catalog", + base_type_str: "integer", + not_null: false, + type_modifier: null, + array_dimensions: null, + collation: null, + default_bin: defaultValue, + default_value: defaultValue, + owner: "postgres", + comment: null, + constraints: [], + privileges: [], + }); +} + +function domainWithConstraint(name: string, expression: string) { + return new Domain({ + schema: "public", + name: "score", + base_type: "int4", + base_type_schema: "pg_catalog", + base_type_str: "integer", + not_null: false, + type_modifier: null, + array_dimensions: null, + collation: null, + default_bin: null, + default_value: null, + owner: "postgres", + comment: null, + constraints: [ + { + name, + validated: true, + is_local: true, + no_inherit: false, + check_expression: expression, + }, + ], + privileges: [], + }); +} + +function procedure(argumentTypes: string[]) { + return new Procedure({ + schema: "public", + name: "normalize_value", + kind: "f", + return_type: "integer", + return_type_schema: "pg_catalog", + language: "sql", + security_definer: false, + volatility: "i", + parallel_safety: "u", + execution_cost: 100, + result_rows: 0, + is_strict: false, + leakproof: false, + returns_set: false, + argument_count: argumentTypes.length, + argument_default_count: 0, + argument_names: argumentTypes.map((_, index) => `arg${index + 1}`), + argument_types: argumentTypes, + all_argument_types: null, + argument_modes: null, + argument_defaults: null, + source_code: "SELECT 1", + binary_path: null, + sql_body: null, + config: null, + definition: "CREATE FUNCTION public.normalize_value(...) RETURNS integer", + owner: "postgres", + comment: null, + privileges: [], + }); +} + async function catalogWithDepends(depends: PgDepend[]) { const base = await createEmptyCatalog(170000, "postgres"); // oxlint-disable-next-line typescript/no-misused-spread @@ -178,16 +461,389 @@ async function catalogWithDepends(depends: PgDepend[]) { } function changeLabel(change: Change) { + if (change instanceof AlterDomainDropConstraint) { + return `${change.constructor.name}:${change.domain.name}.${change.constraint.name}`; + } + if (change instanceof AlterDomainAddConstraint) { + return `${change.constructor.name}:${change.domain.name}.${change.constraint.name}`; + } + if (change instanceof DropDomain) { + return `${change.constructor.name}:${change.domain.name}`; + } if (change instanceof AlterTableDropConstraint) { return `${change.constructor.name}:${change.table.name}.${change.constraint.name}`; } + if (change instanceof AlterTableAddConstraint) { + return `${change.constructor.name}:${change.table.name}.${change.constraint.name}`; + } if (change instanceof DropTable) { return `${change.constructor.name}:${change.table.name}`; } + if (change instanceof DropProcedure || change instanceof CreateProcedure) { + return `${change.constructor.name}:${change.procedure.stableId}`; + } return change.constructor.name; } describe("sortChanges", () => { + test("orders owned sequence owner after owning table owner", async () => { + const owningTable = new Table({ + ...baseTableProps, + name: "items", + columns: [{ ...integerColumn("id", 1), not_null: true }], + constraints: [], + owner: "old_owner", + }); + const ownedSequence = sequenceOwnedBy("items", "id"); + const changes: Change[] = [ + new AlterSequenceChangeOwner({ + sequence: ownedSequence, + owner: "app_owner", + }), + new AlterTableChangeOwner({ table: owningTable, owner: "app_owner" }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const tableOwnerIndex = sorted.findIndex( + (change) => change instanceof AlterTableChangeOwner, + ); + const sequenceOwnerIndex = sorted.findIndex( + (change) => change instanceof AlterSequenceChangeOwner, + ); + + expect(tableOwnerIndex).toBeGreaterThan(-1); + expect(sequenceOwnerIndex).toBeGreaterThan(tableOwnerIndex); + }); + + test("orders sequence privilege replay before owner restore", async () => { + const sequence = new Sequence({ + schema: "public", + name: "items_id_seq", + data_type: "bigint", + start_value: 1, + minimum_value: BigInt(1), + maximum_value: BigInt("9223372036854775807"), + increment: 1, + cycle_option: false, + cache_size: 1, + persistence: "p", + owned_by_schema: null, + owned_by_table: null, + owned_by_column: null, + comment: null, + privileges: [], + owner: "app_owner", + security_labels: [], + }); + const changes: Change[] = [ + new CreateSequence({ sequence }), + new AlterSequenceChangeOwner({ sequence, owner: "app_owner" }), + new GrantSequencePrivileges({ + sequence, + grantee: "app_reader", + privileges: [{ privilege: "USAGE", grantable: false }], + version: 170000, + }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const grantIndex = sorted.findIndex( + (change) => change instanceof GrantSequencePrivileges, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterSequenceChangeOwner, + ); + + expect(grantIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(grantIndex); + }); + + test("orders trigger comment replay before table owner restore", async () => { + const owningTable = table("items"); + const trigger = triggerOnTable("items"); + const changes: Change[] = [ + new AlterTableChangeOwner({ table: owningTable, owner: "app_owner" }), + new CreateCommentOnTrigger({ trigger }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnTrigger, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterTableChangeOwner, + ); + + expect(commentIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(commentIndex); + }); + + test("orders quoted-dot table trigger metadata before table owner restore", async () => { + const owningTable = table('"a.b"'); + const trigger = triggerOnTable('"a.b"', { + name: "audit_trigger", + definition: + 'CREATE TRIGGER audit_trigger AFTER UPDATE ON public."a.b" FOR EACH ROW EXECUTE FUNCTION public.audit_row()', + }); + const changes: Change[] = [ + new AlterTableChangeOwner({ table: owningTable, owner: "app_owner" }), + new CreateCommentOnTrigger({ trigger }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnTrigger, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterTableChangeOwner, + ); + + expect(commentIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(commentIndex); + }); + + test("orders view metadata replay before view owner restore", async () => { + const retainedView = new View({ + schema: "public", + name: "active_users", + definition: "SELECT id FROM users", + row_security: false, + force_row_security: false, + has_indexes: false, + has_rules: true, + has_triggers: false, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + options: null, + partition_bound: null, + owner: "app_owner", + comment: "retained view comment", + columns: [integerColumn("id", 1)], + privileges: [ + { + grantee: "app_reader", + privilege: "SELECT", + grantable: false, + }, + ], + }); + const changes: Change[] = [ + new CreateView({ view: retainedView }), + new AlterViewChangeOwner({ view: retainedView, owner: "app_owner" }), + new CreateCommentOnView({ view: retainedView }), + new GrantViewPrivileges({ + view: retainedView, + grantee: "app_reader", + privileges: [{ privilege: "SELECT", grantable: false }], + version: 170000, + }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnView, + ); + const grantIndex = sorted.findIndex( + (change) => change instanceof GrantViewPrivileges, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterViewChangeOwner, + ); + + expect(commentIndex).toBeGreaterThan(-1); + expect(grantIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(commentIndex); + expect(ownerIndex).toBeGreaterThan(grantIndex); + }); + + test("orders view trigger replay before view owner restore", async () => { + const retainedView = new View({ + schema: "public", + name: "active_users", + definition: "SELECT id FROM users", + row_security: false, + force_row_security: false, + has_indexes: false, + has_rules: false, + has_triggers: true, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + options: null, + partition_bound: null, + owner: "app_owner", + comment: null, + columns: [integerColumn("id", 1)], + privileges: [], + }); + const trigger = triggerOnTable("active_users", { + table_relkind: "v", + definition: + "CREATE TRIGGER active_users_audit_trigger INSTEAD OF UPDATE ON public.active_users FOR EACH ROW EXECUTE FUNCTION public.audit_row()", + enabled: "R", + }); + const changes: Change[] = [ + new AlterViewChangeOwner({ view: retainedView, owner: "app_owner" }), + new CreateTrigger({ trigger }), + new SetTriggerEnabledState({ trigger }), + new CreateCommentOnTrigger({ trigger }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const createTriggerIndex = sorted.findIndex( + (change) => change instanceof CreateTrigger, + ); + const enabledStateIndex = sorted.findIndex( + (change) => change instanceof SetTriggerEnabledState, + ); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnTrigger, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterViewChangeOwner, + ); + + expect(createTriggerIndex).toBeGreaterThan(-1); + expect(enabledStateIndex).toBeGreaterThan(-1); + expect(commentIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(createTriggerIndex); + expect(ownerIndex).toBeGreaterThan(enabledStateIndex); + expect(ownerIndex).toBeGreaterThan(commentIndex); + }); + + test("orders view rule metadata replay before view owner restore", async () => { + const retainedView = new View({ + schema: "public", + name: "active_users", + definition: "SELECT id FROM users", + row_security: false, + force_row_security: false, + has_indexes: false, + has_rules: true, + has_triggers: false, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + options: null, + partition_bound: null, + owner: "app_owner", + comment: null, + columns: [integerColumn("id", 1)], + privileges: [], + }); + const rule = ruleOnView("active_users"); + const changes: Change[] = [ + new CreateRule({ rule }), + new AlterViewChangeOwner({ view: retainedView, owner: "app_owner" }), + new CreateCommentOnRule({ rule }), + new SetRuleEnabledState({ rule }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnRule, + ); + const enabledStateIndex = sorted.findIndex( + (change) => change instanceof SetRuleEnabledState, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterViewChangeOwner, + ); + + expect(commentIndex).toBeGreaterThan(-1); + expect(enabledStateIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(commentIndex); + expect(ownerIndex).toBeGreaterThan(enabledStateIndex); + }); + + test("orders publication table replay before publication owner restore", async () => { + const publication = new Publication({ + name: "items_pub", + owner: "app_owner", + comment: null, + all_tables: false, + publish_insert: true, + publish_update: true, + publish_delete: true, + publish_truncate: true, + publish_via_partition_root: false, + tables: [ + { + schema: "public", + name: "items", + columns: null, + row_filter: "(value > 0)", + }, + ], + schemas: [], + }); + const changes: Change[] = [ + new AlterPublicationSetOwner({ + publication, + owner: "app_owner", + }), + new AlterPublicationAddTables({ + publication, + tables: publication.tables, + }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const addTablesIndex = sorted.findIndex( + (change) => change instanceof AlterPublicationAddTables, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterPublicationSetOwner, + ); + + expect(addTablesIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(addTablesIndex); + }); + + test("orders materialized view indexes after recreated materialized views", async () => { + const branchMaterializedView = materializedView("user_ids_mv"); + const branchIndex = indexOnMaterializedView( + "user_ids_mv", + "user_ids_mv_id_idx", + ); + const changes: Change[] = [ + new CreateIndex({ + index: branchIndex, + indexableObject: branchMaterializedView, + }), + new CreateMaterializedView({ materializedView: branchMaterializedView }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "CreateMaterializedView", + "CreateIndex", + ]); + }); + test("orders dependent view drop before drop-phase column type rewrite", async () => { const branchTable = table("users"); const mainColumn = { @@ -387,4 +1043,333 @@ describe("sortChanges", () => { "AlterTableDropConstraint:posts.posts_lab_id_fkey", ); }); + + test("orders signature replacement around a covered column default update", async () => { + const mainProcedure = procedure(["integer"]); + const branchProcedure = procedure(["bigint"]); + const branchTable = table("items"); + const branchColumn = { + ...integerColumn("value", 4), + default: "public.normalize_value(1::bigint)", + }; + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableAlterColumnDropDefault({ + table: branchTable, + column: branchColumn, + }), + new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchColumn, + }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: branchProcedure.stableId, + deptype: "n", + }, + ]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "AlterTableAlterColumnDropDefault", + `DropProcedure:${mainProcedure.stableId}`, + `CreateProcedure:${branchProcedure.stableId}`, + "AlterTableAlterColumnSetDefault", + ]); + }); + + test("orders parent partition default restore before child default restore", async () => { + const parentTable = table("partitioned_scores"); + const childTable = new Table({ + ...baseTableProps, + name: "partitioned_scores_2026", + columns: [ + { ...integerColumn("id", 1), not_null: true }, + integerColumn("post_id", 2), + integerColumn("lab_id", 3), + ], + constraints: [], + is_partition: true, + parent_schema: "public", + parent_name: "partitioned_scores", + partition_bound: "FOR VALUES FROM (2026) TO (2027)", + }); + const parentSetDefault = new AlterTableAlterColumnSetDefault({ + table: parentTable, + column: { + ...integerColumn("lab_id", 3), + default: "public.normalize_value(1)", + }, + }); + const childSetDefault = new AlterTableAlterColumnSetDefault({ + table: childTable, + column: { + ...integerColumn("lab_id", 3), + default: "public.normalize_value(3)", + }, + }); + const changes: Change[] = [childSetDefault, parentSetDefault]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.indexOf(parentSetDefault)).toBeLessThan( + sorted.indexOf(childSetDefault), + ); + }); + + test("orders domain default removal before dropping the referenced function", async () => { + const mainProcedure = procedure(["integer"]); + const mainDomain = domain("public.normalize_value(1)"); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new AlterDomainDropDefault({ domain: mainDomain }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: mainDomain.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "AlterDomainDropDefault", + `DropProcedure:${mainProcedure.stableId}`, + ]); + }); + + test("orders domain expression replay before domain owner restore", async () => { + const branchDomain = new Domain({ + // oxlint-disable-next-line typescript/no-misused-spread + ...domainWithConstraint("score_check", "VALUE > 0"), + default_bin: "public.normalize_value(1)", + default_value: "public.normalize_value(1)", + owner: "app_owner", + }); + const addConstraint = new AlterDomainAddConstraint({ + domain: branchDomain, + constraint: branchDomain.constraints[0], + }); + const setDefault = new AlterDomainSetDefault({ + domain: branchDomain, + defaultValue: "public.normalize_value(1)", + }); + const ownerRestore = new AlterDomainChangeOwner({ + domain: branchDomain, + owner: "app_owner", + }); + const changes: Change[] = [ownerRestore, setDefault, addConstraint]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const setDefaultIndex = sorted.indexOf(setDefault); + const addConstraintIndex = sorted.indexOf(addConstraint); + const ownerIndex = sorted.indexOf(ownerRestore); + + expect(setDefaultIndex).toBeGreaterThan(-1); + expect(addConstraintIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(setDefaultIndex); + expect(ownerIndex).toBeGreaterThan(addConstraintIndex); + }); + + test("orders unchanged domain check replacement around same-signature procedure replacement", async () => { + const mainProcedure = procedure(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + }); + const mainDomain = domainWithConstraint( + "score_check", + "public.normalize_value(VALUE) > 0", + ); + const branchDomain = domainWithConstraint( + "score_check", + "public.normalize_value(VALUE) > 0", + ); + const changes: Change[] = [ + new CreateProcedure({ procedure: branchProcedure }), + new DropProcedure({ procedure: mainProcedure }), + new AlterDomainAddConstraint({ + domain: branchDomain, + constraint: branchDomain.constraints[0], + }), + new AlterDomainDropConstraint({ + domain: mainDomain, + constraint: mainDomain.constraints[0], + }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: "constraint:public.score.score_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([ + { + dependent_stable_id: "constraint:public.score.score_check", + referenced_stable_id: branchProcedure.stableId, + deptype: "n", + }, + ]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "AlterDomainDropConstraint:score.score_check", + `DropProcedure:${mainProcedure.stableId}`, + `CreateProcedure:${branchProcedure.stableId}`, + "AlterDomainAddConstraint:score.score_check", + ]); + }); + + test("drops the old overloaded function before restoring expressions that can still resolve to it", async () => { + const mainProcedure = procedure(["integer"]); + const branchProcedure = procedure(["bigint"]); + const mainTable = table("items", [ + checkConstraint("items_value_check", "public.normalize_value(id) > 0"), + ]); + const branchTable = table("items", [ + checkConstraint("items_value_check", "public.normalize_value(id) > 0"), + ]); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableAddConstraint({ + table: branchTable, + constraint: branchTable.constraints[0], + }), + new AlterTableDropConstraint({ + table: mainTable, + constraint: mainTable.constraints[0], + }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([ + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: branchProcedure.stableId, + deptype: "n", + }, + ]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "AlterTableDropConstraint:items.items_value_check", + `DropProcedure:${mainProcedure.stableId}`, + `CreateProcedure:${branchProcedure.stableId}`, + "AlterTableAddConstraint:items.items_value_check", + ]); + }); + + test("drops the old overloaded function before restoring rebuilt views that can still resolve to it", async () => { + const mainProcedure = procedure(["integer"]); + const branchProcedure = procedure(["bigint"]); + const mainView = view("score_view"); + const branchView = view("score_view"); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new CreateView({ view: branchView, orReplace: true }), + new DropView({ view: mainView }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: mainView.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([ + { + dependent_stable_id: branchView.stableId, + referenced_stable_id: branchProcedure.stableId, + deptype: "n", + }, + ]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "DropView", + `DropProcedure:${mainProcedure.stableId}`, + `CreateProcedure:${branchProcedure.stableId}`, + "CreateView", + ]); + }); + + test("drops defaulted old overloads before creating shorter replacements", async () => { + const mainProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...procedure(["integer", "integer"]), + argument_default_count: 1, + argument_defaults: "DEFAULT 1", + }); + const branchProcedure = procedure(["integer"]); + const changes: Change[] = [ + new CreateProcedure({ procedure: branchProcedure }), + new DropProcedure({ procedure: mainProcedure }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + `DropProcedure:${mainProcedure.stableId}`, + `CreateProcedure:${branchProcedure.stableId}`, + ]); + }); + + test("drops old routines before dropping their old argument domains", async () => { + const oldDomain = domain(null, "old_score"); + const mainProcedure = procedure(["public.old_score"]); + const branchProcedure = procedure(["integer"]); + const changes: Change[] = [ + new DropDomain({ domain: oldDomain }), + new CreateProcedure({ procedure: branchProcedure }), + new DropProcedure({ procedure: mainProcedure }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: mainProcedure.stableId, + referenced_stable_id: oldDomain.stableId, + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + `DropProcedure:${mainProcedure.stableId}`, + "DropDomain:old_score", + `CreateProcedure:${branchProcedure.stableId}`, + ]); + }); }); diff --git a/packages/pg-delta/src/core/sort/sort-changes.ts b/packages/pg-delta/src/core/sort/sort-changes.ts index 4b6ba9e23..6030678ee 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.ts @@ -96,7 +96,10 @@ function sortChangesByPhasedGraph( create_alter_object: [], }; - // Partition changes into execution phases + // Keep routine drops in the drop phase even for same-name signature + // replacements. Dependent expressions/views are released in this phase and + // restored in create/alter; moving the routine drop later breaks old + // dependency drops such as argument domains and defaulted overloads. for (const changeItem of changeList) { const phase = getExecutionPhase(changeItem); changesByPhase[phase].push(changeItem); diff --git a/packages/pg-delta/src/core/sort/utils.ts b/packages/pg-delta/src/core/sort/utils.ts index 1737615b3..c441c054e 100644 --- a/packages/pg-delta/src/core/sort/utils.ts +++ b/packages/pg-delta/src/core/sort/utils.ts @@ -1,4 +1,5 @@ import type { Change } from "../change.types.ts"; +import { AlterDomainDropDefault } from "../objects/domain/changes/domain.alter.ts"; import { AlterTableAlterColumnDropDefault, AlterTableAlterColumnDropIdentity, @@ -80,6 +81,7 @@ export function getExecutionPhase(change: Change): Phase { // edges (column → sequence, column → identity sequence) order them // before the matching DROP statement. if ( + change instanceof AlterDomainDropDefault || change instanceof AlterTableAlterColumnDropDefault || change instanceof AlterTableAlterColumnDropIdentity ) { diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts new file mode 100644 index 000000000..91cf6a0ef --- /dev/null +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -0,0 +1,3355 @@ +import { describe, expect, test } from "bun:test"; +import dedent from "dedent"; +import { POSTGRES_VERSIONS } from "../constants.ts"; +import { withDb, withDbIsolated } from "../utils.ts"; +import { roundtripFidelityTest } from "./roundtrip.ts"; + +function expectNoTableReplacement(sqlStatements: string[]) { + expect( + sqlStatements.filter( + (statement) => + statement.startsWith("DROP TABLE ") || + statement.startsWith("CREATE TABLE "), + ), + ).toEqual([]); +} + +async function expectTableRowCount( + query: (sql: string) => Promise<{ rows: Array<{ count: string | bigint }> }>, + tableName: string, + expected: number, +) { + const { rows } = await query(`SELECT count(*) FROM ${tableName}`); + expect(Number(rows[0]?.count)).toBe(expected); +} + +for (const pgVersion of POSTGRES_VERSIONS) { + describe(`function signature expression dependencies (pg${pgVersion})`, () => { + test( + "column default update for replaced function signature does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_quantity(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.orders ( + id integer NOT NULL, + quantity integer DEFAULT test_schema.default_quantity(1::integer) + ); + + INSERT INTO test_schema.orders (id) VALUES (1); + `, + testSql: dedent` + CREATE FUNCTION test_schema.default_quantity(value bigint) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value::integer + 2 + $function$; + + ALTER TABLE test_schema.orders + ALTER COLUMN quantity + SET DEFAULT test_schema.default_quantity(1::bigint); + + DROP FUNCTION test_schema.default_quantity(integer); + `, + assertSqlStatements: expectNoTableReplacement, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.orders", + 1, + ); + }), + ); + + test( + "check constraint update for replaced function signature does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.is_positive(value integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 0 + $function$; + + CREATE TABLE test_schema.measurements ( + id integer NOT NULL, + value integer NOT NULL, + CONSTRAINT measurements_value_check + CHECK (test_schema.is_positive(value)) + ); + + INSERT INTO test_schema.measurements (id, value) VALUES (1, 10); + `, + testSql: dedent` + CREATE FUNCTION test_schema.is_positive(value bigint) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 1 + $function$; + + ALTER TABLE test_schema.measurements + DROP CONSTRAINT measurements_value_check; + ALTER TABLE test_schema.measurements + ADD CONSTRAINT measurements_value_check + CHECK (test_schema.is_positive(value::bigint)); + + DROP FUNCTION test_schema.is_positive(integer); + `, + assertSqlStatements: expectNoTableReplacement, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.measurements", + 1, + ); + }), + ); + + test( + "unchanged check constraint for replaced function argument name does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.is_valid(value integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 0 + $function$; + + CREATE TABLE test_schema.measurement_labels ( + id integer NOT NULL, + value integer NOT NULL, + CONSTRAINT measurement_labels_value_check + CHECK (test_schema.is_valid(value)) + ); + + INSERT INTO test_schema.measurement_labels (id, value) + VALUES (1, 10); + `, + testSql: dedent` + ALTER TABLE test_schema.measurement_labels + DROP CONSTRAINT measurement_labels_value_check; + + DROP FUNCTION test_schema.is_valid(integer); + + CREATE FUNCTION test_schema.is_valid(input integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input > 0 + $function$; + + ALTER TABLE test_schema.measurement_labels + ADD CONSTRAINT measurement_labels_value_check + CHECK (test_schema.is_valid(value)); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.measurement_labels DROP CONSTRAINT measurement_labels_value_check", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.is_valid"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE FUNCTION test_schema.is_valid"), + ); + const addConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.measurement_labels ADD CONSTRAINT measurement_labels_value_check", + ), + ); + + expect(dropConstraintIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropConstraintIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addConstraintIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.measurement_labels", + 1, + ); + }), + ); + + test( + "dependent view is recreated when aggregate signature changes", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE TABLE test_schema.aggregate_inputs ( + value integer NOT NULL + ); + + CREATE AGGREGATE test_schema.total_value(integer) ( + SFUNC = int4pl, + STYPE = integer, + INITCOND = '0' + ); + + CREATE VIEW test_schema.aggregate_totals AS + SELECT test_schema.total_value(value) AS total + FROM test_schema.aggregate_inputs; + + INSERT INTO test_schema.aggregate_inputs (value) VALUES (1); + `, + testSql: dedent` + DROP VIEW test_schema.aggregate_totals; + + DROP AGGREGATE test_schema.total_value(integer); + + CREATE AGGREGATE test_schema.total_value(bigint) ( + SFUNC = int8pl, + STYPE = bigint, + INITCOND = '0' + ); + + CREATE VIEW test_schema.aggregate_totals AS + SELECT test_schema.total_value(value::bigint) AS total + FROM test_schema.aggregate_inputs; + `, + assertSqlStatements: (sqlStatements) => { + const dropViewIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP VIEW test_schema.aggregate_totals"), + ); + const dropAggregateIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP AGGREGATE test_schema.total_value(integer)", + ), + ); + const createAggregateIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE AGGREGATE test_schema.total_value(bigint)", + ), + ); + const createViewIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE VIEW test_schema.aggregate_totals"), + ); + + expect(dropViewIndex).toBeGreaterThanOrEqual(0); + expect(dropAggregateIndex).toBeGreaterThan(dropViewIndex); + expect(createAggregateIndex).toBeGreaterThan(dropAggregateIndex); + expect(createViewIndex).toBeGreaterThan(createAggregateIndex); + }, + }); + + const { rows } = await db.main.query( + "SELECT total FROM test_schema.aggregate_totals", + ); + expect(rows[0]?.total).toBe(1n); + }), + ); + + test( + "removed check constraint for replaced function argument name does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.is_allowed(value integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 0 + $function$; + + CREATE TABLE test_schema.measurement_inputs ( + id integer NOT NULL, + value integer NOT NULL, + CONSTRAINT measurement_inputs_value_check + CHECK (test_schema.is_allowed(value)) + ); + + INSERT INTO test_schema.measurement_inputs (id, value) + VALUES (1, 10); + `, + testSql: dedent` + ALTER TABLE test_schema.measurement_inputs + DROP CONSTRAINT measurement_inputs_value_check; + + DROP FUNCTION test_schema.is_allowed(integer); + + CREATE FUNCTION test_schema.is_allowed(input integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input > 0 + $function$; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.measurement_inputs DROP CONSTRAINT measurement_inputs_value_check", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.is_allowed"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE FUNCTION test_schema.is_allowed"), + ); + + expect(dropConstraintIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropConstraintIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.measurement_inputs", + 1, + ); + }), + ); + + test( + "removed column default for replaced function argument name does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_input(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.form_inputs ( + id integer NOT NULL, + value integer DEFAULT test_schema.default_input(1::integer), + keep_value integer NOT NULL + ); + + INSERT INTO test_schema.form_inputs (id, keep_value) + VALUES (1, 10); + `, + testSql: dedent` + ALTER TABLE test_schema.form_inputs + DROP COLUMN value; + + DROP FUNCTION test_schema.default_input(integer); + + CREATE FUNCTION test_schema.default_input(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.form_inputs DROP COLUMN value", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.default_input"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE FUNCTION test_schema.default_input"), + ); + + expect(dropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.form_inputs", + 1, + ); + }), + ); + + test( + "unchanged column default with added foreign key for replaced function argument name is restored", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_form_value(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.form_value_parents ( + id integer PRIMARY KEY + ); + + CREATE TABLE test_schema.form_values ( + id integer NOT NULL, + value integer DEFAULT test_schema.default_form_value(1) + ); + + INSERT INTO test_schema.form_value_parents (id) VALUES (2); + INSERT INTO test_schema.form_values (id) VALUES (1); + `, + testSql: dedent` + ALTER TABLE test_schema.form_values + ALTER COLUMN value DROP DEFAULT; + + DROP FUNCTION test_schema.default_form_value(integer); + + CREATE FUNCTION test_schema.default_form_value(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.form_values + ALTER COLUMN value + SET DEFAULT test_schema.default_form_value(1); + + ALTER TABLE test_schema.form_values + ADD CONSTRAINT form_values_value_fkey + FOREIGN KEY (value) REFERENCES test_schema.form_value_parents(id); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropDefaultIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.form_values ALTER COLUMN value DROP DEFAULT", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.default_form_value", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.default_form_value", + ), + ); + const restoreDefaultIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.form_values ALTER COLUMN value SET DEFAULT", + ), + ); + const addForeignKeyIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.form_values ADD CONSTRAINT form_values_value_fkey", + ), + ); + + expect(dropDefaultIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropDefaultIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(restoreDefaultIndex).toBeGreaterThan(createFunctionIndex); + expect(addForeignKeyIndex).toBeGreaterThanOrEqual(0); + }, + }); + + const { rows } = await db.main.query( + "SELECT pg_get_expr(adbin, adrelid) AS expression FROM pg_attrdef WHERE adrelid = 'test_schema.form_values'::regclass AND adnum = 2", + ); + expect(rows[0]?.expression).toContain( + "test_schema.default_form_value(1)", + ); + }), + ); + + test.skipIf(pgVersion < 17)( + "generated column update for replaced function signature does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoices ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_total(subtotal)) STORED + ); + + INSERT INTO test_schema.invoices (id, subtotal) VALUES (1, 20); + `, + testSql: dedent` + CREATE FUNCTION test_schema.compute_total(value bigint) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value::integer + 2 + $function$; + + ALTER TABLE test_schema.invoices + ALTER COLUMN total + SET EXPRESSION AS + (test_schema.compute_total(subtotal::bigint)); + + DROP FUNCTION test_schema.compute_total(integer); + `, + assertSqlStatements: expectNoTableReplacement, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.invoices", + 1, + ); + }), + ); + + test.skipIf(pgVersion < 17)( + "generated column update for replaced function argument name does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_invoice_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED + ); + + INSERT INTO test_schema.invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_invoice_total(integer); + + CREATE FUNCTION test_schema.compute_invoice_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal + 1)) STORED; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + expect( + sqlStatements.some((statement) => + statement.includes(" SET EXPRESSION AS "), + ), + ).toBe(false); + + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_invoice_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_invoice_total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD COLUMN total", + ), + ); + + expect(dropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.invoice_totals", + 1, + ); + }), + ); + + test( + "unchanged generated column for replaced function argument name does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_invoice_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED + ); + + INSERT INTO test_schema.invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_invoice_total(integer); + + CREATE FUNCTION test_schema.compute_invoice_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_invoice_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_invoice_total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD COLUMN total", + ), + ); + + expect(dropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.invoice_totals", + 1, + ); + }), + ); + + test( + "unchanged commented generated column for replaced function argument name does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_invoice_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED + ); + + COMMENT ON COLUMN test_schema.invoice_totals.total + IS 'computed invoice total'; + + INSERT INTO test_schema.invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_invoice_total(integer); + + CREATE FUNCTION test_schema.compute_invoice_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED; + + COMMENT ON COLUMN test_schema.invoice_totals.total + IS 'computed invoice total'; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_invoice_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_invoice_total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD COLUMN total", + ), + ); + const commentIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "COMMENT ON COLUMN test_schema.invoice_totals.total", + ), + ); + + expect(dropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); + expect(commentIndex).toBeGreaterThan(addColumnIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.invoice_totals", + 1, + ); + const { rows } = await db.main.query(dedent` + SELECT col_description(attrelid, attnum) AS comment + FROM pg_catalog.pg_attribute + WHERE attrelid = 'test_schema.invoice_totals'::regclass + AND attname = 'total' + `); + expect(rows[0]?.comment).toBe("computed invoice total"); + }), + ); + + test.skipIf(pgVersion >= 17)( + "covered generated column recreation restores retained comments before PostgreSQL 17", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_commented_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.commented_invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_commented_total(subtotal)) STORED + ); + + COMMENT ON COLUMN test_schema.commented_invoice_totals.total + IS 'computed invoice total'; + + INSERT INTO test_schema.commented_invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.commented_invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_commented_total(integer); + + CREATE FUNCTION test_schema.compute_commented_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.commented_invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_commented_total(subtotal + 1)) STORED; + + COMMENT ON COLUMN test_schema.commented_invoice_totals.total + IS 'computed invoice total'; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.commented_invoice_totals ADD COLUMN total", + ), + ); + const commentIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "COMMENT ON COLUMN test_schema.commented_invoice_totals.total", + ), + ); + + expect(addColumnIndex).toBeGreaterThanOrEqual(0); + expect(commentIndex).toBeGreaterThan(addColumnIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.commented_invoice_totals", + 1, + ); + const { rows } = await db.main.query(dedent` + SELECT col_description(attrelid, attnum) AS comment + FROM pg_catalog.pg_attribute + WHERE attrelid = 'test_schema.commented_invoice_totals'::regclass + AND attname = 'total' + `); + expect(rows[0]?.comment).toBe("computed invoice total"); + }), + ); + + test( + "unchanged check constraint for overloaded replacement drops old function before restore", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.is_valid_score(value integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 0 + $function$; + + CREATE TABLE test_schema.score_labels ( + id integer NOT NULL, + value integer NOT NULL, + CONSTRAINT score_labels_value_check + CHECK (test_schema.is_valid_score(value)) + ); + + INSERT INTO test_schema.score_labels (id, value) + VALUES (1, 10); + `, + testSql: dedent` + ALTER TABLE test_schema.score_labels + DROP CONSTRAINT score_labels_value_check; + + CREATE FUNCTION test_schema.is_valid_score(value bigint) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 0 + $function$; + + DROP FUNCTION test_schema.is_valid_score(integer); + + ALTER TABLE test_schema.score_labels + ADD CONSTRAINT score_labels_value_check + CHECK (test_schema.is_valid_score(value)); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.score_labels DROP CONSTRAINT score_labels_value_check", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.is_valid_score", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.is_valid_score"), + ); + const addConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.score_labels ADD CONSTRAINT score_labels_value_check", + ), + ); + + expect(dropConstraintIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropConstraintIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addConstraintIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.score_labels", + 1, + ); + }), + ); + + test( + "unchanged generated column with retained dependents does not recreate table", + withDbIsolated(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_invoice_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED NOT NULL, + CONSTRAINT invoice_totals_total_nonnegative CHECK (total >= 0) + ); + + CREATE UNIQUE INDEX invoice_totals_total_identity_idx + ON test_schema.invoice_totals (total); + + ALTER TABLE test_schema.invoice_totals + REPLICA IDENTITY USING INDEX invoice_totals_total_identity_idx; + + CREATE INDEX invoice_totals_total_idx + ON test_schema.invoice_totals ((total + 1)); + + ALTER TABLE test_schema.invoice_totals + CLUSTER ON invoice_totals_total_idx; + + ALTER INDEX test_schema.invoice_totals_total_idx + ALTER COLUMN 1 SET STATISTICS 100; + + COMMENT ON INDEX test_schema.invoice_totals_total_idx + IS 'generated total lookup'; + + CREATE VIEW test_schema.invoice_total_values AS + SELECT id, total FROM test_schema.invoice_totals; + + CREATE TABLE test_schema.invoice_total_audit ( + invoice_id integer NOT NULL, + total integer NOT NULL + ); + + CREATE FUNCTION test_schema.record_invoice_total_update() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + INSERT INTO test_schema.invoice_total_audit (invoice_id, total) + VALUES (NEW.id, NEW.total); + RETURN NEW; + END + $function$; + + CREATE TRIGGER invoice_totals_total_trigger + AFTER UPDATE OF total ON test_schema.invoice_totals + FOR EACH ROW + WHEN (OLD.total IS DISTINCT FROM NEW.total) + EXECUTE FUNCTION test_schema.record_invoice_total_update(); + + ALTER TABLE test_schema.invoice_totals + DISABLE TRIGGER invoice_totals_total_trigger; + + CREATE TABLE test_schema.invoice_total_rule_audit ( + invoice_id integer NOT NULL, + total integer NOT NULL + ); + + CREATE RULE invoice_totals_total_rule AS + ON UPDATE TO test_schema.invoice_totals + DO ALSO INSERT INTO test_schema.invoice_total_rule_audit + (invoice_id, total) + VALUES (NEW.id, NEW.total); + + ALTER TABLE test_schema.invoice_totals + ENABLE REPLICA RULE invoice_totals_total_rule; + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM pg_catalog.pg_roles + WHERE rolname = 'invoice_total_reader' + ) THEN + CREATE ROLE invoice_total_reader; + END IF; + END + $$; + + GRANT SELECT (total) + ON TABLE test_schema.invoice_totals TO invoice_total_reader; + + INSERT INTO test_schema.invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + DROP VIEW test_schema.invoice_total_values; + + DROP TRIGGER invoice_totals_total_trigger + ON test_schema.invoice_totals; + + DROP RULE invoice_totals_total_rule + ON test_schema.invoice_totals; + + DROP INDEX test_schema.invoice_totals_total_idx; + + ALTER TABLE test_schema.invoice_totals + REPLICA IDENTITY DEFAULT; + + DROP INDEX test_schema.invoice_totals_total_identity_idx; + + ALTER TABLE test_schema.invoice_totals + DROP CONSTRAINT invoice_totals_total_nonnegative; + + ALTER TABLE test_schema.invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_invoice_total(integer); + + CREATE FUNCTION test_schema.compute_invoice_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED NOT NULL; + + ALTER TABLE test_schema.invoice_totals + ADD CONSTRAINT invoice_totals_total_nonnegative CHECK (total >= 0); + + CREATE UNIQUE INDEX invoice_totals_total_identity_idx + ON test_schema.invoice_totals (total); + + ALTER TABLE test_schema.invoice_totals + REPLICA IDENTITY USING INDEX invoice_totals_total_identity_idx; + + CREATE INDEX invoice_totals_total_idx + ON test_schema.invoice_totals ((total + 1)); + + ALTER TABLE test_schema.invoice_totals + CLUSTER ON invoice_totals_total_idx; + + ALTER INDEX test_schema.invoice_totals_total_idx + ALTER COLUMN 1 SET STATISTICS 100; + + COMMENT ON INDEX test_schema.invoice_totals_total_idx + IS 'generated total lookup'; + + CREATE VIEW test_schema.invoice_total_values AS + SELECT id, total FROM test_schema.invoice_totals; + + CREATE TRIGGER invoice_totals_total_trigger + AFTER UPDATE OF total ON test_schema.invoice_totals + FOR EACH ROW + WHEN (OLD.total IS DISTINCT FROM NEW.total) + EXECUTE FUNCTION test_schema.record_invoice_total_update(); + + ALTER TABLE test_schema.invoice_totals + DISABLE TRIGGER invoice_totals_total_trigger; + + CREATE RULE invoice_totals_total_rule AS + ON UPDATE TO test_schema.invoice_totals + DO ALSO INSERT INTO test_schema.invoice_total_rule_audit + (invoice_id, total) + VALUES (NEW.id, NEW.total); + + ALTER TABLE test_schema.invoice_totals + ENABLE REPLICA RULE invoice_totals_total_rule; + + GRANT SELECT (total) + ON TABLE test_schema.invoice_totals TO invoice_total_reader; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropViewIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP VIEW test_schema.invoice_total_values", + ), + ); + const dropIndexIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP INDEX test_schema.invoice_totals_total_idx", + ), + ); + const dropReplicaIdentityIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "DROP INDEX test_schema.invoice_totals_total_identity_idx", + ), + ); + const dropConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP CONSTRAINT invoice_totals_total_nonnegative", + ), + ); + const dropTriggerIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP TRIGGER invoice_totals_total_trigger ON test_schema.invoice_totals", + ), + ); + const dropRuleIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP RULE invoice_totals_total_rule ON test_schema.invoice_totals", + ), + ); + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_invoice_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_invoice_total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD COLUMN total", + ), + ); + const addConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD CONSTRAINT invoice_totals_total_nonnegative", + ), + ); + const createReplicaIdentityIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "CREATE UNIQUE INDEX invoice_totals_total_identity_idx ON test_schema.invoice_totals", + ), + ); + const restoreReplicaIdentityIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals REPLICA IDENTITY USING INDEX invoice_totals_total_identity_idx", + ), + ); + const createIndexIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE INDEX invoice_totals_total_idx ON test_schema.invoice_totals", + ), + ); + const indexCommentIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "COMMENT ON INDEX test_schema.invoice_totals_total_idx", + ), + ); + const alterIndexStatisticsIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER INDEX test_schema.invoice_totals_total_idx ALTER COLUMN 1 SET STATISTICS 100", + ), + ); + const restoreClusterIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals CLUSTER ON invoice_totals_total_idx", + ), + ); + const createViewIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE VIEW test_schema.invoice_total_values", + ), + ); + const createTriggerIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE TRIGGER invoice_totals_total_trigger", + ), + ); + const restoreTriggerEnabledIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DISABLE TRIGGER invoice_totals_total_trigger", + ), + ); + const createRuleIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE RULE invoice_totals_total_rule"), + ); + const restoreRuleEnabledIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ENABLE REPLICA RULE invoice_totals_total_rule", + ), + ); + const grantColumnIndex = sqlStatements.findIndex( + (statement) => + statement.includes("SELECT (total)") && + statement.includes("test_schema.invoice_totals") && + statement.includes("invoice_total_reader"), + ); + + expect(dropViewIndex).toBeGreaterThanOrEqual(0); + expect(dropIndexIndex).toBeGreaterThanOrEqual(0); + expect(dropReplicaIdentityIndex).toBeGreaterThanOrEqual(0); + expect(dropConstraintIndex).toBeGreaterThanOrEqual(0); + expect(dropTriggerIndex).toBeGreaterThanOrEqual(0); + expect(dropRuleIndex).toBeGreaterThanOrEqual(0); + expect(dropColumnIndex).toBeGreaterThan(dropViewIndex); + expect(dropColumnIndex).toBeGreaterThan(dropIndexIndex); + expect(dropColumnIndex).toBeGreaterThan(dropReplicaIdentityIndex); + expect(dropColumnIndex).toBeGreaterThan(dropConstraintIndex); + expect(dropColumnIndex).toBeGreaterThan(dropTriggerIndex); + expect(dropColumnIndex).toBeGreaterThan(dropRuleIndex); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); + expect(addConstraintIndex).toBeGreaterThan(addColumnIndex); + expect(createReplicaIdentityIndex).toBeGreaterThan(addColumnIndex); + expect(restoreReplicaIdentityIndex).toBeGreaterThan( + createReplicaIdentityIndex, + ); + expect(createIndexIndex).toBeGreaterThan(addColumnIndex); + expect(indexCommentIndex).toBeGreaterThan(createIndexIndex); + expect(alterIndexStatisticsIndex).toBeGreaterThan(createIndexIndex); + expect(restoreClusterIndex).toBeGreaterThan(createIndexIndex); + expect(createViewIndex).toBeGreaterThan(addColumnIndex); + expect(createTriggerIndex).toBeGreaterThan(addColumnIndex); + expect(restoreTriggerEnabledIndex).toBeGreaterThan( + createTriggerIndex, + ); + expect(createRuleIndex).toBeGreaterThan(addColumnIndex); + expect(restoreRuleEnabledIndex).toBeGreaterThan(createRuleIndex); + expect(grantColumnIndex).toBeGreaterThan(addColumnIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.invoice_totals", + 1, + ); + const { rows } = await db.main.query( + "SELECT to_regclass('test_schema.invoice_totals_total_idx')::text AS index_name", + ); + expect(rows[0]?.index_name).toBe( + "test_schema.invoice_totals_total_idx", + ); + const { rows: commentRows } = await db.main.query(dedent` + SELECT obj_description( + 'test_schema.invoice_totals_total_idx'::regclass, + 'pg_class' + ) AS comment + `); + expect(commentRows[0]?.comment).toBe("generated total lookup"); + const { rows: statisticsRows } = await db.main.query(dedent` + SELECT attstattarget + FROM pg_catalog.pg_attribute + WHERE attrelid = 'test_schema.invoice_totals_total_idx'::regclass + AND attnum = 1 + `); + expect(statisticsRows[0]?.attstattarget).toBe(100); + const { rows: clusterRows } = await db.main.query(dedent` + SELECT i.indisclustered + FROM pg_catalog.pg_index i + JOIN pg_catalog.pg_class idx ON idx.oid = i.indexrelid + JOIN pg_catalog.pg_namespace n ON n.oid = idx.relnamespace + WHERE n.nspname = 'test_schema' + AND idx.relname = 'invoice_totals_total_idx' + `); + expect(clusterRows[0]?.indisclustered).toBe(true); + const { rows: replicaIdentityRows } = await db.main.query(dedent` + SELECT c.relreplident, i.indisreplident + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_index i ON i.indrelid = c.oid + JOIN pg_catalog.pg_class idx ON idx.oid = i.indexrelid + WHERE n.nspname = 'test_schema' + AND c.relname = 'invoice_totals' + AND idx.relname = 'invoice_totals_total_identity_idx' + `); + expect(replicaIdentityRows[0]).toMatchObject({ + relreplident: "i", + indisreplident: true, + }); + const { rows: triggerRows } = await db.main.query(dedent` + SELECT pg_get_triggerdef(oid) AS definition, tgenabled + FROM pg_catalog.pg_trigger + WHERE tgname = 'invoice_totals_total_trigger' + AND NOT tgisinternal + `); + expect(triggerRows[0]?.definition).toContain("UPDATE OF total"); + expect(triggerRows[0]?.tgenabled).toBe("D"); + const { rows: ruleRows } = await db.main.query(dedent` + SELECT pg_get_ruledef(r.oid) AS definition, r.ev_enabled + FROM pg_catalog.pg_rewrite r + JOIN pg_catalog.pg_class c ON c.oid = r.ev_class + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE r.rulename = 'invoice_totals_total_rule' + AND n.nspname = 'test_schema' + AND c.relname = 'invoice_totals' + `); + expect(ruleRows[0]?.definition).toContain("new.total"); + expect(ruleRows[0]?.ev_enabled).toBe("R"); + const { rows: privilegeRows } = await db.main.query(dedent` + SELECT has_column_privilege( + 'invoice_total_reader', + 'test_schema.invoice_totals', + 'total', + 'SELECT' + ) AS has_privilege + `); + expect(privilegeRows[0]?.has_privilege).toBe(true); + }), + ); + + test( + "retained unique generated column referenced by foreign key is recreated safely", + withDbIsolated(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_invoice_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED, + CONSTRAINT invoice_totals_total_key UNIQUE (total) + ); + + CREATE TABLE test_schema.invoice_total_refs ( + total integer NOT NULL, + CONSTRAINT invoice_total_refs_total_fkey + FOREIGN KEY (total) + REFERENCES test_schema.invoice_totals(total) + ); + + INSERT INTO test_schema.invoice_totals (id, subtotal) + VALUES (1, 20); + INSERT INTO test_schema.invoice_total_refs (total) + VALUES (21); + `, + testSql: dedent` + ALTER TABLE test_schema.invoice_total_refs + DROP CONSTRAINT invoice_total_refs_total_fkey; + + ALTER TABLE test_schema.invoice_totals + DROP CONSTRAINT invoice_totals_total_key; + + ALTER TABLE test_schema.invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_invoice_total(integer); + + CREATE FUNCTION test_schema.compute_invoice_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED; + + ALTER TABLE test_schema.invoice_totals + ADD CONSTRAINT invoice_totals_total_key UNIQUE (total); + + ALTER TABLE test_schema.invoice_total_refs + ADD CONSTRAINT invoice_total_refs_total_fkey + FOREIGN KEY (total) + REFERENCES test_schema.invoice_totals(total); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropForeignKeyIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_total_refs DROP CONSTRAINT invoice_total_refs_total_fkey", + ), + ); + const dropUniqueIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP CONSTRAINT invoice_totals_total_key", + ), + ); + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP COLUMN total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD COLUMN total", + ), + ); + const addUniqueIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD CONSTRAINT invoice_totals_total_key", + ), + ); + const addForeignKeyIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_total_refs ADD CONSTRAINT invoice_total_refs_total_fkey", + ), + ); + + expect(dropForeignKeyIndex).toBeGreaterThanOrEqual(0); + expect(dropUniqueIndex).toBeGreaterThan(dropForeignKeyIndex); + expect(dropColumnIndex).toBeGreaterThan(dropUniqueIndex); + expect(addColumnIndex).toBeGreaterThan(dropColumnIndex); + expect(addUniqueIndex).toBeGreaterThan(addColumnIndex); + expect(addForeignKeyIndex).toBeGreaterThan(addUniqueIndex); + }, + }); + + const { rows } = await db.main.query( + "SELECT count(*) FROM test_schema.invoice_total_refs WHERE total = 21", + ); + expect(Number(rows[0]?.count)).toBe(1); + }), + ); + + test( + "regular-to-generated column recreation restores retained column grants", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_regular_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.regular_invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer DEFAULT test_schema.compute_regular_total(1) + ); + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM pg_catalog.pg_roles + WHERE rolname = 'regular_total_reader' + ) THEN + CREATE ROLE regular_total_reader; + END IF; + END + $$; + + GRANT SELECT (total) + ON TABLE test_schema.regular_invoice_totals + TO regular_total_reader; + + INSERT INTO test_schema.regular_invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.regular_invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_regular_total(integer); + + CREATE FUNCTION test_schema.compute_regular_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.regular_invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_regular_total(subtotal)) STORED; + + GRANT SELECT (total) + ON TABLE test_schema.regular_invoice_totals + TO regular_total_reader; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.regular_invoice_totals ADD COLUMN total", + ), + ); + const grantColumnIndex = sqlStatements.findIndex( + (statement) => + statement.includes("SELECT (total)") && + statement.includes("test_schema.regular_invoice_totals") && + statement.includes("regular_total_reader"), + ); + + expect(addColumnIndex).toBeGreaterThanOrEqual(0); + expect(grantColumnIndex).toBeGreaterThan(addColumnIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.regular_invoice_totals", + 1, + ); + const { rows } = await db.main.query(dedent` + SELECT has_column_privilege( + 'regular_total_reader', + 'test_schema.regular_invoice_totals', + 'total', + 'SELECT' + ) AS can_select + `); + expect(rows[0]?.can_select).toBe(true); + }), + ); + + test.skipIf(pgVersion < 18)( + "unchanged generated column in publication column list does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_publication_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.published_invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_publication_total(subtotal)) STORED + ); + + CREATE PUBLICATION invoice_total_pub + FOR TABLE test_schema.published_invoice_totals (id, total); + + INSERT INTO test_schema.published_invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + ALTER PUBLICATION invoice_total_pub + SET TABLE test_schema.published_invoice_totals (id); + + ALTER TABLE test_schema.published_invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_publication_total(integer); + + CREATE FUNCTION test_schema.compute_publication_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.published_invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_publication_total(subtotal)) STORED; + + ALTER PUBLICATION invoice_total_pub + SET TABLE test_schema.published_invoice_totals (id, total); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const publicationDropStatements = sqlStatements.filter( + (statement) => + statement.startsWith( + "ALTER PUBLICATION invoice_total_pub DROP TABLE", + ), + ); + const publicationAddStatements = sqlStatements.filter((statement) => + statement.startsWith( + "ALTER PUBLICATION invoice_total_pub ADD TABLE", + ), + ); + const releasePublicationIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER PUBLICATION invoice_total_pub DROP TABLE test_schema.published_invoice_totals", + ), + ); + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.published_invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_publication_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_publication_total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.published_invoice_totals ADD COLUMN total", + ), + ); + const restorePublicationIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER PUBLICATION invoice_total_pub ADD TABLE test_schema.published_invoice_totals (id, total)", + ), + ); + + expect(publicationDropStatements).toHaveLength(1); + expect(publicationAddStatements).toHaveLength(1); + expect(releasePublicationIndex).toBeGreaterThanOrEqual(0); + expect(dropColumnIndex).toBeGreaterThan(releasePublicationIndex); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); + expect(restorePublicationIndex).toBeGreaterThan(addColumnIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.published_invoice_totals", + 1, + ); + const { rows: publicationRows } = await db.main.query(dedent` + SELECT json_agg(att.attname ORDER BY cols.ord) AS columns + FROM pg_catalog.pg_publication p + JOIN pg_catalog.pg_publication_rel pr ON pr.prpubid = p.oid + JOIN pg_catalog.pg_class c ON c.oid = pr.prrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN unnest(pr.prattrs) WITH ORDINALITY AS cols(attnum, ord) + ON true + JOIN pg_catalog.pg_attribute att + ON att.attrelid = c.oid + AND att.attnum = cols.attnum + WHERE p.pubname = 'invoice_total_pub' + AND n.nspname = 'test_schema' + AND c.relname = 'published_invoice_totals' + `); + expect(publicationRows[0]?.columns).toEqual(["id", "total"]); + }), + ); + + test.skipIf(pgVersion < 18)( + "multiple generated publication column lists are refreshed independently", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_shared_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.published_invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_shared_total(subtotal)) STORED + ); + + CREATE TABLE test_schema.published_order_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_shared_total(subtotal)) STORED + ); + + CREATE PUBLICATION shared_total_pub + FOR TABLE test_schema.published_invoice_totals (id, total), + test_schema.published_order_totals (id, total); + + INSERT INTO test_schema.published_invoice_totals (id, subtotal) + VALUES (1, 20); + INSERT INTO test_schema.published_order_totals (id, subtotal) + VALUES (1, 30); + `, + testSql: dedent` + ALTER PUBLICATION shared_total_pub + SET TABLE test_schema.published_invoice_totals (id), + test_schema.published_order_totals (id); + + ALTER TABLE test_schema.published_invoice_totals + DROP COLUMN total; + ALTER TABLE test_schema.published_order_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_shared_total(integer); + + CREATE FUNCTION test_schema.compute_shared_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.published_invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_shared_total(subtotal)) STORED; + ALTER TABLE test_schema.published_order_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_shared_total(subtotal)) STORED; + + ALTER PUBLICATION shared_total_pub + SET TABLE test_schema.published_invoice_totals (id, total), + test_schema.published_order_totals (id, total); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const publicationDropStatements = sqlStatements.filter( + (statement) => + statement.startsWith( + "ALTER PUBLICATION shared_total_pub DROP TABLE", + ), + ); + const publicationAddStatements = sqlStatements.filter((statement) => + statement.startsWith( + "ALTER PUBLICATION shared_total_pub ADD TABLE", + ), + ); + + expect(publicationDropStatements).toHaveLength(1); + expect(publicationAddStatements).toHaveLength(1); + expect(publicationDropStatements).toContain( + "ALTER PUBLICATION shared_total_pub DROP TABLE test_schema.published_invoice_totals, test_schema.published_order_totals", + ); + expect(publicationAddStatements).toContain( + "ALTER PUBLICATION shared_total_pub ADD TABLE test_schema.published_invoice_totals (id, total), TABLE test_schema.published_order_totals (id, total)", + ); + }, + }); + + for (const tableName of [ + "published_invoice_totals", + "published_order_totals", + ]) { + const { rows } = await db.main.query(dedent` + SELECT json_agg(att.attname ORDER BY cols.ord) AS columns + FROM pg_catalog.pg_publication p + JOIN pg_catalog.pg_publication_rel pr ON pr.prpubid = p.oid + JOIN pg_catalog.pg_class c ON c.oid = pr.prrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN unnest(pr.prattrs) WITH ORDINALITY AS cols(attnum, ord) + ON true + JOIN pg_catalog.pg_attribute att + ON att.attrelid = c.oid + AND att.attnum = cols.attnum + WHERE p.pubname = 'shared_total_pub' + AND n.nspname = 'test_schema' + AND c.relname = '${tableName}' + `); + expect(rows[0]?.columns).toEqual(["id", "total"]); + } + }), + ); + + test( + "generated publication row filters without column lists are refreshed before column recreation", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_filtered_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.filtered_invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_filtered_total(subtotal)) STORED + ); + + CREATE PUBLICATION filtered_total_pub + FOR TABLE test_schema.filtered_invoice_totals + WHERE (total > 0); + + INSERT INTO test_schema.filtered_invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + CREATE ROLE filtered_pub_owner; + + ALTER PUBLICATION filtered_total_pub + DROP TABLE test_schema.filtered_invoice_totals; + + ALTER TABLE test_schema.filtered_invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_filtered_total(integer); + + CREATE FUNCTION test_schema.compute_filtered_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.filtered_invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_filtered_total(subtotal)) STORED; + + ALTER PUBLICATION filtered_total_pub + ADD TABLE test_schema.filtered_invoice_totals + WHERE (total > 0); + + ALTER PUBLICATION filtered_total_pub + OWNER TO filtered_pub_owner; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const publicationDropIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER PUBLICATION filtered_total_pub DROP TABLE test_schema.filtered_invoice_totals", + ), + ); + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.filtered_invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_filtered_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_filtered_total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.filtered_invoice_totals ADD COLUMN total", + ), + ); + const publicationAddIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER PUBLICATION filtered_total_pub ADD TABLE test_schema.filtered_invoice_totals WHERE (total > 0)", + ), + ); + const publicationOwnerIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER PUBLICATION filtered_total_pub OWNER TO filtered_pub_owner", + ), + ); + + expect(publicationDropIndex).toBeGreaterThanOrEqual(0); + expect(dropColumnIndex).toBeGreaterThan(publicationDropIndex); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); + expect(publicationAddIndex).toBeGreaterThan(addColumnIndex); + expect(publicationOwnerIndex).toBeGreaterThan(publicationAddIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.filtered_invoice_totals", + 1, + ); + const { rows } = await db.main.query(dedent` + SELECT + pg_get_expr(pr.prqual, pr.prrelid) AS row_filter, + pr.prattrs IS NULL AS all_columns + FROM pg_catalog.pg_publication p + JOIN pg_catalog.pg_publication_rel pr ON pr.prpubid = p.oid + JOIN pg_catalog.pg_class c ON c.oid = pr.prrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE p.pubname = 'filtered_total_pub' + AND n.nspname = 'test_schema' + AND c.relname = 'filtered_invoice_totals' + `); + expect(rows[0]?.row_filter).toBe("(total > 0)"); + expect(rows[0]?.all_columns).toBe(true); + const { rows: ownerRows } = await db.main.query(dedent` + SELECT pg_get_userbyid(p.pubowner) AS owner + FROM pg_catalog.pg_publication p + WHERE p.pubname = 'filtered_total_pub' + `); + expect(ownerRows[0]?.owner).toBe("filtered_pub_owner"); + }), + ); + + test( + "clustered materialized view indexes survive dependent function replacement", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_mv_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.mv_inputs ( + id integer NOT NULL, + subtotal integer NOT NULL + ); + + CREATE MATERIALIZED VIEW test_schema.invoice_total_mv AS + SELECT + id, + test_schema.compute_mv_total(subtotal) AS total + FROM test_schema.mv_inputs; + + CREATE INDEX invoice_total_mv_total_idx + ON test_schema.invoice_total_mv (total); + + ALTER MATERIALIZED VIEW test_schema.invoice_total_mv + CLUSTER ON invoice_total_mv_total_idx; + + INSERT INTO test_schema.mv_inputs (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + DROP INDEX test_schema.invoice_total_mv_total_idx; + + DROP MATERIALIZED VIEW test_schema.invoice_total_mv; + + DROP FUNCTION test_schema.compute_mv_total(integer); + + CREATE FUNCTION test_schema.compute_mv_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + CREATE MATERIALIZED VIEW test_schema.invoice_total_mv AS + SELECT + id, + test_schema.compute_mv_total(subtotal) AS total + FROM test_schema.mv_inputs; + + CREATE INDEX invoice_total_mv_total_idx + ON test_schema.invoice_total_mv (total); + + ALTER MATERIALIZED VIEW test_schema.invoice_total_mv + CLUSTER ON invoice_total_mv_total_idx; + `, + assertSqlStatements: (sqlStatements) => { + const dropIndexIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP INDEX test_schema.invoice_total_mv_total_idx", + ), + ); + const dropMaterializedViewIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "DROP MATERIALIZED VIEW test_schema.invoice_total_mv", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_mv_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_mv_total", + ), + ); + const createMaterializedViewIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "CREATE MATERIALIZED VIEW test_schema.invoice_total_mv", + ), + ); + const createIndexIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE INDEX invoice_total_mv_total_idx ON test_schema.invoice_total_mv", + ), + ); + const restoreClusterIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER MATERIALIZED VIEW test_schema.invoice_total_mv CLUSTER ON invoice_total_mv_total_idx", + ), + ); + + expect(dropMaterializedViewIndex).toBeGreaterThanOrEqual(0); + if (dropIndexIndex >= 0) { + expect(dropMaterializedViewIndex).toBeGreaterThan(dropIndexIndex); + } + expect(dropFunctionIndex).toBeGreaterThan( + dropMaterializedViewIndex, + ); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(createMaterializedViewIndex).toBeGreaterThan( + createFunctionIndex, + ); + expect(createIndexIndex).toBeGreaterThan( + createMaterializedViewIndex, + ); + expect(restoreClusterIndex).toBeGreaterThan(createIndexIndex); + }, + }); + + const { rows } = await db.main.query(dedent` + SELECT i.indisclustered + FROM pg_catalog.pg_index i + JOIN pg_catalog.pg_class idx ON idx.oid = i.indexrelid + JOIN pg_catalog.pg_namespace n ON n.oid = idx.relnamespace + WHERE n.nspname = 'test_schema' + AND idx.relname = 'invoice_total_mv_total_idx' + `); + expect(rows[0]?.indisclustered).toBe(true); + }), + ); + + test( + "constraint-backed generated column index metadata survives dependent function replacement", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_invoice_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED NOT NULL, + CONSTRAINT invoice_totals_total_key UNIQUE (total) + ); + + COMMENT ON INDEX test_schema.invoice_totals_total_key + IS 'retained unique total lookup'; + + ALTER TABLE test_schema.invoice_totals + CLUSTER ON invoice_totals_total_key; + + ALTER TABLE test_schema.invoice_totals + REPLICA IDENTITY USING INDEX invoice_totals_total_key; + + INSERT INTO test_schema.invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + CREATE FUNCTION test_schema.compute_invoice_total(input bigint) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input::integer + 1 + $function$; + + ALTER TABLE test_schema.invoice_totals + DROP COLUMN total; + + ALTER TABLE test_schema.invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal::bigint)) STORED NOT NULL; + + ALTER TABLE test_schema.invoice_totals + ADD CONSTRAINT invoice_totals_total_key UNIQUE (total); + + COMMENT ON INDEX test_schema.invoice_totals_total_key + IS 'retained unique total lookup'; + + ALTER TABLE test_schema.invoice_totals + CLUSTER ON invoice_totals_total_key; + + ALTER TABLE test_schema.invoice_totals + REPLICA IDENTITY USING INDEX invoice_totals_total_key; + + DROP FUNCTION test_schema.compute_invoice_total(integer); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const addConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD CONSTRAINT invoice_totals_total_key", + ), + ); + const commentIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "COMMENT ON INDEX test_schema.invoice_totals_total_key IS 'retained unique total lookup'", + ), + ); + const clusterIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals CLUSTER ON invoice_totals_total_key", + ), + ); + const replicaIdentityIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals REPLICA IDENTITY USING INDEX invoice_totals_total_key", + ), + ); + + expect(addConstraintIndex).toBeGreaterThanOrEqual(0); + expect(commentIndex).toBeGreaterThan(addConstraintIndex); + expect(clusterIndex).toBeGreaterThan(addConstraintIndex); + expect(replicaIdentityIndex).toBeGreaterThan(addConstraintIndex); + }, + }); + + const { rows } = await db.main.query(dedent` + SELECT + d.description, + i.indisclustered, + i.indisreplident + FROM pg_catalog.pg_class idx + JOIN pg_catalog.pg_namespace n ON n.oid = idx.relnamespace + JOIN pg_catalog.pg_index i ON i.indexrelid = idx.oid + LEFT JOIN pg_catalog.pg_description d + ON d.objoid = idx.oid + AND d.objsubid = 0 + WHERE n.nspname = 'test_schema' + AND idx.relname = 'invoice_totals_total_key' + `); + expect(rows[0]?.description).toBe("retained unique total lookup"); + expect(rows[0]?.indisclustered).toBe(true); + expect(rows[0]?.indisreplident).toBe(true); + }), + ); + + test( + "aggregate dependents are recreated before replacing support functions", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.sum_state(state integer, value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT state + value + $function$; + + CREATE AGGREGATE test_schema.total_amount(integer) ( + SFUNC = test_schema.sum_state, + STYPE = integer, + INITCOND = '0' + ); + + CREATE TABLE test_schema.aggregate_inputs ( + value integer NOT NULL + ); + + INSERT INTO test_schema.aggregate_inputs (value) + VALUES (1), (2); + `, + testSql: dedent` + DROP AGGREGATE test_schema.total_amount(integer); + + DROP FUNCTION test_schema.sum_state(integer, integer); + + CREATE FUNCTION test_schema.sum_state(current_total integer, input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT current_total + input + $function$; + + CREATE AGGREGATE test_schema.total_amount(integer) ( + SFUNC = test_schema.sum_state, + STYPE = integer, + INITCOND = '0' + ); + `, + assertSqlStatements: (sqlStatements) => { + const dropAggregateIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP AGGREGATE test_schema.total_amount"), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.sum_state"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE FUNCTION test_schema.sum_state"), + ); + const createAggregateIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE AGGREGATE test_schema.total_amount"), + ); + + expect(dropAggregateIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropAggregateIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(createAggregateIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + const { rows } = await db.main.query(dedent` + SELECT test_schema.total_amount(value) AS total + FROM test_schema.aggregate_inputs + `); + expect(rows[0]?.total).toBe(3); + }), + ); + + test( + "unchanged generated partition column for replaced function argument name does not emit child DDL", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_partition_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.partitioned_invoice_totals ( + id integer NOT NULL, + period integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_partition_total(subtotal)) STORED, + CONSTRAINT partitioned_invoice_totals_total_nonnegative + CHECK (total >= 0) + ) PARTITION BY RANGE (period); + + CREATE TABLE test_schema.partitioned_invoice_totals_2026 + PARTITION OF test_schema.partitioned_invoice_totals + FOR VALUES FROM (2026) TO (2027); + + INSERT INTO test_schema.partitioned_invoice_totals + (id, period, subtotal) + VALUES (1, 2026, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.partitioned_invoice_totals + DROP CONSTRAINT partitioned_invoice_totals_total_nonnegative; + + ALTER TABLE test_schema.partitioned_invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_partition_total(integer); + + CREATE FUNCTION test_schema.compute_partition_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.partitioned_invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_partition_total(subtotal)) STORED; + + ALTER TABLE test_schema.partitioned_invoice_totals + ADD CONSTRAINT partitioned_invoice_totals_total_nonnegative + CHECK (total >= 0); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_invoice_totals_2026 DROP COLUMN total", + ), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_invoice_totals_2026 DROP CONSTRAINT partitioned_invoice_totals_total_nonnegative", + ), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_invoice_totals_2026 ADD CONSTRAINT partitioned_invoice_totals_total_nonnegative", + ), + ), + ).toBe(false); + + const parentDropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_partition_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_partition_total", + ), + ); + const parentAddColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_invoice_totals ADD COLUMN total", + ), + ); + + expect(parentDropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(parentDropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(parentAddColumnIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.partitioned_invoice_totals", + 1, + ); + }), + ); + + test.skipIf(pgVersion < 17)( + "child-specific generated partition expression without parent recreation avoids child column DDL", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_child_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.partitioned_child_only_totals ( + id integer NOT NULL, + period integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS (subtotal + 1) STORED + ) PARTITION BY RANGE (period); + + CREATE TABLE test_schema.partitioned_child_only_totals_2026 + PARTITION OF test_schema.partitioned_child_only_totals ( + total GENERATED ALWAYS AS + (test_schema.compute_child_total(subtotal)) STORED + ) + FOR VALUES FROM (2026) TO (2027); + `, + testSql: dedent` + DROP TABLE test_schema.partitioned_child_only_totals_2026; + + DROP FUNCTION test_schema.compute_child_total(integer); + + CREATE FUNCTION test_schema.compute_child_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + CREATE TABLE test_schema.partitioned_child_only_totals_2026 + PARTITION OF test_schema.partitioned_child_only_totals ( + total GENERATED ALWAYS AS + (test_schema.compute_child_total(subtotal)) STORED + ) + FOR VALUES FROM (2026) TO (2027); + `, + assertSqlStatements: (sqlStatements) => { + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_child_only_totals_2026 DROP COLUMN total", + ), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_child_only_totals_2026 ADD COLUMN total", + ), + ), + ).toBe(false); + + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_child_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_child_total", + ), + ); + + expect(dropFunctionIndex).toBeGreaterThanOrEqual(0); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + + const childDropTableIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP TABLE test_schema.partitioned_child_only_totals_2026", + ), + ); + const childCreateTableIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE TABLE test_schema.partitioned_child_only_totals_2026", + ), + ); + const childSetExpressionIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_child_only_totals_2026 ALTER COLUMN total SET EXPRESSION", + ), + ); + + expect(childDropTableIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(childDropTableIndex); + expect(childCreateTableIndex).toBeGreaterThan(createFunctionIndex); + expect(sqlStatements[childCreateTableIndex]).toContain( + "total GENERATED ALWAYS AS (test_schema.compute_child_total(subtotal)) STORED", + ); + expect(childSetExpressionIndex).toBe(-1); + }, + }); + }), + ); + + test.skipIf(pgVersion < 17)( + "child-specific generated partition expression is restored after parent recreation", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_partition_adjusted_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.partitioned_adjusted_totals ( + id integer NOT NULL, + period integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_partition_adjusted_total(subtotal)) STORED + ) PARTITION BY RANGE (period); + + CREATE TABLE test_schema.partitioned_adjusted_totals_2026 + PARTITION OF test_schema.partitioned_adjusted_totals ( + total GENERATED ALWAYS AS + (test_schema.compute_partition_adjusted_total(subtotal + 1)) STORED + ) + FOR VALUES FROM (2026) TO (2027); + + INSERT INTO test_schema.partitioned_adjusted_totals + (id, period, subtotal) + VALUES (1, 2026, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.partitioned_adjusted_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_partition_adjusted_total(integer); + + CREATE FUNCTION test_schema.compute_partition_adjusted_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.partitioned_adjusted_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_partition_adjusted_total(subtotal)) STORED; + + ALTER TABLE test_schema.partitioned_adjusted_totals_2026 + ALTER COLUMN total + SET EXPRESSION AS + (test_schema.compute_partition_adjusted_total(subtotal + 1)); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_adjusted_totals_2026 DROP COLUMN total", + ), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_adjusted_totals_2026 ADD COLUMN total", + ), + ), + ).toBe(false); + + const parentDropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_adjusted_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_partition_adjusted_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_partition_adjusted_total", + ), + ); + const parentAddColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_adjusted_totals ADD COLUMN total", + ), + ); + const childSetExpressionIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_adjusted_totals_2026 ALTER COLUMN total SET EXPRESSION", + ), + ); + + expect(parentDropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(parentDropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(parentAddColumnIndex).toBeGreaterThan(createFunctionIndex); + expect(childSetExpressionIndex).toBeGreaterThan( + parentAddColumnIndex, + ); + }, + }); + + const { rows } = await db.main.query(dedent` + SELECT total + FROM test_schema.partitioned_adjusted_totals + WHERE id = 1 + `); + expect(rows[0]?.total).toBe(22); + }), + ); + + test( + "local partition column default for replaced function argument name is released on the child", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_partition_score(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.partitioned_scores ( + id integer NOT NULL, + period integer NOT NULL, + score integer + ) PARTITION BY RANGE (period); + + CREATE TABLE test_schema.partitioned_scores_2026 + PARTITION OF test_schema.partitioned_scores + FOR VALUES FROM (2026) TO (2027); + + ALTER TABLE test_schema.partitioned_scores_2026 + ALTER COLUMN score + SET DEFAULT test_schema.default_partition_score(1); + + INSERT INTO test_schema.partitioned_scores (id, period) + VALUES (1, 2026); + `, + testSql: dedent` + ALTER TABLE test_schema.partitioned_scores_2026 + ALTER COLUMN score DROP DEFAULT; + + DROP FUNCTION test_schema.default_partition_score(integer); + + CREATE FUNCTION test_schema.default_partition_score(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.partitioned_scores_2026 + ALTER COLUMN score + SET DEFAULT test_schema.default_partition_score(1); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropDefaultIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_scores_2026 ALTER COLUMN score DROP DEFAULT", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.default_partition_score", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.default_partition_score", + ), + ); + const restoreDefaultIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_scores_2026 ALTER COLUMN score SET DEFAULT", + ), + ); + + expect(dropDefaultIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropDefaultIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(restoreDefaultIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.partitioned_scores", + 1, + ); + }), + ); + + test( + "parent partition default is restored when only child default changes for replaced function argument name", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_partition_score(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.partitioned_scores_with_parent_default ( + id integer NOT NULL, + period integer NOT NULL, + score integer DEFAULT test_schema.default_partition_score(1) + ) PARTITION BY RANGE (period); + + CREATE TABLE test_schema.partitioned_scores_with_parent_default_2026 + PARTITION OF test_schema.partitioned_scores_with_parent_default + FOR VALUES FROM (2026) TO (2027); + + ALTER TABLE test_schema.partitioned_scores_with_parent_default_2026 + ALTER COLUMN score + SET DEFAULT test_schema.default_partition_score(2); + + INSERT INTO test_schema.partitioned_scores_with_parent_default + (id, period) + VALUES (1, 2026); + `, + testSql: dedent` + ALTER TABLE test_schema.partitioned_scores_with_parent_default_2026 + ALTER COLUMN score DROP DEFAULT; + + ALTER TABLE test_schema.partitioned_scores_with_parent_default + ALTER COLUMN score DROP DEFAULT; + + DROP FUNCTION test_schema.default_partition_score(integer); + + CREATE FUNCTION test_schema.default_partition_score(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.partitioned_scores_with_parent_default + ALTER COLUMN score + SET DEFAULT test_schema.default_partition_score(1); + + ALTER TABLE test_schema.partitioned_scores_with_parent_default_2026 + ALTER COLUMN score + SET DEFAULT test_schema.default_partition_score(3); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const parentDropDefaultIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_scores_with_parent_default ALTER COLUMN score DROP DEFAULT", + ), + ); + const childDropDefaultIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_scores_with_parent_default_2026 ALTER COLUMN score DROP DEFAULT", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.default_partition_score", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.default_partition_score", + ), + ); + const parentRestoreDefaultIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_scores_with_parent_default ALTER COLUMN score SET DEFAULT", + ), + ); + const childRestoreDefaultIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_scores_with_parent_default_2026 ALTER COLUMN score SET DEFAULT", + ), + ); + + expect(parentDropDefaultIndex).toBeGreaterThanOrEqual(0); + expect(childDropDefaultIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan( + Math.max(parentDropDefaultIndex, childDropDefaultIndex), + ); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(parentRestoreDefaultIndex).toBeGreaterThan( + createFunctionIndex, + ); + expect(childRestoreDefaultIndex).toBeGreaterThan( + createFunctionIndex, + ); + }, + }); + + const { rows } = await db.main.query( + "SELECT pg_get_expr(adbin, adrelid) AS expression FROM pg_attrdef WHERE adrelid = 'test_schema.partitioned_scores_with_parent_default'::regclass AND adnum = 3", + ); + expect(rows[0]?.expression).toContain( + "test_schema.default_partition_score(1)", + ); + }), + ); + + test( + "removed generated partition column for dropped function does not emit child DDL", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_archived_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.partitioned_archive_totals ( + id integer NOT NULL, + period integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_archived_total(subtotal)) STORED + ) PARTITION BY RANGE (period); + + CREATE TABLE test_schema.partitioned_archive_totals_2026 + PARTITION OF test_schema.partitioned_archive_totals + FOR VALUES FROM (2026) TO (2027); + + INSERT INTO test_schema.partitioned_archive_totals + (id, period, subtotal) + VALUES (1, 2026, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.partitioned_archive_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_archived_total(integer); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_archive_totals_2026 DROP COLUMN total", + ), + ), + ).toBe(false); + + const parentDropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_archive_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_archived_total", + ), + ); + + expect(parentDropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(parentDropColumnIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.partitioned_archive_totals", + 1, + ); + }), + ); + + test( + "domain default update for replaced function signature does not recreate domain or table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_score(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE DOMAIN test_schema.score AS integer + DEFAULT test_schema.default_score(1::integer); + + CREATE TABLE test_schema.results ( + id integer NOT NULL, + score test_schema.score + ); + + INSERT INTO test_schema.results (id) VALUES (1); + `, + testSql: dedent` + CREATE FUNCTION test_schema.default_score(value bigint) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value::integer + 2 + $function$; + + ALTER DOMAIN test_schema.score + SET DEFAULT test_schema.default_score(1::bigint); + + DROP FUNCTION test_schema.default_score(integer); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + expect( + sqlStatements.some((statement) => + statement.startsWith("DROP DOMAIN "), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith("CREATE DOMAIN "), + ), + ).toBe(false); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.results", + 1, + ); + }), + ); + + test( + "unchanged domain check constraint for replaced function argument name does not recreate domain or table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.accept_score(value integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 0 + $function$; + + CREATE DOMAIN test_schema.score AS integer + CONSTRAINT score_accepts_value + CHECK (test_schema.accept_score(VALUE)); + + CREATE TABLE test_schema.results ( + id integer NOT NULL, + score test_schema.score + ); + + INSERT INTO test_schema.results (id, score) VALUES (1, 10); + `, + testSql: dedent` + ALTER DOMAIN test_schema.score + DROP CONSTRAINT score_accepts_value; + + DROP FUNCTION test_schema.accept_score(integer); + + CREATE FUNCTION test_schema.accept_score(input integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input > 0 + $function$; + + ALTER DOMAIN test_schema.score + ADD CONSTRAINT score_accepts_value + CHECK (test_schema.accept_score(VALUE)); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + expect( + sqlStatements.some((statement) => + statement.startsWith("DROP DOMAIN "), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith("CREATE DOMAIN "), + ), + ).toBe(false); + + const dropConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER DOMAIN test_schema.score DROP CONSTRAINT score_accepts_value", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.accept_score"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE FUNCTION test_schema.accept_score"), + ); + const addConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER DOMAIN test_schema.score ADD CONSTRAINT score_accepts_value", + ), + ); + + expect(dropConstraintIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropConstraintIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addConstraintIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.results", + 1, + ); + }), + ); + + test( + "rebuilt view for overloaded replacement drops old function before restore", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.score_label(value integer) + RETURNS text + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT 'old:' || value::text + $function$; + + CREATE TABLE test_schema.score_labels ( + id integer NOT NULL, + value integer NOT NULL + ); + + CREATE VIEW test_schema.score_label_view AS + SELECT id, test_schema.score_label(value) AS label + FROM test_schema.score_labels; + + INSERT INTO test_schema.score_labels (id, value) + VALUES (1, 10); + `, + testSql: dedent` + DROP VIEW test_schema.score_label_view; + + CREATE FUNCTION test_schema.score_label(value bigint) + RETURNS text + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT 'new:' || value::text + $function$; + + DROP FUNCTION test_schema.score_label(integer); + + CREATE VIEW test_schema.score_label_view AS + SELECT id, test_schema.score_label(value) AS label + FROM test_schema.score_labels; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropViewIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP VIEW test_schema.score_label_view"), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.score_label"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE FUNCTION test_schema.score_label"), + ); + const createViewIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith("CREATE") && + statement.includes("VIEW test_schema.score_label_view"), + ); + + expect(dropViewIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropViewIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(createViewIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.score_labels", + 1, + ); + }), + ); + + test( + "defaulted old overload drops before shorter replacement is created", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.normalize_score( + value integer, + scale integer DEFAULT 1 + ) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value * scale + $function$; + `, + testSql: dedent` + DROP FUNCTION test_schema.normalize_score(integer, integer); + + CREATE FUNCTION test_schema.normalize_score(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + `, + assertSqlStatements: (sqlStatements) => { + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.normalize_score"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.normalize_score", + ), + ); + + expect(dropFunctionIndex).toBeGreaterThanOrEqual(0); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + }, + }); + }), + ); + + test( + "old argument domain drops after the old overloaded function", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE DOMAIN test_schema.old_score AS integer; + + CREATE FUNCTION test_schema.normalize_score(value test_schema.old_score) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value::integer + $function$; + `, + testSql: dedent` + DROP FUNCTION test_schema.normalize_score(test_schema.old_score); + DROP DOMAIN test_schema.old_score; + + CREATE FUNCTION test_schema.normalize_score(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + `, + assertSqlStatements: (sqlStatements) => { + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.normalize_score"), + ); + const dropDomainIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP DOMAIN test_schema.old_score"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.normalize_score", + ), + ); + + expect(dropFunctionIndex).toBeGreaterThanOrEqual(0); + expect(dropDomainIndex).toBeGreaterThan(dropFunctionIndex); + expect(createFunctionIndex).toBeGreaterThan(dropDomainIndex); + }, + }); + }), + ); + }); +} diff --git a/packages/pg-delta/tests/integration/materialized-view-operations.test.ts b/packages/pg-delta/tests/integration/materialized-view-operations.test.ts index 5583f6be1..dfa30508e 100644 --- a/packages/pg-delta/tests/integration/materialized-view-operations.test.ts +++ b/packages/pg-delta/tests/integration/materialized-view-operations.test.ts @@ -97,6 +97,90 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "replace materialized view definition preserves retained indexes", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE TABLE test_schema.mv_inputs ( + id integer NOT NULL, + subtotal integer NOT NULL + ); + + CREATE MATERIALIZED VIEW test_schema.invoice_total_mv AS + SELECT id, subtotal AS total + FROM test_schema.mv_inputs; + + CREATE INDEX invoice_total_mv_total_idx + ON test_schema.invoice_total_mv (total); + + ALTER MATERIALIZED VIEW test_schema.invoice_total_mv + CLUSTER ON invoice_total_mv_total_idx; + `, + testSql: dedent` + DROP MATERIALIZED VIEW test_schema.invoice_total_mv; + + CREATE MATERIALIZED VIEW test_schema.invoice_total_mv AS + SELECT id, subtotal + 1 AS total + FROM test_schema.mv_inputs; + + CREATE INDEX invoice_total_mv_total_idx + ON test_schema.invoice_total_mv (total); + + ALTER MATERIALIZED VIEW test_schema.invoice_total_mv + CLUSTER ON invoice_total_mv_total_idx; + `, + assertSqlStatements: (statements) => { + const dropIndexIndex = statements.findIndex((statement) => + statement.startsWith( + "DROP INDEX test_schema.invoice_total_mv_total_idx", + ), + ); + const dropMatviewIndex = statements.findIndex((statement) => + statement.startsWith( + "DROP MATERIALIZED VIEW test_schema.invoice_total_mv", + ), + ); + const createMatviewIndex = statements.findIndex((statement) => + statement.startsWith( + "CREATE MATERIALIZED VIEW test_schema.invoice_total_mv", + ), + ); + const createIndexIndex = statements.findIndex((statement) => + statement.startsWith( + "CREATE INDEX invoice_total_mv_total_idx ON test_schema.invoice_total_mv", + ), + ); + const restoreClusterIndex = statements.findIndex((statement) => + statement.startsWith( + "ALTER MATERIALIZED VIEW test_schema.invoice_total_mv CLUSTER ON invoice_total_mv_total_idx", + ), + ); + + expect(dropIndexIndex).toBeGreaterThanOrEqual(0); + expect(dropMatviewIndex).toBeGreaterThan(dropIndexIndex); + expect(createMatviewIndex).toBeGreaterThan(dropMatviewIndex); + expect(createIndexIndex).toBeGreaterThan(createMatviewIndex); + expect(restoreClusterIndex).toBeGreaterThan(createIndexIndex); + }, + }); + + const { rows } = await db.main.query(dedent` + SELECT i.indisclustered + FROM pg_catalog.pg_index i + JOIN pg_catalog.pg_class idx ON idx.oid = i.indexrelid + JOIN pg_catalog.pg_namespace n ON n.oid = idx.relnamespace + WHERE n.nspname = 'test_schema' + AND idx.relname = 'invoice_total_mv_total_idx' + `); + expect(rows[0]?.indisclustered).toBe(true); + }), + ); + test( "replace materialized view with dependent index and view", withDb(pgVersion, async (db) => { diff --git a/packages/pg-delta/tests/integration/security-label-operations.test.ts b/packages/pg-delta/tests/integration/security-label-operations.test.ts index a8cfe3cec..be05a8974 100644 --- a/packages/pg-delta/tests/integration/security-label-operations.test.ts +++ b/packages/pg-delta/tests/integration/security-label-operations.test.ts @@ -1,4 +1,4 @@ -import { describe, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { POSTGRES_VERSIONS } from "../constants.ts"; import { shouldSkipDummySeclabelBuild } from "../postgres-alpine.ts"; import { withDb, withDbIsolated } from "../utils.ts"; @@ -91,6 +91,82 @@ for (const pgVersion of POSTGRES_VERSIONS) { }); }), ); + + test( + "retained label on recreated generated column", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + ${DUMMY_PROVIDER_SETUP} + CREATE FUNCTION public.compute_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE public.invoices ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (public.compute_total(subtotal)) STORED + ); + + SECURITY LABEL FOR dummy ON COLUMN public.invoices.total + IS 'classified'; + `, + testSql: ` + ALTER TABLE public.invoices DROP COLUMN total; + + DROP FUNCTION public.compute_total(integer); + + CREATE FUNCTION public.compute_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE public.invoices + ADD COLUMN total integer GENERATED ALWAYS AS + (public.compute_total(subtotal)) STORED; + + SECURITY LABEL FOR dummy ON COLUMN public.invoices.total + IS 'classified'; + `, + assertSqlStatements: (sqlStatements) => { + expect( + sqlStatements.some((statement) => + statement.startsWith("DROP TABLE "), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith("CREATE TABLE "), + ), + ).toBe(false); + + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE public.invoices ADD COLUMN total", + ), + ); + const securityLabelIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "SECURITY LABEL FOR dummy ON COLUMN public.invoices.total", + ), + ); + + expect(addColumnIndex).toBeGreaterThanOrEqual(0); + expect(securityLabelIndex).toBeGreaterThan(addColumnIndex); + }, + }); + }), + ); }, ); diff --git a/packages/pg-delta/tests/integration/sequence-operations.test.ts b/packages/pg-delta/tests/integration/sequence-operations.test.ts index 9519f00b5..11275d9fa 100644 --- a/packages/pg-delta/tests/integration/sequence-operations.test.ts +++ b/packages/pg-delta/tests/integration/sequence-operations.test.ts @@ -3,11 +3,12 @@ */ import { describe, expect, test } from "bun:test"; +import { applyPlan } from "../../src/core/plan/apply.ts"; import { createPlan } from "../../src/core/plan/create.ts"; +import { flattenPlanStatements } from "../../src/core/plan/render.ts"; import { POSTGRES_VERSIONS } from "../constants.ts"; import { withDb } from "../utils.ts"; import { roundtripFidelityTest } from "./roundtrip.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; for (const pgVersion of POSTGRES_VERSIONS) { describe(`sequence operations (pg${pgVersion})`, () => { @@ -93,6 +94,113 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "sequence owner restores run after detach and privilege replay", + withDb(pgVersion, async (db) => { + const initialSetup = ` + CREATE SCHEMA test_schema; + DO $$ + BEGIN + CREATE ROLE seq_review_old_owner; + EXCEPTION WHEN duplicate_object THEN NULL; + END + $$; + DO $$ + BEGIN + CREATE ROLE seq_review_new_owner; + EXCEPTION WHEN duplicate_object THEN NULL; + END + $$; + DO $$ + BEGIN + CREATE ROLE seq_review_reader; + EXCEPTION WHEN duplicate_object THEN NULL; + END + $$; + + CREATE SEQUENCE test_schema.existing_owned_seq; + CREATE TABLE test_schema.old_items ( + id bigint DEFAULT nextval('test_schema.existing_owned_seq'::regclass) + ); + ALTER SEQUENCE test_schema.existing_owned_seq OWNED BY test_schema.old_items.id; + ALTER TABLE test_schema.old_items OWNER TO seq_review_old_owner; + `; + const testSql = ` + ALTER SEQUENCE test_schema.existing_owned_seq OWNED BY NONE; + ALTER SEQUENCE test_schema.existing_owned_seq OWNER TO seq_review_new_owner; + + CREATE SEQUENCE test_schema.acl_seq; + GRANT USAGE ON SEQUENCE test_schema.acl_seq TO seq_review_reader; + ALTER SEQUENCE test_schema.acl_seq OWNER TO seq_review_new_owner; + `; + + await db.main.query(initialSetup); + await db.branch.query(initialSetup); + await db.branch.query(testSql); + + const planResult = await createPlan(db.main, db.branch); + expect(planResult).not.toBeNull(); + if (!planResult) return; + + const sqlStatements = flattenPlanStatements(planResult.plan); + const existingDetachIndex = sqlStatements.indexOf( + "ALTER SEQUENCE test_schema.existing_owned_seq OWNED BY NONE", + ); + const existingOwnerIndex = sqlStatements.indexOf( + "ALTER SEQUENCE test_schema.existing_owned_seq OWNER TO seq_review_new_owner", + ); + const createdGrantIndex = sqlStatements.indexOf( + "GRANT USAGE ON SEQUENCE test_schema.acl_seq TO seq_review_reader", + ); + const createdOwnerIndex = sqlStatements.indexOf( + "ALTER SEQUENCE test_schema.acl_seq OWNER TO seq_review_new_owner", + ); + + expect(existingDetachIndex).toBeGreaterThan(-1); + expect(existingOwnerIndex).toBeGreaterThan(existingDetachIndex); + expect(createdGrantIndex).toBeGreaterThan(-1); + expect(createdOwnerIndex).toBeGreaterThan(createdGrantIndex); + + const applyResult = await applyPlan( + planResult.plan, + db.main, + db.branch, + { + verifyPostApply: false, + }, + ); + expect(applyResult.status).toBe("applied"); + + const { rows } = await db.main.query<{ + existing_owner: string; + acl_owner: string; + reader_has_usage: boolean; + }>(` + SELECT + pg_get_userbyid(existing.relowner) AS existing_owner, + pg_get_userbyid(acl.relowner) AS acl_owner, + has_sequence_privilege( + 'seq_review_reader', + 'test_schema.acl_seq', + 'USAGE' + ) AS reader_has_usage + FROM pg_class existing + CROSS JOIN pg_class acl + JOIN pg_namespace existing_ns ON existing_ns.oid = existing.relnamespace + JOIN pg_namespace acl_ns ON acl_ns.oid = acl.relnamespace + WHERE existing_ns.nspname = 'test_schema' + AND existing.relname = 'existing_owned_seq' + AND acl_ns.nspname = 'test_schema' + AND acl.relname = 'acl_seq'; + `); + expect(rows[0]).toEqual({ + existing_owner: "seq_review_new_owner", + acl_owner: "seq_review_new_owner", + reader_has_usage: true, + }); + }), + ); + test( "sequence comments", withDb(pgVersion, async (db) => {