diff --git a/.changeset/few-rules-rebuild.md b/.changeset/few-rules-rebuild.md new file mode 100644 index 000000000..203f26ec5 --- /dev/null +++ b/.changeset/few-rules-rebuild.md @@ -0,0 +1,13 @@ +--- +"@supabase/pg-delta": patch +--- + +Recreate dependent rules, triggers, check constraints, generated column expressions, aggregate definitions, publication table filters, and unchanged generated-column/materialized-view indexes around function replacements and column rewrites so PostgreSQL can apply the generated migration without dependency errors or losing dependent metadata. Column rewrites now also restore CLUSTER markers for rebuilt table and materialized-view indexes, drop generated-column indexes before resetting incompatible expressions, and avoid PostgreSQL's forbidden `USING` clause when altering generated column types. + +Generated columns on PostgreSQL 17+ now use the drop/add rebuild path when the target type has no assignment or implicit cast from the existing stored type, and aggregate signature changes are seeded as replacement roots so dependent rewrite objects are dropped before the old aggregate signature. + +Domain CHECK constraints and defaults that depend on routines rebuilt from column invalidations are now dropped and restored around the routine replacement, and PostgreSQL 17+ generated columns are rebuilt when either side of the type change uses a domain type that could reject temporary NULL resets. + +Column and domain defaults that depend on replaced routine signatures are now dropped and restored around the routine replacement instead of escalating to owning table or domain recreation. Publication table filters reached from routine replacement roots are released with their publication table membership, and generated-column rebuilds now restore column comments, security labels, and column grants even when the old column was a regular column without a default. + +Publication table release now filters out tables already covered by existing publication ADD/DROP changes, avoiding duplicate `ALTER PUBLICATION ... ADD TABLE` statements when routine-filter release overlaps a normal publication membership diff. diff --git a/.changeset/quiet-routines-rebuild.md b/.changeset/quiet-routines-rebuild.md new file mode 100644 index 000000000..4b9238c4d --- /dev/null +++ b/.changeset/quiet-routines-rebuild.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Preserve column defaults, routine metadata, and index comments when column type rewrites rebuild dependent objects. 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..edc855ee6 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,44 @@ 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, +} 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 { 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 } from "./objects/index/index.model.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 } from "./objects/materialized-view/materialized-view.model.ts"; +import { stableId } from "./objects/utils.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 } from "./objects/procedure/changes/procedure.privilege.ts"; +import { CreateSecurityLabelOnProcedure } from "./objects/procedure/changes/procedure.security-label.ts"; import { Procedure } from "./objects/procedure/procedure.model.ts"; +import { + AlterPublicationAddTables, + AlterPublicationDropTables, +} from "./objects/publication/changes/publication.alter.ts"; +import { Publication } from "./objects/publication/publication.model.ts"; import { AlterRlsPolicySetUsingExpression, AlterRlsPolicySetWithCheckExpression, @@ -14,41 +48,64 @@ 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 { 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 { Rule } from "./objects/rule/rule.model.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 { sortChanges } from "./sort/sort-changes.ts"; import { + AlterTableAddColumn, + AlterTableAddConstraint, + AlterTableAlterColumnDropDefault, AlterTableAlterColumnSetDefault, + AlterTableAlterColumnType, AlterTableChangeOwner, AlterTableDropColumn, AlterTableDropConstraint, AlterTableEnableRowLevelSecurity, 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 { Table } from "./objects/table/table.model.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 { 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 { 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 { CreateSecurityLabelOnView } from "./objects/view/changes/view.security-label.ts"; import { View } from "./objects/view/view.model.ts"; function mockChange(overrides: { creates?: string[]; drops?: string[]; + invalidates?: string[]; }): Change { - const { creates = [], drops = [] } = overrides; + const { creates = [], drops = [], invalidates = [] } = overrides; return { objectType: "table", operation: "create", scope: "object", creates, drops, - invalidates: [], + invalidates, requires: [], table: { schema: "public", name: "t" }, serialize: () => [], @@ -58,6 +115,17 @@ function mockChange(overrides: { } as unknown as Change; } +function catalogWith( + catalog: Catalog, + overrides: Partial[0]>, +): Catalog { + return new Catalog( + Object.assign({}, catalog, overrides) as ConstructorParameters< + typeof Catalog + >[0], + ); +} + function mockInvalidatingChange(invalidates: string[]): Change { return { objectType: "table", @@ -72,6 +140,309 @@ function mockInvalidatingChange(invalidates: string[]): Change { } as unknown as Change; } +function makeTable( + name: string, + columns: ConstructorParameters[0]["columns"], + overrides: Partial[0]> = {}, +): Table { + return new Table({ + schema: "public", + name, + 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, + constraints: [], + privileges: [], + security_labels: [], + ...overrides, + }); +} + +function makeMaterializedView( + overrides: Partial[0]> = {}, +): MaterializedView { + return new MaterializedView({ + schema: "public", + name: "account_statuses", + definition: + "CREATE MATERIALIZED VIEW public.account_statuses AS SELECT id, status::text AS status_text FROM public.accounts WITH NO DATA", + row_security: false, + force_row_security: false, + has_indexes: true, + has_rules: false, + has_triggers: false, + has_subclasses: false, + is_populated: false, + replica_identity: "d", + is_partition: false, + options: null, + partition_bound: null, + owner: "postgres", + comment: null, + columns: [ + { + 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, + }, + { + name: "status_text", + position: 2, + data_type: "text", + data_type_str: "text", + 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: [], + security_labels: [], + ...overrides, + }); +} + +function makeProcedure( + overrides: Partial[0]> = {}, +): Procedure { + return new Procedure({ + schema: "public", + name: "account_status_text", + kind: "f", + return_type: "text", + return_type_schema: "pg_catalog", + language: "sql", + security_definer: false, + volatility: "s", + parallel_safety: "u", + execution_cost: 100, + result_rows: 0, + is_strict: false, + leakproof: false, + returns_set: false, + argument_count: 0, + argument_default_count: 0, + argument_names: null, + argument_types: [], + all_argument_types: null, + argument_modes: null, + argument_defaults: null, + source_code: "", + binary_path: null, + sql_body: "SELECT status::text FROM public.accounts WHERE id = 1", + definition: + "CREATE FUNCTION public.account_status_text() RETURNS text LANGUAGE sql STABLE BEGIN ATOMIC SELECT status::text FROM public.accounts WHERE id = 1; END", + config: null, + owner: "postgres", + comment: null, + privileges: [], + security_labels: [], + ...overrides, + }); +} + +function makeAggregate( + overrides: Partial[0]> = {}, +): Aggregate { + return new Aggregate({ + schema: "public", + name: "total_amount", + identity_arguments: "integer", + kind: "a", + aggkind: "n", + num_direct_args: 0, + return_type: "bigint", + return_type_schema: "pg_catalog", + parallel_safety: "u", + is_strict: false, + transition_function: "public.amount_transition(bigint,integer)", + state_data_type: "bigint", + 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: "0", + 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: 1, + argument_default_count: 0, + argument_names: null, + argument_types: ["integer"], + all_argument_types: null, + argument_modes: null, + argument_defaults: null, + owner: "postgres", + comment: null, + privileges: [], + security_labels: [], + ...overrides, + }); +} + +function makeDomain( + overrides: Partial[0]> = {}, +): Domain { + return new Domain({ + schema: "public", + name: "account_label", + base_type: "text", + base_type_schema: "pg_catalog", + base_type_str: "text", + not_null: false, + type_modifier: null, + array_dimensions: null, + collation: null, + default_bin: null, + default_value: null, + owner: "postgres", + comment: null, + constraints: [], + privileges: [], + security_labels: [], + ...overrides, + }); +} + +function makeIndex( + overrides: Partial[0]> = {}, +): Index { + return new Index({ + schema: "public", + table_name: "accounts", + name: "accounts_status_expr_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: [0], + column_collations: [null], + operator_classes: ["text_ops"], + column_options: [0], + index_expressions: "lower(status)", + 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 accounts_status_expr_idx ON public.accounts USING btree (lower(status))", + comment: null, + owner: "postgres", + ...overrides, + }); +} + +function makeView( + overrides: Partial[0]> = {}, +): View { + return new View({ + schema: "public", + name: "account_statuses", + definition: + "SELECT id, status::text AS status_text FROM public.accounts WHERE status IS NOT NULL", + 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, + owner: "postgres", + comment: null, + columns: [], + privileges: [], + security_labels: [], + ...overrides, + }); +} + +function makePublication( + overrides: Partial[0]> = {}, +): Publication { + return new Publication({ + name: "pub_accounts", + 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: "accounts", + columns: null, + row_filter: "status = 'active'::text", + }, + ], + schemas: [], + security_labels: [], + ...overrides, + }); +} + describe("expandReplaceDependencies", () => { test("returns changes unchanged when there are no replace roots", async () => { const catalog = await createEmptyCatalog(160004, "u"); @@ -120,6 +491,127 @@ describe("expandReplaceDependencies", () => { expect(result.replacedTableIds.size).toBe(0); }); + test("does not treat publication table membership removal as a table replacement root", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const accountsTable = makeTable("accounts", [ + { + 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: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ]); + const accountsView = new View({ + schema: "public", + name: "account_ids", + owner: "postgres", + definition: " SELECT id FROM public.accounts;", + 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, + comment: null, + columns: [ + { + 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, + }, + ], + options: null, + privileges: [], + security_labels: [], + }); + const mainPublication = makePublication({ + name: "pub_accounts", + tables: [ + { + schema: "public", + name: "accounts", + columns: null, + row_filter: null, + }, + ], + }); + const branchPublication = makePublication({ + name: "pub_accounts", + tables: [], + }); + const mainCatalog = catalogWith(baseline, { + tables: { [accountsTable.stableId]: accountsTable }, + views: { [accountsView.stableId]: accountsView }, + publications: { [mainPublication.stableId]: mainPublication }, + depends: [ + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: accountsTable.stableId, + deptype: "n", + }, + { + dependent_stable_id: accountsView.stableId, + referenced_stable_id: accountsTable.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + tables: { [accountsTable.stableId]: accountsTable }, + views: { [accountsView.stableId]: accountsView }, + publications: { [branchPublication.stableId]: branchPublication }, + }); + const changes: Change[] = [ + new AlterPublicationDropTables({ + publication: mainPublication, + tables: mainPublication.tables, + }), + ]; + + const result = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(result.changes).toBe(changes); + expect( + result.changes.some( + (change) => change instanceof DropView || change instanceof CreateView, + ), + ).toBe(false); + expect(result.replacedTableIds.size).toBe(0); + }); + test("promotes surviving dependent view when its referenced table is dropped without a same-name create", async () => { // Reproduces issue #228 case 3: ALTER TABLE users RENAME TO members. // pg-delta sees `users` as drop-only and `members` as create-only — the @@ -379,41 +871,188 @@ describe("expandReplaceDependencies", () => { expect(expanded.replacedTableIds.size).toBe(0); }); - test("reports replaced tables for downstream post-diff normalization", async () => { - // Reproduction guard for the enum-replacement expansion case: the expander - // must report which dependent tables it promoted to DropTable+CreateTable, - // but the pruning of same-table AlterTableDropColumn/DropConstraint belongs - // to the later post-diff normalization pass, not this expansion step. + test("rebuilds generated columns and publication table membership that depend on an invalidated column", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); - const mainEnum = new Enum({ - schema: "public", - name: "item_status", - owner: "postgres", - labels: [ - { sort_order: 1, label: "draft" }, - { sort_order: 2, label: "published" }, - { sort_order: 3, label: "archived" }, + const mainAccounts = makeTable("accounts", [ + { + 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: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + { + name: "status", + position: 2, + data_type: "text", + data_type_str: "text", + 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, + }, + { + name: "status_label", + position: 3, + data_type: "text", + data_type_str: "text", + 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: "upper(status)", + comment: null, + }, + ]); + const branchAccounts = makeTable("accounts", [ + { + ...mainAccounts.columns[0], + }, + { + ...mainAccounts.columns[1], + data_type: "character varying", + data_type_str: "character varying(32)", + }, + { + ...mainAccounts.columns[2], + data_type: "character varying", + data_type_str: "character varying(64)", + }, + ]); + const mainPublication = makePublication(); + const branchPublication = makePublication({ + tables: [ + { + schema: "public", + name: "accounts", + columns: null, + row_filter: "(status)::text = 'active'::text", + }, ], - comment: null, - privileges: [], }); - const branchEnum = new Enum({ - // oxlint-disable-next-line typescript/no-misused-spread - ...mainEnum, - labels: [ - { sort_order: 1, label: "draft" }, - { sort_order: 2, label: "published" }, + const columnTypeChange = new AlterTableAlterColumnType({ + table: branchAccounts, + column: branchAccounts.columns[1], + previousColumn: mainAccounts.columns[1], + }); + const generatedExpressionChange = new AlterTableAlterColumnSetDefault({ + table: branchAccounts, + column: branchAccounts.columns[2], + }); + const generatedColumnTypeChange = new AlterTableAlterColumnType({ + table: branchAccounts, + column: branchAccounts.columns[2], + previousColumn: mainAccounts.columns[2], + }); + const mainCatalog = catalogWith(baseline, { + tables: { [mainAccounts.stableId]: mainAccounts }, + publications: { [mainPublication.stableId]: mainPublication }, + depends: [ + { + dependent_stable_id: "column:public.accounts.status_label", + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, ], }); - const columnTemplate = { - data_type: "integer" as const, - data_type_str: "integer", - is_custom_type: false as const, + const branchCatalog = catalogWith(baseline, { + tables: { [branchAccounts.stableId]: branchAccounts }, + publications: { [branchPublication.stableId]: branchPublication }, + }); + + const expanded = expandReplaceDependencies({ + changes: [ + columnTypeChange, + generatedExpressionChange, + generatedColumnTypeChange, + ], + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.some( + (change) => + change instanceof AlterTableDropColumn && + change.column.name === "status_label", + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => + change instanceof AlterTableAddColumn && + change.column.name === "status_label", + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => + change instanceof AlterTableAlterColumnSetDefault && + change.column.name === "status_label", + ), + ).toBe(false); + expect( + expanded.changes.some( + (change) => + change instanceof AlterTableAlterColumnType && + change.column.name === "status_label", + ), + ).toBe(false); + expect( + expanded.changes.some( + (change) => change instanceof AlterPublicationDropTables, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toBe(true); + }); + + test("restores generated column dependents when the column itself is rebuilt", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const baseColumn = { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, custom_type_type: null, custom_type_category: null, custom_type_schema: null, custom_type_name: null, - not_null: false, + not_null: true, is_identity: false, is_identity_always: false, is_generated: false, @@ -421,57 +1060,765 @@ describe("expandReplaceDependencies", () => { default: null, comment: null, }; - const mainChildren = new Table({ - schema: "public", - name: "children", - 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, + const generatedTextColumn = { + name: "status_label", + position: 2, + data_type: "text", + data_type_str: "text", + 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: "upper(status)", + comment: "status label", + security_labels: [{ provider: "dummy", label: "classified" }], + }; + const generatedVarcharColumn = { + ...generatedTextColumn, + data_type: "character varying", + data_type_str: "character varying(64)", + }; + const checkConstraint = { + name: "accounts_status_label_check", + 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: ["status_label"], + 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: "status_label <> ''", owner: "postgres", - comment: null, - parent_schema: null, - parent_name: null, - columns: [ - { ...columnTemplate, name: "id", position: 1, not_null: true }, - { ...columnTemplate, name: "parent_ref", position: 2 }, + definition: "CHECK (status_label <> '')", + comment: "label guard", + }; + const mainTable = makeTable("accounts", [baseColumn, generatedTextColumn], { + constraints: [checkConstraint], + privileges: [ { - ...columnTemplate, - name: "status", - position: 3, - data_type: "item_status", - data_type_str: "public.item_status", - is_custom_type: true, - custom_type_type: "e", - custom_type_category: "E", - custom_type_schema: "public", - custom_type_name: "item_status", + grantee: "generated_reader", + privilege: "SELECT", + grantable: false, + columns: ["status_label"], }, ], - privileges: [], }); - const branchChildren = new Table({ - // oxlint-disable-next-line typescript/no-misused-spread - ...mainChildren, - columns: [ - { ...columnTemplate, name: "id", position: 1, not_null: true }, + const branchTable = makeTable( + "accounts", + [baseColumn, generatedVarcharColumn], + { + constraints: [checkConstraint], + privileges: mainTable.privileges, + }, + ); + const index = makeIndex({ + name: "accounts_status_label_idx", + key_columns: [2], + index_expressions: null, + definition: + "CREATE INDEX accounts_status_label_idx ON public.accounts USING btree (status_label)", + comment: "label lookup", + }); + const mainPublication = makePublication({ + tables: [ { - ...columnTemplate, - name: "status", - position: 2, - data_type: "item_status", - data_type_str: "public.item_status", - is_custom_type: true, - custom_type_type: "e", + schema: "public", + name: "accounts", + columns: ["status", "status_label"], + row_filter: null, + }, + ], + }); + const branchPublication = makePublication({ + tables: [ + { + schema: "public", + name: "accounts", + columns: ["status", "status_label"], + row_filter: null, + }, + ], + }); + const columnId = stableId.column("public", "accounts", "status_label"); + const changes: Change[] = [ + new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[1], + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[1], + }), + ]; + const mainCatalog = catalogWith(baseline, { + tables: { [mainTable.stableId]: mainTable }, + indexes: { [index.stableId]: index }, + indexableObjects: { [mainTable.stableId]: mainTable }, + publications: { [mainPublication.stableId]: mainPublication }, + depends: [ + { + dependent_stable_id: + "constraint:public.accounts.accounts_status_label_check", + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: index.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + tables: { [branchTable.stableId]: branchTable }, + indexes: { [index.stableId]: index }, + indexableObjects: { [branchTable.stableId]: branchTable }, + publications: { [branchPublication.stableId]: branchPublication }, + depends: mainCatalog.depends, + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.some( + (change) => change instanceof CreateCommentOnColumn, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof CreateSecurityLabelOnColumn, + ), + ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof GrantTablePrivileges), + ).toBe(true); + expect(expanded.changes.some((change) => change instanceof DropIndex)).toBe( + true, + ); + expect( + expanded.changes.some((change) => change instanceof CreateIndex), + ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof CreateCommentOnIndex), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterPublicationDropTables, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAddConstraint, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof CreateCommentOnConstraint, + ), + ).toBe(true); + }); + + test("restores generated column metadata when a regular column is rebuilt as generated", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const baseColumn = { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }; + const regularColumn = { + ...baseColumn, + name: "status_label", + position: 2, + not_null: false, + comment: "status label", + security_labels: [{ provider: "dummy", label: "classified" }], + }; + const generatedColumn = { + ...regularColumn, + is_generated: true, + default: "upper(status)", + }; + const mainTable = makeTable("accounts", [baseColumn, regularColumn], { + privileges: [ + { + grantee: "generated_reader", + privilege: "SELECT", + grantable: false, + columns: ["status_label"], + }, + ], + }); + const branchTable = makeTable("accounts", [baseColumn, generatedColumn], { + privileges: mainTable.privileges, + }); + const changes: Change[] = [ + new AlterTableDropColumn({ + table: mainTable, + column: regularColumn, + }), + new AlterTableAddColumn({ + table: branchTable, + column: generatedColumn, + }), + ]; + const mainCatalog = catalogWith(baseline, { + tables: { [mainTable.stableId]: mainTable }, + depends: [], + }); + const branchCatalog = catalogWith(baseline, { + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.some( + (change) => change instanceof CreateCommentOnColumn, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof CreateSecurityLabelOnColumn, + ), + ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof GrantTablePrivileges), + ).toBe(true); + }); + + test("reapplies replica identity after a generated column rebuild restores its index", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const baseColumn = { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }; + const generatedColumn = { + name: "status_label", + position: 2, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: true, + collation: null, + default: "upper(status)", + comment: null, + }; + const table = makeTable("accounts", [baseColumn, generatedColumn], { + replica_identity: "i", + replica_identity_index: "accounts_status_label_key", + }); + const index = makeIndex({ + name: "accounts_status_label_key", + key_columns: [2], + index_expressions: null, + is_unique: true, + is_replica_identity: true, + definition: + "CREATE UNIQUE INDEX accounts_status_label_key ON public.accounts USING btree (status_label)", + }); + const columnId = "column:public.accounts.status_label"; + const changes: Change[] = [ + new AlterTableDropColumn({ + table, + column: table.columns[1], + }), + new AlterTableAddColumn({ + table, + column: table.columns[1], + }), + ]; + const mainCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + indexes: { [index.stableId]: index }, + indexableObjects: { [table.stableId]: table }, + depends: [ + { + dependent_stable_id: index.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + indexes: { [index.stableId]: index }, + indexableObjects: { [table.stableId]: table }, + depends: mainCatalog.depends, + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const createIndexIdx = sorted.findIndex( + (change) => change instanceof CreateIndex, + ); + const replicaIdentityIdx = sorted.findIndex( + (change) => change instanceof AlterTableSetReplicaIdentity, + ); + + expect(createIndexIdx).toBeGreaterThanOrEqual(0); + expect(replicaIdentityIdx).toBeGreaterThan(createIndexIdx); + expect( + sorted.filter((change) => change instanceof AlterTableSetReplicaIdentity), + ).toHaveLength(1); + }); + + test("replays constraint-owned index statistics after a generated column rebuild restores a constraint", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const baseColumn = { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }; + const generatedColumn = { + name: "status_label", + position: 2, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: true, + collation: null, + default: "upper(status)", + comment: null, + }; + const uniqueConstraint = { + name: "accounts_status_label_key", + constraint_type: "u" 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: ["status_label"], + 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 (status_label)", + comment: null, + }; + const table = makeTable("accounts", [baseColumn, generatedColumn], { + constraints: [uniqueConstraint], + }); + const backingIndex = makeIndex({ + name: "accounts_status_label_key", + key_columns: [2], + index_expressions: null, + is_unique: true, + is_owned_by_constraint: true, + definition: + "CREATE UNIQUE INDEX accounts_status_label_key ON public.accounts USING btree (status_label)", + statistics_target: [250], + }); + const columnId = "column:public.accounts.status_label"; + const constraintId = "constraint:public.accounts.accounts_status_label_key"; + const changes: Change[] = [ + new AlterTableDropColumn({ + table, + column: table.columns[1], + }), + new AlterTableAddColumn({ + table, + column: table.columns[1], + }), + ]; + const mainCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + indexes: { [backingIndex.stableId]: backingIndex }, + depends: [ + { + dependent_stable_id: constraintId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + indexes: { [backingIndex.stableId]: backingIndex }, + depends: mainCatalog.depends, + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addConstraintIdx = sorted.findIndex( + (change) => change instanceof AlterTableAddConstraint, + ); + const statisticsIdx = sorted.findIndex( + (change) => + change instanceof AlterIndexSetStatistics && + change.index.stableId === backingIndex.stableId && + change.columnTargets.some( + (target) => target.columnNumber === 1 && target.statistics === 250, + ), + ); + + expect(addConstraintIdx).toBeGreaterThanOrEqual(0); + expect(statisticsIdx).toBeGreaterThan(addConstraintIdx); + }); + + test("drops cross-table constraints before rebuilding generated columns reached from invalidation", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const statusColumn = { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }; + const statusLabelColumn = { + name: "status_label", + position: 2, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: true, + collation: null, + default: "upper(status)", + comment: null, + }; + const parent = makeTable("accounts", [statusColumn, statusLabelColumn]); + const childFk = { + name: "account_events_status_label_fkey", + constraint_type: "f" 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: ["status_label"], + foreign_key_columns: ["status_label"], + foreign_key_table: "accounts", + 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: "accounts", + on_update: "a" as const, + on_delete: "a" as const, + match_type: "s" as const, + check_expression: null, + owner: "postgres", + definition: + "FOREIGN KEY (status_label) REFERENCES public.accounts(status_label)", + comment: "generated label ref", + }; + const child = makeTable( + "account_events", + [{ ...statusLabelColumn, is_generated: false, default: null }], + { constraints: [childFk] }, + ); + const columnTypeChange = new AlterTableAlterColumnType({ + table: parent, + column: { + ...statusColumn, + data_type: "character varying", + data_type_str: "character varying(32)", + }, + previousColumn: statusColumn, + }); + const generatedExpressionChange = new AlterTableAlterColumnSetDefault({ + table: parent, + column: statusLabelColumn, + }); + const mainCatalog = catalogWith(baseline, { + tables: { + [parent.stableId]: parent, + [child.stableId]: child, + }, + depends: [ + { + dependent_stable_id: "column:public.accounts.status_label", + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + { + dependent_stable_id: + "constraint:public.account_events.account_events_status_label_fkey", + referenced_stable_id: "column:public.accounts.status_label", + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + tables: { + [parent.stableId]: parent, + [child.stableId]: child, + }, + depends: mainCatalog.depends, + }); + + const expanded = expandReplaceDependencies({ + changes: [columnTypeChange, generatedExpressionChange], + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.some( + (change) => + change instanceof AlterTableDropConstraint && + change.table.name === "account_events", + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => + change instanceof AlterTableAddConstraint && + change.table.name === "account_events", + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => + change instanceof CreateCommentOnConstraint && + change.table.name === "account_events", + ), + ).toBe(true); + }); + + test("reports replaced tables for downstream post-diff normalization", async () => { + // Reproduction guard for the enum-replacement expansion case: the expander + // must report which dependent tables it promoted to DropTable+CreateTable, + // but the pruning of same-table AlterTableDropColumn/DropConstraint belongs + // to the later post-diff normalization pass, not this expansion step. + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainEnum = new Enum({ + schema: "public", + name: "item_status", + owner: "postgres", + labels: [ + { sort_order: 1, label: "draft" }, + { sort_order: 2, label: "published" }, + { sort_order: 3, label: "archived" }, + ], + comment: null, + privileges: [], + }); + const branchEnum = new Enum({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainEnum, + labels: [ + { sort_order: 1, label: "draft" }, + { sort_order: 2, label: "published" }, + ], + }); + const columnTemplate = { + data_type: "integer" as const, + data_type_str: "integer", + is_custom_type: false as const, + 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 mainChildren = new Table({ + schema: "public", + name: "children", + 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: [ + { ...columnTemplate, name: "id", position: 1, not_null: true }, + { ...columnTemplate, name: "parent_ref", position: 2 }, + { + ...columnTemplate, + name: "status", + position: 3, + data_type: "item_status", + data_type_str: "public.item_status", + is_custom_type: true, + custom_type_type: "e", + custom_type_category: "E", + custom_type_schema: "public", + custom_type_name: "item_status", + }, + ], + privileges: [], + }); + const branchChildren = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainChildren, + columns: [ + { ...columnTemplate, name: "id", position: 1, not_null: true }, + { + ...columnTemplate, + name: "status", + position: 2, + data_type: "item_status", + data_type_str: "public.item_status", + is_custom_type: true, + custom_type_type: "e", custom_type_category: "E", custom_type_schema: "public", custom_type_name: "item_status", @@ -479,98 +1826,2858 @@ describe("expandReplaceDependencies", () => { ], }); - // Pre-existing planner output: the enum replacement from diffEnums plus - // targeted ALTER TABLE statements from diffTables. The two cycle-forming - // ALTERs (drop-column, drop-constraint) must be elided. The privilege - // ALTER and the owner / RLS / replica-identity ALTERs must all survive. - const droppedColumn = mainChildren.columns.find( - (c) => c.name === "parent_ref", - ); - if (!droppedColumn) throw new Error("test setup: parent_ref missing"); - const preExistingDropColumn = new AlterTableDropColumn({ - table: mainChildren, - column: droppedColumn, + // Pre-existing planner output: the enum replacement from diffEnums plus + // targeted ALTER TABLE statements from diffTables. The two cycle-forming + // ALTERs (drop-column, drop-constraint) must be elided. The privilege + // ALTER and the owner / RLS / replica-identity ALTERs must all survive. + const droppedColumn = mainChildren.columns.find( + (c) => c.name === "parent_ref", + ); + if (!droppedColumn) throw new Error("test setup: parent_ref missing"); + const preExistingDropColumn = new AlterTableDropColumn({ + table: mainChildren, + column: droppedColumn, + }); + const preExistingDropConstraint = new AlterTableDropConstraint({ + table: mainChildren, + constraint: { + name: "children_parent_ref_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: ["parent_ref"], + 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 (parent_ref) REFERENCES public.parents(id)", + comment: null, + }, + }); + const preExistingChangeOwner = new AlterTableChangeOwner({ + table: branchChildren, + owner: "new_owner", + }); + const preExistingEnableRls = new AlterTableEnableRowLevelSecurity({ + table: branchChildren, + }); + const preExistingReplicaIdentity = new AlterTableSetReplicaIdentity({ + table: branchChildren, + mode: "f", + }); + const preExistingGrant = new GrantTablePrivileges({ + table: branchChildren, + grantee: "reader", + privileges: [{ privilege: "SELECT", grantable: false }], + }); + const changes: Change[] = [ + new DropEnum({ enum: mainEnum }), + new CreateEnum({ enum: branchEnum }), + preExistingDropColumn, + preExistingDropConstraint, + preExistingChangeOwner, + preExistingEnableRls, + preExistingReplicaIdentity, + preExistingGrant, + ]; + + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + enums: { [mainEnum.stableId]: mainEnum }, + tables: { [mainChildren.stableId]: mainChildren }, + // pg_depend: column children.status depends on type item_status. + depends: [ + { + dependent_stable_id: "column:public.children.status", + referenced_stable_id: mainEnum.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + enums: { [branchEnum.stableId]: branchEnum }, + tables: { [branchChildren.stableId]: branchChildren }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + // The replace-table pair was added. + expect(expanded.changes.some((c) => c instanceof DropTable)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateTable)).toBe(true); + expect(expanded.replacedTableIds.has(mainChildren.stableId)).toBe(true); + // Expansion itself keeps the pre-existing ALTERs; the post-diff cycle pass + // decides which of them are superseded by the replacement. + expect(expanded.changes).toContain(preExistingDropColumn); + expect(expanded.changes).toContain(preExistingDropConstraint); + expect( + expanded.changes.some((c) => c instanceof AlterTableDropColumn), + ).toBe(true); + expect( + expanded.changes.some((c) => c instanceof AlterTableDropConstraint), + ).toBe(true); + // The enum replace roots are still present. + expect(expanded.changes.some((c) => c instanceof DropEnum)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateEnum)).toBe(true); + // Non-cycle object-scope ALTERs are carried through untouched. + expect(expanded.changes).toContain(preExistingChangeOwner); + expect(expanded.changes).toContain(preExistingEnableRls); + expect(expanded.changes).toContain(preExistingReplicaIdentity); + // Privilege-scope ALTER on the recreated table survives. + expect(expanded.changes).toContain(preExistingGrant); + expect(expanded.replacedTableIds.has("table:public.parents")).toBe(false); + }); + + test("promotes dependent view when a procedure's parameter types change", async () => { + // Procedure stableIds are signature-qualified, so a parameter-type change + // produces different stableIds in `createdIds` and `droppedIds`. The + // expander must still treat the (schema, name)-matched pair as a replace + // root so a dependent view is promoted from `CREATE OR REPLACE VIEW` to + // `DROP VIEW` + `CREATE VIEW` (otherwise `DROP FUNCTION` fails with + // "cannot drop function because other objects depend on it"). + const baseline = await createEmptyCatalog(170000, "postgres"); + const procedureBase = { + schema: "public", + name: "format_id", + kind: "f" as const, + return_type: "text", + return_type_schema: "pg_catalog", + language: "sql", + security_definer: false, + volatility: "i" as const, + parallel_safety: "u" as const, + execution_cost: 100, + result_rows: 0, + is_strict: false, + leakproof: false, + returns_set: false, + argument_count: 1, + argument_default_count: 0, + argument_names: ["id"], + all_argument_types: null, + argument_modes: null, + argument_defaults: null, + source_code: "SELECT 'id:' || id::text", + binary_path: null, + sql_body: null, + config: null, + owner: "postgres", + comment: null, + privileges: [], + }; + const mainProcedure = new Procedure({ + ...procedureBase, + argument_types: ["int4"], + definition: "CREATE FUNCTION public.format_id(id integer) ...", + }); + const branchProcedure = new Procedure({ + ...procedureBase, + argument_types: ["int8"], + definition: "CREATE FUNCTION public.format_id(id bigint) ...", + }); + const viewBase = { + schema: "public", + name: "items_formatted", + 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" as const, + is_partition: false, + options: null, + partition_bound: null, + owner: "postgres", + comment: null, + columns: [], + privileges: [], + }; + const mainView = new View({ + ...viewBase, + definition: "SELECT public.format_id(id) FROM public.items", + }); + const branchView = new View({ + ...viewBase, + definition: "SELECT public.format_id(id::bigint) FROM public.items", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + // view.diff emits this because pg_get_viewdef text differs after the + // underlying function signature changes. + new CreateView({ view: branchView, orReplace: true }), + ]; + + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + views: { [mainView.stableId]: mainView }, + depends: [ + { + dependent_stable_id: mainView.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 }, + views: { [branchView.stableId]: branchView }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropView)).toBe(true); + }); + + test("replays view metadata when column invalidation adds only a view drop", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainView = makeView({ + owner: "view_owner", + comment: "status view", + security_labels: [{ provider: "dummy", label: "classified" }], + privileges: [ + { grantee: "view_reader", privilege: "SELECT", grantable: false }, + ], + }); + const branchView = makeView({ + owner: mainView.owner, + definition: + "SELECT id, status::text AS status_text FROM public.accounts WHERE status <> 'archived'::text", + comment: mainView.comment, + security_labels: mainView.security_labels, + privileges: mainView.privileges, + }); + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + new CreateView({ view: branchView, orReplace: true }), + ]; + const mainCatalog = catalogWith(baseline, { + views: { [mainView.stableId]: mainView }, + depends: [ + { + dependent_stable_id: mainView.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + views: { [branchView.stableId]: branchView }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes.some((c) => c instanceof DropView)).toBe(true); + expect( + expanded.changes.filter((c) => c instanceof CreateView), + ).toHaveLength(1); + expect( + expanded.changes.some((c) => c instanceof AlterViewChangeOwner), + ).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateCommentOnView)).toBe( + true, + ); + expect( + expanded.changes.some((c) => c instanceof CreateSecurityLabelOnView), + ).toBe(true); + expect(expanded.changes.some((c) => c instanceof GrantViewPrivileges)).toBe( + true, + ); + }); + + test("replaces dependent constraints without replacing their table when a procedure signature changes", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = makeProcedure({ + name: "is_valid_status", + argument_count: 1, + argument_names: ["status"], + argument_types: ["text"], + argument_default_count: 0, + argument_defaults: null, + source_code: "SELECT status <> ''", + definition: + "CREATE FUNCTION public.is_valid_status(status text) RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT status <> '' $$", + }); + const branchProcedure = makeProcedure({ + name: "is_valid_status", + argument_count: 2, + argument_names: ["status", "expected"], + argument_types: ["text", "text"], + argument_default_count: 1, + argument_defaults: "'active'::text", + source_code: "SELECT status <> '' AND expected <> ''", + definition: + "CREATE FUNCTION public.is_valid_status(status text, expected text DEFAULT 'active'::text) RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT status <> '' AND expected <> '' $$", + }); + const constraint = { + name: "accounts_status_check", + 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: ["status"], + 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: "is_valid_status(status)", + owner: "postgres", + definition: "CHECK (is_valid_status(status))", + comment: "status guard", + }; + const table = makeTable( + "accounts", + [ + { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ], + { constraints: [constraint] }, + ); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = catalogWith(baseline, { + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [table.stableId]: table }, + depends: [ + { + dependent_stable_id: + "constraint:public.accounts.accounts_status_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [table.stableId]: table }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropTable)).toBe(false); + expect(expanded.changes.some((c) => c instanceof CreateTable)).toBe(false); + expect( + expanded.changes.some((c) => c instanceof AlterTableDropConstraint), + ).toBe(true); + expect( + expanded.changes.some((c) => c instanceof AlterTableAddConstraint), + ).toBe(true); + expect( + expanded.changes.some((c) => + c.serialize().includes("COMMENT ON CONSTRAINT accounts_status_check"), + ), + ).toBe(true); + }); + + test("keeps drop-only constraint dependents when the branch table is missing", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = makeProcedure({ + name: "is_valid_status", + argument_count: 1, + argument_names: ["status"], + argument_types: ["text"], + source_code: "SELECT status <> ''", + definition: + "CREATE FUNCTION public.is_valid_status(status text) RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT status <> '' $$", + }); + const branchProcedure = makeProcedure({ + name: "is_valid_status", + argument_count: 2, + argument_names: ["status", "expected"], + argument_types: ["text", "text"], + argument_default_count: 1, + argument_defaults: "'active'::text", + source_code: "SELECT status <> '' AND expected <> ''", + definition: + "CREATE FUNCTION public.is_valid_status(status text, expected text DEFAULT 'active'::text) RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT status <> '' AND expected <> '' $$", + }); + const constraint = { + name: "accounts_status_check", + 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: [], + 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: "is_valid_status(status)", + owner: "postgres", + definition: "CHECK (public.is_valid_status(status))", + comment: null, + }; + const table = makeTable( + "accounts", + [ + { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ], + { constraints: [constraint] }, + ); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = catalogWith(baseline, { + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [table.stableId]: table }, + depends: [ + { + dependent_stable_id: + "constraint:public.accounts.accounts_status_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: {}, + indexes: { + "index:public.other_table.other_table_name_idx": makeIndex({ + table_name: "other_table", + name: "other_table_name_idx", + is_owned_by_constraint: true, + definition: + "CREATE INDEX other_table_name_idx ON public.other_table USING btree (name)", + }), + }, + depends: [], + }); + + expect(() => + expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }), + ).not.toThrow(); + }); + + test("replays backing index comments when constraint rebuilds recreate owned indexes", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = makeProcedure({ + name: "is_valid_status", + argument_count: 1, + argument_names: ["status"], + argument_types: ["text"], + source_code: "SELECT status <> ''", + definition: + "CREATE FUNCTION public.is_valid_status(status text) RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT status <> '' $$", + }); + const branchProcedure = makeProcedure({ + name: "is_valid_status", + argument_count: 2, + argument_names: ["status", "expected"], + argument_types: ["text", "text"], + argument_default_count: 1, + argument_defaults: "'active'::text", + source_code: "SELECT status <> '' AND expected <> ''", + definition: + "CREATE FUNCTION public.is_valid_status(status text, expected text DEFAULT 'active'::text) RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT status <> '' AND expected <> '' $$", + }); + const constraint = { + name: "accounts_status_key", + constraint_type: "u" 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: ["status"], + 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: "is_valid_status(status)", + owner: "postgres", + definition: "UNIQUE (status)", + comment: null, + }; + const table = makeTable( + "accounts", + [ + { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ], + { constraints: [constraint] }, + ); + const backingIndex = makeIndex({ + name: "accounts_status_key", + key_columns: [1], + index_expressions: null, + is_unique: true, + is_owned_by_constraint: true, + definition: + "CREATE UNIQUE INDEX accounts_status_key ON public.accounts USING btree (status)", + comment: "constraint-owned index comment", + statistics_target: [250], + }); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = catalogWith(baseline, { + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [table.stableId]: table }, + indexes: { [backingIndex.stableId]: backingIndex }, + depends: [ + { + dependent_stable_id: "constraint:public.accounts.accounts_status_key", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [table.stableId]: table }, + indexes: { [backingIndex.stableId]: backingIndex }, + depends: mainCatalog.depends, + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.some((change) => change instanceof CreateCommentOnIndex), + ).toBe(true); + expect( + expanded.changes.some( + (change) => + change instanceof AlterIndexSetStatistics && + change.index.stableId === backingIndex.stableId && + change.columnTargets.some( + (target) => target.columnNumber === 1 && target.statistics === 250, + ), + ), + ).toBe(true); + }); + + test("reapplies replica identity when constraint rebuilds recreate owned indexes", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = makeProcedure({ + name: "is_valid_status", + argument_count: 1, + argument_names: ["status"], + argument_types: ["text"], + source_code: "SELECT status <> ''", + definition: + "CREATE FUNCTION public.is_valid_status(status text) RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT status <> '' $$", + }); + const branchProcedure = makeProcedure({ + name: "is_valid_status", + argument_count: 2, + argument_names: ["status", "expected"], + argument_types: ["text", "text"], + argument_default_count: 1, + argument_defaults: "'active'::text", + source_code: "SELECT status <> '' AND expected <> ''", + definition: + "CREATE FUNCTION public.is_valid_status(status text, expected text DEFAULT 'active'::text) RETURNS boolean LANGUAGE sql IMMUTABLE AS $$ SELECT status <> '' AND expected <> '' $$", + }); + const constraint = { + name: "accounts_status_key", + constraint_type: "u" 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: ["status"], + 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: "is_valid_status(status)", + owner: "postgres", + definition: "UNIQUE (status)", + comment: null, + }; + const table = makeTable( + "accounts", + [ + { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ], + { + constraints: [constraint], + replica_identity: "i", + replica_identity_index: "accounts_status_key", + }, + ); + const backingIndex = makeIndex({ + name: "accounts_status_key", + key_columns: [1], + index_expressions: null, + is_unique: true, + is_replica_identity: true, + is_owned_by_constraint: true, + definition: + "CREATE UNIQUE INDEX accounts_status_key ON public.accounts USING btree (status)", + }); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = catalogWith(baseline, { + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [table.stableId]: table }, + indexes: { [backingIndex.stableId]: backingIndex }, + indexableObjects: { [table.stableId]: table }, + depends: [ + { + dependent_stable_id: "constraint:public.accounts.accounts_status_key", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [table.stableId]: table }, + indexes: { [backingIndex.stableId]: backingIndex }, + indexableObjects: { [table.stableId]: table }, + depends: mainCatalog.depends, + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addConstraintIdx = sorted.findIndex( + (change) => change instanceof AlterTableAddConstraint, + ); + const replicaIdentityChanges = sorted.filter( + (change) => change instanceof AlterTableSetReplicaIdentity, + ); + const replicaIdentityIdx = sorted.findIndex( + (change) => change instanceof AlterTableSetReplicaIdentity, + ); + + expect(addConstraintIdx).toBeGreaterThanOrEqual(0); + expect(replicaIdentityChanges).toHaveLength(1); + expect(replicaIdentityIdx).toBeGreaterThan(addConstraintIdx); + + const preExistingReplicaIdentity = new AlterTableSetReplicaIdentity({ + table, + mode: "i", + indexName: "accounts_status_key", + }); + const expandedWithExisting = expandReplaceDependencies({ + changes: [...changes, preExistingReplicaIdentity], + mainCatalog, + branchCatalog, + }); + + expect( + expandedWithExisting.changes.filter( + (change) => change instanceof AlterTableSetReplicaIdentity, + ), + ).toHaveLength(1); + }); + + test("promotes dependent RLS policy when a procedure's signature changes", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const procedureBase = { + schema: "public", + name: "check_role", + kind: "f" as const, + return_type: "boolean", + return_type_schema: "pg_catalog", + language: "plpgsql", + security_definer: false, + volatility: "v" as const, + parallel_safety: "u" as const, + execution_cost: 100, + result_rows: 0, + is_strict: false, + leakproof: false, + returns_set: false, + argument_names: ["id", "role"], + all_argument_types: null, + argument_modes: null, + source_code: "BEGIN RETURN true; END;", + binary_path: null, + sql_body: null, + config: null, + owner: "postgres", + comment: null, + privileges: [], + }; + const mainProcedure = new Procedure({ + ...procedureBase, + argument_count: 2, + argument_default_count: 0, + argument_types: ["uuid", "text"], + argument_defaults: null, + definition: + "CREATE FUNCTION public.check_role(id uuid, role text) RETURNS boolean ...", + }); + const branchProcedure = new Procedure({ + ...procedureBase, + argument_count: 3, + argument_default_count: 1, + argument_names: ["id", "role", "extra"], + argument_types: ["uuid", "text", "text"], + argument_defaults: "'default'::text", + definition: + "CREATE FUNCTION public.check_role(id uuid, role text, extra text DEFAULT 'default'::text) RETURNS boolean ...", + }); + const policyBase = { + schema: "public", + table_name: "profiles", + name: "check_role_policy", + command: "r" as const, + permissive: true, + roles: ["public"], + using_expression: "public.check_role(id, role)", + with_check_expression: null, + owner: "postgres", + comment: "policy comment", + referenced_relations: [], + }; + const mainPolicy = new RlsPolicy({ + ...policyBase, + referenced_procedures: [ + { + schema: "public", + name: "check_role", + argument_types: ["uuid", "text"], + }, + ], + }); + const branchPolicy = new RlsPolicy({ + ...policyBase, + referenced_procedures: [ + { + schema: "public", + name: "check_role", + argument_types: ["uuid", "text", "text"], + }, + ], + }); + + 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 }, + rlsPolicies: { [mainPolicy.stableId]: mainPolicy }, + depends: [ + { + dependent_stable_id: mainPolicy.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 }, + rlsPolicies: { [branchPolicy.stableId]: branchPolicy }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropRlsPolicy)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateRlsPolicy)).toBe( + true, + ); + expect( + expanded.changes.some((c) => c instanceof CreateCommentOnRlsPolicy), + ).toBe(true); + }); + + test("promotes dependent RLS policy when a referenced column is invalidated", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const columnTemplate = { + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }; + const tableBase = { + schema: "public", + name: "solution_categories_with_policy", + persistence: "p" as const, + row_security: true, + force_row_security: false, + has_indexes: false, + has_rules: false, + has_triggers: false, + has_subclasses: false, + is_populated: true, + replica_identity: "d" as const, + is_partition: false, + options: null, + partition_bound: null, + partition_by: null, + owner: "postgres", + comment: null, + parent_schema: null, + parent_name: null, + constraints: [], + privileges: [], + }; + const mainRoleColumn = { + ...columnTemplate, + name: "role", + }; + const branchRoleColumn = { + ...columnTemplate, + name: "role", + data_type: "user_role_enum", + data_type_str: "public.user_role_enum", + is_custom_type: true, + custom_type_type: "e", + custom_type_category: "E", + custom_type_schema: "public", + custom_type_name: "user_role_enum", + }; + const mainTable = new Table({ + ...tableBase, + columns: [mainRoleColumn], + }); + const branchTable = new Table({ + ...tableBase, + columns: [branchRoleColumn], + }); + const policyBase = { + schema: "public", + table_name: "solution_categories_with_policy", + name: "categories_admin_manage", + command: "*" as const, + permissive: true, + roles: ["public"], + owner: "postgres", + comment: null, + referenced_relations: [], + referenced_procedures: [], + }; + const mainPolicy = new RlsPolicy({ + ...policyBase, + using_expression: "role = 'admin'", + with_check_expression: "role = 'admin'", + }); + const branchPolicy = new RlsPolicy({ + ...policyBase, + using_expression: "role = 'admin'::public.user_role_enum", + with_check_expression: "role = 'admin'::public.user_role_enum", + }); + const alterUsing = new AlterRlsPolicySetUsingExpression({ + policy: mainPolicy, + usingExpression: branchPolicy.using_expression, + }); + const alterWithCheck = new AlterRlsPolicySetWithCheckExpression({ + policy: mainPolicy, + withCheckExpression: branchPolicy.with_check_expression, + }); + const changes: Change[] = [ + mockInvalidatingChange([ + "column:public.solution_categories_with_policy.role", + ]), + alterUsing, + alterWithCheck, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + tables: { [mainTable.stableId]: mainTable }, + rlsPolicies: { [mainPolicy.stableId]: mainPolicy }, + depends: [ + { + dependent_stable_id: mainPolicy.stableId, + referenced_stable_id: + "column:public.solution_categories_with_policy.role", + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + tables: { [branchTable.stableId]: branchTable }, + rlsPolicies: { [branchPolicy.stableId]: branchPolicy }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropRlsPolicy)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateRlsPolicy)).toBe( + true, + ); + expect(expanded.changes).not.toContain(alterUsing); + expect(expanded.changes).not.toContain(alterWithCheck); + }); + + test("promotes dependent rule when a procedure's signature changes", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const procedureBase = { + schema: "public", + name: "is_valid_amount", + kind: "f" as const, + return_type: "boolean", + return_type_schema: "pg_catalog", + language: "sql", + security_definer: false, + volatility: "i" as const, + parallel_safety: "u" as const, + execution_cost: 100, + result_rows: 0, + is_strict: false, + leakproof: false, + returns_set: false, + argument_count: 1, + argument_default_count: 0, + argument_names: ["value"], + all_argument_types: null, + argument_modes: null, + argument_defaults: null, + source_code: "SELECT value > 0", + binary_path: null, + sql_body: null, + config: null, + owner: "postgres", + comment: null, + privileges: [], + }; + const mainProcedure = new Procedure({ + ...procedureBase, + argument_types: ["int4"], + definition: + "CREATE FUNCTION public.is_valid_amount(value integer) RETURNS boolean ...", + }); + const branchProcedure = new Procedure({ + ...procedureBase, + argument_types: ["int8"], + definition: + "CREATE FUNCTION public.is_valid_amount(value bigint) RETURNS boolean ...", + }); + const ruleBase = { + schema: "public", + name: "block_invalid_amount", + table_name: "items", + relation_kind: "r" as const, + event: "INSERT" as const, + enabled: "D" as const, + is_instead: true, + owner: "postgres", + definition: + "CREATE RULE block_invalid_amount AS ON INSERT TO public.items WHERE NOT public.is_valid_amount(new.amount) DO INSTEAD NOTHING", + comment: "rule comment", + columns: ["amount"], + }; + const mainRule = new Rule(ruleBase); + const branchRule = new Rule({ + ...ruleBase, + definition: + "CREATE RULE block_invalid_amount AS ON INSERT TO public.items WHERE NOT public.is_valid_amount(new.amount::bigint) DO INSTEAD NOTHING", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new CreateRule({ rule: branchRule, orReplace: true }), + ]; + const mainCatalog = catalogWith(baseline, { + procedures: { [mainProcedure.stableId]: mainProcedure }, + rules: { [mainRule.stableId]: mainRule }, + depends: [ + { + dependent_stable_id: mainRule.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + procedures: { [branchProcedure.stableId]: branchProcedure }, + rules: { [branchRule.stableId]: branchRule }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropRule)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateRule)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateCommentOnRule)).toBe( + true, + ); + }); + + test("promotes dependent rule and trigger when a column is invalidated", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const ruleBase = { + schema: "public", + name: "block_blocked_accounts", + table_name: "accounts", + relation_kind: "r" as const, + event: "INSERT" as const, + enabled: "O" as const, + is_instead: true, + owner: "postgres", + definition: + "CREATE RULE block_blocked_accounts AS ON INSERT TO public.accounts WHERE new.status = 'blocked' DO INSTEAD NOTHING", + comment: null, + columns: ["status"], + }; + const mainRule = new Rule(ruleBase); + const branchRule = new Rule({ + ...ruleBase, + definition: + "CREATE RULE block_blocked_accounts AS ON INSERT TO public.accounts WHERE new.status = 'blocked'::public.account_status DO INSTEAD NOTHING", + }); + const triggerBase = { + schema: "public", + name: "block_blocked_accounts", + table_name: "accounts", + table_relkind: "r" as const, + function_schema: "public", + function_name: "noop_trigger", + trigger_type: 23, + enabled: "O" as const, + is_internal: false, + deferrable: false, + initially_deferred: false, + argument_count: 0, + column_numbers: [], + arguments: [], + when_condition: "new.status = 'blocked'::text", + 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 block_blocked_accounts BEFORE INSERT ON public.accounts FOR EACH ROW WHEN (new.status = 'blocked'::text) EXECUTE FUNCTION public.noop_trigger()", + comment: "trigger comment", + }; + const mainTrigger = new Trigger(triggerBase); + const branchTrigger = new Trigger({ + ...triggerBase, + enabled: "D", + when_condition: "new.status = 'blocked'::public.account_status", + definition: + "CREATE TRIGGER block_blocked_accounts BEFORE INSERT ON public.accounts FOR EACH ROW WHEN (new.status = 'blocked'::public.account_status) EXECUTE FUNCTION public.noop_trigger()", + }); + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + new CreateRule({ rule: branchRule, orReplace: true }), + new CreateTrigger({ trigger: branchTrigger, orReplace: true }), + ]; + const mainCatalog = catalogWith(baseline, { + rules: { [mainRule.stableId]: mainRule }, + triggers: { [mainTrigger.stableId]: mainTrigger }, + depends: [ + { + dependent_stable_id: mainRule.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + { + dependent_stable_id: mainTrigger.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + rules: { [branchRule.stableId]: branchRule }, + triggers: { [branchTrigger.stableId]: branchTrigger }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropRule)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateRule)).toBe(true); + expect(expanded.changes.some((c) => c instanceof DropTrigger)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateTrigger)).toBe(true); + expect( + expanded.changes.some((c) => c instanceof CreateCommentOnTrigger), + ).toBe(true); + expect( + expanded.changes.some((c) => c.serialize().includes("DISABLE TRIGGER")), + ).toBe(true); + }); + + test("does not replace tables for constraint dependents of invalidated columns", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const table = new Table({ + schema: "public", + name: "accounts", + 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: "status", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ], + privileges: [], + }); + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + ]; + const mainCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + depends: [ + { + dependent_stable_id: + "constraint:public.accounts.accounts_status_check", + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropTable)).toBe(false); + expect(expanded.changes.some((c) => c instanceof CreateTable)).toBe(false); + expect(expanded.replacedTableIds.has(table.stableId)).toBe(false); + }); + + test("promotes dependent SQL routines when a column is invalidated", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const procedureBase = { + schema: "public", + name: "account_status_text", + kind: "f" as const, + return_type: "text", + return_type_schema: "pg_catalog", + language: "sql", + security_definer: false, + volatility: "s" as const, + parallel_safety: "u" as const, + execution_cost: 100, + result_rows: 0, + is_strict: false, + leakproof: false, + returns_set: false, + argument_count: 0, + argument_default_count: 0, + argument_names: null, + argument_types: [], + all_argument_types: null, + argument_modes: null, + argument_defaults: null, + source_code: "", + binary_path: null, + sql_body: "SELECT status::text FROM public.accounts WHERE id = 1", + config: null, + owner: "postgres", + comment: null, + privileges: [], + }; + const mainProcedure = new Procedure({ + ...procedureBase, + definition: + "CREATE FUNCTION public.account_status_text() RETURNS text LANGUAGE sql STABLE BEGIN ATOMIC SELECT status::text FROM public.accounts WHERE id = 1; END", + }); + const branchProcedure = new Procedure({ + ...procedureBase, + definition: + "CREATE FUNCTION public.account_status_text() RETURNS text LANGUAGE sql STABLE BEGIN ATOMIC SELECT status::text FROM public.accounts WHERE id = 1; END", + }); + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + ]; + const mainCatalog = catalogWith(baseline, { + procedures: { [mainProcedure.stableId]: mainProcedure }, + depends: [ + { + dependent_stable_id: mainProcedure.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + procedures: { [branchProcedure.stableId]: branchProcedure }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropProcedure)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateProcedure)).toBe( + true, + ); + }); + + test("promotes aggregate dependents when a column invalidation rebuilds a SQL routine", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const procedure = makeProcedure({ + name: "status_len_state", + argument_types: ["integer", "integer"], + argument_count: 2, + return_type: "integer", + return_type_schema: "pg_catalog", + source_code: "", + sql_body: + "SELECT state + length(status::text) FROM public.accounts WHERE id = account_id", + definition: + "CREATE FUNCTION public.status_len_state(state integer, account_id integer) RETURNS integer LANGUAGE sql STABLE BEGIN ATOMIC SELECT state + length(status::text) FROM public.accounts WHERE id = account_id; END", + }); + const aggregate = makeAggregate({ + name: "status_len_sum", + identity_arguments: "integer", + return_type: "integer", + return_type_schema: "pg_catalog", + transition_function: "public.status_len_state(integer,integer)", + state_data_type: "integer", + state_data_type_schema: "pg_catalog", + initial_condition: "0", + argument_types: ["integer"], + }); + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + ]; + const mainCatalog = catalogWith(baseline, { + procedures: { [procedure.stableId]: procedure }, + aggregates: { [aggregate.stableId]: aggregate }, + depends: [ + { + dependent_stable_id: procedure.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + { + dependent_stable_id: aggregate.stableId, + referenced_stable_id: procedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + procedures: { [procedure.stableId]: procedure }, + aggregates: { [aggregate.stableId]: aggregate }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropProcedure)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateProcedure)).toBe( + true, + ); + expect(expanded.changes.some((c) => c instanceof DropAggregate)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateAggregate)).toBe( + true, + ); + }); + + test("promotes aggregate dependents when an aggregate signature changes", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainAggregate = makeAggregate({ + name: "total_status", + identity_arguments: "integer", + argument_types: ["integer"], + transition_function: "public.amount_transition(bigint,integer)", + state_data_type: "bigint", + state_data_type_schema: "pg_catalog", + }); + const branchAggregate = makeAggregate({ + name: "total_status", + identity_arguments: "numeric", + argument_types: ["numeric"], + transition_function: "public.amount_transition(bigint,numeric)", + state_data_type: "bigint", + state_data_type_schema: "pg_catalog", + }); + const mainView = makeView({ + name: "status_totals", + definition: + "SELECT public.total_status(status_id) AS total FROM public.accounts", + }); + const branchView = makeView({ + name: "status_totals", + definition: + "SELECT public.total_status(status_value) AS total FROM public.accounts", + }); + const changes: Change[] = [ + new DropAggregate({ aggregate: mainAggregate }), + new CreateAggregate({ aggregate: branchAggregate }), + ]; + const mainCatalog = catalogWith(baseline, { + aggregates: { [mainAggregate.stableId]: mainAggregate }, + views: { [mainView.stableId]: mainView }, + depends: [ + { + dependent_stable_id: mainView.stableId, + referenced_stable_id: mainAggregate.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + aggregates: { [branchAggregate.stableId]: branchAggregate }, + views: { [branchView.stableId]: branchView }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const dropViewIdx = sorted.findIndex((c) => c instanceof DropView); + const dropAggregateIdx = sorted.findIndex( + (c) => c instanceof DropAggregate, + ); + const createViewIdx = sorted.findIndex((c) => c instanceof CreateView); + const createAggregateIdx = sorted.findIndex( + (c) => c instanceof CreateAggregate, + ); + + expect(dropViewIdx).toBeGreaterThanOrEqual(0); + expect(createViewIdx).toBeGreaterThanOrEqual(0); + expect(dropViewIdx).toBeLessThan(dropAggregateIdx); + expect(createViewIdx).toBeGreaterThan(createAggregateIdx); + }); + + test("drops constraints that depend on routines rebuilt from column invalidation", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const accountIdColumn = { + name: "account_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: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }; + const constraint = { + name: "account_events_status_check", + 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: ["account_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: "public.account_status_is_open(account_id)", + owner: "postgres", + definition: "CHECK (public.account_status_is_open(account_id))", + comment: null, + }; + const accountEvents = makeTable("account_events", [accountIdColumn], { + constraints: [constraint], + }); + const procedure = makeProcedure({ + name: "account_status_is_open", + argument_types: ["integer"], + argument_count: 1, + return_type: "boolean", + return_type_schema: "pg_catalog", + source_code: "", + sql_body: + "SELECT status::text = 'open' FROM public.accounts WHERE id = account_id", + definition: + "CREATE FUNCTION public.account_status_is_open(account_id integer) RETURNS boolean LANGUAGE sql STABLE BEGIN ATOMIC SELECT status::text = 'open' FROM public.accounts WHERE id = account_id; END", + }); + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + ]; + const constraintStableId = + "constraint:public.account_events.account_events_status_check"; + const mainCatalog = catalogWith(baseline, { + tables: { [accountEvents.stableId]: accountEvents }, + procedures: { [procedure.stableId]: procedure }, + depends: [ + { + dependent_stable_id: procedure.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + { + dependent_stable_id: constraintStableId, + referenced_stable_id: procedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + tables: { [accountEvents.stableId]: accountEvents }, + procedures: { [procedure.stableId]: procedure }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropProcedure)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateProcedure)).toBe( + true, + ); + expect( + expanded.changes.some((c) => c instanceof AlterTableDropConstraint), + ).toBe(true); + expect( + expanded.changes.some((c) => c instanceof AlterTableAddConstraint), + ).toBe(true); + }); + + test("drops domain constraints that depend on routines rebuilt from column invalidation", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const procedure = makeProcedure({ + name: "account_label_is_valid", + argument_types: ["text"], + argument_count: 1, + return_type: "boolean", + return_type_schema: "pg_catalog", + source_code: "", + sql_body: "SELECT status::text = value FROM public.accounts WHERE id = 1", + definition: + "CREATE FUNCTION public.account_label_is_valid(value text) RETURNS boolean LANGUAGE sql STABLE BEGIN ATOMIC SELECT status::text = value FROM public.accounts WHERE id = 1; END", + }); + const domainConstraint = { + name: "account_label_check", + validated: true, + is_local: true, + no_inherit: false, + check_expression: "public.account_label_is_valid(VALUE)", + }; + const domain = makeDomain({ + name: "account_label", + constraints: [domainConstraint], + }); + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + ]; + const constraintStableId = stableId.constraint( + domain.schema, + domain.name, + domainConstraint.name, + ); + const mainCatalog = catalogWith(baseline, { + domains: { [domain.stableId]: domain }, + procedures: { [procedure.stableId]: procedure }, + depends: [ + { + dependent_stable_id: procedure.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + { + dependent_stable_id: constraintStableId, + referenced_stable_id: procedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + domains: { [domain.stableId]: domain }, + procedures: { [procedure.stableId]: procedure }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropProcedure)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateProcedure)).toBe( + true, + ); + expect( + expanded.changes.filter((c) => c instanceof AlterDomainDropConstraint), + ).toHaveLength(1); + expect( + expanded.changes.filter((c) => c instanceof AlterDomainAddConstraint), + ).toHaveLength(1); + }); + + test("drops and re-adds column defaults that depend on invalidated routines", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const statusTextColumn = { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }; + const statusEnumColumn = { + ...statusTextColumn, + data_type: "account_status", + data_type_str: "public.account_status", + is_custom_type: true, + custom_type_type: "e" as const, + custom_type_category: "E", + custom_type_schema: "public", + custom_type_name: "account_status", + }; + const mainAccounts = makeTable("accounts", [statusTextColumn]); + const branchAccounts = makeTable("accounts", [statusEnumColumn]); + const defaultedColumn = { + name: "label", + position: 1, + data_type: "text", + data_type_str: "text", + 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: "public.account_status_text()", + comment: null, + }; + const mainAudit = makeTable("account_audit", [defaultedColumn]); + const branchAudit = makeTable("account_audit", [defaultedColumn]); + const procedure = makeProcedure(); + const changes: Change[] = [ + new AlterTableAlterColumnType({ + table: branchAccounts, + column: statusEnumColumn, + previousColumn: statusTextColumn, + }), + ]; + + const mainCatalog = catalogWith(baseline, { + tables: { + [mainAccounts.stableId]: mainAccounts, + [mainAudit.stableId]: mainAudit, + }, + procedures: { [procedure.stableId]: procedure }, + depends: [ + { + dependent_stable_id: procedure.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + { + dependent_stable_id: "column:public.account_audit.label", + referenced_stable_id: procedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + tables: { + [branchAccounts.stableId]: branchAccounts, + [branchAudit.stableId]: branchAudit, + }, + procedures: { [procedure.stableId]: procedure }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + const defaultDrops = expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ); + const defaultSets = expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ); + expect(defaultDrops).toHaveLength(1); + expect(defaultSets).toHaveLength(1); + expect(defaultDrops[0].column.name).toBe("label"); + expect(defaultSets[0].column.name).toBe("label"); + expect(expanded.changes.some((c) => c instanceof DropProcedure)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateProcedure)).toBe( + true, + ); + }); + + test("drops and restores domain defaults that depend on invalidated routines", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const procedure = makeProcedure(); + const domain = makeDomain({ + default_bin: "public.account_status_text()", + default_value: "public.account_status_text()", + }); + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + ]; + const mainCatalog = catalogWith(baseline, { + domains: { [domain.stableId]: domain }, + procedures: { [procedure.stableId]: procedure }, + depends: [ + { + dependent_stable_id: procedure.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + { + dependent_stable_id: domain.stableId, + referenced_stable_id: procedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + domains: { [domain.stableId]: domain }, + procedures: { [procedure.stableId]: procedure }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropProcedure)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateProcedure)).toBe( + true, + ); + expect( + expanded.changes.filter((c) => c instanceof AlterDomainDropDefault), + ).toHaveLength(1); + expect( + expanded.changes.filter((c) => c instanceof AlterDomainSetDefault), + ).toHaveLength(1); + }); + + test("drops and re-adds column defaults that depend on replaced routine signatures", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const defaultedColumn = { + name: "label", + position: 1, + data_type: "text", + data_type_str: "text", + 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: "public.account_status_text()", + comment: null, + }; + const branchDefaultedColumn = { + ...defaultedColumn, + default: "public.account_status_text(1)", + }; + const mainAudit = makeTable("account_audit", [defaultedColumn]); + const branchAudit = makeTable("account_audit", [branchDefaultedColumn]); + const mainProcedure = makeProcedure(); + const branchProcedure = makeProcedure({ + argument_count: 1, + argument_default_count: 0, + argument_types: ["integer"], + definition: + "CREATE FUNCTION public.account_status_text(fallback integer) RETURNS text LANGUAGE sql STABLE BEGIN ATOMIC SELECT fallback::text; END", + sql_body: "SELECT fallback::text", + }); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = catalogWith(baseline, { + tables: { [mainAudit.stableId]: mainAudit }, + procedures: { [mainProcedure.stableId]: mainProcedure }, + depends: [ + { + dependent_stable_id: "column:public.account_audit.label", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + tables: { [branchAudit.stableId]: branchAudit }, + procedures: { [branchProcedure.stableId]: branchProcedure }, + 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("drops and restores domain defaults that depend on replaced routine signatures", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = makeProcedure(); + const branchProcedure = makeProcedure({ + argument_count: 1, + argument_default_count: 0, + argument_types: ["integer"], + definition: + "CREATE FUNCTION public.account_status_text(fallback integer) RETURNS text LANGUAGE sql STABLE BEGIN ATOMIC SELECT fallback::text; END", + sql_body: "SELECT fallback::text", + }); + const mainDomain = makeDomain({ + default_bin: "public.account_status_text()", + default_value: "public.account_status_text()", + }); + const branchDomain = makeDomain({ + default_bin: "public.account_status_text(1)", + default_value: "public.account_status_text(1)", + }); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = catalogWith(baseline, { + domains: { [mainDomain.stableId]: mainDomain }, + procedures: { [mainProcedure.stableId]: mainProcedure }, + depends: [ + { + dependent_stable_id: mainDomain.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + domains: { [branchDomain.stableId]: branchDomain }, + procedures: { [branchProcedure.stableId]: branchProcedure }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + 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("drops and restores publication table filters that depend on replaced routine signatures", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const statusColumn = { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + 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 table = makeTable("accounts", [statusColumn]); + const mainProcedure = makeProcedure({ + name: "is_active_status", + argument_count: 1, + argument_types: ["text"], + return_type: "boolean", + return_type_schema: "pg_catalog", + definition: + "CREATE FUNCTION public.is_active_status(status text) RETURNS boolean LANGUAGE sql STABLE BEGIN ATOMIC SELECT status = 'active'; END", + sql_body: "SELECT status = 'active'", + }); + const branchProcedure = makeProcedure({ + name: "is_active_status", + argument_count: 2, + argument_default_count: 0, + argument_types: ["text", "text"], + return_type: "boolean", + return_type_schema: "pg_catalog", + definition: + "CREATE FUNCTION public.is_active_status(status text, expected text) RETURNS boolean LANGUAGE sql STABLE BEGIN ATOMIC SELECT status = expected; END", + sql_body: "SELECT status = expected", + }); + const mainPublication = makePublication({ + tables: [ + { + schema: "public", + name: "accounts", + columns: null, + row_filter: "public.is_active_status(status)", + }, + ], + }); + const branchPublication = makePublication({ + tables: [ + { + schema: "public", + name: "accounts", + columns: null, + row_filter: "public.is_active_status(status, 'active'::text)", + }, + ], + }); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + depends: [ + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + 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 table adds when releasing routine filters", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const statusColumn = { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + 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 accountsTable = makeTable("accounts", [statusColumn]); + const auditTable = makeTable("audit_accounts", [statusColumn]); + const mainProcedure = makeProcedure({ + name: "is_active_status", + argument_count: 1, + argument_types: ["text"], + return_type: "boolean", + return_type_schema: "pg_catalog", + definition: + "CREATE FUNCTION public.is_active_status(status text) RETURNS boolean LANGUAGE sql STABLE BEGIN ATOMIC SELECT status = 'active'; END", + sql_body: "SELECT status = 'active'", + }); + const branchProcedure = makeProcedure({ + name: "is_active_status", + argument_count: 2, + argument_default_count: 0, + argument_types: ["text", "text"], + return_type: "boolean", + return_type_schema: "pg_catalog", + definition: + "CREATE FUNCTION public.is_active_status(status text, expected text) RETURNS boolean LANGUAGE sql STABLE BEGIN ATOMIC SELECT status = expected; END", + sql_body: "SELECT status = expected", + }); + const mainPublication = makePublication({ + tables: [ + { + schema: "public", + name: "accounts", + columns: null, + row_filter: "public.is_active_status(status)", + }, + ], + }); + const branchPublication = makePublication({ + tables: [ + { + schema: "public", + name: "accounts", + columns: null, + row_filter: "public.is_active_status(status, 'active'::text)", + }, + { + schema: "public", + name: "audit_accounts", + columns: null, + row_filter: "public.is_active_status(status, 'active'::text)", + }, + ], + }); + const auditPublicationTable = branchPublication.tables.find( + (table) => table.name === "audit_accounts", + ); + if (!auditPublicationTable) { + throw new Error("missing audit_accounts publication table"); + } + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterPublicationAddTables({ + publication: branchPublication, + tables: [auditPublicationTable], + }), + ]; + const mainCatalog = catalogWith(baseline, { + tables: { [accountsTable.stableId]: accountsTable }, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + depends: [ + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + tables: { + [accountsTable.stableId]: accountsTable, + [auditTable.stableId]: auditTable, + }, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + const addedTableNames = expanded.changes + .filter((change) => change instanceof AlterPublicationAddTables) + .flatMap((change) => change.tables.map((table) => table.name)); + + expect(addedTableNames.filter((name) => name === "accounts")).toHaveLength( + 1, + ); + expect( + addedTableNames.filter((name) => name === "audit_accounts"), + ).toHaveLength(1); + }); + + test("replays routine metadata when a column invalidation rebuilds a procedure", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const procedure = makeProcedure({ + owner: "routine_owner", + comment: "routine comment", + security_labels: [{ provider: "dummy", label: "classified" }], + privileges: [ + { grantee: "routine_executor", privilege: "EXECUTE", grantable: false }, + ], + }); + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + ]; + const mainCatalog = catalogWith(baseline, { + procedures: { [procedure.stableId]: procedure }, + depends: [ + { + dependent_stable_id: procedure.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + procedures: { [procedure.stableId]: procedure }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes.some((c) => c instanceof DropProcedure)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateProcedure)).toBe( + true, + ); + expect( + expanded.changes.some((c) => c instanceof AlterProcedureChangeOwner), + ).toBe(true); + expect( + expanded.changes.some((c) => c instanceof CreateCommentOnProcedure), + ).toBe(true); + expect( + expanded.changes.some((c) => c instanceof CreateSecurityLabelOnProcedure), + ).toBe(true); + expect( + expanded.changes.some((c) => c instanceof GrantProcedurePrivileges), + ).toBe(true); + }); + + test("replays routine metadata when a column invalidation adds a drop before an existing procedure create", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const procedure = makeProcedure({ + owner: "routine_owner", + comment: "routine comment", + security_labels: [{ provider: "dummy", label: "classified" }], + privileges: [ + { grantee: "routine_executor", privilege: "EXECUTE", grantable: false }, + ], + }); + const changes: Change[] = [ + new CreateProcedure({ procedure, orReplace: true }), + mockChange({ invalidates: ["column:public.accounts.status"] }), + ]; + const mainCatalog = catalogWith(baseline, { + procedures: { [procedure.stableId]: procedure }, + depends: [ + { + dependent_stable_id: procedure.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + procedures: { [procedure.stableId]: procedure }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes.some((c) => c instanceof DropProcedure)).toBe(true); + expect( + expanded.changes.filter((c) => c instanceof CreateProcedure), + ).toHaveLength(1); + expect( + expanded.changes.some((c) => c instanceof AlterProcedureChangeOwner), + ).toBe(true); + expect( + expanded.changes.some((c) => c instanceof CreateCommentOnProcedure), + ).toBe(true); + expect( + expanded.changes.some((c) => c instanceof CreateSecurityLabelOnProcedure), + ).toBe(true); + expect( + expanded.changes.some((c) => c instanceof GrantProcedurePrivileges), + ).toBe(true); + }); + + test("replays aggregate metadata when expansion adds a drop before an existing aggregate create", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = makeProcedure({ + name: "amount_transition", + argument_types: ["bigint", "integer"], + argument_count: 2, + return_type: "bigint", + source_code: "SELECT state + value", + definition: + "CREATE FUNCTION public.amount_transition(state bigint, value integer) RETURNS bigint LANGUAGE sql IMMUTABLE AS $$ SELECT state + value $$", + }); + const branchProcedure = makeProcedure({ + name: "amount_transition", + argument_types: ["numeric", "integer"], + argument_count: 2, + return_type: "numeric", + source_code: "SELECT state + value", + definition: + "CREATE FUNCTION public.amount_transition(state numeric, value integer) RETURNS numeric LANGUAGE sql IMMUTABLE AS $$ SELECT state + value $$", + }); + const aggregate = makeAggregate({ + owner: "aggregate_owner", + comment: "aggregate comment", + security_labels: [{ provider: "dummy", label: "classified" }], + privileges: [ + { + grantee: "aggregate_executor", + privilege: "EXECUTE", + grantable: false, + }, + ], + }); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new CreateAggregate({ aggregate, orReplace: true }), + ]; + const mainCatalog = catalogWith(baseline, { + procedures: { [mainProcedure.stableId]: mainProcedure }, + aggregates: { [aggregate.stableId]: aggregate }, + depends: [ + { + dependent_stable_id: aggregate.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + procedures: { [branchProcedure.stableId]: branchProcedure }, + aggregates: { [aggregate.stableId]: aggregate }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes.some((c) => c instanceof DropAggregate)).toBe(true); + expect( + expanded.changes.filter((c) => c instanceof CreateAggregate), + ).toHaveLength(1); + expect( + expanded.changes.some((c) => c instanceof AlterAggregateChangeOwner), + ).toBe(true); + expect( + expanded.changes.some((c) => c instanceof CreateCommentOnAggregate), + ).toBe(true); + expect( + expanded.changes.some((c) => c instanceof CreateSecurityLabelOnAggregate), + ).toBe(true); + expect( + expanded.changes.some((c) => c instanceof GrantAggregatePrivileges), + ).toBe(true); + }); + + test("replays index comments when a column invalidation rebuilds an index", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const index = makeIndex({ comment: "status expression index" }); + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + ]; + const mainCatalog = catalogWith(baseline, { + indexes: { [index.stableId]: index }, + depends: [ + { + dependent_stable_id: index.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + indexes: { [index.stableId]: index }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropIndex)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateIndex)).toBe(true); + expect( + expanded.changes.some((c) => c instanceof CreateCommentOnIndex), + ).toBe(true); + }); + + test("replays index statistics when a column invalidation rebuilds an index", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const index = makeIndex({ statistics_target: [250] }); + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + ]; + const mainCatalog = catalogWith(baseline, { + indexes: { [index.stableId]: index }, + depends: [ + { + dependent_stable_id: index.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + indexes: { [index.stableId]: index }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((c) => c instanceof DropIndex)).toBe(true); + expect(expanded.changes.some((c) => c instanceof CreateIndex)).toBe(true); + expect( + expanded.changes.some((c) => c instanceof AlterIndexSetStatistics), + ).toBe(true); + }); + + test("reapplies replica identity after a column invalidation rebuilds an index", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const statusColumn = { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }; + const table = makeTable("accounts", [statusColumn], { + replica_identity: "i", + replica_identity_index: "accounts_status_key", + }); + const index = makeIndex({ + name: "accounts_status_key", + key_columns: [1], + index_expressions: null, + is_unique: true, + is_replica_identity: true, + definition: + "CREATE UNIQUE INDEX accounts_status_key ON public.accounts USING btree (status)", + }); + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + ]; + const mainCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + indexes: { [index.stableId]: index }, + indexableObjects: { [table.stableId]: table }, + depends: [ + { + dependent_stable_id: index.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + ], + }); + const branchCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + indexes: { [index.stableId]: index }, + indexableObjects: { [table.stableId]: table }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const createIndexIdx = sorted.findIndex( + (change) => change instanceof CreateIndex, + ); + const replicaIdentityChanges = sorted.filter( + (change) => change instanceof AlterTableSetReplicaIdentity, + ); + const replicaIdentityIdx = sorted.findIndex( + (change) => change instanceof AlterTableSetReplicaIdentity, + ); + + expect(createIndexIdx).toBeGreaterThanOrEqual(0); + expect(replicaIdentityChanges).toHaveLength(1); + expect(replicaIdentityIdx).toBeGreaterThan(createIndexIdx); + + const preExistingReplicaIdentity = new AlterTableSetReplicaIdentity({ + table, + mode: "i", + indexName: "accounts_status_key", + }); + const expandedWithExisting = expandReplaceDependencies({ + changes: [...changes, preExistingReplicaIdentity], + mainCatalog, + branchCatalog, + }); + + expect( + expandedWithExisting.changes.filter( + (change) => change instanceof AlterTableSetReplicaIdentity, + ), + ).toHaveLength(1); + }); + + test("rebuilds FK-backed standalone indexes after dropping dependent foreign keys", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const statusColumn = { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }; + const accounts = makeTable("accounts", [statusColumn]); + const accountEventsStatus = { ...statusColumn }; + const accountEventsFk = { + name: "account_events_status_fkey", + constraint_type: "f" as const, + deferrable: false, + initially_deferred: false, + validated: false, + 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: ["status"], + foreign_key_columns: ["status"], + foreign_key_table: "accounts", + 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: "accounts", + on_update: "a" as const, + on_delete: "a" as const, + match_type: "s" as const, + check_expression: null, + owner: "postgres", + definition: + "FOREIGN KEY (status) REFERENCES public.accounts(status) NOT VALID", + comment: "account status reference", + }; + const accountEvents = makeTable("account_events", [accountEventsStatus], { + constraints: [accountEventsFk], }); - const preExistingDropConstraint = new AlterTableDropConstraint({ - table: mainChildren, - constraint: { - name: "children_parent_ref_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: ["parent_ref"], - 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 (parent_ref) REFERENCES public.parents(id)", - comment: null, - }, + const index = makeIndex({ + name: "accounts_status_key_idx", + table_name: "accounts", + key_columns: [1], + index_expressions: null, + is_unique: true, + definition: + "CREATE UNIQUE INDEX accounts_status_key_idx ON public.accounts USING btree (status)", }); - const preExistingChangeOwner = new AlterTableChangeOwner({ - table: branchChildren, - owner: "new_owner", + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + ]; + const mainCatalog = catalogWith(baseline, { + tables: { + [accounts.stableId]: accounts, + [accountEvents.stableId]: accountEvents, + }, + indexes: { [index.stableId]: index }, + indexableObjects: { [accounts.stableId]: accounts }, + depends: [ + { + dependent_stable_id: index.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + { + dependent_stable_id: + "constraint:public.account_events.account_events_status_fkey", + referenced_stable_id: index.stableId, + deptype: "n", + }, + ], }); - const preExistingEnableRls = new AlterTableEnableRowLevelSecurity({ - table: branchChildren, + const branchCatalog = catalogWith(baseline, { + tables: { + [accounts.stableId]: accounts, + [accountEvents.stableId]: accountEvents, + }, + indexes: { [index.stableId]: index }, + indexableObjects: { [accounts.stableId]: accounts }, + depends: mainCatalog.depends, }); - const preExistingReplicaIdentity = new AlterTableSetReplicaIdentity({ - table: branchChildren, - mode: "f", + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, }); - const preExistingGrant = new GrantTablePrivileges({ - table: branchChildren, - grantee: "reader", - privileges: [{ privilege: "SELECT", grantable: false }], + + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const dropConstraintIdx = sorted.findIndex( + (change) => + change instanceof AlterTableDropConstraint && + change.table.name === "account_events", + ); + const dropIndexIdx = sorted.findIndex( + (change) => + change instanceof DropIndex && + change.index.name === "accounts_status_key_idx", + ); + const createIndexIdx = sorted.findIndex( + (change) => + change instanceof CreateIndex && + change.index.name === "accounts_status_key_idx", + ); + const addConstraintIdx = sorted.findIndex( + (change) => + change instanceof AlterTableAddConstraint && + change.table.name === "account_events", + ); + + expect(dropConstraintIdx).toBeGreaterThanOrEqual(0); + expect(dropIndexIdx).toBeGreaterThanOrEqual(0); + expect(createIndexIdx).toBeGreaterThanOrEqual(0); + expect(addConstraintIdx).toBeGreaterThanOrEqual(0); + expect(dropConstraintIdx).toBeLessThan(dropIndexIdx); + expect(createIndexIdx).toBeLessThan(addConstraintIdx); + expect( + sorted.some( + (change) => + change instanceof CreateCommentOnConstraint && + change.table.name === "account_events", + ), + ).toBe(true); + expect( + sorted.some( + (change) => + change instanceof AlterTableAddConstraint && + change.table.name === "account_events" && + change.constraint.validated === false, + ), + ).toBe(true); + }); + + test("replays matview indexes when a column invalidation rebuilds the matview", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const materializedView = makeMaterializedView(); + const index = makeIndex({ + table_name: materializedView.name, + name: "account_statuses_id_idx", + key_columns: [1], + index_expressions: null, + table_relkind: "m", + definition: + "CREATE INDEX account_statuses_id_idx ON public.account_statuses USING btree (id)", + comment: "matview id lookup", + statistics_target: [250], + is_clustered: true, }); const changes: Change[] = [ - new DropEnum({ enum: mainEnum }), - new CreateEnum({ enum: branchEnum }), - preExistingDropColumn, - preExistingDropConstraint, - preExistingChangeOwner, - preExistingEnableRls, - preExistingReplicaIdentity, - preExistingGrant, + mockChange({ invalidates: ["column:public.accounts.status"] }), ]; - - const mainCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - enums: { [mainEnum.stableId]: mainEnum }, - tables: { [mainChildren.stableId]: mainChildren }, - // pg_depend: column children.status depends on type item_status. + const mainCatalog = catalogWith(baseline, { + materializedViews: { + [materializedView.stableId]: materializedView, + }, + indexes: { [index.stableId]: index }, + indexableObjects: { + [materializedView.stableId]: materializedView, + }, depends: [ { - dependent_stable_id: "column:public.children.status", - referenced_stable_id: mainEnum.stableId, + dependent_stable_id: materializedView.stableId, + referenced_stable_id: "column:public.accounts.status", deptype: "n", }, ], }); - const branchCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - enums: { [branchEnum.stableId]: branchEnum }, - tables: { [branchChildren.stableId]: branchChildren }, + const branchCatalog = catalogWith(baseline, { + materializedViews: { + [materializedView.stableId]: materializedView, + }, + indexes: { [index.stableId]: index }, + indexableObjects: { + [materializedView.stableId]: materializedView, + }, depends: [], }); @@ -578,135 +4685,94 @@ describe("expandReplaceDependencies", () => { changes, mainCatalog, branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, }); - // The replace-table pair was added. - expect(expanded.changes.some((c) => c instanceof DropTable)).toBe(true); - expect(expanded.changes.some((c) => c instanceof CreateTable)).toBe(true); - expect(expanded.replacedTableIds.has(mainChildren.stableId)).toBe(true); - // Expansion itself keeps the pre-existing ALTERs; the post-diff cycle pass - // decides which of them are superseded by the replacement. - expect(expanded.changes).toContain(preExistingDropColumn); - expect(expanded.changes).toContain(preExistingDropConstraint); expect( - expanded.changes.some((c) => c instanceof AlterTableDropColumn), + expanded.changes.some((c) => c instanceof DropMaterializedView), ).toBe(true); expect( - expanded.changes.some((c) => c instanceof AlterTableDropConstraint), + expanded.changes.some((c) => c instanceof CreateMaterializedView), ).toBe(true); - // The enum replace roots are still present. - expect(expanded.changes.some((c) => c instanceof DropEnum)).toBe(true); - expect(expanded.changes.some((c) => c instanceof CreateEnum)).toBe(true); - // Non-cycle object-scope ALTERs are carried through untouched. - expect(expanded.changes).toContain(preExistingChangeOwner); - expect(expanded.changes).toContain(preExistingEnableRls); - expect(expanded.changes).toContain(preExistingReplicaIdentity); - // Privilege-scope ALTER on the recreated table survives. - expect(expanded.changes).toContain(preExistingGrant); - expect(expanded.replacedTableIds.has("table:public.parents")).toBe(false); + expect(expanded.changes.some((c) => c instanceof CreateIndex)).toBe(true); + expect( + expanded.changes.some((c) => c instanceof CreateCommentOnIndex), + ).toBe(true); + expect( + expanded.changes.some((c) => c instanceof AlterIndexSetStatistics), + ).toBe(true); + + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const createIndexIdx = sorted.findIndex( + (change) => + change instanceof CreateIndex && + change.index.name === "account_statuses_id_idx", + ); + const clusterIdx = sorted.findIndex( + (change) => change.constructor.name === "AlterMaterializedViewSetCluster", + ); + const clusterChange = sorted[clusterIdx]; + + expect(createIndexIdx).toBeGreaterThanOrEqual(0); + expect(clusterIdx).toBeGreaterThan(createIndexIdx); + expect(clusterChange?.serialize()).toBe( + "ALTER MATERIALIZED VIEW public.account_statuses CLUSTER ON account_statuses_id_idx", + ); }); - test("promotes dependent view when a procedure's parameter types change", async () => { - // Procedure stableIds are signature-qualified, so a parameter-type change - // produces different stableIds in `createdIds` and `droppedIds`. The - // expander must still treat the (schema, name)-matched pair as a replace - // root so a dependent view is promoted from `CREATE OR REPLACE VIEW` to - // `DROP VIEW` + `CREATE VIEW` (otherwise `DROP FUNCTION` fails with - // "cannot drop function because other objects depend on it"). + test("keeps a drop-only dependent trigger when a column is invalidated", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); - const procedureBase = { - schema: "public", - name: "format_id", - kind: "f" as const, - return_type: "text", - return_type_schema: "pg_catalog", - language: "sql", - security_definer: false, - volatility: "i" as const, - parallel_safety: "u" as const, - execution_cost: 100, - result_rows: 0, - is_strict: false, - leakproof: false, - returns_set: false, - argument_count: 1, - argument_default_count: 0, - argument_names: ["id"], - all_argument_types: null, - argument_modes: null, - argument_defaults: null, - source_code: "SELECT 'id:' || id::text", - binary_path: null, - sql_body: null, - config: null, - owner: "postgres", - comment: null, - privileges: [], - }; - const mainProcedure = new Procedure({ - ...procedureBase, - argument_types: ["int4"], - definition: "CREATE FUNCTION public.format_id(id integer) ...", - }); - const branchProcedure = new Procedure({ - ...procedureBase, - argument_types: ["int8"], - definition: "CREATE FUNCTION public.format_id(id bigint) ...", - }); - const viewBase = { + const trigger = new Trigger({ schema: "public", - name: "items_formatted", - 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" as const, - is_partition: false, - options: null, - partition_bound: null, + name: "block_blocked_accounts", + table_name: "accounts", + table_relkind: "r", + function_schema: "public", + function_name: "noop_trigger", + trigger_type: 23, + enabled: "O", + is_internal: false, + deferrable: false, + initially_deferred: false, + argument_count: 0, + column_numbers: [], + arguments: [], + when_condition: "new.status = 'blocked'::text", + 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 block_blocked_accounts BEFORE INSERT ON public.accounts FOR EACH ROW WHEN (new.status = 'blocked'::text) EXECUTE FUNCTION public.noop_trigger()", comment: null, - columns: [], - privileges: [], - }; - const mainView = new View({ - ...viewBase, - definition: "SELECT public.format_id(id) FROM public.items", - }); - const branchView = new View({ - ...viewBase, - definition: "SELECT public.format_id(id::bigint) FROM public.items", }); - - const changes: Change[] = [ - new DropProcedure({ procedure: mainProcedure }), - new CreateProcedure({ procedure: branchProcedure }), - // view.diff emits this because pg_get_viewdef text differs after the - // underlying function signature changes. - new CreateView({ view: branchView, orReplace: true }), + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts.status"] }), + new DropTrigger({ trigger }), ]; - - const mainCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - procedures: { [mainProcedure.stableId]: mainProcedure }, - views: { [mainView.stableId]: mainView }, + const mainCatalog = catalogWith(baseline, { + triggers: { [trigger.stableId]: trigger }, depends: [ { - dependent_stable_id: mainView.stableId, - referenced_stable_id: mainProcedure.stableId, + dependent_stable_id: trigger.stableId, + referenced_stable_id: "column:public.accounts.status", deptype: "n", }, ], }); - const branchCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - procedures: { [branchProcedure.stableId]: branchProcedure }, - views: { [branchView.stableId]: branchView }, + const branchCatalog = catalogWith(baseline, { + triggers: {}, depends: [], }); @@ -716,113 +4782,145 @@ describe("expandReplaceDependencies", () => { branchCatalog, }); - expect(expanded.changes.some((c) => c instanceof DropView)).toBe(true); + expect( + expanded.changes.filter((c) => c instanceof DropTrigger), + ).toHaveLength(1); + expect(expanded.changes.some((c) => c instanceof CreateTrigger)).toBe( + false, + ); }); - test("promotes dependent RLS policy when a procedure's signature changes", async () => { + test("does not promote dependent trigger clones on partitions", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); - const procedureBase = { + const triggerBase = { schema: "public", - name: "check_role", - kind: "f" as const, - return_type: "boolean", - return_type_schema: "pg_catalog", - language: "plpgsql", - security_definer: false, - volatility: "v" as const, - parallel_safety: "u" as const, - execution_cost: 100, - result_rows: 0, - is_strict: false, - leakproof: false, - returns_set: false, - argument_names: ["id", "role"], - all_argument_types: null, - argument_modes: null, - source_code: "BEGIN RETURN true; END;", - binary_path: null, - sql_body: null, - config: null, + name: "validate_amount", + table_name: "accounts_2026", + table_relkind: "r" as const, + function_schema: "public", + function_name: "noop_trigger", + trigger_type: 23, + enabled: "O" as const, + is_internal: false, + deferrable: false, + initially_deferred: false, + argument_count: 0, + column_numbers: [], + arguments: [], + when_condition: "new.amount > 0", + old_table: null, + new_table: null, + is_partition_clone: true, + parent_trigger_name: "validate_amount", + parent_table_schema: "public", + parent_table_name: "accounts", + is_on_partitioned_table: false, owner: "postgres", + definition: + "CREATE TRIGGER validate_amount BEFORE INSERT ON public.accounts_2026 FOR EACH ROW WHEN (new.amount > 0) EXECUTE FUNCTION public.noop_trigger()", comment: null, - privileges: [], }; - const mainProcedure = new Procedure({ - ...procedureBase, - argument_count: 2, - argument_default_count: 0, - argument_types: ["uuid", "text"], - argument_defaults: null, + const mainTrigger = new Trigger(triggerBase); + const branchTrigger = new Trigger({ + ...triggerBase, + when_condition: "new.amount > 0::bigint", definition: - "CREATE FUNCTION public.check_role(id uuid, role text) RETURNS boolean ...", - }); - const branchProcedure = new Procedure({ - ...procedureBase, - argument_count: 3, - argument_default_count: 1, - argument_names: ["id", "role", "extra"], - argument_types: ["uuid", "text", "text"], - argument_defaults: "'default'::text", - definition: - "CREATE FUNCTION public.check_role(id uuid, role text, extra text DEFAULT 'default'::text) RETURNS boolean ...", + "CREATE TRIGGER validate_amount BEFORE INSERT ON public.accounts_2026 FOR EACH ROW WHEN (new.amount > 0::bigint) EXECUTE FUNCTION public.noop_trigger()", }); - const policyBase = { - schema: "public", - table_name: "profiles", - name: "check_role_policy", - command: "r" as const, - permissive: true, - roles: ["public"], - using_expression: "public.check_role(id, role)", - with_check_expression: null, - owner: "postgres", - comment: "policy comment", - referenced_relations: [], - }; - const mainPolicy = new RlsPolicy({ - ...policyBase, - referenced_procedures: [ + const changes: Change[] = [ + mockChange({ invalidates: ["column:public.accounts_2026.amount"] }), + ]; + const mainCatalog = catalogWith(baseline, { + triggers: { [mainTrigger.stableId]: mainTrigger }, + depends: [ { - schema: "public", - name: "check_role", - argument_types: ["uuid", "text"], + dependent_stable_id: mainTrigger.stableId, + referenced_stable_id: "column:public.accounts_2026.amount", + deptype: "n", }, ], }); - const branchPolicy = new RlsPolicy({ - ...policyBase, - referenced_procedures: [ + const branchCatalog = catalogWith(baseline, { + triggers: { [branchTrigger.stableId]: branchTrigger }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toHaveLength(1); + expect(expanded.changes[0]).toBe(changes[0]); + }); + + test("parses quoted table stable ids when rebuilding generated columns and publications", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const tableName = '"a.b"'; + const statusColumn = { + name: "status", + position: 1, + data_type: "text", + data_type_str: "text", + 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 statusLabelColumn = { + ...statusColumn, + name: "status_label", + position: 2, + is_generated: true, + default: "upper(status)", + }; + const table = makeTable(tableName, [statusColumn, statusLabelColumn]); + const publication = makePublication({ + tables: [ { schema: "public", - name: "check_role", - argument_types: ["uuid", "text", "text"], + name: tableName, + columns: ["status", "status_label"], + 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 }, - rlsPolicies: { [mainPolicy.stableId]: mainPolicy }, + const statusColumnId = stableId.column("public", tableName, "status"); + const statusLabelColumnId = stableId.column( + "public", + tableName, + "status_label", + ); + const changes: Change[] = [mockChange({ invalidates: [statusColumnId] })]; + const mainCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + publications: { [publication.stableId]: publication }, depends: [ { - dependent_stable_id: mainPolicy.stableId, - referenced_stable_id: mainProcedure.stableId, + dependent_stable_id: statusLabelColumnId, + referenced_stable_id: statusColumnId, + deptype: "n", + }, + { + dependent_stable_id: publication.stableId, + referenced_stable_id: statusColumnId, deptype: "n", }, ], }); - const branchCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - procedures: { [branchProcedure.stableId]: branchProcedure }, - rlsPolicies: { [branchPolicy.stableId]: branchPolicy }, - depends: [], + const branchCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + publications: { [publication.stableId]: publication }, + depends: mainCatalog.depends, }); const expanded = expandReplaceDependencies({ @@ -831,18 +4929,42 @@ describe("expandReplaceDependencies", () => { branchCatalog, }); - expect(expanded.changes.some((c) => c instanceof DropRlsPolicy)).toBe(true); - expect(expanded.changes.some((c) => c instanceof CreateRlsPolicy)).toBe( - true, - ); expect( - expanded.changes.some((c) => c instanceof CreateCommentOnRlsPolicy), + expanded.changes.some( + (change) => + change instanceof AlterTableDropColumn && + change.table.name === tableName && + change.column.name === "status_label", + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => + change instanceof AlterTableAddColumn && + change.table.name === tableName && + change.column.name === "status_label", + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => + change instanceof AlterPublicationDropTables && + change.tables.some((table) => table.name === tableName), + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => + change instanceof AlterPublicationAddTables && + change.tables.some((table) => table.name === tableName), + ), ).toBe(true); }); - test("promotes dependent RLS policy when a referenced column is invalidated", async () => { + test("replays clustered index markers after invalidated column rebuilds", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); - const columnTemplate = { + const statusColumn = { + name: "status", position: 1, data_type: "text", data_type_str: "text", @@ -851,7 +4973,7 @@ describe("expandReplaceDependencies", () => { custom_type_category: null, custom_type_schema: null, custom_type_name: null, - not_null: true, + not_null: false, is_identity: false, is_identity_always: false, is_generated: false, @@ -859,108 +4981,32 @@ describe("expandReplaceDependencies", () => { default: null, comment: null, }; - const tableBase = { - schema: "public", - name: "solution_categories_with_policy", - persistence: "p" as const, - row_security: true, - force_row_security: false, - has_indexes: false, - has_rules: false, - has_triggers: false, - has_subclasses: false, - is_populated: true, - replica_identity: "d" as const, - is_partition: false, - options: null, - partition_bound: null, - partition_by: null, - owner: "postgres", - comment: null, - parent_schema: null, - parent_name: null, - constraints: [], - privileges: [], - }; - const mainRoleColumn = { - ...columnTemplate, - name: "role", - }; - const branchRoleColumn = { - ...columnTemplate, - name: "role", - data_type: "user_role_enum", - data_type_str: "public.user_role_enum", - is_custom_type: true, - custom_type_type: "e", - custom_type_category: "E", - custom_type_schema: "public", - custom_type_name: "user_role_enum", - }; - const mainTable = new Table({ - ...tableBase, - columns: [mainRoleColumn], - }); - const branchTable = new Table({ - ...tableBase, - columns: [branchRoleColumn], - }); - const policyBase = { - schema: "public", - table_name: "solution_categories_with_policy", - name: "categories_admin_manage", - command: "*" as const, - permissive: true, - roles: ["public"], - owner: "postgres", - comment: null, - referenced_relations: [], - referenced_procedures: [], - }; - const mainPolicy = new RlsPolicy({ - ...policyBase, - using_expression: "role = 'admin'", - with_check_expression: "role = 'admin'", - }); - const branchPolicy = new RlsPolicy({ - ...policyBase, - using_expression: "role = 'admin'::public.user_role_enum", - with_check_expression: "role = 'admin'::public.user_role_enum", - }); - const alterUsing = new AlterRlsPolicySetUsingExpression({ - policy: mainPolicy, - usingExpression: branchPolicy.using_expression, - }); - const alterWithCheck = new AlterRlsPolicySetWithCheckExpression({ - policy: mainPolicy, - withCheckExpression: branchPolicy.with_check_expression, + const table = makeTable("accounts", [statusColumn]); + const index = makeIndex({ + name: "accounts_status_expr_idx", + is_clustered: true, + definition: + "CREATE INDEX accounts_status_expr_idx ON public.accounts USING btree (lower(status))", }); const changes: Change[] = [ - mockInvalidatingChange([ - "column:public.solution_categories_with_policy.role", - ]), - alterUsing, - alterWithCheck, + mockChange({ invalidates: ["column:public.accounts.status"] }), ]; - const mainCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - tables: { [mainTable.stableId]: mainTable }, - rlsPolicies: { [mainPolicy.stableId]: mainPolicy }, + const mainCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + indexes: { [index.stableId]: index }, + indexableObjects: { [table.stableId]: table }, depends: [ { - dependent_stable_id: mainPolicy.stableId, - referenced_stable_id: - "column:public.solution_categories_with_policy.role", + dependent_stable_id: index.stableId, + referenced_stable_id: "column:public.accounts.status", deptype: "n", }, ], }); - const branchCatalog = new Catalog({ - // oxlint-disable-next-line typescript/no-misused-spread - ...baseline, - tables: { [branchTable.stableId]: branchTable }, - rlsPolicies: { [branchPolicy.stableId]: branchPolicy }, + const branchCatalog = catalogWith(baseline, { + tables: { [table.stableId]: table }, + indexes: { [index.stableId]: index }, + indexableObjects: { [table.stableId]: table }, depends: [], }); @@ -969,12 +5015,20 @@ describe("expandReplaceDependencies", () => { mainCatalog, branchCatalog, }); - - expect(expanded.changes.some((c) => c instanceof DropRlsPolicy)).toBe(true); - expect(expanded.changes.some((c) => c instanceof CreateRlsPolicy)).toBe( - true, + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, ); - expect(expanded.changes).not.toContain(alterUsing); - expect(expanded.changes).not.toContain(alterWithCheck); + const createIndexIdx = sorted.findIndex( + (change) => + change instanceof CreateIndex && + change.index.name === "accounts_status_expr_idx", + ); + const clusterIdx = sorted.findIndex( + (change) => change.constructor.name === "AlterTableSetCluster", + ); + + expect(createIndexIdx).toBeGreaterThanOrEqual(0); + expect(clusterIdx).toBeGreaterThan(createIndexIdx); }); }); diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index c1a464418..257b43915 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -1,22 +1,80 @@ import type { Catalog } from "./catalog.model.ts"; import type { Change } from "./change.types.ts"; import type { ObjectDiffContext } from "./objects/diff-context.ts"; +import { diffAggregates } from "./objects/aggregate/aggregate.diff.ts"; +import { AlterAggregateChangeOwner } from "./objects/aggregate/changes/aggregate.alter.ts"; +import { CreateAggregate } from "./objects/aggregate/changes/aggregate.create.ts"; +import { DropAggregate } from "./objects/aggregate/changes/aggregate.drop.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 { AlterMaterializedViewSetCluster } 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 { filterPublicBuiltInDefaults } from "./objects/base.privilege-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 } from "./objects/procedure/changes/procedure.privilege.ts"; +import { CreateSecurityLabelOnProcedure } from "./objects/procedure/changes/procedure.security-label.ts"; +import { diffProcedures } from "./objects/procedure/procedure.diff.ts"; +import { + AlterPublicationAddTables, + AlterPublicationDropTables, +} from "./objects/publication/changes/publication.alter.ts"; +import { + ReplaceRule, + SetRuleEnabledState, +} from "./objects/rule/changes/rule.alter.ts"; +import { + CreateCommentOnRule, + DropCommentOnRule, +} 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 { 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 { + AlterTableAddColumn, + AlterTableAddConstraint, + AlterTableAlterColumnDropDefault, + AlterTableAlterColumnSetDefault, + AlterTableAlterColumnType, + AlterTableDropConstraint, + AlterTableDropColumn, + AlterTableSetCluster, + 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 { + ReplaceTrigger, + SetTriggerEnabledState, +} from "./objects/trigger/changes/trigger.alter.ts"; +import { + CreateCommentOnTrigger, + DropCommentOnTrigger, +} 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"; @@ -55,11 +113,27 @@ 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]; branch: Catalog["rlsPolicies"][string]; } + | { + kind: "rule"; + main: Catalog["rules"][string]; + branch: Catalog["rules"][string]; + } + | { + kind: "trigger"; + main: Catalog["triggers"][string]; + branch: Catalog["triggers"][string]; + branchIndexableObject: Catalog["indexableObjects"][string] | undefined; + } | { kind: "enum"; main: Catalog["enums"][string]; @@ -110,10 +184,12 @@ export function expandReplaceDependencies({ }): ExpandReplaceDependenciesResult { const createdIds = new Set(); const droppedIds = new Set(); + const invalidatedIds = new Set(); for (const change of changes) { for (const id of change.creates ?? []) createdIds.add(id); for (const id of change.drops ?? []) droppedIds.add(id); + for (const id of change.invalidates ?? []) invalidatedIds.add(id); } const replaceRoots = new Set(); @@ -122,6 +198,12 @@ export function expandReplaceDependencies({ replaceRoots.add(id); } } + for (const id of invalidatedIds) { + // In-place rewrites such as ALTER COLUMN TYPE do not drop/recreate the + // referenced object, but PostgreSQL still requires dependent rewrite + // objects (rules, trigger WHEN expressions, etc.) to be removed first. + replaceRoots.add(id); + } const promotedRlsPolicyIds = new Set(); const additions: Change[] = collectInvalidatedRlsPolicyReplacements({ @@ -152,6 +234,22 @@ export function expandReplaceDependencies({ } } + // Aggregate stableIds are also signature-qualified + // (`aggregate:schema.name(argtypes)`). When an aggregate argument signature + // changes, dependents in the main catalog still reference the old signature + // and must be promoted before DROP AGGREGATE runs. + const createdAggregateNames = new Set(); + for (const id of createdIds) { + const key = parseAggregateSchemaName(id); + if (key) createdAggregateNames.add(key); + } + for (const id of droppedIds) { + const key = parseAggregateSchemaName(id); + if (key && createdAggregateNames.has(key)) { + replaceRoots.add(id); + } + } + // Drop-only objects (no matching create — typically a renamed-away table or // type) are also expansion roots: anything in main that depends on them via // pg_depend must drop before the parent does. Without this seed, a renamed @@ -194,8 +292,19 @@ export function expandReplaceDependencies({ list.add(dep.dependent_stable_id); } + const columnReplaceRoots = new Set( + [...replaceRoots].filter( + (id) => + id.startsWith("column:") && createdIds.has(id) && droppedIds.has(id), + ), + ); + const visitedTargets = new Set(); const visitedRefs = new Set(replaceRoots); + const refsReachedFromInvalidation = new Set([ + ...invalidatedIds, + ...columnReplaceRoots, + ]); const queue: string[] = [...replaceRoots]; // Tables being replaced by an expansion-added DropTable+CreateTable pair. // Any pre-existing targeted AlterTable*(T) object-scope change is superseded @@ -203,9 +312,31 @@ export function expandReplaceDependencies({ // 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 rulesReplacedByExpansion = new Set(); + const triggersReplacedByExpansion = new Set(); + const generatedColumnsReplacedByExpansion = new Set(); + const restoredGeneratedColumnDependents = new Set(); while (queue.length > 0) { const refId = queue.shift() as string; + const reachedFromInvalidation = refsReachedFromInvalidation.has(refId); + const shouldReleaseExpressionDependents = + reachedFromInvalidation || refId.startsWith("procedure:"); + if ( + columnReplaceRoots.has(refId) && + !restoredGeneratedColumnDependents.has(refId) && + isGeneratedColumnReplacementTarget(refId, mainCatalog, branchCatalog) + ) { + const replacementChanges = + buildGeneratedColumnDependentReplacementChanges(refId, branchCatalog, [ + ...changes, + ...additions, + ]); + additions.push(...replacementChanges); + trackChangeIds(replacementChanges, createdIds, droppedIds); + restoredGeneratedColumnDependents.add(refId); + } + const dependents = dependentsByReferenced.get(refId); if (!dependents) continue; @@ -221,18 +352,109 @@ export function expandReplaceDependencies({ continue; } + if ( + shouldReleaseExpressionDependents && + dependentRaw.startsWith("column:") + ) { + const replacementChanges = buildColumnDefaultReplacementChanges( + dependentRaw, + mainCatalog, + branchCatalog, + [...changes, ...additions], + ); + additions.push(...replacementChanges); + trackChangeIds(replacementChanges, createdIds, droppedIds); + if ( + isGeneratedColumnReplacementTarget( + dependentRaw, + mainCatalog, + branchCatalog, + ) + ) { + generatedColumnsReplacedByExpansion.add(dependentRaw); + columnReplaceRoots.add(dependentRaw); + refsReachedFromInvalidation.add(dependentRaw); + if (!visitedRefs.has(dependentRaw)) { + visitedRefs.add(dependentRaw); + queue.push(dependentRaw); + } + } + if (replacementChanges.length > 0) continue; + } + if ( + shouldReleaseExpressionDependents && + dependentRaw.startsWith("publication:") + ) { + const replacementChanges = buildPublicationTableReplacementChanges( + refId, + dependentRaw, + mainCatalog, + branchCatalog, + [...changes, ...additions], + ); + additions.push(...replacementChanges); + if (replacementChanges.length > 0) continue; + } + if ( + shouldReleaseExpressionDependents && + dependentRaw.startsWith("domain:") + ) { + const replacementChanges = buildDomainDefaultReplacementChanges( + dependentRaw, + mainCatalog, + branchCatalog, + [...changes, ...additions], + ); + additions.push(...replacementChanges); + trackChangeIds(replacementChanges, createdIds, droppedIds); + if (replacementChanges.length > 0) continue; + } + const isColumnReplacementRoot = + refId.startsWith("column:") && + createdIds.has(refId) && + droppedIds.has(refId); + if ( + dependentRaw.startsWith("constraint:") && + (!reachedFromInvalidation || + isColumnReplacementRoot || + refId.startsWith("index:") || + refId.startsWith("procedure:")) + ) { + const replacementChanges = buildConstraintReplacementChanges( + dependentRaw, + mainCatalog, + branchCatalog, + [...changes, ...additions], + ); + additions.push(...replacementChanges); + trackChangeIds(replacementChanges, createdIds, droppedIds); + continue; + } + + const targetId = normalizeDependentId(dependentRaw); + if (!targetId) continue; + if ( + reachedFromInvalidation && + !isRebuildableInvalidationDependent(targetId) + ) { + continue; + } + // Continue traversing the dependency graph from the raw dependent id. if (!visitedRefs.has(dependentRaw)) { visitedRefs.add(dependentRaw); + if (reachedFromInvalidation) { + refsReachedFromInvalidation.add(dependentRaw); + } queue.push(dependentRaw); } - const targetId = normalizeDependentId(dependentRaw); - if (!targetId) continue; - // Also traverse using the normalized owning object id (e.g., table for a column). if (!visitedRefs.has(targetId)) { visitedRefs.add(targetId); + if (reachedFromInvalidation) { + refsReachedFromInvalidation.add(targetId); + } queue.push(targetId); } @@ -255,13 +477,18 @@ export function expandReplaceDependencies({ const addDrop = !hasDrop; const addCreate = !hasCreate; + const replaceRewriteObject = + addDrop && (resolved.kind === "rule" || resolved.kind === "trigger"); + const effectiveAddCreate = addCreate || replaceRewriteObject; - if (!addDrop && !addCreate) continue; + if (!addDrop && !effectiveAddCreate) continue; const replacementChanges = buildReplaceChanges(resolved, { addDrop, - addCreate, + addCreate: effectiveAddCreate, + branchCatalog, diffContext, + existingChanges: [...changes, ...additions], }); if (!replacementChanges) continue; @@ -276,12 +503,15 @@ export function expandReplaceDependencies({ if (resolved.kind === "table" && addDrop) { tablesReplacedByExpansion.add(targetId); } + if (resolved.kind === "rule" && addDrop && effectiveAddCreate) { + rulesReplacedByExpansion.add(targetId); + } + if (resolved.kind === "trigger" && addDrop && effectiveAddCreate) { + triggersReplacedByExpansion.add(targetId); + } // Track new creates/drops so we don't duplicate work for downstream dependents. - for (const change of replacementChanges) { - for (const id of change.creates ?? []) createdIds.add(id); - for (const id of change.drops ?? []) droppedIds.add(id); - } + trackChangeIds(replacementChanges, createdIds, droppedIds); } } @@ -292,11 +522,23 @@ export function expandReplaceDependencies({ }; } + const retainedChanges = removeSupersededRlsPolicyAlters( + removeSupersededGeneratedColumnAlters( + changes, + generatedColumnsReplacedByExpansion, + ), + promotedRlsPolicyIds, + ).filter( + (change) => + !isSupersededReplaceChange( + change, + rulesReplacedByExpansion, + triggersReplacedByExpansion, + ), + ); + return { - changes: [ - ...removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds), - ...additions, - ], + changes: [...retainedChanges, ...additions], replacedTableIds: tablesReplacedByExpansion, }; } @@ -350,6 +592,7 @@ function collectInvalidatedRlsPolicyReplacements({ const replacementChanges = buildReplaceChanges(resolved, { addDrop, addCreate, + branchCatalog, }); if (!replacementChanges) continue; @@ -364,6 +607,17 @@ function collectInvalidatedRlsPolicyReplacements({ return replacements; } +function trackChangeIds( + 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 removeSupersededRlsPolicyAlters( changes: Change[], promotedRlsPolicyIds: ReadonlySet, @@ -415,155 +669,1192 @@ function isOwnedSequenceColumnDependency( ); } -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); -} +function buildColumnDefaultReplacementChanges( + columnStableId: string, + mainCatalog: Catalog, + branchCatalog: Catalog, + existingChanges: readonly Change[], +): Change[] { + const parsed = parseColumnStableId(columnStableId); + if (!parsed) return []; -function normalizeDependentId(dependentId: string): string | null { - let id = dependentId; + const tableStableId = stableId.table(parsed.schema, parsed.table); + const mainTable = mainCatalog.tables[tableStableId]; + const branchTable = branchCatalog.tables[tableStableId]; + if (!mainTable || !branchTable) return []; - while (id.startsWith("comment:")) { - id = id.slice("comment:".length); + const mainColumn = mainTable.columns.find( + (column) => column.name === parsed.column, + ); + const branchColumn = branchTable.columns.find( + (column) => column.name === parsed.column, + ); + if (!mainColumn || !branchColumn) return []; + if (mainColumn.default === null) return []; + + if (mainColumn.is_generated || branchColumn.is_generated) { + // Generated columns cannot be safely reset through SET EXPRESSION here: + // older servers do not support that syntax, and constrained columns can + // reject the temporary NULL expression. Rebuild the column instead. + const replacementChanges: Change[] = []; + if (!hasColumnDropChange(existingChanges, columnStableId)) { + replacementChanges.push( + new AlterTableDropColumn({ + table: mainTable, + column: mainColumn, + }), + ); + } + if (!hasColumnAddChange(existingChanges, columnStableId)) { + replacementChanges.push( + new AlterTableAddColumn({ + table: branchTable, + column: branchColumn, + }), + ); + } + replacementChanges.push( + ...buildGeneratedColumnDependentReplacementChanges( + columnStableId, + branchCatalog, + [...existingChanges, ...replacementChanges], + ), + ); + return replacementChanges; } + const replacementChanges: Change[] = []; + if (!hasColumnDefaultDropChange(existingChanges, columnStableId)) { + replacementChanges.push( + new AlterTableAlterColumnDropDefault({ + table: branchTable, + column: branchColumn, + }), + ); + } if ( - id.startsWith("acl:") || - id.startsWith("defacl:") || - id.startsWith("membership:") || - id.startsWith("role:") || - id.startsWith("schema:") + branchColumn.default !== null && + !hasColumnDefaultSetChange(existingChanges, columnStableId) ) { - return null; + replacementChanges.push( + new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchColumn, + }), + ); } - if (id.startsWith("column:")) { - const parts = id.slice("column:".length).split("."); - if (parts.length >= 2) { - const [schema, table] = parts; - return `table:${schema}.${table}`; - } - return null; - } + return replacementChanges; +} - if (id.startsWith("constraint:")) { - const parts = id.slice("constraint:".length).split("."); - if (parts.length >= 2) { - const [schema, table] = parts; - return `table:${schema}.${table}`; - } - return null; - } +function isGeneratedColumnReplacementTarget( + columnStableId: string, + mainCatalog: Catalog, + branchCatalog: Catalog, +): boolean { + const parsed = parseColumnStableId(columnStableId); + if (!parsed) return false; - return id; + const tableStableId = stableId.table(parsed.schema, parsed.table); + const mainTable = mainCatalog.tables[tableStableId]; + const branchTable = branchCatalog.tables[tableStableId]; + if (!mainTable || !branchTable) return false; + + const mainColumn = mainTable.columns.find( + (column) => column.name === parsed.column, + ); + const branchColumn = branchTable.columns.find( + (column) => column.name === parsed.column, + ); + if (!mainColumn || !branchColumn) return false; + + return mainColumn.is_generated || branchColumn.is_generated; } -function resolveObjectForStableId( - stableId: string, - mainCatalog: Catalog, +function hasColumnDropChange( + changes: readonly Change[], + columnStableId: string, +): boolean { + return changes.some( + (change) => + change instanceof AlterTableDropColumn && + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ) === columnStableId, + ); +} + +function hasColumnAddChange( + changes: readonly Change[], + columnStableId: string, +): boolean { + return changes.some( + (change) => + change instanceof AlterTableAddColumn && + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ) === columnStableId, + ); +} + +function buildGeneratedColumnDependentReplacementChanges( + columnStableId: string, 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; - } + existingChanges: readonly Change[], +): Change[] { + const parsed = parseColumnStableId(columnStableId); + if (!parsed) return []; - if (stableId.startsWith("view:")) { - const main = mainCatalog.views[stableId]; - const branch = branchCatalog.views[stableId]; - return main && branch ? { kind: "view", main, branch } : null; - } + const tableStableId = stableId.table(parsed.schema, parsed.table); + const branchTable = branchCatalog.tables[tableStableId]; + if (!branchTable) return []; - if (stableId.startsWith("materializedView:")) { - const main = mainCatalog.materializedViews[stableId]; - const branch = branchCatalog.materializedViews[stableId]; - return main && branch ? { kind: "materialized_view", main, branch } : null; - } + const branchColumn = branchTable.columns.find( + (column) => column.name === parsed.column, + ); + if (!branchColumn) return []; - 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; - } + const replacementChanges: Change[] = []; - if (stableId.startsWith("procedure:")) { - const main = mainCatalog.procedures[stableId]; - const branch = branchCatalog.procedures[stableId]; - return main && branch ? { kind: "procedure", main, branch } : null; + if ( + branchColumn.comment !== null && + branchColumn.comment !== undefined && + !hasChangeCreating( + [...existingChanges, ...replacementChanges], + stableId.comment(columnStableId), + ) + ) { + replacementChanges.push( + new CreateCommentOnColumn({ + table: branchTable, + column: branchColumn, + }), + ); } - if (stableId.startsWith("rlsPolicy:")) { - const main = mainCatalog.rlsPolicies[stableId]; - const branch = branchCatalog.rlsPolicies[stableId]; - return main && branch ? { kind: "rls_policy", main, branch } : null; + for (const securityLabel of branchColumn.security_labels ?? []) { + const securityLabelStableId = stableId.securityLabel( + columnStableId, + securityLabel.provider, + ); + if ( + hasChangeCreating( + [...existingChanges, ...replacementChanges], + securityLabelStableId, + ) + ) { + continue; + } + replacementChanges.push( + new CreateSecurityLabelOnColumn({ + table: branchTable, + column: branchColumn, + securityLabel, + }), + ); } - if (stableId.startsWith("domain:")) { - const main = mainCatalog.domains[stableId]; - const branch = branchCatalog.domains[stableId]; - return main && branch ? { kind: "domain", main, branch } : null; - } + const dependentConstraintIds = new Set( + branchCatalog.depends + .filter( + (dep) => + dep.referenced_stable_id === columnStableId && + dep.dependent_stable_id.startsWith("constraint:"), + ) + .map((dep) => dep.dependent_stable_id), + ); - if (stableId.startsWith("type:")) { - const enumMain = mainCatalog.enums[stableId]; - const enumBranch = branchCatalog.enums[stableId]; - if (enumMain && enumBranch) { - return { kind: "enum", main: enumMain, branch: enumBranch }; + for (const constraint of branchTable.constraints ?? []) { + if (constraint.is_partition_clone) continue; + + const constraintStableId = stableId.constraint( + branchTable.schema, + branchTable.name, + constraint.name, + ); + if (!dependentConstraintIds.has(constraintStableId)) continue; + + if ( + !hasChangeCreating( + [...existingChanges, ...replacementChanges], + constraintStableId, + ) + ) { + replacementChanges.push( + new AlterTableAddConstraint({ + table: branchTable, + constraint, + }), + ); } - const rangeMain = mainCatalog.ranges[stableId]; - const rangeBranch = branchCatalog.ranges[stableId]; - if (rangeMain && rangeBranch) { - return { kind: "range", main: rangeMain, branch: rangeBranch }; + const constraintCommentStableId = stableId.comment(constraintStableId); + if ( + constraint.comment !== null && + constraint.comment !== undefined && + !hasChangeCreating( + [...existingChanges, ...replacementChanges], + constraintCommentStableId, + ) + ) { + replacementChanges.push( + new CreateCommentOnConstraint({ + table: branchTable, + constraint, + }), + ); } - const compositeMain = mainCatalog.compositeTypes[stableId]; - const compositeBranch = branchCatalog.compositeTypes[stableId]; - if (compositeMain && compositeBranch) { - return { - kind: "composite_type", - main: compositeMain, - branch: compositeBranch, + replacementChanges.push( + ...buildConstraintBackingIndexMetadataReplacementChanges( + branchCatalog, + branchTable, + constraint.name, + [...existingChanges, ...replacementChanges], + ), + ); + } + + replacementChanges.push( + ...buildGeneratedColumnPrivilegeReplacementChanges( + branchTable, + parsed.column, + existingChanges, + replacementChanges, + ), + ); + + return replacementChanges; +} + +function buildGeneratedColumnPrivilegeReplacementChanges( + branchTable: Catalog["tables"][string], + columnName: string, + existingChanges: readonly Change[], + replacementChanges: readonly Change[], +): Change[] { + const changes: Change[] = []; + const privilegesByKey = new Map< + string, + { + grantee: string; + grantable: boolean; + columns: string[]; + privileges: Set; + } + >(); + + for (const privilege of branchTable.privileges) { + if (privilege.grantee === branchTable.owner) continue; + if (!privilege.columns?.includes(columnName)) continue; + + const columns = [...privilege.columns].sort(); + const key = JSON.stringify({ + grantee: privilege.grantee, + grantable: privilege.grantable, + columns, + }); + let group = privilegesByKey.get(key); + if (!group) { + group = { + grantee: privilege.grantee, + grantable: privilege.grantable, + columns, + privileges: new Set(), }; + privilegesByKey.set(key, group); } + group.privileges.add(privilege.privilege); } - return null; -} + for (const group of privilegesByKey.values()) { + const candidate = new GrantTablePrivileges({ + table: branchTable, + grantee: group.grantee, + privileges: [...group.privileges].sort().map((privilege) => ({ + privilege, + grantable: group.grantable, + })), + columns: group.columns, + }); -function buildReplaceChanges( - resolved: ResolvedObject, - options: { - addDrop: boolean; - addCreate: boolean; - diffContext?: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >; - }, -): Change[] | null { - const { addDrop, addCreate, diffContext } = options; + if ( + hasEquivalentColumnGrant(candidate, [ + ...existingChanges, + ...replacementChanges, + ...changes, + ]) + ) { + continue; + } + changes.push(candidate); + } - if (!addDrop && !addCreate) return null; + return changes; +} - switch (resolved.kind) { - case "table": - return [ - ...(addDrop ? [new DropTable({ table: resolved.main })] : []), - ...(addCreate - ? [ - new CreateTable({ table: resolved.branch }), +function hasChangeCreating( + changes: readonly Change[], + createdStableId: string, +): boolean { + return changes.some((change) => + (change.creates as readonly string[]).includes(createdStableId), + ); +} + +function hasEquivalentColumnGrant( + candidate: GrantTablePrivileges, + changes: readonly Change[], +): boolean { + return changes.some((change) => { + if (!(change instanceof GrantTablePrivileges)) return false; + if (change.table.stableId !== candidate.table.stableId) return false; + if (change.grantee !== candidate.grantee) return false; + if (!sameStringSet(change.columns ?? [], candidate.columns ?? [])) { + return false; + } + if ( + !sameStringSet( + change.privileges.map((privilege) => privilege.privilege), + candidate.privileges.map((privilege) => privilege.privilege), + ) + ) { + return false; + } + return change.privileges.every( + (privilege) => privilege.grantable === candidate.privileges[0]?.grantable, + ); + }); +} + +function sameStringSet( + left: readonly string[], + right: readonly string[], +): boolean { + if (left.length !== right.length) return false; + const sortedLeft = [...left].sort(); + const sortedRight = [...right].sort(); + return sortedLeft.every((value, index) => value === sortedRight[index]); +} + +function buildPublicationTableReplacementChanges( + referencedStableId: string, + publicationStableId: string, + mainCatalog: Catalog, + branchCatalog: Catalog, + existingChanges: readonly Change[], +): Change[] { + const mainPublication = mainCatalog.publications[publicationStableId]; + const branchPublication = branchCatalog.publications[publicationStableId]; + if (!mainPublication || !branchPublication) return []; + + const mainTables = getPublicationTablesToRelease( + referencedStableId, + mainPublication, + ); + const branchTables = getPublicationTablesToRelease( + referencedStableId, + branchPublication, + ); + const mainTablesToDrop = mainTables.filter( + (table) => + !hasPublicationDropTablesChange(existingChanges, publicationStableId, [ + table, + ]), + ); + const branchTablesToAdd = branchTables.filter( + (table) => + !hasPublicationAddTablesChange(existingChanges, publicationStableId, [ + table, + ]), + ); + if (mainTablesToDrop.length === 0 && branchTablesToAdd.length === 0) { + return []; + } + + const replacementChanges: Change[] = []; + if (mainTablesToDrop.length > 0) { + replacementChanges.push( + new AlterPublicationDropTables({ + publication: mainPublication, + tables: mainTablesToDrop, + }), + ); + } + if (branchTablesToAdd.length > 0) { + replacementChanges.push( + new AlterPublicationAddTables({ + publication: branchPublication, + tables: branchTablesToAdd, + }), + ); + } + + return replacementChanges; +} + +function getPublicationTablesToRelease( + referencedStableId: string, + publication: Catalog["publications"][string], +): Catalog["publications"][string]["tables"] { + const parsed = parseColumnStableId(referencedStableId); + if (parsed) { + return publication.tables.filter( + (table) => table.schema === parsed.schema && table.name === parsed.table, + ); + } + + if (referencedStableId.startsWith("procedure:")) { + return publication.tables.filter((table) => table.row_filter !== null); + } + + return []; +} + +function buildConstraintReplacementChanges( + constraintStableId: string, + mainCatalog: Catalog, + branchCatalog: Catalog, + existingChanges: readonly Change[], +): Change[] { + const parsed = parseConstraintStableId(constraintStableId); + if (!parsed) return []; + + const tableStableId = stableId.table(parsed.schema, parsed.table); + const mainTable = mainCatalog.tables[tableStableId]; + const branchTable = branchCatalog.tables[tableStableId]; + if (!mainTable && !branchTable) { + return buildDomainConstraintReplacementChanges( + parsed, + constraintStableId, + mainCatalog, + branchCatalog, + existingChanges, + ); + } + + const mainConstraint = mainTable?.constraints.find( + (constraint) => constraint.name === parsed.constraint, + ); + const branchConstraint = branchTable?.constraints.find( + (constraint) => constraint.name === parsed.constraint, + ); + if (!mainConstraint && !branchConstraint) return []; + + const replacementChanges: Change[] = []; + if ( + mainTable && + mainConstraint && + !hasConstraintDropChange(existingChanges, constraintStableId) + ) { + replacementChanges.push( + new AlterTableDropConstraint({ + table: mainTable, + constraint: mainConstraint, + }), + ); + } + + if ( + branchTable && + branchConstraint && + !hasConstraintAddChange( + [...existingChanges, ...replacementChanges], + constraintStableId, + ) + ) { + replacementChanges.push( + new AlterTableAddConstraint({ + table: branchTable, + constraint: branchConstraint, + }), + ); + } + + if ( + branchTable && + branchConstraint?.comment !== null && + branchConstraint?.comment !== undefined && + !hasChangeCreating( + [...existingChanges, ...replacementChanges], + stableId.comment(constraintStableId), + ) + ) { + replacementChanges.push( + new CreateCommentOnConstraint({ + table: branchTable, + constraint: branchConstraint, + }), + ); + } + + if (branchTable && branchConstraint) { + replacementChanges.push( + ...buildConstraintBackingIndexMetadataReplacementChanges( + branchCatalog, + branchTable, + parsed.constraint, + [...existingChanges, ...replacementChanges], + ), + ); + } + + return replacementChanges; +} + +function buildDomainConstraintReplacementChanges( + parsed: { schema: string; table: string; constraint: string }, + constraintStableId: string, + mainCatalog: Catalog, + branchCatalog: Catalog, + existingChanges: readonly Change[], +): Change[] { + const domainStableId = `domain:${parsed.schema}.${parsed.table}`; + const mainDomain = mainCatalog.domains[domainStableId]; + const branchDomain = branchCatalog.domains[domainStableId]; + if (!mainDomain && !branchDomain) return []; + + const mainConstraint = mainDomain?.constraints.find( + (constraint) => constraint.name === parsed.constraint, + ); + const branchConstraint = branchDomain?.constraints.find( + (constraint) => constraint.name === parsed.constraint, + ); + if (!mainConstraint && !branchConstraint) return []; + + const replacementChanges: Change[] = []; + if ( + mainDomain && + mainConstraint && + !hasDomainConstraintDropChange(existingChanges, constraintStableId) + ) { + replacementChanges.push( + new AlterDomainDropConstraint({ + domain: mainDomain, + constraint: mainConstraint, + }), + ); + } + + if ( + branchDomain && + branchConstraint && + !hasDomainConstraintAddChange( + [...existingChanges, ...replacementChanges], + constraintStableId, + ) + ) { + replacementChanges.push( + new AlterDomainAddConstraint({ + domain: branchDomain, + constraint: branchConstraint, + }), + ); + } + + return replacementChanges; +} + +function buildDomainDefaultReplacementChanges( + domainStableId: string, + mainCatalog: Catalog, + branchCatalog: Catalog, + existingChanges: readonly Change[], +): Change[] { + const mainDomain = mainCatalog.domains[domainStableId]; + const branchDomain = branchCatalog.domains[domainStableId]; + if (!mainDomain && !branchDomain) return []; + + const replacementChanges: Change[] = []; + if ( + mainDomain?.default_value !== null && + mainDomain?.default_value !== undefined && + !hasDomainDefaultDropChange(existingChanges, domainStableId) + ) { + replacementChanges.push(new AlterDomainDropDefault({ domain: mainDomain })); + } + + if ( + branchDomain?.default_value !== null && + branchDomain?.default_value !== undefined && + !hasDomainDefaultSetChange( + [...existingChanges, ...replacementChanges], + domainStableId, + ) + ) { + replacementChanges.push( + new AlterDomainSetDefault({ + domain: branchDomain, + defaultValue: branchDomain.default_value, + }), + ); + } + + return replacementChanges; +} + +function buildConstraintBackingIndexMetadataReplacementChanges( + branchCatalog: Catalog, + branchTable: Catalog["tables"][string], + constraintName: string, + existingChanges: readonly Change[], +): Change[] { + const backingIndex = Object.values(branchCatalog.indexes).find( + (index) => + index.is_owned_by_constraint && + index.schema === branchTable.schema && + index.table_name === branchTable.name && + index.name === constraintName, + ); + if (!backingIndex) return []; + + const changes: Change[] = []; + if ( + backingIndex.comment !== null && + backingIndex.comment !== undefined && + !hasChangeCreating( + [...existingChanges, ...changes], + stableId.comment(backingIndex.stableId), + ) + ) { + changes.push(new CreateCommentOnIndex({ index: backingIndex })); + } + + changes.push( + ...buildIndexStatisticsReplacementChanges(backingIndex, [ + ...existingChanges, + ...changes, + ]), + ); + + changes.push( + ...buildIndexReplicaIdentityReplacementChanges( + backingIndex, + branchCatalog, + [...existingChanges, ...changes], + ), + ); + + changes.push( + ...buildIndexClusterReplacementChanges(backingIndex, branchCatalog, [ + ...existingChanges, + ...changes, + ]), + ); + + return changes; +} + +function splitStableIdPath(path: string): string[] { + const parts: string[] = []; + let current = ""; + let inQuotes = false; + + for (let index = 0; index < path.length; index += 1) { + const char = path[index] ?? ""; + const nextChar = path[index + 1]; + + if (char === '"') { + current += char; + if (inQuotes && nextChar === '"') { + current += nextChar; + index += 1; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (char === "." && !inQuotes) { + parts.push(current); + current = ""; + continue; + } + + current += char; + } + + parts.push(current); + return parts; +} + +function parseConstraintStableId( + constraintStableId: string, +): { schema: string; table: string; constraint: string } | null { + if (!constraintStableId.startsWith("constraint:")) return null; + const parts = splitStableIdPath( + constraintStableId.slice("constraint:".length), + ); + if (parts.length < 3) return null; + const [schema, table, ...constraintParts] = parts; + return { schema, table, constraint: constraintParts.join(".") }; +} + +function hasConstraintDropChange( + changes: readonly Change[], + constraintStableId: string, +): boolean { + return changes.some( + (change) => + change instanceof AlterTableDropConstraint && + stableId.constraint( + change.table.schema, + change.table.name, + change.constraint.name, + ) === constraintStableId, + ); +} + +function hasConstraintAddChange( + changes: readonly Change[], + constraintStableId: string, +): boolean { + return changes.some( + (change) => + change instanceof AlterTableAddConstraint && + stableId.constraint( + change.table.schema, + change.table.name, + change.constraint.name, + ) === constraintStableId, + ); +} + +function hasDomainConstraintDropChange( + changes: readonly Change[], + constraintStableId: string, +): boolean { + return changes.some( + (change) => + change instanceof AlterDomainDropConstraint && + stableId.constraint( + change.domain.schema, + change.domain.name, + change.constraint.name, + ) === constraintStableId, + ); +} + +function hasDomainConstraintAddChange( + changes: readonly Change[], + constraintStableId: string, +): boolean { + return changes.some( + (change) => + change instanceof AlterDomainAddConstraint && + stableId.constraint( + change.domain.schema, + change.domain.name, + change.constraint.name, + ) === constraintStableId, + ); +} + +function hasDomainDefaultDropChange( + changes: readonly Change[], + domainStableId: string, +): boolean { + return changes.some( + (change) => + change instanceof AlterDomainDropDefault && + change.domain.stableId === domainStableId, + ); +} + +function hasDomainDefaultSetChange( + changes: readonly Change[], + domainStableId: string, +): boolean { + return changes.some( + (change) => + change instanceof AlterDomainSetDefault && + change.domain.stableId === domainStableId, + ); +} + +function parseColumnStableId( + columnStableId: string, +): { schema: string; table: string; column: string } | null { + if (!columnStableId.startsWith("column:")) return null; + const parts = splitStableIdPath(columnStableId.slice("column:".length)); + if (parts.length < 3) return null; + const [schema, table, ...columnParts] = parts; + return { schema, table, column: columnParts.join(".") }; +} + +function hasColumnDefaultDropChange( + changes: readonly Change[], + columnStableId: string, +): boolean { + return changes.some( + (change) => + change instanceof AlterTableAlterColumnDropDefault && + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ) === columnStableId, + ); +} + +function hasColumnDefaultSetChange( + changes: readonly Change[], + columnStableId: string, +): boolean { + return changes.some( + (change) => + change instanceof AlterTableAlterColumnSetDefault && + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ) === columnStableId, + ); +} + +function removeSupersededGeneratedColumnAlters( + changes: readonly Change[], + replacedGeneratedColumnIds: ReadonlySet, +): Change[] { + if (replacedGeneratedColumnIds.size === 0) return [...changes]; + + return changes.filter((change) => { + if ( + !( + change instanceof AlterTableAlterColumnDropDefault || + change instanceof AlterTableAlterColumnSetDefault || + change instanceof AlterTableAlterColumnType + ) + ) { + return true; + } + + const columnStableId = stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ); + return !replacedGeneratedColumnIds.has(columnStableId); + }); +} + +function hasPublicationDropTablesChange( + changes: readonly Change[], + publicationStableId: string, + tables: readonly { schema: string; name: string }[], +): boolean { + return changes.some( + (change) => + change instanceof AlterPublicationDropTables && + change.publication.stableId === publicationStableId && + includesAllPublicationTables(change.tables, tables), + ); +} + +function hasPublicationAddTablesChange( + changes: readonly Change[], + publicationStableId: string, + tables: readonly { schema: string; name: string }[], +): boolean { + return changes.some( + (change) => + change instanceof AlterPublicationAddTables && + change.publication.stableId === publicationStableId && + includesAllPublicationTables(change.tables, tables), + ); +} + +function includesAllPublicationTables( + actual: readonly { schema: string; name: string }[], + expected: readonly { schema: string; name: string }[], +): boolean { + return expected.every((expectedTable) => + actual.some( + (actualTable) => + actualTable.schema === expectedTable.schema && + actualTable.name === expectedTable.name, + ), + ); +} + +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); +} + +function parseAggregateSchemaName(stableId: string): string | null { + if (!stableId.startsWith("aggregate:")) return null; + const paren = stableId.indexOf("("); + if (paren === -1) return null; + return stableId.slice("aggregate:".length, paren); +} + +function isRebuildableInvalidationDependent(dependentId: string): boolean { + let id = dependentId; + + while (id.startsWith("comment:")) { + id = id.slice("comment:".length); + } + + // In-place invalidations, such as ALTER COLUMN TYPE, only need to synthesize + // replacements for catalog objects that are safe to drop and recreate around + // the rewrite. Constraints and columns are owned by the table diff path; if + // they were normalized to table:* here, the expander could emit destructive + // DropTable/CreateTable DDL for a table that should only be altered. + return ( + id.startsWith("view:") || + id.startsWith("materializedView:") || + id.startsWith("index:") || + id.startsWith("procedure:") || + id.startsWith("aggregate:") || + id.startsWith("rlsPolicy:") || + id.startsWith("rule:") || + id.startsWith("trigger:") + ); +} + +function normalizeDependentId(dependentId: string): 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 columnRef = parseColumnStableId(id); + if (columnRef) return stableId.table(columnRef.schema, columnRef.table); + return null; + } + + if (id.startsWith("constraint:")) { + const constraintRef = parseConstraintStableId(id); + if (constraintRef) { + return stableId.table(constraintRef.schema, constraintRef.table); + } + return null; + } + + return id; +} + +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], + } + : 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("rule:")) { + const main = mainCatalog.rules[stableId]; + const branch = branchCatalog.rules[stableId]; + return main && branch ? { kind: "rule", main, branch } : null; + } + + if (stableId.startsWith("trigger:")) { + const main = mainCatalog.triggers[stableId]; + const branch = branchCatalog.triggers[stableId]; + if (!main || !branch) return null; + // PostgreSQL manages partition trigger clones from the parent trigger, so + // dependency expansion must match diffTriggers and never emit explicit clone + // drop/create DDL. + if (main.is_partition_clone || branch.is_partition_clone) return null; + + const tableStableId = `table:${branch.schema}.${branch.table_name}`; + return { + kind: "trigger", + main, + branch, + branchIndexableObject: branchCatalog.indexableObjects[tableStableId], + }; + } + + 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]; + const rangeBranch = branchCatalog.ranges[stableId]; + if (rangeMain && rangeBranch) { + return { kind: "range", main: rangeMain, branch: rangeBranch }; + } + + const compositeMain = mainCatalog.compositeTypes[stableId]; + const compositeBranch = branchCatalog.compositeTypes[stableId]; + if (compositeMain && compositeBranch) { + return { + kind: "composite_type", + main: compositeMain, + branch: compositeBranch, + }; + } + } + + return null; +} + +function buildMaterializedViewIndexReplacementChanges( + materializedViewStableId: string, + branchCatalog: Catalog | undefined, + existingChanges: readonly Change[], +): Change[] { + if (!branchCatalog) return []; + + const changes: Change[] = []; + for (const index of Object.values(branchCatalog.indexes)) { + if (index.tableStableId !== materializedViewStableId) continue; + if (!isStandaloneRecreatableIndex(index)) continue; + if (hasChangeCreating([...existingChanges, ...changes], index.stableId)) { + continue; + } + + changes.push( + new CreateIndex({ + index, + indexableObject: branchCatalog.indexableObjects[index.tableStableId], + }), + ); + + if ( + index.comment !== null && + !hasChangeCreating( + [...existingChanges, ...changes], + stableId.comment(index.stableId), + ) + ) { + changes.push(new CreateCommentOnIndex({ index })); + } + + changes.push( + ...buildIndexStatisticsReplacementChanges(index, [ + ...existingChanges, + ...changes, + ]), + ); + + changes.push( + ...buildIndexClusterReplacementChanges(index, branchCatalog, [ + ...existingChanges, + ...changes, + ]), + ); + } + + return changes; +} + +function isStandaloneRecreatableIndex( + index: Catalog["indexes"][string], +): boolean { + return ( + !index.is_owned_by_constraint && + !index.is_primary && + !index.is_index_partition + ); +} + +function buildReplaceChanges( + resolved: ResolvedObject, + options: { + addDrop: boolean; + addCreate: boolean; + branchCatalog?: Catalog; + existingChanges?: readonly Change[]; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; + }, +): Change[] | null { + const { + addDrop, + addCreate, + branchCatalog, + existingChanges = [], + diffContext, + } = options; + + if (!addDrop && !addCreate) return null; + + switch (resolved.kind) { + case "table": + return [ + ...(addDrop ? [new DropTable({ table: resolved.main })] : []), + ...(addCreate + ? [ + new CreateTable({ table: resolved.branch }), ...((resolved.branch.constraints ?? []) .filter((c) => !c.is_partition_clone) .flatMap((constraint) => { @@ -594,7 +1885,13 @@ function buildReplaceChanges( ...(addDrop ? [new DropView({ view: resolved.main })] : []), ...(addCreate ? buildCreateViewReplacementChanges(resolved.branch, diffContext) - : []), + : addDrop + ? buildViewMetadataReplacementChanges( + resolved.branch, + diffContext, + existingChanges, + ) + : []), ]; case "materialized_view": return [ @@ -602,13 +1899,23 @@ function buildReplaceChanges( ? [new DropMaterializedView({ materializedView: resolved.main })] : []), ...(addCreate - ? diffContext - ? buildCreateMaterializedViewChanges(diffContext, resolved.branch) - : [ - new CreateMaterializedView({ - materializedView: resolved.branch, - }), - ] + ? [ + ...(diffContext + ? buildCreateMaterializedViewChanges( + diffContext, + resolved.branch, + ) + : [ + new CreateMaterializedView({ + materializedView: resolved.branch, + }), + ]), + ...buildMaterializedViewIndexReplacementChanges( + resolved.branch.stableId, + branchCatalog, + existingChanges, + ), + ] : []), ]; case "index": @@ -638,6 +1945,23 @@ function buildReplaceChanges( index: resolved.branch, indexableObject: resolved.branchIndexableObject, }), + ...(resolved.branch.comment !== null + ? [new CreateCommentOnIndex({ index: resolved.branch })] + : []), + ...buildIndexStatisticsReplacementChanges( + resolved.branch, + existingChanges, + ), + ...buildIndexReplicaIdentityReplacementChanges( + resolved.branch, + branchCatalog, + existingChanges, + ), + ...buildIndexClusterReplacementChanges( + resolved.branch, + branchCatalog, + existingChanges, + ), ] : []), ]; @@ -645,8 +1969,31 @@ function buildReplaceChanges( return [ ...(addDrop ? [new DropProcedure({ procedure: resolved.main })] : []), ...(addCreate - ? [new CreateProcedure({ procedure: resolved.branch })] - : []), + ? buildCreateProcedureReplacementChanges( + resolved.branch, + diffContext, + existingChanges, + ) + : addDrop + ? buildProcedureMetadataReplacementChanges( + resolved.branch, + diffContext, + existingChanges, + ) + : []), + ]; + case "aggregate": + return [ + ...(addDrop ? [new DropAggregate({ aggregate: resolved.main })] : []), + ...(addCreate + ? buildCreateAggregateReplacementChanges(resolved.branch, diffContext) + : addDrop + ? buildAggregateMetadataReplacementChanges( + resolved.branch, + diffContext, + existingChanges, + ) + : []), ]; case "rls_policy": return [ @@ -660,6 +2007,39 @@ function buildReplaceChanges( ] : []), ]; + 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 })] + : []), + ] + : []), + ]; + case "trigger": + 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 "enum": return [ ...(addDrop ? [new DropEnum({ enum: resolved.main })] : []), @@ -689,6 +2069,256 @@ function buildReplaceChanges( } } +function isSupersededReplaceChange( + change: Change, + ruleIds: ReadonlySet, + triggerIds: ReadonlySet, +): boolean { + if (change instanceof ReplaceRule || change instanceof CreateRule) { + return ruleIds.has(change.rule.stableId); + } + if ( + change instanceof CreateCommentOnRule || + change instanceof DropCommentOnRule || + change instanceof SetRuleEnabledState + ) { + return ruleIds.has(change.rule.stableId); + } + if (change instanceof ReplaceTrigger || change instanceof CreateTrigger) { + return triggerIds.has(change.trigger.stableId); + } + if ( + change instanceof CreateCommentOnTrigger || + change instanceof DropCommentOnTrigger || + change instanceof SetTriggerEnabledState + ) { + return triggerIds.has(change.trigger.stableId); + } + return false; +} + +function buildCreateAggregateReplacementChanges( + aggregate: Catalog["aggregates"][string], + diffContext: + | Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + > + | undefined, +): Change[] { + return diffContext + ? diffAggregates(diffContext, {}, { [aggregate.stableId]: aggregate }) + : [new CreateAggregate({ aggregate })]; +} + +function buildAggregateMetadataReplacementChanges( + aggregate: Catalog["aggregates"][string], + diffContext: + | Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + > + | undefined, + existingChanges: readonly Change[], +): Change[] { + const candidateChanges = diffContext + ? diffAggregates(diffContext, {}, { [aggregate.stableId]: aggregate }) + : []; + const changes: Change[] = []; + + for (const candidate of candidateChanges) { + if (candidate instanceof CreateAggregate) continue; + if ( + hasEquivalentAggregateMetadataChange(candidate, [ + ...existingChanges, + ...changes, + ]) + ) { + continue; + } + changes.push(candidate); + } + + return changes; +} + +function hasEquivalentAggregateMetadataChange( + candidate: Change, + changes: readonly Change[], +): boolean { + if (candidate instanceof AlterAggregateChangeOwner) { + return changes.some( + (change) => + change instanceof AlterAggregateChangeOwner && + change.aggregate.stableId === candidate.aggregate.stableId && + change.owner === candidate.owner, + ); + } + + const createdIds = candidate.creates ?? []; + if (createdIds.length > 0) { + return createdIds.every((id) => hasChangeCreating(changes, id)); + } + + const serialized = candidate.serialize(); + return changes.some( + (change) => + change.constructor === candidate.constructor && + change.serialize() === serialized, + ); +} + +function buildCreateProcedureReplacementChanges( + procedure: Catalog["procedures"][string], + diffContext: + | Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + > + | undefined, + existingChanges: readonly Change[] = [], +): Change[] { + const changes: Change[] = diffContext + ? diffProcedures(diffContext, {}, { [procedure.stableId]: procedure }) + : [new CreateProcedure({ procedure })]; + + appendMissingProcedureMetadataChanges( + changes, + procedure, + diffContext, + existingChanges, + ); + + return changes; +} + +function buildProcedureMetadataReplacementChanges( + procedure: Catalog["procedures"][string], + diffContext: + | Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + > + | undefined, + existingChanges: readonly Change[], +): Change[] { + const changes: Change[] = []; + appendMissingProcedureMetadataChanges( + changes, + procedure, + diffContext, + existingChanges, + ); + return changes; +} + +function appendMissingProcedureMetadataChanges( + changes: Change[], + procedure: Catalog["procedures"][string], + diffContext: + | Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + > + | undefined, + existingChanges: readonly Change[], +): void { + const candidateChanges = buildProcedureMetadataCandidateChanges( + procedure, + diffContext, + ); + + for (const candidate of candidateChanges) { + if ( + hasEquivalentProcedureChange(candidate, [...existingChanges, ...changes]) + ) { + continue; + } + changes.push(candidate); + } +} + +function buildProcedureMetadataCandidateChanges( + procedure: Catalog["procedures"][string], + diffContext: + | Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + > + | undefined, +): Change[] { + if (diffContext) { + return diffProcedures( + diffContext, + {}, + { [procedure.stableId]: procedure }, + ).filter((change) => !(change instanceof CreateProcedure)); + } + + const changes: Change[] = [ + new AlterProcedureChangeOwner({ + procedure, + owner: procedure.owner, + }), + ]; + + if (procedure.comment !== null) { + changes.push(new CreateCommentOnProcedure({ procedure })); + } + for (const securityLabel of procedure.security_labels) { + changes.push( + new CreateSecurityLabelOnProcedure({ + procedure, + securityLabel, + }), + ); + } + + const grantedPrivileges = filterPublicBuiltInDefaults( + "procedure", + procedure.privileges, + ).filter((privilege) => privilege.grantee !== procedure.owner); + const grantsByGrantee = new Map< + string, + { privilege: string; grantable: boolean }[] + >(); + for (const privilege of grantedPrivileges) { + const key = `${privilege.grantee}\0${privilege.grantable ? "1" : "0"}`; + const existing = grantsByGrantee.get(key); + const target = existing ?? []; + target.push({ + privilege: privilege.privilege, + grantable: privilege.grantable, + }); + if (!existing) grantsByGrantee.set(key, target); + } + + for (const [key, privileges] of grantsByGrantee) { + const [grantee] = key.split("\0"); + changes.push( + new GrantProcedurePrivileges({ + procedure, + grantee, + privileges, + version: undefined, + }), + ); + } + + return changes; +} + +function hasEquivalentProcedureChange( + candidate: Change, + existingChanges: readonly Change[], +): boolean { + return existingChanges.some( + (change) => + change.constructor === candidate.constructor && + change.serialize() === candidate.serialize(), + ); +} + function buildCreateViewReplacementChanges( view: Catalog["views"][string], diffContext: @@ -705,3 +2335,125 @@ function buildCreateViewReplacementChanges( ? buildCreateViewChanges(diffContext, view) : [new CreateView({ view })]; } + +function buildViewMetadataReplacementChanges( + view: Catalog["views"][string], + diffContext: + | Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + > + | undefined, + existingChanges: readonly Change[], +): Change[] { + if (!diffContext) return []; + + const changes: Change[] = []; + const candidateChanges = buildCreateViewChanges(diffContext, view).filter( + (change) => !(change instanceof CreateView), + ); + + for (const candidate of candidateChanges) { + if ( + hasEquivalentReplacementMetadataChange(candidate, [ + ...existingChanges, + ...changes, + ]) + ) { + continue; + } + changes.push(candidate); + } + + return changes; +} + +function buildIndexStatisticsReplacementChanges( + index: Catalog["indexes"][string], + existingChanges: readonly Change[], +): Change[] { + const columnTargets = index.statistics_target.flatMap( + (statistics, columnIndex) => + statistics >= 0 ? [{ columnNumber: columnIndex + 1, statistics }] : [], + ); + if (columnTargets.length === 0) return []; + + const change = new AlterIndexSetStatistics({ index, columnTargets }); + return hasEquivalentReplacementMetadataChange(change, existingChanges) + ? [] + : [change]; +} + +function buildIndexReplicaIdentityReplacementChanges( + index: Catalog["indexes"][string], + branchCatalog: Catalog | undefined, + existingChanges: readonly Change[], +): Change[] { + if (!branchCatalog) return []; + const branchTable = branchCatalog.tables[index.tableStableId]; + if (!branchTable) return []; + if ( + branchTable.replica_identity !== "i" || + branchTable.replica_identity_index !== index.name + ) { + return []; + } + + const change = new AlterTableSetReplicaIdentity({ + table: branchTable, + mode: "i", + indexName: branchTable.replica_identity_index, + }); + return hasEquivalentReplacementMetadataChange(change, existingChanges) + ? [] + : [change]; +} + +function buildIndexClusterReplacementChanges( + index: Catalog["indexes"][string], + branchCatalog: Catalog | undefined, + existingChanges: readonly Change[], +): Change[] { + if (!branchCatalog || !index.is_clustered) return []; + + if (index.table_relkind === "m") { + const branchMaterializedView = + branchCatalog.materializedViews[index.tableStableId]; + if (!branchMaterializedView) return []; + + const change = new AlterMaterializedViewSetCluster({ + materializedView: branchMaterializedView, + indexName: index.name, + }); + return hasEquivalentReplacementMetadataChange(change, existingChanges) + ? [] + : [change]; + } + + const branchTable = branchCatalog.tables[index.tableStableId]; + if (!branchTable) return []; + + const change = new AlterTableSetCluster({ + table: branchTable, + indexName: index.name, + }); + return hasEquivalentReplacementMetadataChange(change, existingChanges) + ? [] + : [change]; +} + +function hasEquivalentReplacementMetadataChange( + candidate: Change, + existingChanges: readonly Change[], +): boolean { + const createdIds = candidate.creates ?? []; + if (createdIds.length > 0) { + return createdIds.every((id) => hasChangeCreating(existingChanges, id)); + } + + return existingChanges.some( + (change) => + change.constructor === candidate.constructor && + change.serialize() === candidate.serialize(), + ); +} diff --git a/packages/pg-delta/src/core/objects/base.model.ts b/packages/pg-delta/src/core/objects/base.model.ts index a4e6543a6..c58f8cbd6 100644 --- a/packages/pg-delta/src/core/objects/base.model.ts +++ b/packages/pg-delta/src/core/objects/base.model.ts @@ -6,7 +6,9 @@ export const columnPropsSchema = z.object({ name: z.string(), position: z.number(), data_type: z.string(), + data_type_oid: z.string().optional(), data_type_str: z.string(), + assignment_cast_source_type_oids: z.array(z.string()).optional(), is_custom_type: z.boolean(), custom_type_type: z.string().nullable(), custom_type_category: z.string().nullable(), @@ -27,7 +29,12 @@ export type ColumnProps = z.infer; export function normalizeColumns(columns: ColumnProps[]) { return columns .map((column) => { - const { position: _position, ...rest } = column; + const { + position: _position, + data_type_oid: _dataTypeOid, + assignment_cast_source_type_oids: _assignmentCastSourceTypeOids, + ...rest + } = column; return rest; }) .sort((a, b) => a.name.localeCompare(b.name)); 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..d3861d363 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,66 @@ 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_index", + 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_index ON public.test_matview (id)", + comment: null, + owner: "test", + }); + + const change = new CreateIndex({ + index, + indexableObject: { + columns: [ + { + 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, + }, + ], + }, + }); + + 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..e2661880c 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)); + // Table-like parent dependency: table or materialized view. + dependencies.add(this.index.tableStableId); // Owner dependency dependencies.add(stableId.role(this.index.owner)); diff --git a/packages/pg-delta/src/core/objects/index/index.diff.test.ts b/packages/pg-delta/src/core/objects/index/index.diff.test.ts index a7d011f62..8639a7135 100644 --- a/packages/pg-delta/src/core/objects/index/index.diff.test.ts +++ b/packages/pg-delta/src/core/objects/index/index.diff.test.ts @@ -4,6 +4,7 @@ import { AlterIndexSetStorageParams, AlterIndexSetTablespace, } from "./changes/index.alter.ts"; +import { CreateCommentOnIndex } from "./changes/index.comment.ts"; import { CreateIndex } from "./changes/index.create.ts"; import { DropIndex } from "./changes/index.drop.ts"; import { diffIndexes } from "./index.diff.ts"; @@ -140,14 +141,16 @@ describe.concurrent("index.diff", () => { ...base, index_type: "hash", is_unique: true, + comment: "rebuilt index comment", }); const changes = diffIndexes( { [main.stableId]: main }, { [branch.stableId]: branch }, {}, ); - expect(changes).toHaveLength(2); + expect(changes).toHaveLength(3); expect(changes[0]).toBeInstanceOf(DropIndex); expect(changes[1]).toBeInstanceOf(CreateIndex); + expect(changes[2]).toBeInstanceOf(CreateCommentOnIndex); }); }); diff --git a/packages/pg-delta/src/core/objects/index/index.diff.ts b/packages/pg-delta/src/core/objects/index/index.diff.ts index b57652b9a..5bb12b88e 100644 --- a/packages/pg-delta/src/core/objects/index/index.diff.ts +++ b/packages/pg-delta/src/core/objects/index/index.diff.ts @@ -137,6 +137,9 @@ export function diffIndexes( indexableObject: branchIndexableObjects[branchIndex.tableStableId], }), ); + if (branchIndex.comment !== null) { + changes.push(new CreateCommentOnIndex({ index: branchIndex })); + } } else { // Only alterable properties changed - check each one diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.test.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.test.ts index b705f1387..c02571fa9 100644 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.test.ts +++ b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.test.ts @@ -6,6 +6,7 @@ import { } from "../materialized-view.model.ts"; import { AlterMaterializedViewChangeOwner, + AlterMaterializedViewSetCluster, AlterMaterializedViewSetStorageParams, } from "./materialized-view.alter.ts"; @@ -126,5 +127,43 @@ describe.concurrent("materialized-view", () => { ].join(";\n"), ); }); + + test("set cluster index", async () => { + const materializedView = new MaterializedView({ + schema: "public", + name: "test_mv", + definition: "SELECT * FROM test_table", + 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: "test", + comment: null, + columns: [], + privileges: [], + }); + + const change = new AlterMaterializedViewSetCluster({ + materializedView, + indexName: "test_mv_lookup_idx", + }); + + await assertValidSql(change.serialize()); + + expect(change.requires).toEqual([ + "materializedView:public.test_mv", + "index:public.test_mv.test_mv_lookup_idx", + ]); + expect(change.serialize()).toBe( + "ALTER MATERIALIZED VIEW public.test_mv CLUSTER ON test_mv_lookup_idx", + ); + }); }); }); 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..5bffe33bf 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"; @@ -28,12 +29,13 @@ import { AlterMaterializedViewChange } from "./materialized-view.base.ts"; * Notes for diff-based generation: * - We currently only emit OWNER TO when owner differs. * - Name/schema changes are treated as identity changes; handled as drop/create by the diff engine. - * - Column attribute changes, CLUSTER are not modeled and thus not emitted. + * - Column attribute changes are not modeled and thus not emitted. * - Changes to definition, options, and other non-alterable properties trigger a replace (drop + create). */ export type AlterMaterializedView = | AlterMaterializedViewChangeOwner + | AlterMaterializedViewSetCluster | AlterMaterializedViewSetStorageParams; /** @@ -64,6 +66,44 @@ export class AlterMaterializedViewChangeOwner extends AlterMaterializedViewChang } } +/** + * ALTER MATERIALIZED VIEW ... CLUSTER ON ... + */ +export class AlterMaterializedViewSetCluster 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.test.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.alter.test.ts index 144563ef7..dcd60e356 100644 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.alter.test.ts +++ b/packages/pg-delta/src/core/objects/publication/changes/publication.alter.test.ts @@ -157,8 +157,8 @@ describe("publication.alter", () => { stableId.table("audit", "events"), ]); expect(change.drops).toEqual([ - stableId.table("public", "logs"), - stableId.table("audit", "events"), + stableId.publicationTable("pub_drop_tables", "public", "logs"), + stableId.publicationTable("pub_drop_tables", "audit", "events"), ]); await assertValidSql(change.serialize()); expect(change.serialize()).toBe( 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..b78144540 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; @@ -154,7 +154,13 @@ export class AlterPublicationDropTables extends AlterPublicationChange { get drops() { // Treat ALTER PUBLICATION ... DROP TABLE as a destructive change so it runs // in the drop phase before DROP TABLE removes the referenced relation. - return this.tables.map((table) => stableId.table(table.schema, table.name)); + return this.tables.map((table) => + stableId.publicationTable( + this.publication.name, + table.schema, + table.name, + ), + ); } serialize(_options?: SerializeOptions): string { diff --git a/packages/pg-delta/src/core/objects/table/changes/table.alter.test.ts b/packages/pg-delta/src/core/objects/table/changes/table.alter.test.ts index 954e43bf2..39d67a8e0 100644 --- a/packages/pg-delta/src/core/objects/table/changes/table.alter.test.ts +++ b/packages/pg-delta/src/core/objects/table/changes/table.alter.test.ts @@ -24,6 +24,7 @@ import { AlterTableNoForceRowLevelSecurity, AlterTableResetStorageParams, AlterTableSetLogged, + AlterTableSetCluster, AlterTableSetReplicaIdentity, AlterTableSetStorageParams, AlterTableSetUnlogged, @@ -391,6 +392,44 @@ describe.concurrent("table", () => { ); }); + test("cluster marker", async () => { + const table = new Table({ + schema: "public", + name: "test_table", + persistence: "p", + row_security: false, + force_row_security: false, + has_indexes: false, + has_rules: false, + has_triggers: false, + has_subclasses: false, + is_populated: false, + replica_identity: "d", + is_partition: false, + options: null, + partition_bound: null, + partition_by: null, + parent_schema: null, + parent_name: null, + owner: "o1", + columns: [], + privileges: [], + }); + const change = new AlterTableSetCluster({ + table, + indexName: "test_table_lookup_idx", + }); + + await assertValidSql(change.serialize()); + expect(change.serialize()).toBe( + "ALTER TABLE public.test_table CLUSTER ON test_table_lookup_idx", + ); + expect(change.requires).toEqual([ + "table:public.test_table", + "index:public.test_table.test_table_lookup_idx", + ]); + }); + test("columns add/drop/alter", async () => { const tableProps: Omit = { schema: "public", @@ -513,6 +552,36 @@ describe.concurrent("table", () => { "ALTER TABLE public.test_table ALTER COLUMN a DROP DEFAULT", ); + const generatedColumn: ColumnProps = { + ...colText, + name: "computed_name", + is_generated: true, + default: "lower((b))", + }; + const changeResetGeneratedExpression = + new AlterTableAlterColumnDropDefault({ + table: withCols, + column: generatedColumn, + previousColumn: { ...generatedColumn, data_type_str: "integer" }, + }); + await assertValidSql(changeResetGeneratedExpression.serialize()); + expect(changeResetGeneratedExpression.serialize()).toBe( + "ALTER TABLE public.test_table ALTER COLUMN computed_name SET EXPRESSION AS (NULL::integer)", + ); + expect(changeResetGeneratedExpression.invalidates).toEqual([ + "column:public.test_table.computed_name", + ]); + + const changeGeneratedType = new AlterTableAlterColumnType({ + table: withCols, + column: generatedColumn, + previousColumn: { ...generatedColumn, data_type_str: "integer" }, + }); + await assertValidSql(changeGeneratedType.serialize()); + expect(changeGeneratedType.serialize()).toBe( + "ALTER TABLE public.test_table ALTER COLUMN computed_name TYPE text", + ); + const changeAddIdentity = new AlterTableAlterColumnAddIdentity({ table: withCols, column: { 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..67fa53266 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 @@ -81,6 +81,7 @@ export type AlterTable = | AlterTableForceRowLevelSecurity | AlterTableNoForceRowLevelSecurity | AlterTableResetStorageParams + | AlterTableSetCluster | AlterTableSetLogged | AlterTableSetReplicaIdentity | AlterTableSetStorageParams @@ -338,13 +339,27 @@ export class AlterTableAddConstraint extends AlterTableChange { } get creates() { - return [ + const createdIds: 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" + ) { + createdIds.push( + stableId.index( + this.table.schema, + this.table.name, + this.constraint.name, + ), + ); + } + return createdIds; } get requires() { @@ -523,6 +538,37 @@ export class AlterTableSetReplicaIdentity extends AlterTableChange { } } +/** + * ALTER TABLE ... CLUSTER ON ... + */ +export class AlterTableSetCluster 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 ... ADD COLUMN ... */ @@ -664,10 +710,12 @@ export class AlterTableAlterColumnType extends AlterTableChange { // previousColumn is optional so direct serializer tests/fixtures can keep // emitting canonical ALTER TYPE SQL without forcing a USING expression. // When provided, we can detect true type changes and add USING for casts - // PostgreSQL cannot perform automatically. + // PostgreSQL cannot perform automatically. Generated columns cannot use a + // USING clause; their expression reset/set flow handles incompatible types. const hasTypeChangedWithPreviousDefinition = this.previousColumn?.data_type_str !== undefined && - this.previousColumn.data_type_str !== this.column.data_type_str; + this.previousColumn.data_type_str !== this.column.data_type_str && + !this.column.is_generated; const parts: string[] = [ "ALTER TABLE", @@ -730,12 +778,18 @@ export class AlterTableAlterColumnSetDefault extends AlterTableChange { export class AlterTableAlterColumnDropDefault extends AlterTableChange { public readonly table: Table; public readonly column: ColumnProps; + public readonly previousColumn: ColumnProps | null; public readonly scope = "object" as const; - constructor(props: { table: Table; column: ColumnProps }) { + constructor(props: { + table: Table; + column: ColumnProps; + previousColumn?: ColumnProps | null; + }) { super(); this.table = props.table; this.column = props.column; + this.previousColumn = props.previousColumn ?? null; } get requires() { @@ -744,7 +798,25 @@ export class AlterTableAlterColumnDropDefault extends AlterTableChange { ]; } + get invalidates() { + return this.column.is_generated + ? [stableId.column(this.table.schema, this.table.name, this.column.name)] + : []; + } + serialize(_options?: SerializeOptions): string { + if (this.column.is_generated) { + const resetColumn = this.previousColumn ?? this.column; + return [ + "ALTER TABLE", + `${this.table.schema}.${this.table.name}`, + "ALTER COLUMN", + this.column.name, + "SET EXPRESSION AS", + `(NULL::${resetColumn.data_type_str})`, + ].join(" "); + } + return [ "ALTER TABLE", `${this.table.schema}.${this.table.name}`, diff --git a/packages/pg-delta/src/core/objects/table/table.diff.test.ts b/packages/pg-delta/src/core/objects/table/table.diff.test.ts index 72c370a77..450408fea 100644 --- a/packages/pg-delta/src/core/objects/table/table.diff.test.ts +++ b/packages/pg-delta/src/core/objects/table/table.diff.test.ts @@ -1055,6 +1055,337 @@ describe.concurrent("table.diff", () => { ]); }); + test("postgres before 17 rebuilds generated columns when their type changes", () => { + const generatedTextColumn = { + name: "status_label", + position: 1, + data_type: "text", + data_type_str: "text", + 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: "upper(status)", + comment: null, + }; + const generatedVarcharColumn = { + ...generatedTextColumn, + data_type: "character varying", + data_type_str: "character varying(64)", + }; + const mainTable = new Table({ + ...base, + name: "t_generated_type", + columns: [generatedTextColumn], + }); + const branchTable = new Table({ + ...base, + name: "t_generated_type", + columns: [generatedVarcharColumn], + }); + + const changes = diffTables( + testContext, + { [mainTable.stableId]: mainTable }, + { [branchTable.stableId]: branchTable }, + ); + + expect(changes.map((change) => change.serialize())).toEqual([ + "ALTER TABLE public.t_generated_type DROP COLUMN status_label", + "ALTER TABLE public.t_generated_type ADD COLUMN status_label character varying(64) GENERATED ALWAYS AS (upper(status)) STORED", + ]); + expect( + changes.some( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toBe(false); + expect( + changes.some((change) => change instanceof AlterTableAlterColumnType), + ).toBe(false); + expect( + changes.some( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toBe(false); + }); + + test("postgres 17 rebuilds constrained generated columns when their type changes", () => { + const pg17Context = { + ...testContext, + version: 170000, + }; + const generatedTextColumn = { + name: "status_label", + position: 1, + data_type: "text", + data_type_str: "text", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: true, + is_identity: false, + is_identity_always: false, + is_generated: true, + collation: null, + default: "upper(status)", + comment: null, + }; + const generatedVarcharColumn = { + ...generatedTextColumn, + data_type: "character varying", + data_type_str: "character varying(64)", + }; + const checkConstraint = { + name: "t_generated_type_status_label_check", + 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: ["status_label"], + 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: "status_label <> ''", + owner: "o1", + definition: "CHECK (status_label <> '')", + comment: null, + }; + const mainTable = new Table({ + ...base, + name: "t_generated_type", + columns: [generatedTextColumn], + constraints: [checkConstraint], + }); + const branchTable = new Table({ + ...base, + name: "t_generated_type", + columns: [generatedVarcharColumn], + constraints: [checkConstraint], + }); + + const changes = diffTables( + pg17Context, + { [mainTable.stableId]: mainTable }, + { [branchTable.stableId]: branchTable }, + ); + + expect( + changes.some((change) => change instanceof AlterTableDropColumn), + ).toBe(true); + expect( + changes.some((change) => change instanceof AlterTableAddColumn), + ).toBe(true); + expect( + changes.some( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toBe(false); + expect( + changes.some((change) => change instanceof AlterTableAlterColumnType), + ).toBe(false); + expect( + changes.some( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toBe(false); + }); + + test("postgres 17 resets generated columns with the current type during incompatible own type changes", () => { + const pg17Context = { + ...testContext, + version: 170000, + }; + const generatedIntegerColumn = { + name: "status_code", + 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: "length(status)", + comment: null, + }; + const generatedTextColumn = { + ...generatedIntegerColumn, + data_type: "text", + data_type_str: "text", + default: "upper(status)", + }; + const mainTable = new Table({ + ...base, + name: "t_generated_incompatible_type", + columns: [generatedIntegerColumn], + }); + const branchTable = new Table({ + ...base, + name: "t_generated_incompatible_type", + columns: [generatedTextColumn], + }); + + const changes = diffTables( + pg17Context, + { [mainTable.stableId]: mainTable }, + { [branchTable.stableId]: branchTable }, + ); + + expect(changes.map((change) => change.serialize())).toEqual([ + "ALTER TABLE public.t_generated_incompatible_type ALTER COLUMN status_code SET EXPRESSION AS (NULL::integer)", + "ALTER TABLE public.t_generated_incompatible_type ALTER COLUMN status_code TYPE text", + "ALTER TABLE public.t_generated_incompatible_type ALTER COLUMN status_code SET EXPRESSION AS (upper(status))", + ]); + }); + + test("postgres 17 rebuilds generated columns when type change lacks an assignment cast", () => { + const pg17Context = { + ...testContext, + version: 170000, + }; + const generatedIntegerColumn = { + name: "status_id", + position: 1, + data_type: "integer", + data_type_oid: "23", + data_type_str: "integer", + assignment_cast_source_type_oids: ["20", "21"], + 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: "id + 1", + comment: null, + }; + const generatedUuidColumn = { + ...generatedIntegerColumn, + data_type: "uuid", + data_type_oid: "2950", + data_type_str: "uuid", + assignment_cast_source_type_oids: [], + default: "gen_random_uuid()", + }; + const mainTable = new Table({ + ...base, + name: "t_generated_no_assignment_cast", + columns: [generatedIntegerColumn], + }); + const branchTable = new Table({ + ...base, + name: "t_generated_no_assignment_cast", + columns: [generatedUuidColumn], + }); + + const changes = diffTables( + pg17Context, + { [mainTable.stableId]: mainTable }, + { [branchTable.stableId]: branchTable }, + ); + + expect(changes.map((change) => change.serialize())).toEqual([ + "ALTER TABLE public.t_generated_no_assignment_cast DROP COLUMN status_id", + "ALTER TABLE public.t_generated_no_assignment_cast ADD COLUMN status_id uuid GENERATED ALWAYS AS (gen_random_uuid()) STORED", + ]); + expect( + changes.some((change) => change instanceof AlterTableAlterColumnType), + ).toBe(false); + }); + + test("postgres 17 rebuilds generated columns when a domain type can reject NULL resets", () => { + const pg17Context = { + ...testContext, + version: 170000, + }; + const generatedTextColumn = { + name: "status_label", + position: 1, + data_type: "text", + data_type_str: "text", + 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: "status::text", + comment: null, + }; + const generatedDomainColumn = { + ...generatedTextColumn, + data_type: "nonempty_text", + data_type_str: "public.nonempty_text", + is_custom_type: true, + custom_type_type: "d" as const, + custom_type_category: "S", + custom_type_schema: "public", + custom_type_name: "nonempty_text", + default: "status::public.nonempty_text", + }; + const mainTable = new Table({ + ...base, + name: "t_generated_domain_type", + columns: [generatedTextColumn], + }); + const branchTable = new Table({ + ...base, + name: "t_generated_domain_type", + columns: [generatedDomainColumn], + }); + + const changes = diffTables( + pg17Context, + { [mainTable.stableId]: mainTable }, + { [branchTable.stableId]: branchTable }, + ); + + expect(changes.map((change) => change.serialize())).toEqual([ + "ALTER TABLE public.t_generated_domain_type DROP COLUMN status_label", + "ALTER TABLE public.t_generated_domain_type ADD COLUMN status_label public.nonempty_text GENERATED ALWAYS AS (status::public.nonempty_text) STORED", + ]); + expect( + changes.some((change) => change instanceof AlterTableAlterColumnType), + ).toBe(false); + }); + test("identity transitions emit drop/add/set-generated changes", () => { const serialColumn = { name: "id", diff --git a/packages/pg-delta/src/core/objects/table/table.diff.ts b/packages/pg-delta/src/core/objects/table/table.diff.ts index c9426e127..1ba429a98 100644 --- a/packages/pg-delta/src/core/objects/table/table.diff.ts +++ b/packages/pg-delta/src/core/objects/table/table.diff.ts @@ -57,6 +57,36 @@ import { import type { TableChange } from "./changes/table.types.ts"; import { Table } from "./table.model.ts"; +type TableColumn = Table["columns"][number]; + +function canAlterColumnTypeWithoutUsing( + mainCol: TableColumn, + branchCol: TableColumn, +): boolean { + if (mainCol.data_type_str === branchCol.data_type_str) { + return true; + } + if ( + mainCol.data_type_oid && + branchCol.data_type_oid && + mainCol.data_type_oid === branchCol.data_type_oid + ) { + return true; + } + + const assignmentCastSourceTypeOids = + branchCol.assignment_cast_source_type_oids; + if (!assignmentCastSourceTypeOids || !mainCol.data_type_oid) { + return true; + } + + return assignmentCastSourceTypeOids.includes(mainCol.data_type_oid); +} + +function usesDomainType(...columns: TableColumn[]): boolean { + return columns.some((column) => column.custom_type_type === "d"); +} + function createAlterConstraintChange(mainTable: Table, branchTable: Table) { const changes: TableChange[] = []; @@ -790,33 +820,60 @@ export function diffTables( const columnCollationChanged = mainCol.collation !== branchCol.collation; const needsDefaultSafeFlow = columnTypeChanged && mainCol.default !== null; + const shouldRebuildGeneratedColumnForTypeChange = + (columnTypeChanged || columnCollationChanged) && + branchCol.is_generated && + (ctx.version < 170000 || + (columnTypeChanged && + !canAlterColumnTypeWithoutUsing(mainCol, branchCol)) || + usesDomainType(mainCol, branchCol) || + mainCol.is_generated !== branchCol.is_generated || + mainCol.not_null || + branchCol.not_null || + hasConstraintReferencingColumn(name, mainTable, branchTable)); // TYPE or COLLATION change if (columnTypeChanged || columnCollationChanged) { // Skip if parent has the same type/collation change if (!parentHasSameColumnPropertyChange(name, "type")) { - if (needsDefaultSafeFlow) { + if (shouldRebuildGeneratedColumnForTypeChange) { + changes.push( + new AlterTableDropColumn({ + table: mainTable, + column: mainCol, + }), + ); + changes.push( + new AlterTableAddColumn({ + table: branchTable, + column: branchCol, + }), + ); + } else if (needsDefaultSafeFlow) { changes.push( new AlterTableAlterColumnDropDefault({ table: branchTable, column: branchCol, + previousColumn: mainCol, }), ); } - changes.push( - new AlterTableAlterColumnType({ - table: branchTable, - column: branchCol, - previousColumn: mainCol, - }), - ); - if (needsDefaultSafeFlow && branchCol.default !== null) { + if (!shouldRebuildGeneratedColumnForTypeChange) { changes.push( - new AlterTableAlterColumnSetDefault({ + new AlterTableAlterColumnType({ table: branchTable, column: branchCol, + previousColumn: mainCol, }), ); + if (needsDefaultSafeFlow && branchCol.default !== null) { + changes.push( + new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchCol, + }), + ); + } } } } @@ -838,6 +895,10 @@ export function diffTables( if (mainCol.default !== branchCol.default) { // Skip if parent has the same default change if (!parentHasSameColumnPropertyChange(name, "default")) { + if (shouldRebuildGeneratedColumnForTypeChange) { + // Rebuilt generated columns carry the branch expression in ADD COLUMN. + continue; + } if (needsDefaultSafeFlow) { // Defaults were already dropped/re-set in the type-change flow above. continue; @@ -1032,3 +1093,13 @@ export function diffTables( return changes; } + +function hasConstraintReferencingColumn( + columnName: string, + mainTable: Table, + branchTable: Table, +): boolean { + return [...(mainTable.constraints ?? []), ...(branchTable.constraints ?? [])] + .filter((constraint) => !constraint.is_partition_clone) + .some((constraint) => constraint.key_columns.includes(columnName)); +} diff --git a/packages/pg-delta/src/core/objects/table/table.model.ts b/packages/pg-delta/src/core/objects/table/table.model.ts index 652e826bf..bc00ac246 100644 --- a/packages/pg-delta/src/core/objects/table/table.model.ts +++ b/packages/pg-delta/src/core/objects/table/table.model.ts @@ -447,7 +447,17 @@ select 'name', quote_ident(a.attname), 'position', a.attnum, 'data_type', a.atttypid::regtype::text, + 'data_type_oid', a.atttypid::oid::text, 'data_type_str', format_type(a.atttypid, a.atttypmod), + 'assignment_cast_source_type_oids', coalesce( + ( + select json_agg(pc.castsource::oid::text order by pc.castsource::oid::text) + from pg_catalog.pg_cast pc + where pc.casttarget = a.atttypid + and pc.castcontext in ('a', 'i') + ), + '[]'::json + ), 'is_custom_type', ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema'), 'custom_type_type', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typtype else null end, 'custom_type_category', case when ty.typnamespace::regnamespace::text not in ('pg_catalog', 'information_schema') then ty.typcategory else null end, 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..12bd39819 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,42 +1,40 @@ import { describe, expect, test } from "bun:test"; import { assertValidSql } from "../../../test-utils/assert-valid-sql.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", () => { + const props: Omit = { + schema: "public", + name: "test_trigger", + table_name: "test_table", + table_relkind: "r", + function_schema: "public", + function_name: "test_function", + trigger_type: 1 << 4, // UPDATE (1<<4) = 16, AFTER is default (0), STATEMENT is default (0) + 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, + }; + test("replace trigger", async () => { - const props: Omit = { - schema: "public", - name: "test_trigger", - table_name: "test_table", - table_relkind: "r", - function_schema: "public", - function_name: "test_function", - trigger_type: 1 << 4, // UPDATE (1<<4) = 16, AFTER is default (0), STATEMENT is default (0) - 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 branch = new Trigger({ - ...props, - enabled: "D", - }); + const branch = new Trigger({ ...props, enabled: "D" }); const change = new ReplaceTrigger({ trigger: branch }); @@ -46,5 +44,38 @@ describe.concurrent("trigger", () => { "CREATE OR REPLACE TRIGGER test_trigger AFTER UPDATE ON public.test_table EXECUTE FUNCTION public.test_function()", ); }); + + test("set trigger enabled state", async () => { + const trigger = new Trigger({ ...props, enabled: "D" }); + const change = new SetTriggerEnabledState({ trigger }); + + await assertValidSql(change.serialize()); + + expect(change.serialize()).toBe( + "ALTER TABLE public.test_table DISABLE TRIGGER test_trigger", + ); + }); + + test("set trigger enabled state supports replica and always", async () => { + const replicaTrigger = new Trigger({ ...props, enabled: "R" }); + const alwaysTrigger = new Trigger({ ...props, enabled: "A" }); + + const replicaChange = new SetTriggerEnabledState({ + trigger: replicaTrigger, + }); + const alwaysChange = new SetTriggerEnabledState({ + trigger: alwaysTrigger, + }); + + await assertValidSql(replicaChange.serialize()); + await assertValidSql(alwaysChange.serialize()); + + expect(replicaChange.serialize()).toBe( + "ALTER TABLE public.test_table ENABLE REPLICA TRIGGER test_trigger", + ); + expect(alwaysChange.serialize()).toBe( + "ALTER TABLE public.test_table ENABLE ALWAYS TRIGGER test_trigger", + ); + }); }); }); 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..3aa04e75b 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,7 +1,8 @@ import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; import { quoteLiteral } from "../../base.change.ts"; import type { TableLikeObject } from "../../base.model.ts"; -import type { Trigger } from "../trigger.model.ts"; +import { stableId } from "../../utils.ts"; +import type { Trigger, TriggerEnabledState } from "../trigger.model.ts"; import { AlterTriggerChange } from "./trigger.base.ts"; import { CreateTrigger } from "./trigger.create.ts"; import { DropTrigger } from "./trigger.drop.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. @@ -34,6 +35,10 @@ export class ReplaceTrigger extends AlterTriggerChange { this.indexableObject = props.indexableObject; } + get creates() { + return [this.trigger.stableId]; + } + get requires() { return [this.trigger.stableId]; } @@ -72,3 +77,44 @@ export class ReplaceTrigger extends AlterTriggerChange { return createChange.serialize(); } } + +export class SetTriggerEnabledState extends AlterTriggerChange { + public readonly trigger: Trigger; + public readonly scope = "object" as const; + public readonly enabled: TriggerEnabledState; + + constructor(props: { trigger: Trigger; enabled?: TriggerEnabledState }) { + 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: TriggerEnabledState) { + 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/objects/trigger/trigger.diff.test.ts b/packages/pg-delta/src/core/objects/trigger/trigger.diff.test.ts index 4fa2e0d9b..5816a2d04 100644 --- a/packages/pg-delta/src/core/objects/trigger/trigger.diff.test.ts +++ b/packages/pg-delta/src/core/objects/trigger/trigger.diff.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { ReplaceTrigger } from "./changes/trigger.alter.ts"; +import { + ReplaceTrigger, + SetTriggerEnabledState, +} from "./changes/trigger.alter.ts"; import { CreateTrigger } from "./changes/trigger.create.ts"; import { DropTrigger } from "./changes/trigger.drop.ts"; import { diffTriggers } from "./trigger.diff.ts"; @@ -81,4 +84,67 @@ describe.concurrent("trigger.diff", () => { expect(changes).toHaveLength(1); expect(changes[0]).toBeInstanceOf(ReplaceTrigger); }); + + test("handle enabled state changes", () => { + const main = new Trigger({ + ...base, + function_name: "fn1", + column_numbers: null, + arguments: [], + when_condition: null, + old_table: null, + new_table: null, + }); + const branch = new Trigger({ + ...base, + function_name: "fn1", + enabled: "D", + column_numbers: null, + arguments: [], + when_condition: null, + old_table: null, + new_table: null, + }); + + const changes = diffTriggers( + { [main.stableId]: main }, + { [branch.stableId]: branch }, + ); + expect( + changes.some((change) => change instanceof SetTriggerEnabledState), + ).toBe(true); + }); + + test("reapply enabled state when replacing trigger", () => { + const main = new Trigger({ + ...base, + function_name: "fn1", + column_numbers: null, + arguments: [], + when_condition: null, + old_table: null, + new_table: null, + }); + const branch = new Trigger({ + ...base, + function_name: "fn2", + enabled: "D", + column_numbers: null, + arguments: [], + when_condition: null, + old_table: null, + new_table: null, + }); + + const changes = diffTriggers( + { [main.stableId]: main }, + { [branch.stableId]: branch }, + ); + expect(changes.some((change) => change instanceof ReplaceTrigger)).toBe( + true, + ); + expect( + changes.some((change) => change instanceof SetTriggerEnabledState), + ).toBe(true); + }); }); diff --git a/packages/pg-delta/src/core/objects/trigger/trigger.diff.ts b/packages/pg-delta/src/core/objects/trigger/trigger.diff.ts index 051a6baa9..a99fc501e 100644 --- a/packages/pg-delta/src/core/objects/trigger/trigger.diff.ts +++ b/packages/pg-delta/src/core/objects/trigger/trigger.diff.ts @@ -1,7 +1,10 @@ import { diffObjects } from "../base.diff.ts"; import type { TableLikeObject } from "../base.model.ts"; import { deepEqual, hasNonAlterableChanges, stableId } from "../utils.ts"; -import { ReplaceTrigger } from "./changes/trigger.alter.ts"; +import { + ReplaceTrigger, + SetTriggerEnabledState, +} from "./changes/trigger.alter.ts"; import { CreateCommentOnTrigger, DropCommentOnTrigger, @@ -45,6 +48,9 @@ export function diffTriggers( if (trigger.comment !== null) { changes.push(new CreateCommentOnTrigger({ trigger: trigger })); } + if (trigger.enabled !== "O") { + changes.push(new SetTriggerEnabledState({ trigger })); + } } for (const triggerId of dropped) { @@ -76,7 +82,6 @@ export function diffTriggers( "function_schema", "function_name", "trigger_type", - "enabled", "is_internal", "deferrable", "initially_deferred", @@ -105,6 +110,12 @@ export function diffTriggers( indexableObject: branchIndexableObjects?.[tableStableId], }), ); + if (branchTrigger.comment !== null) { + changes.push(new CreateCommentOnTrigger({ trigger: branchTrigger })); + } + if (branchTrigger.enabled !== "O") { + changes.push(new SetTriggerEnabledState({ trigger: branchTrigger })); + } } else { // COMMENT if (mainTrigger.comment !== branchTrigger.comment) { @@ -114,6 +125,10 @@ export function diffTriggers( changes.push(new CreateCommentOnTrigger({ trigger: branchTrigger })); } } + + if (mainTrigger.enabled !== branchTrigger.enabled) { + changes.push(new SetTriggerEnabledState({ trigger: branchTrigger })); + } } } diff --git a/packages/pg-delta/src/core/objects/trigger/trigger.model.ts b/packages/pg-delta/src/core/objects/trigger/trigger.model.ts index 2c63de0d5..a8f91b3c8 100644 --- a/packages/pg-delta/src/core/objects/trigger/trigger.model.ts +++ b/packages/pg-delta/src/core/objects/trigger/trigger.model.ts @@ -13,6 +13,7 @@ const TriggerEnabledSchema = z.enum([ "R", // REPLICA - trigger fires only in "replica" mode "A", // ALWAYS - trigger fires regardless of replication mode ]); +export type TriggerEnabledState = z.infer; const TriggerTableRelkindSchema = z.enum([ "r", // ordinary table diff --git a/packages/pg-delta/src/core/objects/utils.ts b/packages/pg-delta/src/core/objects/utils.ts index 80933f50b..07b104165 100644 --- a/packages/pg-delta/src/core/objects/utils.ts +++ b/packages/pg-delta/src/core/objects/utils.ts @@ -95,6 +95,9 @@ export const stableId = { membership(role: string, member: string) { return `membership:${role}->${member}` as const; }, + publicationTable(publication: string, schema: string, table: string) { + return `publicationTable:${publication}:${schema}.${table}` as const; + }, foreignDataWrapper(name: string) { return `foreignDataWrapper:${name}` as const; }, diff --git a/packages/pg-delta/src/core/sort/graph-builder.test.ts b/packages/pg-delta/src/core/sort/graph-builder.test.ts new file mode 100644 index 000000000..a87532da7 --- /dev/null +++ b/packages/pg-delta/src/core/sort/graph-builder.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; +import { AlterPublicationDropTables } from "../objects/publication/changes/publication.alter.ts"; +import { Publication } from "../objects/publication/publication.model.ts"; +import { DropTable } from "../objects/table/changes/table.drop.ts"; +import { Table } from "../objects/table/table.model.ts"; +import { stableId } from "../objects/utils.ts"; +import { buildGraphData } from "./graph-builder.ts"; + +const publication = new Publication({ + name: "pub_accounts", + 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: "accounts", + columns: ["id", "status_label"], + row_filter: null, + }, + ], + schemas: [], +}); + +function makeTable(name: string) { + return new Table({ + schema: "public", + name, + 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: [], + constraints: [], + privileges: [], + }); +} + +const table = makeTable("accounts"); + +describe("buildGraphData", () => { + test("publication table removals only produce table ids when the table is also dropped", () => { + const publicationDrop = new AlterPublicationDropTables({ + publication, + tables: publication.tables, + }); + const publicationDropOnly = buildGraphData([publicationDrop], { + invert: true, + }); + + expect(publicationDropOnly.createdStableIdSets[0].has(table.stableId)).toBe( + false, + ); + + const publicationDropWithTableDrop = buildGraphData( + [publicationDrop, new DropTable({ table })], + { invert: true }, + ); + + expect( + publicationDropWithTableDrop.createdStableIdSets[0].has( + stableId.table("public", "accounts"), + ), + ).toBe(true); + }); + + test("publication table removals do not expose surviving table ids from mixed removals", () => { + const mixedPublication = new Publication({ + name: "pub_accounts", + 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: [ + ...publication.tables, + { + schema: "public", + name: "events", + columns: null, + row_filter: null, + }, + ], + schemas: [], + }); + const events = makeTable("events"); + const publicationDrop = new AlterPublicationDropTables({ + publication: mixedPublication, + tables: mixedPublication.tables, + }); + + const graphData = buildGraphData( + [publicationDrop, new DropTable({ table })], + { invert: true }, + ); + + expect(graphData.createdStableIdSets[0].has(table.stableId)).toBe(true); + expect(graphData.createdStableIdSets[0].has(events.stableId)).toBe(false); + }); +}); diff --git a/packages/pg-delta/src/core/sort/graph-builder.ts b/packages/pg-delta/src/core/sort/graph-builder.ts index eff6d2eee..a45afc134 100644 --- a/packages/pg-delta/src/core/sort/graph-builder.ts +++ b/packages/pg-delta/src/core/sort/graph-builder.ts @@ -1,5 +1,8 @@ import type { Change } from "../change.types.ts"; import { findConsumerIndexes } from "./graph-utils.ts"; +import { AlterPublicationDropTables } from "../objects/publication/changes/publication.alter.ts"; +import { AlterTableDropConstraint } from "../objects/table/changes/table.alter.ts"; +import { stableId } from "../objects/utils.ts"; import type { Constraint, Edge, @@ -87,16 +90,19 @@ export function convertExplicitRequirementsToConstraints( if (requiredIds.size === 0) continue; - // Collect dropped IDs for this change so we can skip requirements - // for stableIds that this change also drops. A change that drops a - // stableId should not depend on another change that creates the same - // stableId, because the entity already exists in the source database. - // This prevents false ordering constraints such as Grant → Revoke - // when both operate on the same ACL stableId. + // Collect stable IDs this change removes or rewrites in place so we can + // skip requirements for the same IDs. A change that drops/invalidates a + // stableId should not depend on another change that produces that same + // stableId, because the entity already exists before the plan runs. This + // prevents false ordering constraints such as Grant -> Revoke for ACLs and + // cycles between multiple same-column in-place ALTERs. const droppedIds = new Set(phaseChanges[consumerIndex].drops); + const invalidatedIds = new Set( + phaseChanges[consumerIndex].invalidates, + ); for (const requiredId of requiredIds) { - if (droppedIds.has(requiredId)) { + if (droppedIds.has(requiredId) || invalidatedIds.has(requiredId)) { continue; } @@ -143,24 +149,59 @@ export function convertExplicitRequirementsToConstraints( * Build graph data structures from phase changes. * * Creates change sets and reverse indexes needed for converting dependencies to Constraints. - * In DROP phase (invert=true), dropped IDs are included in createdStableIdSets. + * In-place invalidations are included in createdStableIdSets so dependents can + * order around the mutation in both phases. In DROP phase (invert=true), + * dropped IDs are also included. */ export function buildGraphData( phaseChanges: Change[], options: PhaseSortOptions, ): GraphData { + const droppedTableIds = new Set(); + const constraintDropTableIds = new Set(); + if (options.invert) { + for (const changeItem of phaseChanges) { + for (const droppedId of changeItem.drops ?? []) { + if (droppedId.startsWith("table:")) { + droppedTableIds.add(droppedId); + } + } + if (changeItem instanceof AlterTableDropConstraint) { + constraintDropTableIds.add(changeItem.table.stableId); + } + } + } + const createdStableIdSets: Array> = phaseChanges.map( (changeItem) => { const createdIds = new Set(changeItem.creates); + // In-place mutations keep the object identity but invalidate dependents. + // Treat them as producers of the invalidated ids in every phase: drop + // sorting inverts the edge so old dependents drop before the mutation, + // while create/alter sorting keeps new dependents after the mutation. + for (const invalidatedId of changeItem.invalidates) { + createdIds.add(invalidatedId); + } if (options.invert) { for (const droppedId of changeItem.drops ?? []) { createdIds.add(droppedId); } - // In-place mutations keep the object identity but invalidate - // dependents, so for drop-phase ordering they behave like producers of - // the invalidated ids without changing Change.drops. - for (const invalidatedId of changeItem.invalidates) { - createdIds.add(invalidatedId); + if (changeItem instanceof AlterPublicationDropTables) { + const publicationTableIds = changeItem.tables.map((table) => + stableId.table(table.schema, table.name), + ); + const dropsPublicationTable = publicationTableIds.some((tableId) => + droppedTableIds.has(tableId), + ); + for (const table of changeItem.tables) { + const tableId = stableId.table(table.schema, table.name); + if ( + droppedTableIds.has(tableId) || + (dropsPublicationTable && constraintDropTableIds.has(tableId)) + ) { + createdIds.add(tableId); + } + } } } return createdIds; 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..d230a7884 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -2,14 +2,23 @@ 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 { AlterDomainDropDefault } from "../objects/domain/changes/domain.alter.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 { Procedure } from "../objects/procedure/procedure.model.ts"; import { AlterPublicationDropTables } from "../objects/publication/changes/publication.alter.ts"; import { Publication } from "../objects/publication/publication.model.ts"; import { + AlterTableAlterColumnDropDefault, AlterTableAlterColumnType, + AlterTableDropColumn, 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 { CreateEnum } from "../objects/type/enum/changes/enum.create.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"; @@ -58,6 +67,24 @@ function integerColumn(name: string, position: number) { }; } +function enumColumn( + name: string, + position: number, + schema: string, + type: string, +) { + return { + ...integerColumn(name, position), + data_type: "USER-DEFINED", + data_type_str: `${schema}.${type}`, + is_custom_type: true, + custom_type_type: "e", + custom_type_category: "E", + custom_type_schema: schema, + custom_type_name: type, + }; +} + function fkConstraint(props: { name: string; fkColumn: string; @@ -171,6 +198,54 @@ function view(name: string, columns = [integerColumn("id", 1)]) { }); } +function enumType(name: string) { + return new Enum({ + schema: "public", + name, + owner: "postgres", + labels: [ + { sort_order: 1, label: "active" }, + { sort_order: 2, label: "blocked" }, + ], + comment: null, + privileges: [], + }); +} + +function procedureReturningType(returnType: string) { + return new Procedure({ + schema: "public", + name: "account_status", + kind: "f", + return_type: returnType, + return_type_schema: returnType.includes(".") ? "public" : "pg_catalog", + language: "sql", + security_definer: false, + volatility: "s", + parallel_safety: "u", + execution_cost: 100, + result_rows: 0, + is_strict: false, + leakproof: false, + returns_set: false, + argument_count: 0, + argument_default_count: 0, + argument_names: null, + argument_types: [], + all_argument_types: null, + argument_modes: null, + argument_defaults: null, + source_code: "", + binary_path: null, + sql_body: "SELECT status FROM public.accounts WHERE id = 1", + config: null, + definition: `CREATE FUNCTION public.account_status() RETURNS ${returnType} LANGUAGE sql STABLE BEGIN ATOMIC SELECT status FROM public.accounts WHERE id = 1; END`, + 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,9 +253,30 @@ async function catalogWithDepends(depends: PgDepend[]) { } function changeLabel(change: Change) { + if (change instanceof AlterDomainDropDefault) { + return `${change.constructor.name}:${change.domain.name}`; + } + if (change instanceof CreateEnum) { + return `${change.constructor.name}:${change.enum.stableId}`; + } + if (change instanceof CreateProcedure || change instanceof DropProcedure) { + return `${change.constructor.name}:${change.procedure.stableId}`; + } + if (change instanceof AlterTableAlterColumnType) { + return `${change.constructor.name}:${change.table.name}.${change.column.name}`; + } + if (change instanceof AlterTableAlterColumnDropDefault) { + return `${change.constructor.name}:${change.table.name}.${change.column.name}`; + } + if (change instanceof AlterTableDropColumn) { + return `${change.constructor.name}:${change.table.name}.${change.column.name}`; + } if (change instanceof AlterTableDropConstraint) { return `${change.constructor.name}:${change.table.name}.${change.constraint.name}`; } + if (change instanceof AlterPublicationDropTables) { + return `${change.constructor.name}:${change.publication.name}`; + } if (change instanceof DropTable) { return `${change.constructor.name}:${change.table.name}`; } @@ -226,11 +322,197 @@ describe("sortChanges", () => { expect(sorted.map(changeLabel)).toEqual([ "DropView", - "AlterTableAlterColumnType", + "AlterTableAlterColumnType:users.age", "CreateView", ]); }); + test("orders same-column generated expression reset before type rewrite without a cycle", async () => { + const branchTable = table("accounts"); + const mainColumn = { + ...integerColumn("status_code", 2), + is_generated: true, + default: "length(status)", + }; + const branchColumn = { + ...integerColumn("status_code", 2), + data_type: "text", + data_type_str: "text", + is_generated: true, + default: "upper(status)", + }; + const changes: Change[] = [ + new AlterTableAlterColumnDropDefault({ + table: branchTable, + column: branchColumn, + previousColumn: mainColumn, + }), + new AlterTableAlterColumnType({ + table: branchTable, + column: branchColumn, + previousColumn: mainColumn, + }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "AlterTableAlterColumnDropDefault:accounts.status_code", + "AlterTableAlterColumnType:accounts.status_code", + ]); + }); + + test("orders domain default drop before dropping the referenced routine", async () => { + const domain = new Domain({ + schema: "public", + name: "account_label", + base_type: "text", + base_type_schema: "pg_catalog", + base_type_str: "text", + not_null: false, + type_modifier: null, + array_dimensions: null, + collation: null, + default_bin: "public.account_status()", + default_value: "public.account_status()", + owner: "postgres", + comment: null, + constraints: [], + privileges: [], + security_labels: [], + }); + const procedure = procedureReturningType("text"); + const changes: Change[] = [ + new DropProcedure({ procedure }), + new AlterDomainDropDefault({ domain }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: domain.stableId, + referenced_stable_id: procedure.stableId, + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "AlterDomainDropDefault:account_label", + `DropProcedure:${procedure.stableId}`, + ]); + }); + + test("orders routine recreation after custom column type rewrite", async () => { + const accountStatusType = enumType("account_status"); + const branchTable = table("accounts"); + const mainColumn = { + ...integerColumn("status", 4), + data_type: "text", + data_type_str: "text", + }; + const branchColumn = enumColumn("status", 4, "public", "account_status"); + const mainProcedure = procedureReturningType("text"); + const branchProcedure = procedureReturningType("public.account_status"); + const changes: Change[] = [ + new CreateEnum({ enum: accountStatusType }), + new AlterTableAlterColumnType({ + table: branchTable, + column: branchColumn, + previousColumn: mainColumn, + }), + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: mainProcedure.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([ + { + dependent_stable_id: branchProcedure.stableId, + referenced_stable_id: "column:public.accounts.status", + deptype: "n", + }, + ]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + `DropProcedure:${mainProcedure.stableId}`, + `CreateEnum:${accountStatusType.stableId}`, + "AlterTableAlterColumnType:accounts.status", + `CreateProcedure:${branchProcedure.stableId}`, + ]); + }); + + test("orders publication table removal before generated column drop on the same table", async () => { + const accounts = new Table({ + ...baseTableProps, + name: "accounts", + columns: [ + { ...integerColumn("id", 1), not_null: true }, + { + ...integerColumn("status_label", 2), + data_type: "text", + data_type_str: "text", + is_generated: true, + default: "upper(id::text)", + }, + ], + }); + const publication = new Publication({ + name: "pub_accounts", + 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: "accounts", + columns: ["id", "status_label"], + row_filter: null, + }, + ], + schemas: [], + }); + const changes: Change[] = [ + new AlterTableDropColumn({ + table: accounts, + column: accounts.columns[1], + }), + new AlterPublicationDropTables({ + publication, + tables: publication.tables, + }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: publication.stableId, + referenced_stable_id: "column:public.accounts.status_label", + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "AlterPublicationDropTables:pub_accounts", + "AlterTableDropColumn:accounts.status_label", + ]); + }); + test("breaks publication FK-chain constraint-drop cycle with one dropped table", async () => { const labs = table("labs", [uniqueConstraint("unique_lab_id", "id")]); const posts = table("posts", [ 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/aggregate-operations.test.ts b/packages/pg-delta/tests/integration/aggregate-operations.test.ts index 02d874c5a..9a57aaca8 100644 --- a/packages/pg-delta/tests/integration/aggregate-operations.test.ts +++ b/packages/pg-delta/tests/integration/aggregate-operations.test.ts @@ -2,7 +2,7 @@ * Integration tests for PostgreSQL aggregate operations. */ -import { describe, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import dedent from "dedent"; import type { Change } from "../../src/core/change.types.ts"; import { POSTGRES_VERSIONS } from "../constants.ts"; @@ -75,6 +75,195 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "aggregate depending on replaced transition function is recreated around the function", + withDbIsolated(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.amount_transition(state bigint, value integer) + RETURNS bigint + LANGUAGE sql + IMMUTABLE + AS $$ SELECT state + value $$; + + CREATE AGGREGATE test_schema.total_amount(integer) + ( + SFUNC = test_schema.amount_transition, + STYPE = bigint, + INITCOND = '0' + ); + `, + testSql: dedent` + DROP AGGREGATE test_schema.total_amount(integer); + DROP FUNCTION test_schema.amount_transition(bigint, integer); + + CREATE FUNCTION test_schema.amount_transition(state numeric, value integer) + RETURNS numeric + LANGUAGE sql + IMMUTABLE + AS $$ SELECT state + value $$; + + CREATE AGGREGATE test_schema.total_amount(integer) + ( + SFUNC = test_schema.amount_transition, + STYPE = numeric, + INITCOND = '0' + ); + `, + }); + }), + ); + + test( + "view depending on old aggregate signature is recreated before aggregate replacement", + withDbIsolated(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE TABLE test_schema.aggregate_events ( + amount integer NOT NULL + ); + + CREATE FUNCTION test_schema.amount_transition(state bigint, value integer) + RETURNS bigint + LANGUAGE sql + IMMUTABLE + AS $$ SELECT state + value $$; + + CREATE AGGREGATE test_schema.total_status(integer) + ( + SFUNC = test_schema.amount_transition, + STYPE = bigint, + INITCOND = '0' + ); + + CREATE VIEW test_schema.total_status_view AS + SELECT test_schema.total_status(amount) AS total + FROM test_schema.aggregate_events; + `, + testSql: dedent` + DROP VIEW test_schema.total_status_view; + DROP AGGREGATE test_schema.total_status(integer); + + CREATE FUNCTION test_schema.amount_transition(state bigint, value numeric) + RETURNS bigint + LANGUAGE sql + IMMUTABLE + AS $$ SELECT state + value::bigint $$; + + CREATE AGGREGATE test_schema.total_status(numeric) + ( + SFUNC = test_schema.amount_transition, + STYPE = bigint, + INITCOND = '0' + ); + + CREATE VIEW test_schema.total_status_view AS + SELECT test_schema.total_status(amount::numeric) AS total + FROM test_schema.aggregate_events; + `, + assertSqlStatements: (sqlStatements) => { + const dropViewIndex = sqlStatements.indexOf( + "DROP VIEW test_schema.total_status_view", + ); + const dropAggregateIndex = sqlStatements.indexOf( + "DROP AGGREGATE test_schema.total_status(integer)", + ); + const createAggregateIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE AGGREGATE test_schema.total_status(numeric)", + ), + ); + const createViewIndex = sqlStatements.findIndex((statement) => + statement.includes("VIEW test_schema.total_status_view AS"), + ); + + expect(dropViewIndex).toBeGreaterThanOrEqual(0); + expect(dropAggregateIndex).toBeGreaterThan(dropViewIndex); + expect(createAggregateIndex).toBeGreaterThan(dropAggregateIndex); + expect(createViewIndex).toBeGreaterThan(createAggregateIndex); + }, + }); + }), + ); + + test( + "aggregate metadata survives transition function replacement", + withDbIsolated(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + CREATE ROLE aggregate_owner; + CREATE ROLE aggregate_executor; + + CREATE FUNCTION test_schema.amount_metadata_transition(state bigint, value integer) + RETURNS bigint + LANGUAGE sql + IMMUTABLE + AS $$ SELECT state + value $$; + + CREATE AGGREGATE test_schema.total_amount_metadata(integer) + ( + SFUNC = test_schema.amount_metadata_transition, + STYPE = bigint, + INITCOND = '0' + ); + + ALTER AGGREGATE test_schema.total_amount_metadata(integer) + OWNER TO aggregate_owner; + COMMENT ON AGGREGATE test_schema.total_amount_metadata(integer) + IS 'aggregate metadata'; + GRANT EXECUTE ON FUNCTION test_schema.total_amount_metadata(integer) + TO aggregate_executor; + `, + testSql: dedent` + DROP AGGREGATE test_schema.total_amount_metadata(integer); + DROP FUNCTION test_schema.amount_metadata_transition(bigint, integer); + + CREATE FUNCTION test_schema.amount_metadata_transition(state numeric, value integer) + RETURNS numeric + LANGUAGE sql + IMMUTABLE + AS $$ SELECT state + value $$; + + CREATE AGGREGATE test_schema.total_amount_metadata(integer) + ( + SFUNC = test_schema.amount_metadata_transition, + STYPE = numeric, + INITCOND = '0' + ); + + ALTER AGGREGATE test_schema.total_amount_metadata(integer) + OWNER TO aggregate_owner; + COMMENT ON AGGREGATE test_schema.total_amount_metadata(integer) + IS 'aggregate metadata'; + GRANT EXECUTE ON FUNCTION test_schema.total_amount_metadata(integer) + TO aggregate_executor; + `, + assertSqlStatements: (sqlStatements) => { + expect(sqlStatements).toContain( + "ALTER AGGREGATE test_schema.total_amount_metadata(integer) OWNER TO aggregate_owner", + ); + expect(sqlStatements).toContain( + "COMMENT ON AGGREGATE test_schema.total_amount_metadata(integer) IS 'aggregate metadata'", + ); + expect(sqlStatements).toContain( + "GRANT ALL ON FUNCTION test_schema.total_amount_metadata(integer) TO aggregate_executor", + ); + }, + }); + }), + ); + test( "aggregate comment creation", withDb(pgVersion, async (db) => { diff --git a/packages/pg-delta/tests/integration/alter-table-operations.test.ts b/packages/pg-delta/tests/integration/alter-table-operations.test.ts index d8fdf7496..f43b01a45 100644 --- a/packages/pg-delta/tests/integration/alter-table-operations.test.ts +++ b/packages/pg-delta/tests/integration/alter-table-operations.test.ts @@ -107,6 +107,60 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "change column type with check constraint does not replace table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE TABLE public.alter_column_type_check_constraint_accounts ( + id integer PRIMARY KEY, + status text NOT NULL, + CONSTRAINT accounts_status_non_empty CHECK (status <> '') + ); + + INSERT INTO public.alter_column_type_check_constraint_accounts + (id, status) + VALUES + (1, 'active'); + `, + testSql: ` + ALTER TABLE public.alter_column_type_check_constraint_accounts + ALTER COLUMN status TYPE character varying(32); + `, + assertSqlStatements: (sqlStatements) => { + expect( + sqlStatements.some((statement) => + statement.startsWith( + "DROP TABLE public.alter_column_type_check_constraint_accounts", + ), + ), + ).toBe(false); + expect(sqlStatements).toContain( + "ALTER TABLE public.alter_column_type_check_constraint_accounts ALTER COLUMN status TYPE character varying(32) USING status::character varying(32)", + ); + expect( + sqlStatements.some((statement) => + statement.startsWith( + "CREATE TABLE public.alter_column_type_check_constraint_accounts", + ), + ), + ).toBe(false); + }, + }); + + const { rows } = await db.main.query<{ + status: string; + }>(` + SELECT status + FROM public.alter_column_type_check_constraint_accounts + WHERE id = 1 + `); + expect(rows).toEqual([{ status: "active" }]); + }), + ); + test( "change column type after dropping dependent view", withDb(pgVersion, async (db) => { @@ -488,6 +542,515 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "alter referenced column type rebuilds constrained generated expression", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.generated_status ( + id integer NOT NULL, + status text NOT NULL, + status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL + ); + + INSERT INTO test_schema.generated_status (id, status) + VALUES (1, 'active'); + `, + testSql: ` + ALTER TABLE test_schema.generated_status + DROP COLUMN status_label; + ALTER TABLE test_schema.generated_status + ALTER COLUMN status TYPE character varying(32); + ALTER TABLE test_schema.generated_status + ADD COLUMN status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL; + `, + assertSqlStatements: (sqlStatements) => { + expect(sqlStatements).toMatchInlineSnapshot(` + [ + "ALTER TABLE test_schema.generated_status DROP COLUMN status_label", + "ALTER TABLE test_schema.generated_status ALTER COLUMN status TYPE character varying(32) USING status::character varying(32)", + "ALTER TABLE test_schema.generated_status ADD COLUMN status_label text GENERATED ALWAYS AS (upper((status)::text)) STORED NOT NULL", + ] + `); + }, + }); + }), + ); + + test.skipIf(pgVersion >= 17)( + "alter generated column type before postgres 17 rebuilds the column", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.generated_own_type ( + status text NOT NULL, + status_label text GENERATED ALWAYS AS (upper(status)) STORED + ); + + INSERT INTO test_schema.generated_own_type (status) + VALUES ('active'); + `, + testSql: ` + ALTER TABLE test_schema.generated_own_type + DROP COLUMN status_label; + ALTER TABLE test_schema.generated_own_type + ADD COLUMN status_label character varying(64) + GENERATED ALWAYS AS (upper(status)) STORED; + `, + assertSqlStatements: (sqlStatements) => { + expect(sqlStatements).toMatchInlineSnapshot(` + [ + "ALTER TABLE test_schema.generated_own_type DROP COLUMN status_label", + "ALTER TABLE test_schema.generated_own_type ADD COLUMN status_label character varying(64) GENERATED ALWAYS AS (upper(status)) STORED", + ] + `); + }, + }); + }), + ); + + test.skipIf(pgVersion < 17)( + "postgres 17 rebuilds not-null generated column for own type changes", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.generated_own_type_not_null ( + status text NOT NULL, + status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL + ); + + INSERT INTO test_schema.generated_own_type_not_null (status) + VALUES ('active'); + `, + testSql: ` + ALTER TABLE test_schema.generated_own_type_not_null + DROP COLUMN status_label; + ALTER TABLE test_schema.generated_own_type_not_null + ADD COLUMN status_label character varying(64) + GENERATED ALWAYS AS (upper(status)) STORED NOT NULL; + `, + assertSqlStatements: (sqlStatements) => { + expect(sqlStatements).toMatchInlineSnapshot(` + [ + "ALTER TABLE test_schema.generated_own_type_not_null DROP COLUMN status_label", + "ALTER TABLE test_schema.generated_own_type_not_null ADD COLUMN status_label character varying(64) GENERATED ALWAYS AS (upper(status)) STORED NOT NULL", + ] + `); + }, + }); + }), + ); + + test.skipIf(pgVersion < 17)( + "postgres 17 resets generated columns with the current type for incompatible own type changes", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.generated_incompatible_own_type ( + status text NOT NULL, + status_code integer GENERATED ALWAYS AS (length(status)) STORED + ); + + INSERT INTO test_schema.generated_incompatible_own_type (status) + VALUES ('active'); + `, + testSql: ` + ALTER TABLE test_schema.generated_incompatible_own_type + DROP COLUMN status_code; + ALTER TABLE test_schema.generated_incompatible_own_type + ADD COLUMN status_code text + GENERATED ALWAYS AS (upper(status)) STORED; + `, + assertSqlStatements: (sqlStatements) => { + expect(sqlStatements).toMatchInlineSnapshot(` + [ + "ALTER TABLE test_schema.generated_incompatible_own_type ALTER COLUMN status_code SET EXPRESSION AS (NULL::integer)", + "ALTER TABLE test_schema.generated_incompatible_own_type ALTER COLUMN status_code TYPE text", + "ALTER TABLE test_schema.generated_incompatible_own_type ALTER COLUMN status_code SET EXPRESSION AS (upper(status))", + ] + `); + }, + }); + }), + ); + + test.skipIf(pgVersion < 17)( + "postgres 17 rebuilds generated columns when target type has no assignment cast", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.generated_no_assignment_cast ( + id integer NOT NULL, + status_id integer GENERATED ALWAYS AS (id + 1) STORED + ); + + INSERT INTO test_schema.generated_no_assignment_cast (id) + VALUES (1); + `, + testSql: ` + ALTER TABLE test_schema.generated_no_assignment_cast + DROP COLUMN status_id; + ALTER TABLE test_schema.generated_no_assignment_cast + ADD COLUMN status_id uuid + GENERATED ALWAYS AS ('00000000-0000-0000-0000-000000000001'::uuid) STORED; + `, + assertSqlStatements: (sqlStatements) => { + expect(sqlStatements).toContain( + "ALTER TABLE test_schema.generated_no_assignment_cast DROP COLUMN status_id", + ); + expect(sqlStatements).toContain( + "ALTER TABLE test_schema.generated_no_assignment_cast ADD COLUMN status_id uuid GENERATED ALWAYS AS ('00000000-0000-0000-0000-000000000001'::uuid) STORED", + ); + expect( + sqlStatements.some((statement) => + statement.includes("ALTER COLUMN status_id TYPE uuid"), + ), + ).toBe(false); + }, + }); + }), + ); + + test.skipIf(pgVersion < 17)( + "postgres 17 rebuilds generated columns when a domain type rejects NULL resets", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA test_schema; + CREATE DOMAIN test_schema.nonempty_text AS text NOT NULL; + CREATE TABLE test_schema.generated_domain_type ( + status text NOT NULL, + status_label text GENERATED ALWAYS AS (status::text) STORED + ); + + INSERT INTO test_schema.generated_domain_type (status) + VALUES ('active'); + `, + testSql: ` + ALTER TABLE test_schema.generated_domain_type + DROP COLUMN status_label; + ALTER TABLE test_schema.generated_domain_type + ADD COLUMN status_label test_schema.nonempty_text + GENERATED ALWAYS AS (status::test_schema.nonempty_text) STORED; + `, + assertSqlStatements: (sqlStatements) => { + expect(sqlStatements).toContain( + "ALTER TABLE test_schema.generated_domain_type DROP COLUMN status_label", + ); + expect(sqlStatements).toContain( + "ALTER TABLE test_schema.generated_domain_type ADD COLUMN status_label test_schema.nonempty_text GENERATED ALWAYS AS ((status)::test_schema.nonempty_text) STORED", + ); + expect( + sqlStatements.some((statement) => + statement.includes("ALTER COLUMN status_label TYPE"), + ), + ).toBe(false); + }, + }); + }), + ); + + test.skipIf(pgVersion < 17)( + "postgres 17 drops generated column indexes before resetting incompatible expressions", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.generated_index_reset_order ( + status text NOT NULL, + status_code integer GENERATED ALWAYS AS (length(status)) STORED + ); + + CREATE UNIQUE INDEX generated_index_reset_order_status_code_idx + ON test_schema.generated_index_reset_order (status_code) NULLS NOT DISTINCT; + + INSERT INTO test_schema.generated_index_reset_order (status) + VALUES ('a'), ('bb'); + `, + testSql: ` + ALTER TABLE test_schema.generated_index_reset_order + DROP COLUMN status_code; + ALTER TABLE test_schema.generated_index_reset_order + ADD COLUMN status_code text + GENERATED ALWAYS AS (upper(status)) STORED; + CREATE UNIQUE INDEX generated_index_reset_order_status_code_idx + ON test_schema.generated_index_reset_order (status_code) NULLS NOT DISTINCT; + `, + assertSqlStatements: (sqlStatements) => { + const dropIndex = sqlStatements.indexOf( + "DROP INDEX test_schema.generated_index_reset_order_status_code_idx", + ); + const resetGenerated = sqlStatements.indexOf( + "ALTER TABLE test_schema.generated_index_reset_order ALTER COLUMN status_code SET EXPRESSION AS (NULL::integer)", + ); + const recreateIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE UNIQUE INDEX generated_index_reset_order_status_code_idx ON test_schema.generated_index_reset_order", + ), + ); + + expect(dropIndex).toBeGreaterThanOrEqual(0); + expect(resetGenerated).toBeGreaterThan(dropIndex); + expect(recreateIndex).toBeGreaterThan(resetGenerated); + }, + }); + }), + ); + + test.skipIf(pgVersion >= 17)( + "alter generated column type before postgres 17 restores rebuilt column metadata", + withDbIsolated(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA test_schema; + CREATE ROLE generated_own_type_reader; + + CREATE TABLE test_schema.generated_own_type_metadata ( + status text NOT NULL, + status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL, + CONSTRAINT generated_own_type_metadata_status_label_key UNIQUE (status_label), + CONSTRAINT generated_own_type_metadata_status_label_check CHECK (status_label <> '') + ); + + CREATE INDEX generated_own_type_metadata_status_label_idx + ON test_schema.generated_own_type_metadata (status_label) + WHERE status_label <> ''; + + COMMENT ON COLUMN test_schema.generated_own_type_metadata.status_label + IS 'generated status label'; + COMMENT ON INDEX test_schema.generated_own_type_metadata_status_label_idx + IS 'generated status label lookup'; + + GRANT SELECT (status_label) + ON test_schema.generated_own_type_metadata + TO generated_own_type_reader; + `, + testSql: ` + ALTER TABLE test_schema.generated_own_type_metadata + DROP COLUMN status_label; + ALTER TABLE test_schema.generated_own_type_metadata + ADD COLUMN status_label character varying(64) + GENERATED ALWAYS AS (upper(status)) STORED NOT NULL; + ALTER TABLE test_schema.generated_own_type_metadata + ADD CONSTRAINT generated_own_type_metadata_status_label_key UNIQUE (status_label); + ALTER TABLE test_schema.generated_own_type_metadata + ADD CONSTRAINT generated_own_type_metadata_status_label_check CHECK (status_label <> ''); + CREATE INDEX generated_own_type_metadata_status_label_idx + ON test_schema.generated_own_type_metadata (status_label) + WHERE status_label <> ''; + COMMENT ON COLUMN test_schema.generated_own_type_metadata.status_label + IS 'generated status label'; + COMMENT ON INDEX test_schema.generated_own_type_metadata_status_label_idx + IS 'generated status label lookup'; + GRANT SELECT (status_label) + ON test_schema.generated_own_type_metadata + TO generated_own_type_reader; + `, + assertSqlStatements: (sqlStatements) => { + expect(sqlStatements).toContain( + "COMMENT ON COLUMN test_schema.generated_own_type_metadata.status_label IS 'generated status label'", + ); + expect(sqlStatements).toContain( + "COMMENT ON INDEX test_schema.generated_own_type_metadata_status_label_idx IS 'generated status label lookup'", + ); + expect(sqlStatements).toContain( + "GRANT SELECT (status_label) ON test_schema.generated_own_type_metadata TO generated_own_type_reader", + ); + }, + }); + }), + ); + + test( + "alter referenced column type restores generated column dependents", + withDbIsolated(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA test_schema; + CREATE ROLE generated_column_metadata_reader; + + CREATE TABLE test_schema.generated_metadata ( + id integer NOT NULL, + status text NOT NULL, + status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL, + CONSTRAINT generated_metadata_status_label_key UNIQUE (status_label), + CONSTRAINT generated_metadata_status_label_check CHECK (status_label <> '') + ); + + COMMENT ON COLUMN test_schema.generated_metadata.status_label + IS 'generated status label'; + + GRANT SELECT (status_label) + ON test_schema.generated_metadata + TO generated_column_metadata_reader; + + INSERT INTO test_schema.generated_metadata (id, status) + VALUES (1, 'active'); + `, + testSql: ` + ALTER TABLE test_schema.generated_metadata + DROP COLUMN status_label; + ALTER TABLE test_schema.generated_metadata + ALTER COLUMN status TYPE character varying(32); + ALTER TABLE test_schema.generated_metadata + ADD COLUMN status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL; + ALTER TABLE test_schema.generated_metadata + ADD CONSTRAINT generated_metadata_status_label_key UNIQUE (status_label); + ALTER TABLE test_schema.generated_metadata + ADD CONSTRAINT generated_metadata_status_label_check CHECK (status_label <> ''); + COMMENT ON COLUMN test_schema.generated_metadata.status_label + IS 'generated status label'; + GRANT SELECT (status_label) + ON test_schema.generated_metadata + TO generated_column_metadata_reader; + `, + assertSqlStatements: (sqlStatements) => { + expect(sqlStatements).toMatchInlineSnapshot(` + [ + "ALTER TABLE test_schema.generated_metadata DROP CONSTRAINT generated_metadata_status_label_check", + "ALTER TABLE test_schema.generated_metadata DROP CONSTRAINT generated_metadata_status_label_key", + "ALTER TABLE test_schema.generated_metadata DROP COLUMN status_label", + "ALTER TABLE test_schema.generated_metadata ALTER COLUMN status TYPE character varying(32) USING status::character varying(32)", + "ALTER TABLE test_schema.generated_metadata ADD COLUMN status_label text GENERATED ALWAYS AS (upper((status)::text)) STORED NOT NULL", + "COMMENT ON COLUMN test_schema.generated_metadata.status_label IS 'generated status label'", + "ALTER TABLE test_schema.generated_metadata ADD CONSTRAINT generated_metadata_status_label_check CHECK (status_label <> ''::text)", + "ALTER TABLE test_schema.generated_metadata ADD CONSTRAINT generated_metadata_status_label_key UNIQUE (status_label)", + "GRANT SELECT (status_label) ON test_schema.generated_metadata TO generated_column_metadata_reader", + ] + `); + }, + }); + }), + ); + + test( + "alter referenced column type drops cross-table FK before generated column rebuild", + withDbIsolated(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA test_schema; + + CREATE TABLE test_schema.generated_fk_parent ( + status text NOT NULL, + status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL, + CONSTRAINT generated_fk_parent_status_label_key UNIQUE (status_label) + ); + + CREATE TABLE test_schema.generated_fk_child ( + status_label text NOT NULL, + CONSTRAINT generated_fk_child_status_label_fkey + FOREIGN KEY (status_label) + REFERENCES test_schema.generated_fk_parent(status_label) + ); + + INSERT INTO test_schema.generated_fk_parent (status) + VALUES ('active'); + INSERT INTO test_schema.generated_fk_child (status_label) + VALUES ('ACTIVE'); + `, + testSql: ` + ALTER TABLE test_schema.generated_fk_child + DROP CONSTRAINT generated_fk_child_status_label_fkey; + ALTER TABLE test_schema.generated_fk_parent + DROP COLUMN status_label; + ALTER TABLE test_schema.generated_fk_parent + ALTER COLUMN status TYPE character varying(32); + ALTER TABLE test_schema.generated_fk_parent + ADD COLUMN status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL; + ALTER TABLE test_schema.generated_fk_parent + ADD CONSTRAINT generated_fk_parent_status_label_key UNIQUE (status_label); + ALTER TABLE test_schema.generated_fk_child + ADD CONSTRAINT generated_fk_child_status_label_fkey + FOREIGN KEY (status_label) + REFERENCES test_schema.generated_fk_parent(status_label); + `, + assertSqlStatements: (sqlStatements) => { + const dropFkIndex = sqlStatements.indexOf( + "ALTER TABLE test_schema.generated_fk_child DROP CONSTRAINT generated_fk_child_status_label_fkey", + ); + const dropColumnIndex = sqlStatements.indexOf( + "ALTER TABLE test_schema.generated_fk_parent DROP COLUMN status_label", + ); + expect(dropFkIndex).toBeGreaterThanOrEqual(0); + expect(dropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFkIndex).toBeLessThan(dropColumnIndex); + }, + }); + }), + ); + + test( + "alter referenced column type restores generated column dependent indexes", + withDbIsolated(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.generated_index_metadata ( + id integer NOT NULL, + status text NOT NULL, + status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL + ); + + CREATE INDEX generated_index_metadata_status_label_idx + ON test_schema.generated_index_metadata (status_label) + WHERE status_label <> ''; + + INSERT INTO test_schema.generated_index_metadata (id, status) + VALUES (1, 'active'); + `, + testSql: ` + ALTER TABLE test_schema.generated_index_metadata + DROP COLUMN status_label; + ALTER TABLE test_schema.generated_index_metadata + ALTER COLUMN status TYPE character varying(32); + ALTER TABLE test_schema.generated_index_metadata + ADD COLUMN status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL; + CREATE INDEX generated_index_metadata_status_label_idx + ON test_schema.generated_index_metadata (status_label) + WHERE status_label <> ''; + `, + assertSqlStatements: (sqlStatements) => { + expect(sqlStatements).toMatchInlineSnapshot(` + [ + "DROP INDEX test_schema.generated_index_metadata_status_label_idx", + "ALTER TABLE test_schema.generated_index_metadata DROP COLUMN status_label", + "ALTER TABLE test_schema.generated_index_metadata ALTER COLUMN status TYPE character varying(32) USING status::character varying(32)", + "ALTER TABLE test_schema.generated_index_metadata ADD COLUMN status_label text GENERATED ALWAYS AS (upper((status)::text)) STORED NOT NULL", + "CREATE INDEX generated_index_metadata_status_label_idx ON test_schema.generated_index_metadata (status_label) WHERE status_label <> ''::text", + ] + `); + }, + }); + }), + ); + test( "table and column comments", withDb(pgVersion, async (db) => { diff --git a/packages/pg-delta/tests/integration/function-operations.test.ts b/packages/pg-delta/tests/integration/function-operations.test.ts index 8e901888b..864fbbdf6 100644 --- a/packages/pg-delta/tests/integration/function-operations.test.ts +++ b/packages/pg-delta/tests/integration/function-operations.test.ts @@ -156,6 +156,392 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "begin atomic sql function depending on rewritten column is recreated", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE TABLE test_schema.accounts ( + user_id int PRIMARY KEY, + balance int NOT NULL DEFAULT 0 + ); + + CREATE FUNCTION test_schema.total_balance() + RETURNS bigint + LANGUAGE SQL + STABLE + BEGIN ATOMIC + SELECT sum(balance)::bigint FROM test_schema.accounts; + END; + `, + testSql: dedent` + DROP FUNCTION test_schema.total_balance(); + + ALTER TABLE test_schema.accounts + ALTER COLUMN balance TYPE bigint + USING balance::bigint; + + CREATE FUNCTION test_schema.total_balance() + RETURNS bigint + LANGUAGE SQL + STABLE + BEGIN ATOMIC + SELECT sum(balance)::bigint FROM test_schema.accounts; + END; + `, + assertSqlStatements: (statements) => { + const dropIndex = statements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.total_balance"), + ); + const alterIndex = statements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.accounts ALTER COLUMN balance TYPE bigint", + ), + ); + const createIndex = statements.findIndex( + (statement) => + statement.startsWith("CREATE") && + statement.includes("FUNCTION test_schema.total_balance()"), + ); + + expect(dropIndex).toBeGreaterThanOrEqual(0); + expect(alterIndex).toBeGreaterThan(dropIndex); + expect(createIndex).toBeGreaterThan(alterIndex); + }, + }); + }), + ); + + test( + "begin atomic sql function returning rewritten custom column is recreated after the rewrite", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE TABLE test_schema.accounts ( + user_id int PRIMARY KEY, + status text NOT NULL DEFAULT 'active' + ); + + CREATE FUNCTION test_schema.current_account_status() + RETURNS text + LANGUAGE SQL + STABLE + BEGIN ATOMIC + SELECT status FROM test_schema.accounts WHERE user_id = 1; + END; + `, + testSql: dedent` + CREATE TYPE test_schema.account_status AS ENUM ('active', 'blocked'); + + DROP FUNCTION test_schema.current_account_status(); + + ALTER TABLE test_schema.accounts + ALTER COLUMN status DROP DEFAULT; + + ALTER TABLE test_schema.accounts + ALTER COLUMN status TYPE test_schema.account_status + USING status::test_schema.account_status; + + CREATE FUNCTION test_schema.current_account_status() + RETURNS test_schema.account_status + LANGUAGE SQL + STABLE + BEGIN ATOMIC + SELECT status FROM test_schema.accounts WHERE user_id = 1; + END; + `, + assertSqlStatements: (statements) => { + const createTypeIndex = statements.findIndex((statement) => + statement.startsWith( + "CREATE TYPE test_schema.account_status AS ENUM", + ), + ); + const dropIndex = statements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.current_account_status", + ), + ); + const alterIndex = statements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.accounts ALTER COLUMN status TYPE test_schema.account_status", + ), + ); + const createIndex = statements.findIndex( + (statement) => + statement.startsWith("CREATE") && + statement.includes( + "FUNCTION test_schema.current_account_status()", + ), + ); + + expect(dropIndex).toBeGreaterThanOrEqual(0); + expect(createTypeIndex).toBeGreaterThan(dropIndex); + expect(alterIndex).toBeGreaterThan(createTypeIndex); + expect(createIndex).toBeGreaterThan(alterIndex); + }, + }); + }), + ); + + test( + "begin atomic sql function used by a column default is rebuilt with metadata after a column rewrite", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + DO $$ + BEGIN + CREATE ROLE routine_owner; + EXCEPTION WHEN duplicate_object THEN + NULL; + END $$; + DO $$ + BEGIN + CREATE ROLE routine_executor; + EXCEPTION WHEN duplicate_object THEN + NULL; + END $$; + GRANT USAGE, CREATE ON SCHEMA test_schema TO routine_owner; + + CREATE TABLE test_schema.accounts ( + id int PRIMARY KEY, + status text NOT NULL DEFAULT 'active' + ); + + CREATE FUNCTION test_schema.account_status_text() + RETURNS text + LANGUAGE SQL + STABLE + BEGIN ATOMIC + SELECT status::text FROM test_schema.accounts WHERE id = 1; + END; + + ALTER FUNCTION test_schema.account_status_text() OWNER TO routine_owner; + COMMENT ON FUNCTION test_schema.account_status_text() IS 'account status helper'; + GRANT EXECUTE ON FUNCTION test_schema.account_status_text() TO routine_executor; + + CREATE TABLE test_schema.account_audit ( + id int, + label text DEFAULT test_schema.account_status_text() + ); + `, + testSql: dedent` + CREATE TYPE test_schema.account_status AS ENUM ('active', 'blocked'); + + ALTER TABLE test_schema.account_audit + ALTER COLUMN label DROP DEFAULT; + + DROP FUNCTION test_schema.account_status_text(); + + ALTER TABLE test_schema.accounts + ALTER COLUMN status DROP DEFAULT; + + ALTER TABLE test_schema.accounts + ALTER COLUMN status TYPE test_schema.account_status + USING status::test_schema.account_status; + + ALTER TABLE test_schema.accounts + ALTER COLUMN status SET DEFAULT 'active'::test_schema.account_status; + + CREATE FUNCTION test_schema.account_status_text() + RETURNS text + LANGUAGE SQL + STABLE + BEGIN ATOMIC + SELECT status::text FROM test_schema.accounts WHERE id = 1; + END; + + ALTER FUNCTION test_schema.account_status_text() OWNER TO routine_owner; + COMMENT ON FUNCTION test_schema.account_status_text() IS 'account status helper'; + GRANT EXECUTE ON FUNCTION test_schema.account_status_text() TO routine_executor; + + ALTER TABLE test_schema.account_audit + ALTER COLUMN label SET DEFAULT test_schema.account_status_text(); + `, + assertSqlStatements: (statements) => { + const auditDefaultDropIndex = statements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.account_audit ALTER COLUMN label DROP DEFAULT", + ), + ); + const routineDropIndex = statements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.account_status_text", + ), + ); + const accountAlterIndex = statements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.accounts ALTER COLUMN status TYPE test_schema.account_status", + ), + ); + const routineCreateIndex = statements.findIndex( + (statement) => + statement.startsWith("CREATE") && + statement.includes( + "FUNCTION test_schema.account_status_text()", + ), + ); + const routineOwnerIndex = statements.findIndex((statement) => + statement.startsWith( + "ALTER FUNCTION test_schema.account_status_text() OWNER TO routine_owner", + ), + ); + const routineCommentIndex = statements.findIndex((statement) => + statement.startsWith( + "COMMENT ON FUNCTION test_schema.account_status_text() IS 'account status helper'", + ), + ); + const routineGrantIndex = statements.findIndex( + (statement) => + statement.startsWith("GRANT ") && + statement.includes( + " ON FUNCTION test_schema.account_status_text() TO routine_executor", + ), + ); + const auditDefaultSetIndex = statements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.account_audit ALTER COLUMN label SET DEFAULT test_schema.account_status_text()", + ), + ); + + expect(auditDefaultDropIndex).toBeGreaterThanOrEqual(0); + expect(routineDropIndex).toBeGreaterThan(auditDefaultDropIndex); + expect(accountAlterIndex).toBeGreaterThan(routineDropIndex); + expect(routineCreateIndex).toBeGreaterThan(accountAlterIndex); + expect(routineOwnerIndex).toBeGreaterThan(routineCreateIndex); + expect(routineCommentIndex).toBeGreaterThan(routineCreateIndex); + expect(routineGrantIndex).toBeGreaterThan(routineCreateIndex); + expect(auditDefaultSetIndex).toBeGreaterThan(routineCreateIndex); + }, + }); + }), + ); + + test( + "column default depending on replaced function signature is restored around the function", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_amount() + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $$ SELECT 1 $$; + + CREATE TABLE test_schema.orders ( + id integer PRIMARY KEY, + amount integer DEFAULT test_schema.default_amount() + ); + `, + testSql: dedent` + ALTER TABLE test_schema.orders + ALTER COLUMN amount DROP DEFAULT; + + DROP FUNCTION test_schema.default_amount(); + + CREATE FUNCTION test_schema.default_amount(fallback integer DEFAULT 1) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $$ SELECT fallback $$; + + ALTER TABLE test_schema.orders + ALTER COLUMN amount SET DEFAULT test_schema.default_amount(); + `, + }); + }), + ); + + test( + "domain default depending on replaced function signature is restored around the function", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_status() + RETURNS text + LANGUAGE sql + IMMUTABLE + AS $$ SELECT 'active'::text $$; + + CREATE DOMAIN test_schema.status_label AS text + DEFAULT test_schema.default_status(); + + CREATE TABLE test_schema.accounts ( + id integer PRIMARY KEY, + status test_schema.status_label NOT NULL + ); + `, + testSql: dedent` + ALTER DOMAIN test_schema.status_label DROP DEFAULT; + + DROP FUNCTION test_schema.default_status(); + + CREATE FUNCTION test_schema.default_status(fallback text DEFAULT 'active') + RETURNS text + LANGUAGE sql + IMMUTABLE + AS $$ SELECT fallback $$; + + ALTER DOMAIN test_schema.status_label + SET DEFAULT test_schema.default_status(); + `, + assertSqlStatements: (statements) => { + const domainDropDefaultIndex = statements.indexOf( + "ALTER DOMAIN test_schema.status_label DROP DEFAULT", + ); + const domainFunctionDropIndex = statements.indexOf( + "DROP FUNCTION test_schema.default_status()", + ); + const domainFunctionCreateIndex = statements.findIndex( + (statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.default_status(", + ), + ); + const domainSetDefaultIndex = statements.findIndex((statement) => + statement.startsWith( + "ALTER DOMAIN test_schema.status_label SET DEFAULT test_schema.default_status", + ), + ); + + expect(domainDropDefaultIndex).toBeGreaterThanOrEqual(0); + expect(domainFunctionDropIndex).toBeGreaterThan( + domainDropDefaultIndex, + ); + expect(domainFunctionCreateIndex).toBeGreaterThan( + domainFunctionDropIndex, + ); + expect(domainSetDefaultIndex).toBeGreaterThan( + domainFunctionCreateIndex, + ); + expect( + statements.some((statement) => + statement.startsWith("DROP DOMAIN test_schema.status_label"), + ), + ).toBe(false); + }, + }); + }), + ); + test( "function signature: parameter type change", withDb(pgVersion, async (db) => { @@ -348,6 +734,198 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "function signature change cascades through a dependent check constraint", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + CREATE FUNCTION test_schema.is_valid_status(status text) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $$ SELECT status <> '' $$; + + CREATE TABLE test_schema.accounts ( + id integer PRIMARY KEY, + status text NOT NULL, + CONSTRAINT accounts_status_check CHECK ( + test_schema.is_valid_status(status) + ) + ); + + COMMENT ON CONSTRAINT accounts_status_check + ON test_schema.accounts + IS 'status guard'; + `, + testSql: dedent` + ALTER TABLE test_schema.accounts + DROP CONSTRAINT accounts_status_check; + + DROP FUNCTION test_schema.is_valid_status(text); + + CREATE FUNCTION test_schema.is_valid_status( + status text, + expected text DEFAULT 'active' + ) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $$ SELECT status <> '' AND expected <> '' $$; + + ALTER TABLE test_schema.accounts + ADD CONSTRAINT accounts_status_check CHECK ( + test_schema.is_valid_status(status) + ); + + COMMENT ON CONSTRAINT accounts_status_check + ON test_schema.accounts + IS 'status guard'; + `, + assertSqlStatements: (statements) => { + const dropTableIdx = statements.findIndex((statement) => + statement.startsWith("DROP TABLE test_schema.accounts"), + ); + const createTableIdx = statements.findIndex((statement) => + statement.startsWith("CREATE TABLE test_schema.accounts"), + ); + const dropConstraintIdx = statements.findIndex((statement) => + statement.includes( + "ALTER TABLE test_schema.accounts DROP CONSTRAINT accounts_status_check", + ), + ); + const dropFunctionIdx = statements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.is_valid_status(", + ), + ); + const createFunctionIdx = statements.findIndex((statement) => + statement.includes("CREATE FUNCTION test_schema.is_valid_status"), + ); + const addConstraintIdx = statements.findIndex((statement) => + statement.includes( + "ALTER TABLE test_schema.accounts ADD CONSTRAINT accounts_status_check", + ), + ); + const commentConstraintIdx = statements.findIndex((statement) => + statement.includes( + "COMMENT ON CONSTRAINT accounts_status_check ON test_schema.accounts", + ), + ); + + expect(dropTableIdx).toBe(-1); + expect(createTableIdx).toBe(-1); + expect(dropConstraintIdx).toBeGreaterThanOrEqual(0); + expect(dropFunctionIdx).toBeGreaterThan(dropConstraintIdx); + expect(createFunctionIdx).toBeGreaterThan(dropFunctionIdx); + expect(addConstraintIdx).toBeGreaterThan(createFunctionIdx); + expect(commentConstraintIdx).toBeGreaterThan(addConstraintIdx); + }, + }); + }), + ); + + test( + "domain check and default release invalidated routine dependencies", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.accounts ( + id integer PRIMARY KEY, + status text NOT NULL + ); + + CREATE FUNCTION test_schema.account_status_text() + RETURNS text + LANGUAGE sql + STABLE + BEGIN ATOMIC + SELECT status::text FROM test_schema.accounts WHERE id = 1; + END; + + CREATE DOMAIN test_schema.account_label AS text + DEFAULT test_schema.account_status_text() + CONSTRAINT account_label_check + CHECK (VALUE IS DISTINCT FROM test_schema.account_status_text()); + `, + testSql: dedent` + ALTER DOMAIN test_schema.account_label + DROP DEFAULT; + ALTER DOMAIN test_schema.account_label + DROP CONSTRAINT account_label_check; + + DROP FUNCTION test_schema.account_status_text(); + + ALTER TABLE test_schema.accounts + ALTER COLUMN status TYPE character varying(32); + + CREATE FUNCTION test_schema.account_status_text() + RETURNS text + LANGUAGE sql + STABLE + BEGIN ATOMIC + SELECT status::text FROM test_schema.accounts WHERE id = 1; + END; + + ALTER DOMAIN test_schema.account_label + ADD CONSTRAINT account_label_check + CHECK (VALUE IS DISTINCT FROM test_schema.account_status_text()); + ALTER DOMAIN test_schema.account_label + SET DEFAULT test_schema.account_status_text(); + `, + assertSqlStatements: (statements) => { + const dropDefaultIdx = statements.indexOf( + "ALTER DOMAIN test_schema.account_label DROP DEFAULT", + ); + const dropConstraintIdx = statements.indexOf( + "ALTER DOMAIN test_schema.account_label DROP CONSTRAINT account_label_check", + ); + const dropFunctionIdx = statements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.account_status_text(", + ), + ); + const alterColumnIdx = statements.findIndex((statement) => + statement.includes( + "ALTER TABLE test_schema.accounts ALTER COLUMN status TYPE character varying(32)", + ), + ); + const createFunctionIdx = statements.findIndex( + (statement) => + statement.startsWith( + "CREATE OR REPLACE FUNCTION test_schema.account_status_text()", + ) || + statement.startsWith( + "CREATE FUNCTION test_schema.account_status_text()", + ), + ); + const addConstraintIdx = statements.findIndex((statement) => + statement.includes( + "ALTER DOMAIN test_schema.account_label ADD CONSTRAINT account_label_check", + ), + ); + const setDefaultIdx = statements.indexOf( + "ALTER DOMAIN test_schema.account_label SET DEFAULT test_schema.account_status_text()", + ); + + expect(dropDefaultIdx).toBeGreaterThanOrEqual(0); + expect(dropConstraintIdx).toBeGreaterThanOrEqual(0); + expect(dropFunctionIdx).toBeGreaterThan(dropDefaultIdx); + expect(dropFunctionIdx).toBeGreaterThan(dropConstraintIdx); + expect(alterColumnIdx).toBeGreaterThan(dropFunctionIdx); + expect(createFunctionIdx).toBeGreaterThan(alterColumnIdx); + expect(addConstraintIdx).toBeGreaterThan(createFunctionIdx); + expect(setDefaultIdx).toBeGreaterThan(createFunctionIdx); + }, + }); + }), + ); + test( "function overloading", withDb(pgVersion, async (db) => { diff --git a/packages/pg-delta/tests/integration/index-operations.test.ts b/packages/pg-delta/tests/integration/index-operations.test.ts index fb0c4a1d1..8e988732d 100644 --- a/packages/pg-delta/tests/integration/index-operations.test.ts +++ b/packages/pg-delta/tests/integration/index-operations.test.ts @@ -280,5 +280,109 @@ for (const pgVersion of POSTGRES_VERSIONS) { }); }), ); + + test( + "index comment is preserved when a column rewrite rebuilds a dependent partial index", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.accounts ( + id integer, + status text + ); + CREATE INDEX accounts_status_partial_idx + ON test_schema.accounts (id) + WHERE status IS NOT NULL; + COMMENT ON INDEX test_schema.accounts_status_partial_idx IS 'status partial index'; + `, + testSql: ` + ALTER TABLE test_schema.accounts + ALTER COLUMN status TYPE varchar(32); + `, + assertSqlStatements: (statements) => { + const dropIndex = statements.findIndex((statement) => + statement.startsWith( + "DROP INDEX test_schema.accounts_status_partial_idx", + ), + ); + const alterIndex = statements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.accounts ALTER COLUMN status TYPE character varying(32)", + ), + ); + const createIndex = statements.findIndex((statement) => + statement.startsWith( + "CREATE INDEX accounts_status_partial_idx ON test_schema.accounts", + ), + ); + const commentIndex = statements.findIndex((statement) => + statement.startsWith( + "COMMENT ON INDEX test_schema.accounts_status_partial_idx IS 'status partial index'", + ), + ); + + expect(dropIndex).toBeGreaterThanOrEqual(0); + expect(alterIndex).toBeGreaterThan(dropIndex); + expect(createIndex).toBeGreaterThan(alterIndex); + expect(commentIndex).toBeGreaterThan(createIndex); + }, + }); + }), + ); + + test( + "cluster marker is preserved when a column rewrite rebuilds a dependent index", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.clustered_accounts ( + id integer, + status text + ); + CREATE INDEX clustered_accounts_status_idx + ON test_schema.clustered_accounts (status); + ALTER TABLE test_schema.clustered_accounts + CLUSTER ON clustered_accounts_status_idx; + `, + testSql: ` + ALTER TABLE test_schema.clustered_accounts + ALTER COLUMN status TYPE varchar(32); + `, + assertSqlStatements: (statements) => { + const dropIndex = statements.findIndex((statement) => + statement.startsWith( + "DROP INDEX test_schema.clustered_accounts_status_idx", + ), + ); + const alterColumn = statements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.clustered_accounts ALTER COLUMN status TYPE character varying(32)", + ), + ); + const createIndex = statements.findIndex((statement) => + statement.startsWith( + "CREATE INDEX clustered_accounts_status_idx ON test_schema.clustered_accounts", + ), + ); + const clusterIndex = statements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.clustered_accounts CLUSTER ON clustered_accounts_status_idx", + ), + ); + + expect(dropIndex).toBeGreaterThanOrEqual(0); + expect(alterColumn).toBeGreaterThan(dropIndex); + expect(createIndex).toBeGreaterThan(alterColumn); + expect(clusterIndex).toBeGreaterThan(createIndex); + }, + }); + }), + ); }); } 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..30a12e823 100644 --- a/packages/pg-delta/tests/integration/materialized-view-operations.test.ts +++ b/packages/pg-delta/tests/integration/materialized-view-operations.test.ts @@ -278,6 +278,114 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "restore materialized view index when replacing for column type rewrite", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.users ( + id integer PRIMARY KEY, + age numeric + ); + + CREATE MATERIALIZED VIEW test_schema.user_ages AS + SELECT id, age + FROM test_schema.users + WHERE age > 0 + WITH NO DATA; + + CREATE INDEX user_ages_constant_idx + ON test_schema.user_ages ((1)); + + ALTER INDEX test_schema.user_ages_constant_idx + ALTER COLUMN 1 SET STATISTICS 250; + + ALTER MATERIALIZED VIEW test_schema.user_ages + CLUSTER ON user_ages_constant_idx; + + COMMENT ON INDEX test_schema.user_ages_constant_idx + IS 'user ages matview index'; + `, + testSql: dedent` + DROP MATERIALIZED VIEW test_schema.user_ages; + + ALTER TABLE test_schema.users + ALTER COLUMN age TYPE integer USING age::integer; + + CREATE MATERIALIZED VIEW test_schema.user_ages AS + SELECT id, age + FROM test_schema.users + WHERE age > 0 + WITH NO DATA; + + CREATE INDEX user_ages_constant_idx + ON test_schema.user_ages ((1)); + + ALTER INDEX test_schema.user_ages_constant_idx + ALTER COLUMN 1 SET STATISTICS 250; + + ALTER MATERIALIZED VIEW test_schema.user_ages + CLUSTER ON user_ages_constant_idx; + + COMMENT ON INDEX test_schema.user_ages_constant_idx + IS 'user ages matview index'; + `, + assertSqlStatements: (statements) => { + const dropMatviewIdx = statements.findIndex((statement) => + statement.includes( + "DROP MATERIALIZED VIEW test_schema.user_ages", + ), + ); + const alterColumnIdx = statements.findIndex((statement) => + statement.includes( + "ALTER TABLE test_schema.users ALTER COLUMN age TYPE integer", + ), + ); + const createMatviewIdx = statements.findIndex((statement) => + statement.includes( + "CREATE MATERIALIZED VIEW test_schema.user_ages", + ), + ); + const createIndexIdx = statements.findIndex((statement) => + statement.includes("CREATE INDEX user_ages_constant_idx"), + ); + const commentIndexIdx = statements.findIndex((statement) => + statement.includes( + "COMMENT ON INDEX test_schema.user_ages_constant_idx", + ), + ); + const setStatisticsIdx = statements.findIndex((statement) => + statement.includes( + "ALTER INDEX test_schema.user_ages_constant_idx ALTER COLUMN 1 SET STATISTICS 250", + ), + ); + const clusterIndexIdx = statements.findIndex((statement) => + statement.includes( + "ALTER MATERIALIZED VIEW test_schema.user_ages CLUSTER ON user_ages_constant_idx", + ), + ); + + expect(dropMatviewIdx).toBeGreaterThanOrEqual(0); + expect(alterColumnIdx).toBeGreaterThanOrEqual(0); + expect(createMatviewIdx).toBeGreaterThanOrEqual(0); + expect(createIndexIdx).toBeGreaterThanOrEqual(0); + expect(setStatisticsIdx).toBeGreaterThanOrEqual(0); + expect(clusterIndexIdx).toBeGreaterThanOrEqual(0); + expect(commentIndexIdx).toBeGreaterThanOrEqual(0); + expect(dropMatviewIdx).toBeLessThan(alterColumnIdx); + expect(alterColumnIdx).toBeLessThan(createMatviewIdx); + expect(createMatviewIdx).toBeLessThan(createIndexIdx); + expect(createIndexIdx).toBeLessThan(setStatisticsIdx); + expect(createIndexIdx).toBeLessThan(clusterIndexIdx); + expect(createIndexIdx).toBeLessThan(commentIndexIdx); + }, + }); + }), + ); + test( "materialized view with aggregations", withDb(pgVersion, async (db) => { diff --git a/packages/pg-delta/tests/integration/publication-operations.test.ts b/packages/pg-delta/tests/integration/publication-operations.test.ts index e4254d91c..42219f300 100644 --- a/packages/pg-delta/tests/integration/publication-operations.test.ts +++ b/packages/pg-delta/tests/integration/publication-operations.test.ts @@ -29,6 +29,92 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "publication row filter depending on rewritten column is recreated around ALTER COLUMN TYPE", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA pub_test; + CREATE TABLE pub_test.filtered_accounts ( + id integer NOT NULL, + status text NOT NULL, + amount integer + ); + + CREATE PUBLICATION pub_filtered_accounts + FOR TABLE pub_test.filtered_accounts + WHERE (status = 'active'); + `, + testSql: ` + ALTER PUBLICATION pub_filtered_accounts + DROP TABLE pub_test.filtered_accounts; + ALTER TABLE pub_test.filtered_accounts + ALTER COLUMN status TYPE character varying(32); + ALTER PUBLICATION pub_filtered_accounts + ADD TABLE pub_test.filtered_accounts + WHERE ((status)::text = 'active'::text); + `, + assertSqlStatements: (sqlStatements) => { + expect(sqlStatements).toMatchInlineSnapshot(` + [ + "ALTER PUBLICATION pub_filtered_accounts DROP TABLE pub_test.filtered_accounts", + "ALTER TABLE pub_test.filtered_accounts ALTER COLUMN status TYPE character varying(32) USING status::character varying(32)", + "ALTER PUBLICATION pub_filtered_accounts ADD TABLE pub_test.filtered_accounts WHERE ((status)::text = 'active'::text)", + ] + `); + }, + }); + }), + ); + + test( + "publication depending on rebuilt generated column is dropped before the column", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA pub_test; + CREATE TABLE pub_test.generated_accounts ( + id integer NOT NULL, + status text NOT NULL, + status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL + ); + + CREATE PUBLICATION pub_generated_accounts + FOR TABLE pub_test.generated_accounts + WHERE (status_label = 'ACTIVE'); + `, + testSql: ` + ALTER PUBLICATION pub_generated_accounts + DROP TABLE pub_test.generated_accounts; + ALTER TABLE pub_test.generated_accounts + DROP COLUMN status_label; + ALTER TABLE pub_test.generated_accounts + ALTER COLUMN status TYPE character varying(32); + ALTER TABLE pub_test.generated_accounts + ADD COLUMN status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL; + ALTER PUBLICATION pub_generated_accounts + ADD TABLE pub_test.generated_accounts + WHERE (status_label = 'ACTIVE'::text); + `, + assertSqlStatements: (sqlStatements) => { + const dropPublicationIndex = sqlStatements.indexOf( + "ALTER PUBLICATION pub_generated_accounts DROP TABLE pub_test.generated_accounts", + ); + const dropColumnIndex = sqlStatements.indexOf( + "ALTER TABLE pub_test.generated_accounts DROP COLUMN status_label", + ); + expect(dropPublicationIndex).toBeGreaterThanOrEqual(0); + expect(dropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropPublicationIndex).toBeLessThan(dropColumnIndex); + }, + }); + }), + ); + test( "create publication for tables in schema", withDb(pgVersion, async (db) => { @@ -148,6 +234,32 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "remove table from publication without rebuilding surviving dependents", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + CREATE SCHEMA pub_test; + CREATE TABLE pub_test.accounts (id integer PRIMARY KEY); + CREATE VIEW pub_test.account_ids AS SELECT id FROM pub_test.accounts; + CREATE PUBLICATION pub_drop_member FOR TABLE pub_test.accounts; + `, + testSql: ` + ALTER PUBLICATION pub_drop_member DROP TABLE pub_test.accounts; + `, + assertSqlStatements: (statements) => { + expect(statements).toMatchInlineSnapshot(` + [ + "ALTER PUBLICATION pub_drop_member DROP TABLE pub_test.accounts", + ] + `); + }, + }); + }), + ); + test( "alter publication schema list", withDb(pgVersion, async (db) => { diff --git a/packages/pg-delta/tests/integration/rule-operations.test.ts b/packages/pg-delta/tests/integration/rule-operations.test.ts index 817f9a571..fe582333f 100644 --- a/packages/pg-delta/tests/integration/rule-operations.test.ts +++ b/packages/pg-delta/tests/integration/rule-operations.test.ts @@ -195,5 +195,85 @@ for (const pgVersion of POSTGRES_VERSIONS) { }); }), ); + + test( + "rule depending on rewritten column is recreated around ALTER COLUMN TYPE", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.accounts ( + id integer PRIMARY KEY, + status text NOT NULL + ); + + CREATE RULE block_blocked_accounts AS + ON INSERT TO test_schema.accounts + WHERE NEW.status = 'blocked' + DO INSTEAD NOTHING; + `, + testSql: dedent` + CREATE TYPE test_schema.account_status AS ENUM ('active', 'blocked'); + + DROP RULE block_blocked_accounts ON test_schema.accounts; + + ALTER TABLE test_schema.accounts + ALTER COLUMN status TYPE test_schema.account_status + USING status::test_schema.account_status; + + CREATE RULE block_blocked_accounts AS + ON INSERT TO test_schema.accounts + WHERE NEW.status = 'blocked'::test_schema.account_status + DO INSTEAD NOTHING; + `, + }); + }), + ); + + test( + "rule depending on replaced function signature is recreated around the function", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.is_valid_amount(value integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $$ SELECT value > 0 $$; + + CREATE TABLE test_schema.items ( + id integer PRIMARY KEY, + amount integer NOT NULL + ); + + CREATE RULE block_invalid_amount AS + ON INSERT TO test_schema.items + WHERE NOT test_schema.is_valid_amount(NEW.amount) + DO INSTEAD NOTHING; + `, + testSql: dedent` + DROP RULE block_invalid_amount ON test_schema.items; + DROP FUNCTION test_schema.is_valid_amount(integer); + + CREATE FUNCTION test_schema.is_valid_amount(value bigint) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $$ SELECT value > 0 $$; + + CREATE RULE block_invalid_amount AS + ON INSERT TO test_schema.items + WHERE NOT test_schema.is_valid_amount(NEW.amount::bigint) + DO INSTEAD NOTHING; + `, + }); + }), + ); }); } diff --git a/packages/pg-delta/tests/integration/table-function-dependency-ordering.test.ts b/packages/pg-delta/tests/integration/table-function-dependency-ordering.test.ts index c2d653a49..1384f850e 100644 --- a/packages/pg-delta/tests/integration/table-function-dependency-ordering.test.ts +++ b/packages/pg-delta/tests/integration/table-function-dependency-ordering.test.ts @@ -6,7 +6,7 @@ * 2. Tables with function-based defaults need functions to exist first (handled by refinement) */ -import { describe, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import dedent from "dedent"; import { POSTGRES_VERSIONS } from "../constants.ts"; import { withDb } from "../utils.ts"; @@ -66,5 +66,146 @@ for (const pgVersion of POSTGRES_VERSIONS) { }); }), ); + + test( + "aggregate depending on invalidated SQL function is rebuilt before the function", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA routine_rebuild; + CREATE TABLE routine_rebuild.accounts ( + id integer PRIMARY KEY, + status text NOT NULL + ); + + CREATE FUNCTION routine_rebuild.status_len_state(state integer, account_id integer) + RETURNS integer + LANGUAGE sql + STABLE + BEGIN ATOMIC + SELECT state + length(status::text) + FROM routine_rebuild.accounts + WHERE id = account_id; + END; + + CREATE AGGREGATE routine_rebuild.status_len_sum(integer) ( + SFUNC = routine_rebuild.status_len_state, + STYPE = integer, + INITCOND = '0' + ); + `, + testSql: dedent` + DROP AGGREGATE routine_rebuild.status_len_sum(integer); + DROP FUNCTION routine_rebuild.status_len_state(integer, integer); + ALTER TABLE routine_rebuild.accounts + ALTER COLUMN status TYPE varchar(32); + + CREATE FUNCTION routine_rebuild.status_len_state(state integer, account_id integer) + RETURNS integer + LANGUAGE sql + STABLE + BEGIN ATOMIC + SELECT state + length(status::text) + FROM routine_rebuild.accounts + WHERE id = account_id; + END; + + CREATE AGGREGATE routine_rebuild.status_len_sum(integer) ( + SFUNC = routine_rebuild.status_len_state, + STYPE = integer, + INITCOND = '0' + ); + `, + assertSqlStatements: (statements) => { + const dropAggregateIndex = statements.findIndex((statement) => + statement.startsWith( + "DROP AGGREGATE routine_rebuild.status_len_sum(integer)", + ), + ); + const dropFunctionIndex = statements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION routine_rebuild.status_len_state(", + ), + ); + + expect(dropAggregateIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThanOrEqual(0); + expect(dropAggregateIndex).toBeLessThan(dropFunctionIndex); + }, + }); + }), + ); + + test( + "constraint depending on invalidated SQL function is dropped before the function", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA routine_rebuild_constraint; + CREATE TABLE routine_rebuild_constraint.accounts ( + id integer PRIMARY KEY, + status text NOT NULL + ); + + CREATE FUNCTION routine_rebuild_constraint.account_status_is_open(account_id integer) + RETURNS boolean + LANGUAGE sql + STABLE + BEGIN ATOMIC + SELECT status::text = 'open' + FROM routine_rebuild_constraint.accounts + WHERE id = account_id; + END; + + CREATE TABLE routine_rebuild_constraint.account_events ( + account_id integer NOT NULL, + CONSTRAINT account_events_status_check + CHECK (routine_rebuild_constraint.account_status_is_open(account_id)) + ); + `, + testSql: dedent` + ALTER TABLE routine_rebuild_constraint.account_events + DROP CONSTRAINT account_events_status_check; + DROP FUNCTION routine_rebuild_constraint.account_status_is_open(integer); + ALTER TABLE routine_rebuild_constraint.accounts + ALTER COLUMN status TYPE varchar(32); + + CREATE FUNCTION routine_rebuild_constraint.account_status_is_open(account_id integer) + RETURNS boolean + LANGUAGE sql + STABLE + BEGIN ATOMIC + SELECT status::text = 'open' + FROM routine_rebuild_constraint.accounts + WHERE id = account_id; + END; + + ALTER TABLE routine_rebuild_constraint.account_events + ADD CONSTRAINT account_events_status_check + CHECK (routine_rebuild_constraint.account_status_is_open(account_id)); + `, + assertSqlStatements: (statements) => { + const dropConstraintIndex = statements.findIndex( + (statement) => + statement === + "ALTER TABLE routine_rebuild_constraint.account_events DROP CONSTRAINT account_events_status_check", + ); + const dropFunctionIndex = statements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION routine_rebuild_constraint.account_status_is_open(", + ), + ); + + expect(dropConstraintIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThanOrEqual(0); + expect(dropConstraintIndex).toBeLessThan(dropFunctionIndex); + }, + }); + }), + ); }); } diff --git a/packages/pg-delta/tests/integration/table-operations.test.ts b/packages/pg-delta/tests/integration/table-operations.test.ts index 60f1818d1..06d875477 100644 --- a/packages/pg-delta/tests/integration/table-operations.test.ts +++ b/packages/pg-delta/tests/integration/table-operations.test.ts @@ -249,6 +249,243 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "regular column rebuilt as generated restores column metadata", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA generated_metadata; + DO $$ + BEGIN + CREATE ROLE generated_metadata_reader; + EXCEPTION WHEN duplicate_object THEN + NULL; + END $$; + CREATE TABLE generated_metadata.accounts ( + id integer PRIMARY KEY, + status text NOT NULL, + status_label text + ); + COMMENT ON COLUMN generated_metadata.accounts.status_label IS 'status label'; + GRANT SELECT (status_label) + ON TABLE generated_metadata.accounts + TO generated_metadata_reader; + `, + testSql: dedent` + ALTER TABLE generated_metadata.accounts DROP COLUMN status_label; + ALTER TABLE generated_metadata.accounts + ADD COLUMN status_label text GENERATED ALWAYS AS (upper(status)) STORED; + COMMENT ON COLUMN generated_metadata.accounts.status_label IS 'status label'; + GRANT SELECT (status_label) + ON TABLE generated_metadata.accounts + TO generated_metadata_reader; + `, + assertSqlStatements: (statements) => { + const addColumnIndex = statements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE generated_metadata.accounts ADD COLUMN status_label text GENERATED ALWAYS AS", + ), + ); + const commentIndex = statements.indexOf( + "COMMENT ON COLUMN generated_metadata.accounts.status_label IS 'status label'", + ); + const grantIndex = statements.findIndex((statement) => + statement.startsWith( + "GRANT SELECT (status_label) ON generated_metadata.accounts TO generated_metadata_reader", + ), + ); + + expect(addColumnIndex).toBeGreaterThanOrEqual(0); + expect(commentIndex).toBeGreaterThan(addColumnIndex); + expect(grantIndex).toBeGreaterThan(addColumnIndex); + }, + }); + }), + ); + + test.skipIf(pgVersion < 17)( + "postgres 17 generated column type change rebuilds constrained columns", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA generated_pg17; + CREATE TABLE generated_pg17.accounts ( + status text NOT NULL, + status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL, + CONSTRAINT accounts_status_label_check CHECK (status_label <> '') + ); + `, + testSql: dedent` + ALTER TABLE generated_pg17.accounts DROP CONSTRAINT accounts_status_label_check; + ALTER TABLE generated_pg17.accounts DROP COLUMN status_label; + ALTER TABLE generated_pg17.accounts + ADD COLUMN status_label varchar(64) GENERATED ALWAYS AS (upper(status)) STORED NOT NULL; + ALTER TABLE generated_pg17.accounts + ADD CONSTRAINT accounts_status_label_check CHECK (status_label <> ''); + `, + assertSqlStatements: (statements) => { + expect(statements).toContain( + "ALTER TABLE generated_pg17.accounts DROP COLUMN status_label", + ); + expect(statements).toContain( + "ALTER TABLE generated_pg17.accounts ADD COLUMN status_label character varying(64) GENERATED ALWAYS AS (upper(status)) STORED NOT NULL", + ); + expect( + statements.some((statement) => + statement.startsWith( + "ALTER TABLE generated_pg17.accounts ADD CONSTRAINT accounts_status_label_check CHECK", + ), + ), + ).toBe(true); + expect( + statements.some((statement) => + statement.includes("ALTER COLUMN status_label DROP DEFAULT"), + ), + ).toBe(false); + expect( + statements.some((statement) => + statement.includes("ALTER COLUMN status_label TYPE"), + ), + ).toBe(false); + }, + }); + }), + ); + + test.skipIf(pgVersion < 17)( + "postgres 17 generated column dependency rebuild removes superseded type alters", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA generated_dep_pg17; + CREATE TABLE generated_dep_pg17.accounts ( + status text NOT NULL, + status_label text GENERATED ALWAYS AS (upper(status)) STORED NOT NULL + ); + `, + testSql: dedent` + ALTER TABLE generated_dep_pg17.accounts DROP COLUMN status_label; + ALTER TABLE generated_dep_pg17.accounts + ALTER COLUMN status TYPE varchar(32) USING status::varchar(32); + ALTER TABLE generated_dep_pg17.accounts + ADD COLUMN status_label varchar(64) GENERATED ALWAYS AS (upper(status)) STORED NOT NULL; + `, + assertSqlStatements: (statements) => { + expect(statements).toContain( + "ALTER TABLE generated_dep_pg17.accounts DROP COLUMN status_label", + ); + expect( + statements.some((statement) => + statement.startsWith( + "ALTER TABLE generated_dep_pg17.accounts ADD COLUMN status_label character varying(64) GENERATED ALWAYS AS", + ), + ), + ).toBe(true); + expect( + statements.some((statement) => + statement.includes("STORED NOT NULL"), + ), + ).toBe(true); + expect( + statements.some((statement) => + statement.includes("upper((status)::text)"), + ), + ).toBe(true); + expect( + statements.some((statement) => + statement.includes("ALTER COLUMN status_label TYPE"), + ), + ).toBe(false); + }, + }); + }), + ); + + test( + "rebuilds FK-backed standalone indexes by replacing dependent foreign keys", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA fk_index_regression; + CREATE TABLE fk_index_regression.accounts ( + status text NOT NULL + ); + CREATE UNIQUE INDEX accounts_status_key_idx + ON fk_index_regression.accounts (status); + CREATE TABLE fk_index_regression.account_events ( + status text NOT NULL + ); + ALTER TABLE fk_index_regression.account_events + ADD CONSTRAINT account_events_status_fkey + FOREIGN KEY (status) REFERENCES fk_index_regression.accounts(status) + NOT VALID; + COMMENT ON CONSTRAINT account_events_status_fkey + ON fk_index_regression.account_events + IS 'account status reference'; + `, + testSql: dedent` + ALTER TABLE fk_index_regression.account_events + DROP CONSTRAINT account_events_status_fkey; + ALTER TABLE fk_index_regression.accounts + ALTER COLUMN status TYPE varchar(32) USING status::varchar(32); + DROP INDEX fk_index_regression.accounts_status_key_idx; + CREATE UNIQUE INDEX accounts_status_key_idx + ON fk_index_regression.accounts (status); + ALTER TABLE fk_index_regression.account_events + ADD CONSTRAINT account_events_status_fkey + FOREIGN KEY (status) REFERENCES fk_index_regression.accounts(status) + NOT VALID; + COMMENT ON CONSTRAINT account_events_status_fkey + ON fk_index_regression.account_events + IS 'account status reference'; + `, + assertSqlStatements: (statements) => { + const dropConstraintIdx = statements.findIndex((statement) => + statement.includes( + "ALTER TABLE fk_index_regression.account_events DROP CONSTRAINT account_events_status_fkey", + ), + ); + const dropIndexIdx = statements.findIndex((statement) => + statement.includes( + "DROP INDEX fk_index_regression.accounts_status_key_idx", + ), + ); + const createIndexIdx = statements.findIndex((statement) => + statement.includes("CREATE UNIQUE INDEX accounts_status_key_idx"), + ); + const addConstraintIdx = statements.findIndex((statement) => + statement.includes( + "ALTER TABLE fk_index_regression.account_events ADD CONSTRAINT account_events_status_fkey", + ), + ); + const commentConstraintIdx = statements.findIndex((statement) => + statement.includes( + "COMMENT ON CONSTRAINT account_events_status_fkey ON fk_index_regression.account_events", + ), + ); + + expect(dropConstraintIdx).toBeGreaterThanOrEqual(0); + expect(dropIndexIdx).toBeGreaterThanOrEqual(0); + expect(createIndexIdx).toBeGreaterThanOrEqual(0); + expect(addConstraintIdx).toBeGreaterThanOrEqual(0); + expect(commentConstraintIdx).toBeGreaterThanOrEqual(0); + expect(statements[addConstraintIdx]).toContain("NOT VALID"); + expect(dropConstraintIdx).toBeLessThan(dropIndexIdx); + expect(createIndexIdx).toBeLessThan(addConstraintIdx); + expect(addConstraintIdx).toBeLessThan(commentConstraintIdx); + }, + }); + }), + ); + test( "replace table via enum dependency does not emit standalone drop/create for PK-owned index", withDb(pgVersion, async (db) => { diff --git a/packages/pg-delta/tests/integration/trigger-operations.test.ts b/packages/pg-delta/tests/integration/trigger-operations.test.ts index 6ce954c6e..f176b5184 100644 --- a/packages/pg-delta/tests/integration/trigger-operations.test.ts +++ b/packages/pg-delta/tests/integration/trigger-operations.test.ts @@ -628,6 +628,150 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "trigger enabled state changes are applied without losing the trigger", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.accounts ( + id integer PRIMARY KEY + ); + + CREATE FUNCTION test_schema.noop_trigger() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RETURN NEW; + END; + $$; + + CREATE TRIGGER accounts_before_insert + BEFORE INSERT ON test_schema.accounts + FOR EACH ROW + EXECUTE FUNCTION test_schema.noop_trigger(); + `, + testSql: dedent` + ALTER TABLE test_schema.accounts + DISABLE TRIGGER accounts_before_insert; + `, + assertSqlStatements: (statements) => { + expect(statements).toContain( + "ALTER TABLE test_schema.accounts DISABLE TRIGGER accounts_before_insert", + ); + }, + }); + }), + ); + + test( + "trigger WHEN condition depending on rewritten column is recreated around ALTER COLUMN TYPE", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + CREATE TABLE test_schema.accounts ( + id integer PRIMARY KEY, + status text NOT NULL + ); + + CREATE FUNCTION test_schema.noop_trigger() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RETURN NEW; + END; + $$; + + CREATE TRIGGER block_blocked_accounts + BEFORE INSERT OR UPDATE ON test_schema.accounts + FOR EACH ROW + WHEN (NEW.status = 'blocked') + EXECUTE FUNCTION test_schema.noop_trigger(); + `, + testSql: dedent` + CREATE TYPE test_schema.account_status AS ENUM ('active', 'blocked'); + + DROP TRIGGER block_blocked_accounts ON test_schema.accounts; + + ALTER TABLE test_schema.accounts + ALTER COLUMN status TYPE test_schema.account_status + USING status::test_schema.account_status; + + CREATE TRIGGER block_blocked_accounts + BEFORE INSERT OR UPDATE ON test_schema.accounts + FOR EACH ROW + WHEN (NEW.status = 'blocked'::test_schema.account_status) + EXECUTE FUNCTION test_schema.noop_trigger(); + + ALTER TABLE test_schema.accounts + DISABLE TRIGGER block_blocked_accounts; + `, + }); + }), + ); + + test( + "trigger WHEN condition depending on replaced function signature is recreated around the function", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.is_valid_amount(value integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $$ SELECT value > 0 $$; + + CREATE FUNCTION test_schema.noop_trigger() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RETURN NEW; + END; + $$; + + CREATE TABLE test_schema.items ( + id integer PRIMARY KEY, + amount integer NOT NULL + ); + + CREATE TRIGGER validate_amount + BEFORE INSERT OR UPDATE ON test_schema.items + FOR EACH ROW + WHEN (test_schema.is_valid_amount(NEW.amount)) + EXECUTE FUNCTION test_schema.noop_trigger(); + `, + testSql: dedent` + DROP TRIGGER validate_amount ON test_schema.items; + DROP FUNCTION test_schema.is_valid_amount(integer); + + CREATE FUNCTION test_schema.is_valid_amount(value bigint) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $$ SELECT value > 0 $$; + + CREATE TRIGGER validate_amount + BEFORE INSERT OR UPDATE ON test_schema.items + FOR EACH ROW + WHEN (test_schema.is_valid_amount(NEW.amount::bigint)) + EXECUTE FUNCTION test_schema.noop_trigger(); + `, + }); + }), + ); + test( "trigger semantic equality", withDb(pgVersion, async (db) => {