From ee3713e5a8ca1da714296f879c5574e7de6e2f2a Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Wed, 10 Jun 2026 20:26:54 +0800 Subject: [PATCH 01/78] test(pg-delta): cover function expression replacement suppression RED evidence before the fix: - Unit: expand-replace-dependencies.test.ts failed because expected DropTable=false but received true for a covered column default alter, and expected DropDomain=false but received true for a covered domain default alter. - Integration PG17: function-signature-expression-dependencies.test.ts failed in all four scenarios because expectNoTableReplacement saw DROP TABLE in generated SQL. Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 241 ++++++++++++++++- ...-signature-expression-dependencies.test.ts | 248 ++++++++++++++++++ 2 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts 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..9f3412848 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -3,6 +3,10 @@ import { Catalog, createEmptyCatalog } from "./catalog.model.ts"; import type { Change } from "./change.types.ts"; import { expandReplaceDependencies } from "./expand-replace-dependencies.ts"; import { DefaultPrivilegeState } from "./objects/base.default-privileges.ts"; +import { 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 { 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"; @@ -29,7 +33,7 @@ import { import { CreateTable } from "./objects/table/changes/table.create.ts"; import { DropTable } from "./objects/table/changes/table.drop.ts"; import { GrantTablePrivileges } from "./objects/table/changes/table.privilege.ts"; -import { Table } from "./objects/table/table.model.ts"; +import { Table, type TableProps } from "./objects/table/table.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"; @@ -72,6 +76,109 @@ function mockInvalidatingChange(invalidates: string[]): Change { } as unknown as Change; } +function procedureWithArgs(argumentTypes: string[]): Procedure { + return new Procedure({ + schema: "public", + name: "normalize_value", + kind: "f", + return_type: "integer", + return_type_schema: "pg_catalog", + language: "sql", + security_definer: false, + volatility: "i", + parallel_safety: "u", + execution_cost: 100, + result_rows: 0, + is_strict: false, + leakproof: false, + returns_set: false, + argument_count: argumentTypes.length, + argument_default_count: 0, + argument_names: argumentTypes.map((_, index) => `arg${index + 1}`), + argument_types: argumentTypes, + all_argument_types: null, + argument_modes: null, + argument_defaults: null, + source_code: "SELECT 1", + binary_path: null, + sql_body: null, + config: null, + definition: "CREATE FUNCTION public.normalize_value(...) RETURNS integer", + owner: "postgres", + comment: null, + privileges: [], + }); +} + +function tableWithDefault( + columnDefault: string | null, + columnOverrides: Partial = {}, +): Table { + return new Table({ + schema: "public", + name: "items", + persistence: "p", + row_security: false, + force_row_security: false, + has_indexes: false, + has_rules: false, + has_triggers: false, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + options: null, + partition_bound: null, + partition_by: null, + owner: "postgres", + comment: null, + parent_schema: null, + parent_name: null, + columns: [ + { + name: "value", + position: 1, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: columnDefault, + comment: null, + ...columnOverrides, + }, + ], + privileges: [], + }); +} + +function domainWithDefault(defaultValue: string): Domain { + return new Domain({ + schema: "public", + name: "item_value", + base_type: "int4", + base_type_schema: "pg_catalog", + base_type_str: "integer", + not_null: false, + type_modifier: null, + array_dimensions: null, + collation: null, + default_bin: defaultValue, + default_value: defaultValue, + owner: "postgres", + comment: null, + constraints: [], + privileges: [], + }); +} + describe("expandReplaceDependencies", () => { test("returns changes unchanged when there are no replace roots", async () => { const catalog = await createEmptyCatalog(160004, "u"); @@ -977,4 +1084,136 @@ describe("expandReplaceDependencies", () => { expect(expanded.changes).not.toContain(alterUsing); expect(expanded.changes).not.toContain(alterWithCheck); }); + + test("does not replace a table when a procedure signature replacement is covered by a column default alter", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithDefault("public.normalize_value(1)"); + const branchTable = tableWithDefault("public.normalize_value((1)::bigint)"); + const setDefault = new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchTable.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + setDefault, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(setDefault); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + + test("does not replace a domain or table when a procedure signature replacement is covered by a domain default alter", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDomain = domainWithDefault("public.normalize_value(1)"); + const branchDomain = domainWithDefault( + "public.normalize_value((1)::bigint)", + ); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + const setDomainDefault = new AlterDomainSetDefault({ + domain: mainDomain, + defaultValue: branchDomain.default_value ?? "NULL", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + setDomainDefault, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: mainDomain.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(setDomainDefault); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + expect( + expanded.changes.some((change) => change instanceof CreateDomain), + ).toBe(false); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(tableUsingDomain.stableId)).toBe( + false, + ); + }); }); diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts new file mode 100644 index 000000000..dc1b44df1 --- /dev/null +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, test } from "bun:test"; +import dedent from "dedent"; +import { POSTGRES_VERSIONS } from "../constants.ts"; +import { withDb } from "../utils.ts"; +import { roundtripFidelityTest } from "./roundtrip.ts"; + +function expectNoTableReplacement(sqlStatements: string[]) { + expect( + sqlStatements.some((statement) => statement.startsWith("DROP TABLE ")), + ).toBe(false); + expect( + sqlStatements.some((statement) => statement.startsWith("CREATE TABLE ")), + ).toBe(false); +} + +async function expectTableRowCount( + query: (sql: string) => Promise<{ rows: Array<{ count: string | bigint }> }>, + tableName: string, + expected: number, +) { + const { rows } = await query(`SELECT count(*) FROM ${tableName}`); + expect(Number(rows[0]?.count)).toBe(expected); +} + +for (const pgVersion of POSTGRES_VERSIONS) { + describe(`function signature expression dependencies (pg${pgVersion})`, () => { + test( + "column default update for replaced function signature does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_quantity(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.orders ( + id integer NOT NULL, + quantity integer DEFAULT test_schema.default_quantity(1::integer) + ); + + INSERT INTO test_schema.orders (id) VALUES (1); + `, + testSql: dedent` + CREATE FUNCTION test_schema.default_quantity(value bigint) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value::integer + 2 + $function$; + + ALTER TABLE test_schema.orders + ALTER COLUMN quantity + SET DEFAULT test_schema.default_quantity(1::bigint); + + DROP FUNCTION test_schema.default_quantity(integer); + `, + assertSqlStatements: expectNoTableReplacement, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.orders", + 1, + ); + }), + ); + + test( + "check constraint update for replaced function signature does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.is_positive(value integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 0 + $function$; + + CREATE TABLE test_schema.measurements ( + id integer NOT NULL, + value integer NOT NULL, + CONSTRAINT measurements_value_check + CHECK (test_schema.is_positive(value)) + ); + + INSERT INTO test_schema.measurements (id, value) VALUES (1, 10); + `, + testSql: dedent` + CREATE FUNCTION test_schema.is_positive(value bigint) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 1 + $function$; + + ALTER TABLE test_schema.measurements + DROP CONSTRAINT measurements_value_check; + ALTER TABLE test_schema.measurements + ADD CONSTRAINT measurements_value_check + CHECK (test_schema.is_positive(value::bigint)); + + DROP FUNCTION test_schema.is_positive(integer); + `, + assertSqlStatements: expectNoTableReplacement, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.measurements", + 1, + ); + }), + ); + + test( + "generated column update for replaced function signature does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoices ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_total(subtotal)) STORED + ); + + INSERT INTO test_schema.invoices (id, subtotal) VALUES (1, 20); + `, + testSql: dedent` + CREATE FUNCTION test_schema.compute_total(value bigint) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value::integer + 2 + $function$; + + ALTER TABLE test_schema.invoices + ALTER COLUMN total + SET EXPRESSION AS + (test_schema.compute_total(subtotal::bigint)); + + DROP FUNCTION test_schema.compute_total(integer); + `, + assertSqlStatements: expectNoTableReplacement, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.invoices", + 1, + ); + }), + ); + + test( + "domain default update for replaced function signature does not recreate domain or table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_score(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE DOMAIN test_schema.score AS integer + DEFAULT test_schema.default_score(1::integer); + + CREATE TABLE test_schema.results ( + id integer NOT NULL, + score test_schema.score + ); + + INSERT INTO test_schema.results (id) VALUES (1); + `, + testSql: dedent` + CREATE FUNCTION test_schema.default_score(value bigint) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value::integer + 2 + $function$; + + ALTER DOMAIN test_schema.score + SET DEFAULT test_schema.default_score(1::bigint); + + DROP FUNCTION test_schema.default_score(integer); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + expect( + sqlStatements.some((statement) => + statement.startsWith("DROP DOMAIN "), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith("CREATE DOMAIN "), + ), + ).toBe(false); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.results", + 1, + ); + }), + ); + }); +} From 4af2d48b6caa30c8cd278f22c5295295bfeddcda Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Wed, 10 Jun 2026 20:27:06 +0800 Subject: [PATCH 02/78] fix(pg-delta): suppress covered expression replacements Suppress procedure-signature replacement expansion when pg_depend points at an expression container that already has a targeted alter, and order signature replacement drops after the new signature and expression update. Validation: - PGDELTA_TEST_POSTGRES_VERSIONS=17 PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1 TESTCONTAINERS_RYUK_DISABLED=true bun run test tests/integration/function-signature-expression-dependencies.test.ts - bun test /abs/path/packages/pg-delta/src/core/expand-replace-dependencies.test.ts - PGDELTA_TEST_POSTGRES_VERSIONS=17 PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1 TESTCONTAINERS_RYUK_DISABLED=true bun run test src/core/sort/sort-changes.test.ts - bun run format-and-lint:fix && bun run check-types && bun run knip --fix Signed-off-by: xiaominghao --- .changeset/yummy-jokes-sing.md | 5 ++ .../src/core/expand-replace-dependencies.ts | 86 ++++++++++++++++++- .../src/core/sort/custom-constraints.ts | 76 ++++++++++++++++ .../pg-delta/src/core/sort/sort-changes.ts | 60 ++++++++++++- 4 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 .changeset/yummy-jokes-sing.md diff --git a/.changeset/yummy-jokes-sing.md b/.changeset/yummy-jokes-sing.md new file mode 100644 index 000000000..c69ef309e --- /dev/null +++ b/.changeset/yummy-jokes-sing.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Avoid recreating tables and domains for covered expression updates during function signature replacement diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index c1a464418..84010cdfc 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -1,6 +1,10 @@ import type { Catalog } from "./catalog.model.ts"; import type { Change } from "./change.types.ts"; import type { ObjectDiffContext } from "./objects/diff-context.ts"; +import { + 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 { CreateIndex } from "./objects/index/changes/index.create.ts"; @@ -13,7 +17,11 @@ import { DropProcedure } from "./objects/procedure/changes/procedure.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 { + AlterTableAddConstraint, + AlterTableAlterColumnDropDefault, + AlterTableAlterColumnSetDefault, +} from "./objects/table/changes/table.alter.ts"; import { 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"; @@ -117,6 +125,7 @@ export function expandReplaceDependencies({ } const replaceRoots = new Set(); + const signatureReplacedProcedureRoots = new Set(); for (const id of createdIds) { if (droppedIds.has(id)) { replaceRoots.add(id); @@ -149,6 +158,7 @@ export function expandReplaceDependencies({ const key = parseProcedureSchemaName(id); if (key && createdProcedureNames.has(key)) { replaceRoots.add(id); + signatureReplacedProcedureRoots.add(id); } } @@ -197,6 +207,8 @@ export function expandReplaceDependencies({ const visitedTargets = new Set(); const visitedRefs = new Set(replaceRoots); const queue: string[] = [...replaceRoots]; + const coveredExpressionDependents = + collectCoveredExpressionDependentIds(changes); // Tables being replaced by an expansion-added DropTable+CreateTable pair. // Any pre-existing targeted AlterTable*(T) object-scope change is superseded // by the replacement and must be removed to avoid contradictions (e.g. an @@ -221,13 +233,25 @@ export function expandReplaceDependencies({ continue; } + const targetId = normalizeDependentId(dependentRaw); + if ( + targetId && + shouldSuppressCoveredExpressionDependent({ + refId, + dependentRaw, + coveredExpressionDependents, + signatureReplacedProcedureRoots, + }) + ) { + continue; + } + // Continue traversing the dependency graph from the raw dependent id. if (!visitedRefs.has(dependentRaw)) { visitedRefs.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). @@ -377,6 +401,64 @@ function removeSupersededRlsPolicyAlters( }); } +function collectCoveredExpressionDependentIds(changes: Change[]): Set { + const ids = new Set(); + + for (const change of changes) { + for (const id of [...(change.creates ?? []), ...(change.drops ?? [])]) { + if (isExpressionContainerStableId(id)) { + ids.add(id); + } + } + + if ( + change instanceof AlterTableAlterColumnSetDefault || + change instanceof AlterTableAlterColumnDropDefault || + change instanceof AlterDomainSetDefault || + change instanceof AlterDomainDropDefault + ) { + for (const id of change.requires ?? []) { + if (isExpressionContainerStableId(id)) { + ids.add(id); + } + } + } + } + + return ids; +} + +function shouldSuppressCoveredExpressionDependent({ + refId, + dependentRaw, + coveredExpressionDependents, + signatureReplacedProcedureRoots, +}: { + refId: string; + dependentRaw: string; + coveredExpressionDependents: ReadonlySet; + signatureReplacedProcedureRoots: ReadonlySet; +}): boolean { + // A changed function signature has different stableIds on main/branch. When + // pg_depend points from the old signature to an expression container that + // already has a targeted alter in the original diff, that alter releases the + // old dependency. Promoting the normalized table/domain owner to DROP+CREATE + // would be redundant and destructive. + return ( + signatureReplacedProcedureRoots.has(refId) && + isExpressionContainerStableId(dependentRaw) && + coveredExpressionDependents.has(dependentRaw) + ); +} + +function isExpressionContainerStableId(stableId: string): boolean { + return ( + stableId.startsWith("column:") || + stableId.startsWith("constraint:") || + stableId.startsWith("domain:") + ); +} + function isOwnedSequenceColumnDependency( referencedId: string, dependentId: string, diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index c1c5ecdc8..c823a9e2a 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -1,5 +1,8 @@ import type { Change } from "../change.types.ts"; import { getSchema } from "../change-utils.ts"; +import { AlterDomainSetDefault } from "../objects/domain/changes/domain.alter.ts"; +import { CreateProcedure } from "../objects/procedure/changes/procedure.create.ts"; +import { DropProcedure } from "../objects/procedure/changes/procedure.drop.ts"; import { GrantRoleDefaultPrivileges, RevokeRoleDefaultPrivileges, @@ -149,6 +152,71 @@ function generateDefaultPrivilegeConstraints(changes: Change[]): Constraint[] { return constraints; } +function generateProcedureSignatureReplacementConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const createProcedureIndexesByName = new Map(); + const createdProcedureIds = new Set(); + const dropProcedureIndexes: Array<{ + index: number; + id: string; + key: string; + }> = []; + const expressionUpdateIndexes: number[] = []; + + for (let i = 0; i < changes.length; i++) { + const change = changes[i]; + + if (change instanceof CreateProcedure) { + for (const id of change.creates ?? []) { + createdProcedureIds.add(id); + const key = parseProcedureSchemaName(id); + if (!key) continue; + const entries = createProcedureIndexesByName.get(key) ?? []; + entries.push(i); + createProcedureIndexesByName.set(key, entries); + } + } else if (change instanceof DropProcedure) { + for (const id of change.drops ?? []) { + const key = parseProcedureSchemaName(id); + if (key) { + dropProcedureIndexes.push({ index: i, id, key }); + } + } + } else if ( + change instanceof AlterTableAlterColumnSetDefault || + change instanceof AlterDomainSetDefault + ) { + expressionUpdateIndexes.push(i); + } + } + + for (const drop of dropProcedureIndexes) { + if (createdProcedureIds.has(drop.id)) continue; + const createIndexes = createProcedureIndexesByName.get(drop.key) ?? []; + if (createIndexes.length === 0) continue; + + for (const createIndex of createIndexes) { + constraints.push({ + sourceChangeIndex: createIndex, + targetChangeIndex: drop.index, + source: "custom", + }); + } + + for (const expressionUpdateIndex of expressionUpdateIndexes) { + constraints.push({ + sourceChangeIndex: expressionUpdateIndex, + targetChangeIndex: drop.index, + source: "custom", + }); + } + } + + return constraints; +} + function generateIdentityTransitionConstraints( changes: Change[], ): Constraint[] { @@ -221,6 +289,7 @@ function generateIdentityTransitionConstraints( */ const customConstraintGenerators: ConstraintGenerator[] = [ generateDefaultPrivilegeConstraints, + generateProcedureSignatureReplacementConstraints, generateIdentityTransitionConstraints, ]; @@ -233,3 +302,10 @@ const customConstraintGenerators: ConstraintGenerator[] = [ export function generateCustomConstraints(changes: Change[]): Constraint[] { return customConstraintGenerators.flatMap((generate) => generate(changes)); } + +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); +} diff --git a/packages/pg-delta/src/core/sort/sort-changes.ts b/packages/pg-delta/src/core/sort/sort-changes.ts index 4b6ba9e23..7a55b35da 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.ts @@ -14,6 +14,7 @@ import debug from "debug"; import type { Catalog } from "../catalog.model.ts"; import type { Change } from "../change.types.ts"; +import { DropProcedure } from "../objects/procedure/changes/procedure.drop.ts"; import { generateCustomConstraints } from "./custom-constraints.ts"; import { tryBreakCycleByChangeInjection } from "./cycle-breakers.ts"; import { printDebugGraph } from "./debug-visualization.ts"; @@ -91,6 +92,8 @@ function sortChangesByPhasedGraph( }, changeList: Change[], ): Change[] { + const createAfterDropProcedureIds = + findSignatureReplacedProcedureDropIds(changeList); const changesByPhase: Record = { drop: [], create_alter_object: [], @@ -98,7 +101,10 @@ function sortChangesByPhasedGraph( // Partition changes into execution phases for (const changeItem of changeList) { - const phase = getExecutionPhase(changeItem); + const phase = getContextualExecutionPhase( + changeItem, + createAfterDropProcedureIds, + ); changesByPhase[phase].push(changeItem); } @@ -119,6 +125,58 @@ function sortChangesByPhasedGraph( return [...sortedDropPhase, ...sortedCreateAlterPhase]; } +function getContextualExecutionPhase( + change: Change, + createAfterDropProcedureIds: ReadonlySet, +): Phase { + if ( + change instanceof DropProcedure && + change.drops.some((id) => createAfterDropProcedureIds.has(id)) + ) { + return "create_alter_object"; + } + + return getExecutionPhase(change); +} + +function findSignatureReplacedProcedureDropIds(changes: Change[]): Set { + const createdProcedureNames = new Set(); + const createdProcedureIds = new Set(); + for (const change of changes) { + if (change.objectType !== "procedure" || change.operation !== "create") { + continue; + } + for (const id of change.creates ?? []) { + createdProcedureIds.add(id); + const key = parseProcedureSchemaName(id); + if (key) createdProcedureNames.add(key); + } + } + + const ids = new Set(); + for (const change of changes) { + if (change.objectType !== "procedure" || change.operation !== "drop") { + continue; + } + for (const id of change.drops ?? []) { + if (createdProcedureIds.has(id)) continue; + const key = parseProcedureSchemaName(id); + if (key && createdProcedureNames.has(key)) { + ids.add(id); + } + } + } + + return ids; +} + +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); +} + /** * Normalize a cycle by rotating it to start with the smallest node index, so * cycles that loop through the same nodes in the same direction compare equal From 367195bb5ca700f7eaa2fb87517b23f9fb8c6dc2 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 00:23:54 +0800 Subject: [PATCH 03/78] test(pg-delta): cover domain default replacement ordering Adds coverage for function signature replacement when a domain default is removed, including the sorter requirement that the domain default drop runs before dropping the referenced function. Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 83 +++++++++++- .../src/core/sort/sort-changes.test.ts | 127 ++++++++++++++++++ 2 files changed, 208 insertions(+), 2 deletions(-) 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 9f3412848..59088bbc1 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -3,7 +3,10 @@ import { Catalog, createEmptyCatalog } from "./catalog.model.ts"; import type { Change } from "./change.types.ts"; import { expandReplaceDependencies } from "./expand-replace-dependencies.ts"; import { DefaultPrivilegeState } from "./objects/base.default-privileges.ts"; -import { AlterDomainSetDefault } from "./objects/domain/changes/domain.alter.ts"; +import { + 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"; @@ -159,7 +162,7 @@ function tableWithDefault( }); } -function domainWithDefault(defaultValue: string): Domain { +function domainWithDefault(defaultValue: string | null): Domain { return new Domain({ schema: "public", name: "item_value", @@ -1216,4 +1219,80 @@ describe("expandReplaceDependencies", () => { false, ); }); + + test("does not replace a domain or table when a procedure signature replacement is covered by dropping a domain default", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDomain = domainWithDefault("public.normalize_value(1)"); + const branchDomain = domainWithDefault(null); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + const dropDomainDefault = new AlterDomainDropDefault({ + domain: mainDomain, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + dropDomainDefault, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: mainDomain.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(dropDomainDefault); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + expect( + expanded.changes.some((change) => change instanceof CreateDomain), + ).toBe(false); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(tableUsingDomain.stableId)).toBe( + false, + ); + }); }); diff --git a/packages/pg-delta/src/core/sort/sort-changes.test.ts b/packages/pg-delta/src/core/sort/sort-changes.test.ts index 2305516e4..528b97a69 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -2,9 +2,15 @@ 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 { + AlterTableAlterColumnSetDefault, AlterTableAlterColumnType, AlterTableDropConstraint, } from "../objects/table/changes/table.alter.ts"; @@ -171,6 +177,60 @@ function view(name: string, columns = [integerColumn("id", 1)]) { }); } +function domain(defaultValue: string | null) { + return new Domain({ + schema: "public", + name: "score", + base_type: "int4", + base_type_schema: "pg_catalog", + base_type_str: "integer", + not_null: false, + type_modifier: null, + array_dimensions: null, + collation: null, + default_bin: defaultValue, + default_value: defaultValue, + owner: "postgres", + comment: null, + constraints: [], + privileges: [], + }); +} + +function procedure(argumentTypes: string[]) { + return new Procedure({ + schema: "public", + name: "normalize_value", + kind: "f", + return_type: "integer", + return_type_schema: "pg_catalog", + language: "sql", + security_definer: false, + volatility: "i", + parallel_safety: "u", + execution_cost: 100, + result_rows: 0, + is_strict: false, + leakproof: false, + returns_set: false, + argument_count: argumentTypes.length, + argument_default_count: 0, + argument_names: argumentTypes.map((_, index) => `arg${index + 1}`), + argument_types: argumentTypes, + all_argument_types: null, + argument_modes: null, + argument_defaults: null, + source_code: "SELECT 1", + binary_path: null, + sql_body: null, + config: null, + definition: "CREATE FUNCTION public.normalize_value(...) RETURNS integer", + owner: "postgres", + comment: null, + privileges: [], + }); +} + async function catalogWithDepends(depends: PgDepend[]) { const base = await createEmptyCatalog(170000, "postgres"); // oxlint-disable-next-line typescript/no-misused-spread @@ -184,6 +244,9 @@ function changeLabel(change: Change) { if (change instanceof DropTable) { return `${change.constructor.name}:${change.table.name}`; } + if (change instanceof DropProcedure || change instanceof CreateProcedure) { + return `${change.constructor.name}:${change.procedure.stableId}`; + } return change.constructor.name; } @@ -387,4 +450,68 @@ describe("sortChanges", () => { "AlterTableDropConstraint:posts.posts_lab_id_fkey", ); }); + + test("orders signature replacement around a covered column default update", async () => { + const mainProcedure = procedure(["integer"]); + const branchProcedure = procedure(["bigint"]); + const branchTable = table("items"); + const branchColumn = { + ...integerColumn("value", 4), + default: "public.normalize_value(1::bigint)", + }; + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchColumn, + }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: branchProcedure.stableId, + deptype: "n", + }, + ]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + `CreateProcedure:${branchProcedure.stableId}`, + "AlterTableAlterColumnSetDefault", + `DropProcedure:${mainProcedure.stableId}`, + ]); + }); + + test("orders domain default removal before dropping the referenced function", async () => { + const mainProcedure = procedure(["integer"]); + const mainDomain = domain("public.normalize_value(1)"); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new AlterDomainDropDefault({ domain: mainDomain }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: mainDomain.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "AlterDomainDropDefault", + `DropProcedure:${mainProcedure.stableId}`, + ]); + }); }); From c9df85f5c23b787be182e7bbd633db540264541c Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 00:24:24 +0800 Subject: [PATCH 04/78] fix(pg-delta): drop domain defaults before routine replacement Route AlterDomainDropDefault through the drop phase so a domain default expression can be removed before the referenced function is dropped during signature replacement. RED: the domain default ordering regression failed with DropProcedure before AlterDomainDropDefault. GREEN: the focused sorter regression passed after this change. Signed-off-by: xiaominghao --- packages/pg-delta/src/core/sort/utils.ts | 2 ++ 1 file changed, 2 insertions(+) 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 ) { From 103bd5920251e66ad4b574893e342f8996d3a89e Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 11:42:47 +0800 Subject: [PATCH 05/78] test(pg-delta): cover procedure expression dependents RED: focused unit tests with only these regressions failed before the implementation. Observed failures included missing AlterTableAlterColumnSetDefault for generated column expression replacement, missing AlterTableDropConstraint/AlterDomainDropConstraint for unchanged CHECK dependents, and table CHECK ordering that placed DropProcedure before AlterTableAddConstraint. Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 824 +++++++++++++++++- .../src/core/sort/sort-changes.test.ts | 179 +++- ...-signature-expression-dependencies.test.ts | 171 ++++ 3 files changed, 1146 insertions(+), 28 deletions(-) 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 59088bbc1..e9ea8aae7 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -4,8 +4,11 @@ import type { Change } from "./change.types.ts"; import { expandReplaceDependencies } from "./expand-replace-dependencies.ts"; import { DefaultPrivilegeState } from "./objects/base.default-privileges.ts"; import { + AlterDomainAddConstraint, + AlterDomainDropConstraint, AlterDomainDropDefault, AlterDomainSetDefault, + AlterDomainValidateConstraint, } from "./objects/domain/changes/domain.alter.ts"; import { CreateDomain } from "./objects/domain/changes/domain.create.ts"; import { DropDomain } from "./objects/domain/changes/domain.drop.ts"; @@ -26,12 +29,15 @@ 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 { + AlterTableAddConstraint, + AlterTableAlterColumnDropDefault, AlterTableAlterColumnSetDefault, AlterTableChangeOwner, AlterTableDropColumn, AlterTableDropConstraint, AlterTableEnableRowLevelSecurity, AlterTableSetReplicaIdentity, + AlterTableValidateConstraint, } from "./objects/table/changes/table.alter.ts"; import { CreateTable } from "./objects/table/changes/table.create.ts"; import { DropTable } from "./objects/table/changes/table.drop.ts"; @@ -79,10 +85,13 @@ function mockInvalidatingChange(invalidates: string[]): Change { } as unknown as Change; } -function procedureWithArgs(argumentTypes: string[]): Procedure { +function procedureWithArgs( + argumentTypes: string[], + name = "normalize_value", +): Procedure { return new Procedure({ schema: "public", - name: "normalize_value", + name, kind: "f", return_type: "integer", return_type_schema: "pg_catalog", @@ -182,6 +191,87 @@ function domainWithDefault(defaultValue: string | null): Domain { }); } +function domainWithConstraint( + checkExpression: string, + constraintOverrides: Partial = {}, +): Domain { + return new Domain({ + schema: "public", + name: "item_value", + base_type: "int4", + base_type_schema: "pg_catalog", + base_type_str: "integer", + not_null: false, + type_modifier: null, + array_dimensions: null, + collation: null, + default_bin: null, + default_value: null, + owner: "postgres", + comment: null, + constraints: [ + { + name: "item_value_check", + validated: true, + is_local: true, + no_inherit: false, + check_expression: checkExpression, + ...constraintOverrides, + }, + ], + privileges: [], + }); +} + +function tableWithCheckConstraint( + checkExpression: string, + constraintOverrides: Partial< + NonNullable[number] + > = {}, +): Table { + const table = tableWithDefault(null); + return new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...table, + constraints: [ + { + name: "items_value_check", + constraint_type: "c", + deferrable: false, + initially_deferred: false, + validated: true, + is_local: true, + no_inherit: false, + is_temporal: false, + is_partition_clone: false, + parent_constraint_schema: null, + parent_constraint_name: null, + parent_table_schema: null, + parent_table_name: null, + key_columns: ["value"], + foreign_key_columns: null, + foreign_key_table: null, + foreign_key_schema: null, + foreign_key_table_is_partition: null, + foreign_key_parent_schema: null, + foreign_key_parent_table: null, + foreign_key_effective_schema: null, + foreign_key_effective_table: null, + on_update: null, + on_delete: null, + match_type: null, + check_expression: checkExpression, + owner: "postgres", + definition: `CHECK (${checkExpression})${ + constraintOverrides.validated === false ? " NOT VALID" : "" + }`, + comment: null, + ...constraintOverrides, + }, + ], + }); +} + describe("expandReplaceDependencies", () => { test("returns changes unchanged when there are no replace roots", async () => { const catalog = await createEmptyCatalog(160004, "u"); @@ -1132,6 +1222,11 @@ describe("expandReplaceDependencies", () => { }); expect(expanded.changes).toContain(setDefault); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toBe(true); expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( false, ); @@ -1141,48 +1236,237 @@ describe("expandReplaceDependencies", () => { expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); }); - test("does not replace a domain or table when a procedure signature replacement is covered by a domain default alter", async () => { + test("synthesizes a column default replacement for an unchanged expression that depends on a replaced procedure", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); - const branchProcedure = procedureWithArgs(["bigint"]); - const mainDomain = domainWithDefault("public.normalize_value(1)"); - const branchDomain = domainWithDefault( - "public.normalize_value((1)::bigint)", + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(1)"); + const branchTable = tableWithDefault("public.normalize_value(1)"); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toBe(true); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, ); - const tableUsingDomain = tableWithDefault(null, { - data_type: "item_value", - data_type_str: "public.item_value", - is_custom_type: true, - custom_type_type: "d", - custom_type_category: "N", - custom_type_schema: "public", - custom_type_name: "item_value", + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("synthesizes a generated column expression update for an unchanged expression that depends on a replaced procedure", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", }); - const setDomainDefault = new AlterDomainSetDefault({ - domain: mainDomain, - defaultValue: branchDomain.default_value ?? "NULL", + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, }); const changes: Change[] = [ new DropProcedure({ procedure: mainProcedure }), new CreateProcedure({ procedure: branchProcedure }), - setDomainDefault, ]; const mainCatalog = new Catalog({ // oxlint-disable-next-line typescript/no-misused-spread ...baseline, procedures: { [mainProcedure.stableId]: mainProcedure }, - domains: { [mainDomain.stableId]: mainDomain }, - tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + tables: { [mainTable.stableId]: mainTable }, depends: [ { - dependent_stable_id: mainDomain.stableId, + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toBe(true); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("synthesizes a table check constraint replacement for an unchanged expression that depends on a replaced procedure", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + ); + const branchTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + ); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "constraint:public.items.items_value_check", referenced_stable_id: mainProcedure.stableId, deptype: "n", }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.some( + (change) => change instanceof AlterTableDropConstraint, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAddConstraint, + ), + ).toBe(true); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("preserves NOT VALID table check state when synthesizing a replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + { validated: false }, + ); + const branchTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + { validated: false }, + ); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ { - dependent_stable_id: "column:public.items.value", - referenced_stable_id: mainDomain.stableId, + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: mainProcedure.stableId, deptype: "n", }, ], @@ -1191,8 +1475,7 @@ describe("expandReplaceDependencies", () => { // oxlint-disable-next-line typescript/no-misused-spread ...baseline, procedures: { [branchProcedure.stableId]: branchProcedure }, - domains: { [branchDomain.stableId]: branchDomain }, - tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + tables: { [branchTable.stableId]: branchTable }, depends: [], }); @@ -1201,8 +1484,495 @@ describe("expandReplaceDependencies", () => { mainCatalog, branchCatalog, }); + const addConstraint = expanded.changes.find( + (change) => change instanceof AlterTableAddConstraint, + ); - expect(expanded.changes).toContain(setDomainDefault); + expect(addConstraint).toBeInstanceOf(AlterTableAddConstraint); + expect(addConstraint?.serialize()).toContain("NOT VALID"); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableValidateConstraint, + ), + ).toBe(false); + }); + + test("adds a missing release when a table check restore is already covered", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + ); + const branchTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + ); + const addConstraint = new AlterTableAddConstraint({ + table: branchTable, + constraint: branchTable.constraints[0] as NonNullable< + (typeof branchTable.constraints)[number] + >, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + addConstraint, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(addConstraint); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddConstraint, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + + test("synthesizes one table check constraint replacement when the expression depends on two replaced procedures", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainNormalize = procedureWithArgs(["integer"], "normalize_value"); + const branchNormalize = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainNormalize, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainShift = procedureWithArgs(["integer"], "shift_value"); + const branchShift = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainShift, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.shift_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithCheckConstraint( + "public.normalize_value(value) > public.shift_value(value)", + ); + const branchTable = tableWithCheckConstraint( + "public.normalize_value(value) > public.shift_value(value)", + ); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainNormalize }), + new CreateProcedure({ procedure: branchNormalize }), + new DropProcedure({ procedure: mainShift }), + new CreateProcedure({ procedure: branchShift }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [mainNormalize.stableId]: mainNormalize, + [mainShift.stableId]: mainShift, + }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: mainNormalize.stableId, + deptype: "n", + }, + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: mainShift.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [branchNormalize.stableId]: branchNormalize, + [branchShift.stableId]: branchShift, + }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddConstraint, + ), + ).toHaveLength(1); + }); + + test("synthesizes a domain check constraint replacement for an unchanged expression that depends on a replaced procedure", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + ); + const branchDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + ); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: "constraint:public.item_value.item_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.some( + (change) => change instanceof AlterDomainDropConstraint, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterDomainAddConstraint, + ), + ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + expect( + expanded.changes.some((change) => change instanceof CreateDomain), + ).toBe(false); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("does not duplicate a covered domain check constraint release", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + ); + const branchDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + ); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + const dropConstraint = new AlterDomainDropConstraint({ + domain: mainDomain, + constraint: mainDomain.constraints[0] as Domain["constraints"][number], + }); + const addConstraint = new AlterDomainAddConstraint({ + domain: branchDomain, + constraint: branchDomain.constraints[0] as Domain["constraints"][number], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + dropConstraint, + addConstraint, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: "constraint:public.item_value.item_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(dropConstraint); + expect(expanded.changes).toContain(addConstraint); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainAddConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + }); + + test("preserves NOT VALID domain check state when synthesizing a replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + { validated: false }, + ); + const branchDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + { validated: false }, + ); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: "constraint:public.item_value.item_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const addConstraint = expanded.changes.find( + (change) => change instanceof AlterDomainAddConstraint, + ); + + expect(addConstraint).toBeInstanceOf(AlterDomainAddConstraint); + expect(addConstraint?.serialize()).toContain("NOT VALID"); + expect( + expanded.changes.some( + (change) => change instanceof AlterDomainValidateConstraint, + ), + ).toBe(false); + }); + + test("does not replace a domain or table when a procedure signature replacement is covered by a domain default alter", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDomain = domainWithDefault("public.normalize_value(1)"); + const branchDomain = domainWithDefault( + "public.normalize_value((1)::bigint)", + ); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + const setDomainDefault = new AlterDomainSetDefault({ + domain: mainDomain, + defaultValue: branchDomain.default_value ?? "NULL", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + setDomainDefault, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: mainDomain.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(setDomainDefault); + expect( + expanded.changes.some( + (change) => change instanceof AlterDomainDropDefault, + ), + ).toBe(true); expect( expanded.changes.some((change) => change instanceof DropDomain), ).toBe(false); 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 528b97a69..3d2c70493 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -2,7 +2,11 @@ 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 { + AlterDomainAddConstraint, + AlterDomainDropConstraint, + 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"; @@ -10,6 +14,7 @@ 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 { + AlterTableAddConstraint, AlterTableAlterColumnSetDefault, AlterTableAlterColumnType, AlterTableDropConstraint, @@ -138,6 +143,40 @@ function uniqueConstraint(name: string, column: string) { }; } +function checkConstraint(name: string, expression: string) { + return { + name, + constraint_type: "c" as const, + deferrable: false, + initially_deferred: false, + validated: true, + is_local: true, + no_inherit: false, + is_temporal: false, + is_partition_clone: false, + parent_constraint_schema: null, + parent_constraint_name: null, + parent_table_schema: null, + parent_table_name: null, + key_columns: ["id"], + foreign_key_columns: null, + foreign_key_table: null, + foreign_key_schema: null, + foreign_key_table_is_partition: null, + foreign_key_parent_schema: null, + foreign_key_parent_table: null, + foreign_key_effective_schema: null, + foreign_key_effective_table: null, + on_update: null, + on_delete: null, + match_type: null, + check_expression: expression, + owner: "postgres", + definition: `CHECK (${expression})`, + comment: null, + }; +} + function table( name: string, constraints: ConstructorParameters[0]["constraints"] = [], @@ -197,6 +236,34 @@ function domain(defaultValue: string | null) { }); } +function domainWithConstraint(name: string, expression: string) { + return new Domain({ + schema: "public", + name: "score", + base_type: "int4", + base_type_schema: "pg_catalog", + base_type_str: "integer", + not_null: false, + type_modifier: null, + array_dimensions: null, + collation: null, + default_bin: null, + default_value: null, + owner: "postgres", + comment: null, + constraints: [ + { + name, + validated: true, + is_local: true, + no_inherit: false, + check_expression: expression, + }, + ], + privileges: [], + }); +} + function procedure(argumentTypes: string[]) { return new Procedure({ schema: "public", @@ -238,9 +305,18 @@ async function catalogWithDepends(depends: PgDepend[]) { } function changeLabel(change: Change) { + if (change instanceof AlterDomainDropConstraint) { + return `${change.constructor.name}:${change.domain.name}.${change.constraint.name}`; + } + if (change instanceof AlterDomainAddConstraint) { + return `${change.constructor.name}:${change.domain.name}.${change.constraint.name}`; + } if (change instanceof AlterTableDropConstraint) { return `${change.constructor.name}:${change.table.name}.${change.constraint.name}`; } + if (change instanceof AlterTableAddConstraint) { + return `${change.constructor.name}:${change.table.name}.${change.constraint.name}`; + } if (change instanceof DropTable) { return `${change.constructor.name}:${change.table.name}`; } @@ -514,4 +590,105 @@ describe("sortChanges", () => { `DropProcedure:${mainProcedure.stableId}`, ]); }); + + test("orders unchanged domain check replacement around same-signature procedure replacement", async () => { + const mainProcedure = procedure(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + }); + const mainDomain = domainWithConstraint( + "score_check", + "public.normalize_value(VALUE) > 0", + ); + const branchDomain = domainWithConstraint( + "score_check", + "public.normalize_value(VALUE) > 0", + ); + const changes: Change[] = [ + new CreateProcedure({ procedure: branchProcedure }), + new DropProcedure({ procedure: mainProcedure }), + new AlterDomainAddConstraint({ + domain: branchDomain, + constraint: branchDomain.constraints[0], + }), + new AlterDomainDropConstraint({ + domain: mainDomain, + constraint: mainDomain.constraints[0], + }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: "constraint:public.score.score_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([ + { + dependent_stable_id: "constraint:public.score.score_check", + referenced_stable_id: branchProcedure.stableId, + deptype: "n", + }, + ]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "AlterDomainDropConstraint:score.score_check", + `DropProcedure:${mainProcedure.stableId}`, + `CreateProcedure:${branchProcedure.stableId}`, + "AlterDomainAddConstraint:score.score_check", + ]); + }); + + test("orders unchanged table check replacement before dropping the old overloaded function", async () => { + const mainProcedure = procedure(["integer"]); + const branchProcedure = procedure(["bigint"]); + const mainTable = table("items", [ + checkConstraint("items_value_check", "public.normalize_value(id) > 0"), + ]); + const branchTable = table("items", [ + checkConstraint( + "items_value_check", + "public.normalize_value(id::bigint) > 0", + ), + ]); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableAddConstraint({ + table: branchTable, + constraint: branchTable.constraints[0], + }), + new AlterTableDropConstraint({ + table: mainTable, + constraint: mainTable.constraints[0], + }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([ + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: branchProcedure.stableId, + deptype: "n", + }, + ]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "AlterTableDropConstraint:items.items_value_check", + `CreateProcedure:${branchProcedure.stableId}`, + "AlterTableAddConstraint:items.items_value_check", + `DropProcedure:${mainProcedure.stableId}`, + ]); + }); }); diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index dc1b44df1..f6c4ac708 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -129,6 +129,86 @@ for (const pgVersion of POSTGRES_VERSIONS) { ); test( + "unchanged check constraint for replaced function argument name does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.is_valid(value integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 0 + $function$; + + CREATE TABLE test_schema.measurement_labels ( + id integer NOT NULL, + value integer NOT NULL, + CONSTRAINT measurement_labels_value_check + CHECK (test_schema.is_valid(value)) + ); + + INSERT INTO test_schema.measurement_labels (id, value) + VALUES (1, 10); + `, + testSql: dedent` + ALTER TABLE test_schema.measurement_labels + DROP CONSTRAINT measurement_labels_value_check; + + DROP FUNCTION test_schema.is_valid(integer); + + CREATE FUNCTION test_schema.is_valid(input integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input > 0 + $function$; + + ALTER TABLE test_schema.measurement_labels + ADD CONSTRAINT measurement_labels_value_check + CHECK (test_schema.is_valid(value)); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.measurement_labels DROP CONSTRAINT measurement_labels_value_check", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.is_valid"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE FUNCTION test_schema.is_valid"), + ); + const addConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.measurement_labels ADD CONSTRAINT measurement_labels_value_check", + ), + ); + + expect(dropConstraintIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropConstraintIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addConstraintIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.measurement_labels", + 1, + ); + }), + ); + + test.skipIf(pgVersion < 17)( "generated column update for replaced function signature does not recreate table", withDb(pgVersion, async (db) => { await roundtripFidelityTest({ @@ -244,5 +324,96 @@ for (const pgVersion of POSTGRES_VERSIONS) { ); }), ); + + test( + "unchanged domain check constraint for replaced function argument name does not recreate domain or table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.accept_score(value integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 0 + $function$; + + CREATE DOMAIN test_schema.score AS integer + CONSTRAINT score_accepts_value + CHECK (test_schema.accept_score(VALUE)); + + CREATE TABLE test_schema.results ( + id integer NOT NULL, + score test_schema.score + ); + + INSERT INTO test_schema.results (id, score) VALUES (1, 10); + `, + testSql: dedent` + ALTER DOMAIN test_schema.score + DROP CONSTRAINT score_accepts_value; + + DROP FUNCTION test_schema.accept_score(integer); + + CREATE FUNCTION test_schema.accept_score(input integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input > 0 + $function$; + + ALTER DOMAIN test_schema.score + ADD CONSTRAINT score_accepts_value + CHECK (test_schema.accept_score(VALUE)); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + expect( + sqlStatements.some((statement) => + statement.startsWith("DROP DOMAIN "), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith("CREATE DOMAIN "), + ), + ).toBe(false); + + const dropConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER DOMAIN test_schema.score DROP CONSTRAINT score_accepts_value", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.accept_score"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE FUNCTION test_schema.accept_score"), + ); + const addConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER DOMAIN test_schema.score ADD CONSTRAINT score_accepts_value", + ), + ); + + expect(dropConstraintIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropConstraintIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addConstraintIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.results", + 1, + ); + }), + ); }); } From 4c77c7cdb926dcd148d544e3c9db6900497bc53b Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 11:43:20 +0800 Subject: [PATCH 06/78] fix(pg-delta): recreate procedure expression dependents GREEN: the same focused unit command now passes with 6 pass / 0 fail. The expander now synthesizes release/restore changes for unchanged column defaults, generated column expressions, table CHECK constraints, domain defaults, and domain CHECK constraints that depend on a replaced procedure, avoiding destructive owner replacement when targeted DDL is sufficient. Signed-off-by: xiaominghao --- .changeset/yummy-jokes-sing.md | 2 +- .../src/core/expand-replace-dependencies.ts | 532 ++++++++++++++++-- .../src/core/sort/custom-constraints.ts | 10 +- 3 files changed, 509 insertions(+), 35 deletions(-) diff --git a/.changeset/yummy-jokes-sing.md b/.changeset/yummy-jokes-sing.md index c69ef309e..c1de629d9 100644 --- a/.changeset/yummy-jokes-sing.md +++ b/.changeset/yummy-jokes-sing.md @@ -2,4 +2,4 @@ "@supabase/pg-delta": patch --- -Avoid recreating tables and domains for covered expression updates during function signature replacement +Recreate expression dependents directly during procedure replacement instead of replacing their owning table or domain. diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 84010cdfc..1883447a0 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -2,6 +2,8 @@ import type { Catalog } from "./catalog.model.ts"; import type { Change } from "./change.types.ts"; import type { ObjectDiffContext } from "./objects/diff-context.ts"; import { + AlterDomainAddConstraint, + AlterDomainDropConstraint, AlterDomainDropDefault, AlterDomainSetDefault, } from "./objects/domain/changes/domain.alter.ts"; @@ -21,6 +23,7 @@ import { AlterTableAddConstraint, AlterTableAlterColumnDropDefault, AlterTableAlterColumnSetDefault, + AlterTableDropConstraint, } from "./objects/table/changes/table.alter.ts"; import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.ts"; import { CreateTable } from "./objects/table/changes/table.create.ts"; @@ -125,10 +128,13 @@ export function expandReplaceDependencies({ } const replaceRoots = new Set(); - const signatureReplacedProcedureRoots = new Set(); + const procedureReplacementRoots = new Set(); for (const id of createdIds) { if (droppedIds.has(id)) { replaceRoots.add(id); + if (id.startsWith("procedure:")) { + procedureReplacementRoots.add(id); + } } } @@ -158,7 +164,7 @@ export function expandReplaceDependencies({ const key = parseProcedureSchemaName(id); if (key && createdProcedureNames.has(key)) { replaceRoots.add(id); - signatureReplacedProcedureRoots.add(id); + procedureReplacementRoots.add(id); } } @@ -207,8 +213,8 @@ export function expandReplaceDependencies({ const visitedTargets = new Set(); const visitedRefs = new Set(replaceRoots); const queue: string[] = [...replaceRoots]; - const coveredExpressionDependents = - collectCoveredExpressionDependentIds(changes); + const expressionDependentCoverage = + collectExpressionDependentCoverage(changes); // Tables being replaced by an expansion-added DropTable+CreateTable pair. // Any pre-existing targeted AlterTable*(T) object-scope change is superseded // by the replacement and must be removed to avoid contradictions (e.g. an @@ -233,14 +239,59 @@ export function expandReplaceDependencies({ continue; } - const targetId = normalizeDependentId(dependentRaw); + if ( + shouldHandleProcedureExpressionDependent({ + refId, + dependentRaw, + procedureReplacementRoots, + }) + ) { + const releaseCovered = + expressionDependentCoverage.release.has(dependentRaw); + const restoreCovered = + expressionDependentCoverage.restore.has(dependentRaw); + if (releaseCovered && restoreCovered) { + continue; + } + + const expressionReplacementChanges = + buildExpressionDependentReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + diffContext, + addRelease: !releaseCovered, + addRestore: !restoreCovered, + }); + + if (expressionReplacementChanges !== null) { + additions.push(...expressionReplacementChanges); + if (!releaseCovered) { + expressionDependentCoverage.release.add(dependentRaw); + } + if (!restoreCovered) { + expressionDependentCoverage.restore.add(dependentRaw); + } + for (const change of expressionReplacementChanges) { + for (const id of change.creates ?? []) createdIds.add(id); + for (const id of change.drops ?? []) droppedIds.add(id); + } + continue; + } + } + + const targetId = normalizeDependentId( + dependentRaw, + mainCatalog, + branchCatalog, + ); if ( targetId && shouldSuppressCoveredExpressionDependent({ refId, dependentRaw, - coveredExpressionDependents, - signatureReplacedProcedureRoots, + expressionDependentCoverage, + procedureReplacementRoots, }) ) { continue; @@ -357,7 +408,11 @@ function collectInvalidatedRlsPolicyReplacements({ for (const dep of mainCatalog.depends) { if (!invalidatedIds.has(dep.referenced_stable_id)) continue; - const targetId = normalizeDependentId(dep.dependent_stable_id); + const targetId = normalizeDependentId( + dep.dependent_stable_id, + mainCatalog, + branchCatalog, + ); if (!targetId?.startsWith("rlsPolicy:")) continue; if (promotedRlsPolicyIds.has(targetId)) continue; if (createdIds.has(targetId) && droppedIds.has(targetId)) continue; @@ -401,53 +456,107 @@ function removeSupersededRlsPolicyAlters( }); } -function collectCoveredExpressionDependentIds(changes: Change[]): Set { - const ids = new Set(); +interface ExpressionDependentCoverage { + release: Set; + restore: Set; +} + +function collectExpressionDependentCoverage( + changes: Change[], +): ExpressionDependentCoverage { + const release = new Set(); + const restore = new Set(); for (const change of changes) { - for (const id of [...(change.creates ?? []), ...(change.drops ?? [])]) { + for (const id of change.creates ?? []) { if (isExpressionContainerStableId(id)) { - ids.add(id); + restore.add(id); + } + } + + for (const id of change.drops ?? []) { + if (isExpressionContainerStableId(id)) { + release.add(id); } } if ( - change instanceof AlterTableAlterColumnSetDefault || change instanceof AlterTableAlterColumnDropDefault || - change instanceof AlterDomainSetDefault || + change instanceof AlterTableDropConstraint || change instanceof AlterDomainDropDefault ) { for (const id of change.requires ?? []) { if (isExpressionContainerStableId(id)) { - ids.add(id); + release.add(id); + } + } + } + + if ( + change instanceof AlterTableAddConstraint || + change instanceof AlterDomainSetDefault || + change instanceof AlterDomainAddConstraint + ) { + for (const id of change.requires ?? []) { + if (isExpressionContainerStableId(id)) { + restore.add(id); + } + } + } + + if (change instanceof AlterTableAlterColumnSetDefault) { + for (const id of change.requires ?? []) { + if (isExpressionContainerStableId(id)) { + restore.add(id); + if (change.column.is_generated) { + release.add(id); + } } } } } - return ids; + return { release, restore }; } function shouldSuppressCoveredExpressionDependent({ refId, dependentRaw, - coveredExpressionDependents, - signatureReplacedProcedureRoots, + expressionDependentCoverage, + procedureReplacementRoots, }: { refId: string; dependentRaw: string; - coveredExpressionDependents: ReadonlySet; - signatureReplacedProcedureRoots: ReadonlySet; + expressionDependentCoverage: { + release: ReadonlySet; + restore: ReadonlySet; + }; + procedureReplacementRoots: ReadonlySet; }): boolean { - // A changed function signature has different stableIds on main/branch. When - // pg_depend points from the old signature to an expression container that - // already has a targeted alter in the original diff, that alter releases the - // old dependency. Promoting the normalized table/domain owner to DROP+CREATE - // would be redundant and destructive. + // Procedure replacement can require expression containers to release their + // pg_depend edge before the old routine is dropped. When the original diff + // already emitted that targeted expression change, promoting the normalized + // table/domain owner to DROP+CREATE would be redundant and destructive. return ( - signatureReplacedProcedureRoots.has(refId) && + procedureReplacementRoots.has(refId) && isExpressionContainerStableId(dependentRaw) && - coveredExpressionDependents.has(dependentRaw) + expressionDependentCoverage.release.has(dependentRaw) && + expressionDependentCoverage.restore.has(dependentRaw) + ); +} + +function shouldHandleProcedureExpressionDependent({ + refId, + dependentRaw, + procedureReplacementRoots, +}: { + refId: string; + dependentRaw: string; + procedureReplacementRoots: ReadonlySet; +}): boolean { + return ( + procedureReplacementRoots.has(refId) && + isExpressionContainerStableId(dependentRaw) ); } @@ -459,6 +568,317 @@ function isExpressionContainerStableId(stableId: string): boolean { ); } +function buildExpressionDependentReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + diffContext, + addRelease, + addRestore, +}: { + dependentRaw: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; + addRelease: boolean; + addRestore: boolean; +}): Change[] | null { + if (dependentRaw.startsWith("column:")) { + return buildColumnExpressionReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + diffContext, + addRelease, + addRestore, + }); + } + + if (dependentRaw.startsWith("domain:")) { + return buildDomainDefaultReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + addRelease, + addRestore, + }); + } + + if (dependentRaw.startsWith("constraint:")) { + return buildConstraintExpressionReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + addRelease, + addRestore, + }); + } + + return null; +} + +function buildColumnExpressionReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + diffContext, + addRelease, + addRestore, +}: { + dependentRaw: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; + addRelease: boolean; + addRestore: boolean; +}): Change[] | null { + const columnRef = parseColumnStableId(dependentRaw); + if (!columnRef) return null; + + const tableId = stableId.table(columnRef.schema, columnRef.table); + const mainTable = mainCatalog.tables[tableId]; + const branchTable = branchCatalog.tables[tableId]; + if (!mainTable || !branchTable) return null; + + const mainColumn = mainTable.columns.find( + (column) => column.name === columnRef.column, + ); + const branchColumn = branchTable.columns.find( + (column) => column.name === columnRef.column, + ); + if (!mainColumn || !branchColumn || mainColumn.default === null) return null; + + const generatedColumnInvolved = + mainColumn.is_generated || branchColumn.is_generated; + if (generatedColumnInvolved) { + // PostgreSQL only gained ALTER COLUMN ... SET EXPRESSION for generated + // columns in v17. Switching generated status still needs the existing + // destructive column/table fallback because SET EXPRESSION applies only + // to an already-generated column. + if ( + (diffContext?.version ?? 0) < 170000 || + mainColumn.is_generated !== branchColumn.is_generated || + !branchColumn.is_generated || + branchColumn.default === null + ) { + return null; + } + + return addRestore + ? [ + new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchColumn, + }), + ] + : null; + } + + const changes: Change[] = []; + + if (addRelease) { + changes.push( + new AlterTableAlterColumnDropDefault({ + table: mainTable, + column: mainColumn, + }), + ); + } + + if (addRestore && branchColumn.default !== null) { + changes.push( + new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchColumn, + }), + ); + } + + return changes; +} + +function buildDomainDefaultReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + addRelease, + addRestore, +}: { + dependentRaw: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + addRelease: boolean; + addRestore: boolean; +}): Change[] | null { + const mainDomain = mainCatalog.domains[dependentRaw]; + const branchDomain = branchCatalog.domains[dependentRaw]; + if (!mainDomain || !branchDomain || mainDomain.default_value === null) { + return null; + } + + const changes: Change[] = []; + if (addRelease) { + changes.push(new AlterDomainDropDefault({ domain: mainDomain })); + } + if (addRestore && branchDomain.default_value !== null) { + changes.push( + new AlterDomainSetDefault({ + domain: branchDomain, + defaultValue: branchDomain.default_value, + }), + ); + } + + return changes; +} + +function buildConstraintExpressionReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + addRelease, + addRestore, +}: { + dependentRaw: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + addRelease: boolean; + addRestore: boolean; +}): Change[] | null { + const constraintRef = parseConstraintStableId(dependentRaw); + if (!constraintRef) return null; + + const domainChanges = buildDomainConstraintReplacementChanges({ + constraintRef, + mainCatalog, + branchCatalog, + addRelease, + addRestore, + }); + if (domainChanges) return domainChanges; + + return buildTableConstraintReplacementChanges({ + constraintRef, + mainCatalog, + branchCatalog, + addRelease, + addRestore, + }); +} + +function buildDomainConstraintReplacementChanges({ + constraintRef, + mainCatalog, + branchCatalog, + addRelease, + addRestore, +}: { + constraintRef: ConstraintStableIdParts; + mainCatalog: Catalog; + branchCatalog: Catalog; + addRelease: boolean; + addRestore: boolean; +}): Change[] | null { + const domainId = `domain:${constraintRef.schema}.${constraintRef.owner}`; + const mainDomain = mainCatalog.domains[domainId]; + const branchDomain = branchCatalog.domains[domainId]; + + if (!mainDomain || !branchDomain) { + return null; + } + + const mainConstraint = mainDomain.constraints.find( + (constraint) => constraint.name === constraintRef.constraint, + ); + const branchConstraint = branchDomain.constraints.find( + (constraint) => constraint.name === constraintRef.constraint, + ); + if (!mainConstraint || !branchConstraint) return null; + + const changes: Change[] = []; + if (addRelease) { + changes.push( + new AlterDomainDropConstraint({ + domain: mainDomain, + constraint: mainConstraint, + }), + ); + } + + if (addRestore) { + changes.push( + new AlterDomainAddConstraint({ + domain: branchDomain, + constraint: branchConstraint, + }), + ); + } + + return changes; +} + +function buildTableConstraintReplacementChanges({ + constraintRef, + mainCatalog, + branchCatalog, + addRelease, + addRestore, +}: { + constraintRef: ConstraintStableIdParts; + mainCatalog: Catalog; + branchCatalog: Catalog; + addRelease: boolean; + addRestore: boolean; +}): Change[] | null { + const tableId = stableId.table(constraintRef.schema, constraintRef.owner); + const mainTable = mainCatalog.tables[tableId]; + const branchTable = branchCatalog.tables[tableId]; + if (!mainTable || !branchTable) return null; + + const mainConstraint = mainTable.constraints.find( + (constraint) => constraint.name === constraintRef.constraint, + ); + const branchConstraint = branchTable.constraints.find( + (constraint) => constraint.name === constraintRef.constraint, + ); + if (!mainConstraint || !branchConstraint) return null; + + const changes: Change[] = []; + if (addRelease) { + changes.push( + new AlterTableDropConstraint({ + table: mainTable, + constraint: mainConstraint, + }), + ); + } + + if (addRestore) { + changes.push( + new AlterTableAddConstraint({ + table: branchTable, + constraint: branchConstraint, + }), + ); + if (branchConstraint.comment !== null) { + changes.push( + new CreateCommentOnConstraint({ + table: branchTable, + constraint: branchConstraint, + }), + ); + } + } + + return changes; +} + function isOwnedSequenceColumnDependency( referencedId: string, dependentId: string, @@ -504,7 +924,11 @@ function parseProcedureSchemaName(stableId: string): string | null { return stableId.slice("procedure:".length, paren); } -function normalizeDependentId(dependentId: string): string | null { +function normalizeDependentId( + dependentId: string, + mainCatalog: Catalog, + branchCatalog: Catalog, +): string | null { let id = dependentId; while (id.startsWith("comment:")) { @@ -531,10 +955,16 @@ function normalizeDependentId(dependentId: string): string | null { } if (id.startsWith("constraint:")) { - const parts = id.slice("constraint:".length).split("."); - if (parts.length >= 2) { - const [schema, table] = parts; - return `table:${schema}.${table}`; + const constraintRef = parseConstraintStableId(id); + if (constraintRef) { + const domainId = `domain:${constraintRef.schema}.${constraintRef.owner}`; + if ( + mainCatalog.domains[domainId] !== undefined || + branchCatalog.domains[domainId] !== undefined + ) { + return domainId; + } + return stableId.table(constraintRef.schema, constraintRef.owner); } return null; } @@ -542,6 +972,44 @@ function normalizeDependentId(dependentId: string): string | null { return id; } +interface ColumnStableIdParts { + schema: string; + table: string; + column: string; +} + +interface ConstraintStableIdParts { + schema: string; + owner: string; + constraint: string; +} + +function parseColumnStableId(stableId: string): ColumnStableIdParts | null { + if (!stableId.startsWith("column:")) return null; + const parts = stableId.slice("column:".length).split("."); + if (parts.length < 3) return null; + const [schema, table, ...columnParts] = parts; + return { + schema, + table, + column: columnParts.join("."), + }; +} + +function parseConstraintStableId( + stableId: string, +): ConstraintStableIdParts | null { + if (!stableId.startsWith("constraint:")) return null; + const parts = stableId.slice("constraint:".length).split("."); + if (parts.length < 3) return null; + const [schema, owner, ...constraintParts] = parts; + return { + schema, + owner, + constraint: constraintParts.join("."), + }; +} + function resolveObjectForStableId( stableId: string, mainCatalog: Catalog, diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index c823a9e2a..dfbea055b 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -1,6 +1,9 @@ import type { Change } from "../change.types.ts"; import { getSchema } from "../change-utils.ts"; -import { AlterDomainSetDefault } from "../objects/domain/changes/domain.alter.ts"; +import { + AlterDomainAddConstraint, + AlterDomainSetDefault, +} from "../objects/domain/changes/domain.alter.ts"; import { CreateProcedure } from "../objects/procedure/changes/procedure.create.ts"; import { DropProcedure } from "../objects/procedure/changes/procedure.drop.ts"; import { @@ -12,6 +15,7 @@ import { AlterTableAlterColumnDropDefault, AlterTableAlterColumnDropIdentity, AlterTableAlterColumnSetDefault, + AlterTableAddConstraint, } from "../objects/table/changes/table.alter.ts"; import type { Constraint } from "./types.ts"; @@ -186,7 +190,9 @@ function generateProcedureSignatureReplacementConstraints( } } else if ( change instanceof AlterTableAlterColumnSetDefault || - change instanceof AlterDomainSetDefault + change instanceof AlterDomainSetDefault || + change instanceof AlterTableAddConstraint || + change instanceof AlterDomainAddConstraint ) { expressionUpdateIndexes.push(i); } From 88aaff0828319c47b580b12e71ed940b188927de Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 12:10:59 +0800 Subject: [PATCH 07/78] test(pg-delta): cover procedure dependent review cases RED: before the fix, the PG17 integration removed-check case emitted DROP TABLE for a release-only CHECK constraint, and the same-signature generated-column case had no column drop/add fallback (dropColumnIndex was -1). Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 84 +++++++++- ...-signature-expression-dependencies.test.ts | 154 ++++++++++++++++++ 2 files changed, 233 insertions(+), 5 deletions(-) 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 e9ea8aae7..8dd8c35dc 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -29,6 +29,7 @@ import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts"; import { diffSequences } from "./objects/sequence/sequence.diff.ts"; import { Sequence } from "./objects/sequence/sequence.model.ts"; import { + AlterTableAddColumn, AlterTableAddConstraint, AlterTableAlterColumnDropDefault, AlterTableAlterColumnSetDefault, @@ -1298,7 +1299,7 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); - test("synthesizes a generated column expression update for an unchanged expression that depends on a replaced procedure", async () => { + test("synthesizes a generated column fallback for an unchanged expression that depends on a same-signature procedure replacement", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); const branchProcedure = new Procedure({ @@ -1352,13 +1353,13 @@ describe("expandReplaceDependencies", () => { }); expect( - expanded.changes.some( - (change) => change instanceof AlterTableAlterColumnSetDefault, + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, ), - ).toBe(true); + ).toHaveLength(1); expect( expanded.changes.filter( - (change) => change instanceof AlterTableAlterColumnSetDefault, + (change) => change instanceof AlterTableAddColumn, ), ).toHaveLength(1); expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( @@ -1568,6 +1569,79 @@ describe("expandReplaceDependencies", () => { ); }); + test("does not replace a table when a replaced procedure only drops a dependent table check constraint", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithCheckConstraint( + "public.normalize_value(value) > 0", + ); + const branchTable = tableWithDefault(null); + const dropConstraint = new AlterTableDropConstraint({ + table: mainTable, + constraint: mainTable.constraints[0] as NonNullable< + (typeof mainTable.constraints)[number] + >, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + dropConstraint, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "constraint:public.items.items_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(dropConstraint); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAddConstraint, + ), + ).toBe(false); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + test("synthesizes one table check constraint replacement when the expression depends on two replaced procedures", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainNormalize = procedureWithArgs(["integer"], "normalize_value"); diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index f6c4ac708..96fa858b7 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -208,6 +208,76 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "removed check constraint for replaced function argument name does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.is_allowed(value integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 0 + $function$; + + CREATE TABLE test_schema.measurement_inputs ( + id integer NOT NULL, + value integer NOT NULL, + CONSTRAINT measurement_inputs_value_check + CHECK (test_schema.is_allowed(value)) + ); + + INSERT INTO test_schema.measurement_inputs (id, value) + VALUES (1, 10); + `, + testSql: dedent` + ALTER TABLE test_schema.measurement_inputs + DROP CONSTRAINT measurement_inputs_value_check; + + DROP FUNCTION test_schema.is_allowed(integer); + + CREATE FUNCTION test_schema.is_allowed(input integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input > 0 + $function$; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.measurement_inputs DROP CONSTRAINT measurement_inputs_value_check", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.is_allowed"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE FUNCTION test_schema.is_allowed"), + ); + + expect(dropConstraintIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropConstraintIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.measurement_inputs", + 1, + ); + }), + ); + test.skipIf(pgVersion < 17)( "generated column update for replaced function signature does not recreate table", withDb(pgVersion, async (db) => { @@ -261,6 +331,90 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test.skipIf(pgVersion < 17)( + "unchanged generated column for replaced function argument name does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_invoice_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED + ); + + INSERT INTO test_schema.invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_invoice_total(integer); + + CREATE FUNCTION test_schema.compute_invoice_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_invoice_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_invoice_total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD COLUMN total", + ), + ); + + expect(dropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.invoice_totals", + 1, + ); + }), + ); + test( "domain default update for replaced function signature does not recreate domain or table", withDb(pgVersion, async (db) => { From 4d829f109f0fbf8967d441a8fc19508b5f02ff8c Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 12:11:43 +0800 Subject: [PATCH 08/78] fix(pg-delta): handle procedure dependent review gaps GREEN: PG17 focused unit review cases pass (2 pass / 0 fail), the full expandReplaceDependencies unit file passed (22 pass / 0 fail), the PG17 function-signature expression integration file passed (8 pass / 0 fail), and format-and-lint, check-types, and knip --fix completed successfully. PG15 was attempted but hung in global setup before starting a Postgres test container, so it is not counted as passing evidence. Signed-off-by: xiaominghao --- .../src/core/expand-replace-dependencies.ts | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 1883447a0..0bad0ffd1 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -20,9 +20,11 @@ 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 { + AlterTableAddColumn, AlterTableAddConstraint, AlterTableAlterColumnDropDefault, AlterTableAlterColumnSetDefault, + AlterTableDropColumn, AlterTableDropConstraint, } from "./objects/table/changes/table.alter.ts"; import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.ts"; @@ -670,6 +672,23 @@ function buildColumnExpressionReplacementChanges({ return null; } + if (addRelease) { + // DROP DEFAULT is invalid for generated columns, while DROP EXPRESSION + // turns the column into a regular column that SET EXPRESSION cannot + // restore. Use the existing column fallback to release the dependency + // before the routine drop and recreate the generated column afterward. + return [ + new AlterTableDropColumn({ + table: mainTable, + column: mainColumn, + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchColumn, + }), + ]; + } + return addRestore ? [ new AlterTableAlterColumnSetDefault({ @@ -677,7 +696,7 @@ function buildColumnExpressionReplacementChanges({ column: branchColumn, }), ] - : null; + : []; } const changes: Change[] = []; @@ -799,7 +818,7 @@ function buildDomainConstraintReplacementChanges({ const branchConstraint = branchDomain.constraints.find( (constraint) => constraint.name === constraintRef.constraint, ); - if (!mainConstraint || !branchConstraint) return null; + if (!mainConstraint) return null; const changes: Change[] = []; if (addRelease) { @@ -812,6 +831,7 @@ function buildDomainConstraintReplacementChanges({ } if (addRestore) { + if (!branchConstraint) return changes; changes.push( new AlterDomainAddConstraint({ domain: branchDomain, @@ -847,7 +867,7 @@ function buildTableConstraintReplacementChanges({ const branchConstraint = branchTable.constraints.find( (constraint) => constraint.name === constraintRef.constraint, ); - if (!mainConstraint || !branchConstraint) return null; + if (!mainConstraint) return null; const changes: Change[] = []; if (addRelease) { @@ -860,6 +880,7 @@ function buildTableConstraintReplacementChanges({ } if (addRestore) { + if (!branchConstraint) return changes; changes.push( new AlterTableAddConstraint({ table: branchTable, From 9b788497a5156f211f5f6c91131857e63006003c Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 14:26:58 +0800 Subject: [PATCH 09/78] test(pg-delta): cover dropped procedure expression columns RED: PGDELTA_TEST_POSTGRES_VERSIONS=17 ./node_modules/.bin/bun run --cwd packages/pg-delta test src/core/expand-replace-dependencies.test.ts --test-name-pattern "only drops a dependent column default" failed because DropTable was generated. RED: PGDELTA_TEST_POSTGRES_VERSIONS=17 ./node_modules/.bin/bun run --cwd packages/pg-delta test tests/integration/function-signature-expression-dependencies.test.ts --test-name-pattern "removed column default" failed because DROP TABLE was present in the generated SQL. Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 73 +++++++++++++++++++ ...-signature-expression-dependencies.test.ts | 69 ++++++++++++++++++ 2 files changed, 142 insertions(+) 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 8dd8c35dc..f07d375d5 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -1299,6 +1299,79 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("does not replace a table when a replaced procedure only drops a dependent column default", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(1)"); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainTable, + columns: [], + }); + const dropColumn = new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0] as TableProps["columns"][number], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + dropColumn, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(dropColumn); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toBe(false); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + test("synthesizes a generated column fallback for an unchanged expression that depends on a same-signature procedure replacement", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index 96fa858b7..e87f0d505 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -278,6 +278,75 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "removed column default for replaced function argument name does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_input(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.form_inputs ( + id integer NOT NULL, + value integer DEFAULT test_schema.default_input(1::integer), + keep_value integer NOT NULL + ); + + INSERT INTO test_schema.form_inputs (id, keep_value) + VALUES (1, 10); + `, + testSql: dedent` + ALTER TABLE test_schema.form_inputs + DROP COLUMN value; + + DROP FUNCTION test_schema.default_input(integer); + + CREATE FUNCTION test_schema.default_input(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.form_inputs DROP COLUMN value", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.default_input"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE FUNCTION test_schema.default_input"), + ); + + expect(dropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.form_inputs", + 1, + ); + }), + ); + test.skipIf(pgVersion < 17)( "generated column update for replaced function signature does not recreate table", withDb(pgVersion, async (db) => { From 64bdcba401b2fe6ab941f01442a9061155a623c2 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 14:27:08 +0800 Subject: [PATCH 10/78] fix(pg-delta): handle dropped expression columns GREEN: PGDELTA_TEST_POSTGRES_VERSIONS=17 ./node_modules/.bin/bun run --cwd packages/pg-delta test src/core/expand-replace-dependencies.test.ts --test-name-pattern "only drops a dependent column default" -> 1 pass / 0 fail. GREEN: PGDELTA_TEST_POSTGRES_VERSIONS=17 ./node_modules/.bin/bun run --cwd packages/pg-delta test tests/integration/function-signature-expression-dependencies.test.ts --test-name-pattern "removed column default" -> 1 pass / 0 fail. Validation: PGDELTA_TEST_POSTGRES_VERSIONS=17 ./node_modules/.bin/bun run --cwd packages/pg-delta test src/core/expand-replace-dependencies.test.ts -> 23 pass / 0 fail; PGDELTA_TEST_POSTGRES_VERSIONS=17 ./node_modules/.bin/bun run --cwd packages/pg-delta test tests/integration/function-signature-expression-dependencies.test.ts -> 9 pass / 0 fail; ./node_modules/.bin/bun run format-and-lint -> pass; ./node_modules/.bin/bun run check-types -> pass; ./node_modules/.bin/bun run knip --fix -> exit 0 with existing config hints only. Package-level PG17 test: PGDELTA_TEST_POSTGRES_VERSIONS=17 ./node_modules/.bin/bun run test:pg-delta was terminated after container health-check/connect timeouts in unrelated FDW/index tests. Reruns after testcontainer cleanup passed: foreign-data-wrapper failed names -> 4 pass / 0 fail; index-extension-deps file -> 3 pass / 0 fail. Signed-off-by: xiaominghao --- .../pg-delta/src/core/expand-replace-dependencies.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 0bad0ffd1..1a25843e5 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -651,10 +651,19 @@ function buildColumnExpressionReplacementChanges({ const mainColumn = mainTable.columns.find( (column) => column.name === columnRef.column, ); + if (!mainColumn || mainColumn.default === null) return null; + const branchColumn = branchTable.columns.find( (column) => column.name === columnRef.column, ); - if (!mainColumn || !branchColumn || mainColumn.default === null) return null; + if (!branchColumn) { + // The branch removed this column. When the original diff already drops it, + // that drop releases the pg_depend edge and there is no expression to + // restore, so this dependent is handled without owner table replacement. + return addRelease + ? [new AlterTableDropColumn({ table: mainTable, column: mainColumn })] + : []; + } const generatedColumnInvolved = mainColumn.is_generated || branchColumn.is_generated; From 0e79c47e4a75e7f8604c007abba1c490465a5a34 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 14:45:39 +0800 Subject: [PATCH 11/78] test(pg-delta): cover pre-17 generated dependents RED: PG15 unit regression failed with expected AlterTableDropColumn length 1 but received 0 for the generated-column fallback before PostgreSQL 17. RED: PG15 integration regression failed because the generated SQL still contained DROP TABLE for an unchanged generated-column dependent. Signed-off-by: xiaominghao --- .../pg-delta/src/core/expand-replace-dependencies.test.ts | 6 +++--- .../function-signature-expression-dependencies.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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 f07d375d5..d1bf18f6b 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -1372,8 +1372,8 @@ describe("expandReplaceDependencies", () => { expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); }); - test("synthesizes a generated column fallback for an unchanged expression that depends on a same-signature procedure replacement", async () => { - const baseline = await createEmptyCatalog(170000, "postgres"); + test("synthesizes a generated column fallback before PostgreSQL 17 for an unchanged expression that depends on a same-signature procedure replacement", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); const branchProcedure = new Procedure({ // oxlint-disable-next-line typescript/no-misused-spread @@ -1419,7 +1419,7 @@ describe("expandReplaceDependencies", () => { mainCatalog, branchCatalog, diffContext: { - version: 170000, + version: 150000, currentUser: "postgres", defaultPrivilegeState: new DefaultPrivilegeState({}), }, diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index e87f0d505..c50b79281 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -400,7 +400,7 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); - test.skipIf(pgVersion < 17)( + test( "unchanged generated column for replaced function argument name does not recreate table", withDb(pgVersion, async (db) => { await roundtripFidelityTest({ From 7987d3532d93acfd04d300b221dc94d07e07f136 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 14:45:46 +0800 Subject: [PATCH 12/78] fix(pg-delta): use generated column fallback before pg17 Move the generated column drop/add fallback before the PostgreSQL 17 SET EXPRESSION guard so PG15/16 can release same-signature routine dependencies without falling back to table replacement. GREEN: PG15 focused unit regression -> 1 pass / 0 fail; PG15 focused integration regression -> 1 pass / 0 fail. Signed-off-by: xiaominghao --- .../src/core/expand-replace-dependencies.ts | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 1a25843e5..98f3711fc 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -668,24 +668,16 @@ function buildColumnExpressionReplacementChanges({ const generatedColumnInvolved = mainColumn.is_generated || branchColumn.is_generated; if (generatedColumnInvolved) { - // PostgreSQL only gained ALTER COLUMN ... SET EXPRESSION for generated - // columns in v17. Switching generated status still needs the existing - // destructive column/table fallback because SET EXPRESSION applies only - // to an already-generated column. - if ( - (diffContext?.version ?? 0) < 170000 || - mainColumn.is_generated !== branchColumn.is_generated || - !branchColumn.is_generated || - branchColumn.default === null - ) { - return null; - } + const canRecreateGeneratedColumn = + mainColumn.is_generated && + branchColumn.is_generated && + branchColumn.default !== null; - if (addRelease) { + if (addRelease && canRecreateGeneratedColumn) { // DROP DEFAULT is invalid for generated columns, while DROP EXPRESSION // turns the column into a regular column that SET EXPRESSION cannot - // restore. Use the existing column fallback to release the dependency - // before the routine drop and recreate the generated column afterward. + // restore. Recreating the column releases the dependency without relying + // on PostgreSQL 17's SET EXPRESSION support. return [ new AlterTableDropColumn({ table: mainTable, @@ -698,6 +690,19 @@ function buildColumnExpressionReplacementChanges({ ]; } + // PostgreSQL only gained ALTER COLUMN ... SET EXPRESSION for generated + // columns in v17. Switching generated status still needs the existing + // destructive column/table fallback because SET EXPRESSION applies only + // to an already-generated column. + if ( + (diffContext?.version ?? 0) < 170000 || + mainColumn.is_generated !== branchColumn.is_generated || + !branchColumn.is_generated || + branchColumn.default === null + ) { + return null; + } + return addRestore ? [ new AlterTableAlterColumnSetDefault({ From 79a034c0430b329524f9567b64f8267d2ff01c9d Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 15:05:28 +0800 Subject: [PATCH 13/78] test(pg-delta): cover generated column dependents RED: focused unit regression failed because no DropIndex was synthesized for a retained index depending on the generated column recreated by the fallback. RED: focused PG15 integration failed because the planned SQL did not include DROP VIEW for a retained view depending on the generated column. Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 211 ++++++++++++++++++ ...-signature-expression-dependencies.test.ts | 151 +++++++++++++ 2 files changed, 362 insertions(+) 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 d1bf18f6b..a3e3d34c2 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -16,6 +16,9 @@ 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 { CreateIndex } from "./objects/index/changes/index.create.ts"; +import { DropIndex } from "./objects/index/changes/index.drop.ts"; +import { Index, type IndexProps } from "./objects/index/index.model.ts"; import { AlterRlsPolicySetUsingExpression, AlterRlsPolicySetWithCheckExpression, @@ -273,6 +276,82 @@ function tableWithCheckConstraint( }); } +function indexOnItemsValue(overrides: Partial = {}): Index { + return new Index({ + schema: "public", + table_name: "items", + name: "items_value_idx", + storage_params: [], + statistics_target: [], + index_type: "btree", + tablespace: null, + is_unique: false, + is_primary: false, + is_exclusion: false, + nulls_not_distinct: false, + immediate: true, + is_clustered: false, + is_replica_identity: false, + key_columns: [], + column_collations: [], + operator_classes: [], + column_options: [], + index_expressions: "value", + partial_predicate: null, + is_owned_by_constraint: false, + table_relkind: "r", + is_partitioned_index: false, + is_index_partition: false, + parent_index_name: null, + definition: "CREATE INDEX items_value_idx ON public.items (value)", + comment: null, + owner: "postgres", + ...overrides, + }); +} + +function viewOnItemsValue(): View { + return new View({ + schema: "public", + name: "items_value_view", + definition: " SELECT value FROM public.items;", + row_security: false, + force_row_security: false, + has_indexes: false, + has_rules: false, + has_triggers: false, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + partition_bound: null, + options: null, + owner: "postgres", + comment: null, + columns: [ + { + name: "value", + position: 1, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ], + privileges: [], + }); +} + describe("expandReplaceDependencies", () => { test("returns changes unchanged when there are no replace roots", async () => { const catalog = await createEmptyCatalog(160004, "u"); @@ -1443,6 +1522,138 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("promotes retained dependents of a recreated generated column", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithCheckConstraint("value >= 0", { + name: "items_value_nonnegative", + definition: "CHECK (value >= 0)", + }); + const branchTable = tableWithCheckConstraint("value >= 0", { + name: "items_value_nonnegative", + definition: "CHECK (value >= 0)", + }); + const generatedColumn = { + is_generated: true, + default: "public.normalize_value(value)", + }; + mainTable.columns[0] = { + ...mainTable.columns[0], + ...generatedColumn, + }; + branchTable.columns[0] = { + ...branchTable.columns[0], + ...generatedColumn, + }; + const mainIndex = indexOnItemsValue(); + const branchIndex = indexOnItemsValue(); + const mainView = viewOnItemsValue(); + const branchView = viewOnItemsValue(); + const columnId = "column:public.items.value"; + const constraintId = "constraint:public.items.items_value_nonnegative"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + views: { [mainView.stableId]: mainView }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainIndex.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainView.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: constraintId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + views: { [branchView.stableId]: branchView }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof DropIndex), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateIndex), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof DropView), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateView), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddConstraint, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + test("synthesizes a table check constraint replacement for an unchanged expression that depends on a replaced procedure", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index c50b79281..86947241e 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -484,6 +484,157 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "unchanged generated column with retained dependents does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_invoice_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED, + CONSTRAINT invoice_totals_total_nonnegative CHECK (total >= 0) + ); + + CREATE INDEX invoice_totals_total_idx + ON test_schema.invoice_totals (total); + + CREATE VIEW test_schema.invoice_total_values AS + SELECT id, total FROM test_schema.invoice_totals; + + INSERT INTO test_schema.invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + DROP VIEW test_schema.invoice_total_values; + + DROP INDEX test_schema.invoice_totals_total_idx; + + ALTER TABLE test_schema.invoice_totals + DROP CONSTRAINT invoice_totals_total_nonnegative; + + ALTER TABLE test_schema.invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_invoice_total(integer); + + CREATE FUNCTION test_schema.compute_invoice_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED; + + ALTER TABLE test_schema.invoice_totals + ADD CONSTRAINT invoice_totals_total_nonnegative CHECK (total >= 0); + + CREATE INDEX invoice_totals_total_idx + ON test_schema.invoice_totals (total); + + CREATE VIEW test_schema.invoice_total_values AS + SELECT id, total FROM test_schema.invoice_totals; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropViewIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP VIEW test_schema.invoice_total_values", + ), + ); + const dropIndexIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP INDEX test_schema.invoice_totals_total_idx", + ), + ); + const dropConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP CONSTRAINT invoice_totals_total_nonnegative", + ), + ); + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_invoice_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_invoice_total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD COLUMN total", + ), + ); + const addConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD CONSTRAINT invoice_totals_total_nonnegative", + ), + ); + const createIndexIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE INDEX invoice_totals_total_idx ON test_schema.invoice_totals", + ), + ); + const createViewIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE VIEW test_schema.invoice_total_values", + ), + ); + + expect(dropViewIndex).toBeGreaterThanOrEqual(0); + expect(dropIndexIndex).toBeGreaterThanOrEqual(0); + expect(dropConstraintIndex).toBeGreaterThanOrEqual(0); + expect(dropColumnIndex).toBeGreaterThan(dropViewIndex); + expect(dropColumnIndex).toBeGreaterThan(dropIndexIndex); + expect(dropColumnIndex).toBeGreaterThan(dropConstraintIndex); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); + expect(addConstraintIndex).toBeGreaterThan(addColumnIndex); + expect(createIndexIndex).toBeGreaterThan(addColumnIndex); + expect(createViewIndex).toBeGreaterThan(addColumnIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.invoice_totals", + 1, + ); + const { rows } = await db.main.query( + "SELECT to_regclass('test_schema.invoice_totals_total_idx')::text AS index_name", + ); + expect(rows[0]?.index_name).toBe( + "test_schema.invoice_totals_total_idx", + ); + }), + ); + test( "domain default update for replaced function signature does not recreate domain or table", withDb(pgVersion, async (db) => { From 6feec63a8a750d55bec8b4c8d95172a779acb891 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 15:07:49 +0800 Subject: [PATCH 14/78] fix(pg-delta): preserve generated column dependents Continue walking from generated columns recreated by the procedure-expression fallback, and recreate column-dependent constraints directly instead of promoting the owner table. GREEN: focused unit regression -> 1 pass / 0 fail; focused PG15 integration regression -> 1 pass / 0 fail. Signed-off-by: xiaominghao --- .../src/core/expand-replace-dependencies.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 98f3711fc..f4fc79409 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -278,10 +278,33 @@ export function expandReplaceDependencies({ for (const id of change.creates ?? []) createdIds.add(id); for (const id of change.drops ?? []) droppedIds.add(id); } + if ( + shouldTraverseExpressionReplacementDependent({ + dependentRaw, + expressionReplacementChanges, + }) + ) { + queueRefForTraversal(dependentRaw, visitedRefs, queue); + } continue; } } + if ( + maybeAddColumnDependentConstraintReplacement({ + refId, + dependentRaw, + mainCatalog, + branchCatalog, + additions, + createdIds, + droppedIds, + visitedTargets, + }) + ) { + continue; + } + const targetId = normalizeDependentId( dependentRaw, mainCatalog, @@ -378,6 +401,16 @@ export function expandReplaceDependencies({ }; } +function queueRefForTraversal( + refId: string, + visitedRefs: Set, + queue: string[], +) { + if (visitedRefs.has(refId)) return; + visitedRefs.add(refId); + queue.push(refId); +} + function collectInvalidatedRlsPolicyReplacements({ changes, mainCatalog, @@ -570,6 +603,21 @@ function isExpressionContainerStableId(stableId: string): boolean { ); } +function shouldTraverseExpressionReplacementDependent({ + dependentRaw, + expressionReplacementChanges, +}: { + dependentRaw: string; + expressionReplacementChanges: Change[]; +}): boolean { + // Some targeted expression fallbacks are themselves destructive. Recreating a + // generated column releases the procedure dependency but also removes or + // blocks objects that depend on that column, so keep walking from the raw id. + return expressionReplacementChanges.some((change) => + change.drops?.some((id) => id === dependentRaw), + ); +} + function buildExpressionDependentReplacementChanges({ dependentRaw, mainCatalog, @@ -622,6 +670,53 @@ function buildExpressionDependentReplacementChanges({ return null; } +function maybeAddColumnDependentConstraintReplacement({ + refId, + dependentRaw, + mainCatalog, + branchCatalog, + additions, + createdIds, + droppedIds, + visitedTargets, +}: { + refId: string; + dependentRaw: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + additions: Change[]; + createdIds: Set; + droppedIds: Set; + visitedTargets: Set; +}): boolean { + if (!refId.startsWith("column:") || !dependentRaw.startsWith("constraint:")) { + return false; + } + if (visitedTargets.has(dependentRaw)) return true; + + const addRelease = !droppedIds.has(dependentRaw); + const addRestore = !createdIds.has(dependentRaw); + if (!addRelease && !addRestore) return true; + + const constraintReplacementChanges = + buildConstraintExpressionReplacementChanges({ + dependentRaw, + mainCatalog, + branchCatalog, + addRelease, + addRestore, + }); + if (!constraintReplacementChanges) return false; + + additions.push(...constraintReplacementChanges); + visitedTargets.add(dependentRaw); + for (const change of constraintReplacementChanges) { + for (const id of change.creates ?? []) createdIds.add(id); + for (const id of change.drops ?? []) droppedIds.add(id); + } + return true; +} + function buildColumnExpressionReplacementChanges({ dependentRaw, mainCatalog, From 71bdf50a01bacc8771964e7bf7882bc5c0002d41 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 15:40:23 +0800 Subject: [PATCH 15/78] test(pg-delta): cover generated set expression release RED before the fix: - unit regression expected one AlterTableDropColumn for the generated column fallback but received 0. - PG17 integration regression still emitted SET EXPRESSION AS for the generated column update. Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 77 ++++++++++++++++ ...-signature-expression-dependencies.test.ts | 90 +++++++++++++++++++ 2 files changed, 167 insertions(+) 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 a3e3d34c2..33513112e 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -1522,6 +1522,83 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("does not count generated SET EXPRESSION as release coverage for same-signature procedure replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value + 1)", { + is_generated: true, + }); + const setExpression = new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchTable.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + setExpression, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect(expanded.changes).not.toContain(setExpression); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + test("promotes retained dependents of a recreated generated column", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index 86947241e..fef70c7bb 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -400,6 +400,96 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test.skipIf(pgVersion < 17)( + "generated column update for replaced function argument name does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_invoice_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED + ); + + INSERT INTO test_schema.invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_invoice_total(integer); + + CREATE FUNCTION test_schema.compute_invoice_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal + 1)) STORED; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + expect( + sqlStatements.some((statement) => + statement.includes(" SET EXPRESSION AS "), + ), + ).toBe(false); + + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_invoice_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_invoice_total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD COLUMN total", + ), + ); + + expect(dropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.invoice_totals", + 1, + ); + }), + ); + test( "unchanged generated column for replaced function argument name does not recreate table", withDb(pgVersion, async (db) => { From 37e2dc1371592b6fe271ed9091c4041108f6fda1 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 15:40:32 +0800 Subject: [PATCH 16/78] fix(pg-delta): release generated set expressions with fallback SET EXPRESSION is restore coverage only; it does not release the old generated-column pg_depend edge before a same-stable-id function drop. When the fallback recreates the generated column, remove the superseded original SET EXPRESSION change. GREEN focused validation: - PGDELTA_TEST_POSTGRES_VERSIONS=17 ./node_modules/.bin/bun run --cwd packages/pg-delta test src/core/expand-replace-dependencies.test.ts --test-name-pattern "SET EXPRESSION as release coverage" -> 1 pass / 0 fail - PGDELTA_TEST_POSTGRES_VERSIONS=17 ./node_modules/.bin/bun run --cwd packages/pg-delta test tests/integration/function-signature-expression-dependencies.test.ts --test-name-pattern "generated column update for replaced function argument name" -> 1 pass / 0 fail Signed-off-by: xiaominghao --- .../src/core/expand-replace-dependencies.ts | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index f4fc79409..b430796df 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -223,6 +223,7 @@ 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 generatedColumnsRecreatedByExpressionFallback = new Set(); while (queue.length > 0) { const refId = queue.shift() as string; @@ -268,6 +269,14 @@ export function expandReplaceDependencies({ if (expressionReplacementChanges !== null) { additions.push(...expressionReplacementChanges); + if ( + recreatesExpressionDependent({ + dependentRaw, + expressionReplacementChanges, + }) + ) { + generatedColumnsRecreatedByExpressionFallback.add(dependentRaw); + } if (!releaseCovered) { expressionDependentCoverage.release.add(dependentRaw); } @@ -394,7 +403,10 @@ export function expandReplaceDependencies({ return { changes: [ - ...removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds), + ...removeSupersededGeneratedColumnSetExpressions( + removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds), + generatedColumnsRecreatedByExpressionFallback, + ), ...additions, ], replacedTableIds: tablesReplacedByExpansion, @@ -542,10 +554,11 @@ function collectExpressionDependentCoverage( if (change instanceof AlterTableAlterColumnSetDefault) { for (const id of change.requires ?? []) { if (isExpressionContainerStableId(id)) { + // SET EXPRESSION installs the branch expression in the create/alter + // phase; it does not release the old pg_depend edge before DROP + // FUNCTION runs, so generated columns still need a drop-phase + // fallback when a replaced procedure keeps the same stable id. restore.add(id); - if (change.column.is_generated) { - release.add(id); - } } } } @@ -618,6 +631,42 @@ function shouldTraverseExpressionReplacementDependent({ ); } +function recreatesExpressionDependent({ + dependentRaw, + expressionReplacementChanges, +}: { + dependentRaw: string; + expressionReplacementChanges: Change[]; +}): boolean { + let dropsDependent = false; + let createsDependent = false; + for (const change of expressionReplacementChanges) { + dropsDependent ||= change.drops?.some((id) => id === dependentRaw) ?? false; + createsDependent ||= + change.creates?.some((id) => id === dependentRaw) ?? false; + } + return dropsDependent && createsDependent; +} + +function removeSupersededGeneratedColumnSetExpressions( + changes: Change[], + recreatedColumnIds: ReadonlySet, +): Change[] { + if (recreatedColumnIds.size === 0) return changes; + + return changes.filter((change) => { + if (!(change instanceof AlterTableAlterColumnSetDefault)) return true; + if (!change.column.is_generated) return true; + + const columnId = stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ); + return !recreatedColumnIds.has(columnId); + }); +} + function buildExpressionDependentReplacementChanges({ dependentRaw, mainCatalog, From cef4b23224c5dbd9368c63bcdb6defdc6336fc76 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 16:06:02 +0800 Subject: [PATCH 17/78] test(pg-delta): cover commented generated column fallback Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 87 +++++++++++++++ ...-signature-expression-dependencies.test.ts | 103 ++++++++++++++++++ 2 files changed, 190 insertions(+) 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 33513112e..a3b40d6db 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -43,6 +43,7 @@ import { AlterTableSetReplicaIdentity, AlterTableValidateConstraint, } from "./objects/table/changes/table.alter.ts"; +import { CreateCommentOnColumn } 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"; @@ -1731,6 +1732,92 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("restores retained comments without replacing the table when walking a recreated generated column", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + comment: "computed value", + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + comment: "computed value", + }); + const columnId = "column:public.items.value"; + const commentId = `comment:${columnId}`; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: commentId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof CreateCommentOnColumn, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + test("synthesizes a table check constraint replacement for an unchanged expression that depends on a replaced procedure", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index fef70c7bb..861e82f2d 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -574,6 +574,109 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "unchanged commented generated column for replaced function argument name does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_invoice_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED + ); + + COMMENT ON COLUMN test_schema.invoice_totals.total + IS 'computed invoice total'; + + INSERT INTO test_schema.invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_invoice_total(integer); + + CREATE FUNCTION test_schema.compute_invoice_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED; + + COMMENT ON COLUMN test_schema.invoice_totals.total + IS 'computed invoice total'; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_invoice_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_invoice_total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD COLUMN total", + ), + ); + const commentIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "COMMENT ON COLUMN test_schema.invoice_totals.total", + ), + ); + + expect(dropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); + expect(commentIndex).toBeGreaterThan(addColumnIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.invoice_totals", + 1, + ); + const { rows } = await db.main.query(dedent` + SELECT col_description(attrelid, attnum) AS comment + FROM pg_catalog.pg_attribute + WHERE attrelid = 'test_schema.invoice_totals'::regclass + AND attname = 'total' + `); + expect(rows[0]?.comment).toBe("computed invoice total"); + }), + ); + test( "unchanged generated column with retained dependents does not recreate table", withDb(pgVersion, async (db) => { From 237899399741365732149fa1b1295e78024e88b0 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 16:06:09 +0800 Subject: [PATCH 18/78] fix(pg-delta): restore comments for generated column fallback Signed-off-by: xiaominghao --- .../src/core/expand-replace-dependencies.ts | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index b430796df..709442712 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -27,7 +27,10 @@ import { AlterTableDropColumn, AlterTableDropConstraint, } from "./objects/table/changes/table.alter.ts"; -import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.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 { CreateCompositeType } from "./objects/type/composite-type/changes/composite-type.create.ts"; @@ -241,6 +244,15 @@ export function expandReplaceDependencies({ ) { continue; } + if ( + generatedColumnsRecreatedByExpressionFallback.has(refId) && + isMetadataDependentStableId(dependentRaw) + ) { + // Column comments are metadata for the recreated column itself. The + // drop/add fallback restores them directly, so walking the comment edge + // would incorrectly promote `comment:column:*` to the owning table. + continue; + } if ( shouldHandleProcedureExpressionDependent({ @@ -263,6 +275,7 @@ export function expandReplaceDependencies({ mainCatalog, branchCatalog, diffContext, + createdIds, addRelease: !releaseCovered, addRestore: !restoreCovered, }); @@ -616,6 +629,10 @@ function isExpressionContainerStableId(stableId: string): boolean { ); } +function isMetadataDependentStableId(stableId: string): boolean { + return stableId.startsWith("comment:"); +} + function shouldTraverseExpressionReplacementDependent({ dependentRaw, expressionReplacementChanges, @@ -672,6 +689,7 @@ function buildExpressionDependentReplacementChanges({ mainCatalog, branchCatalog, diffContext, + createdIds, addRelease, addRestore, }: { @@ -682,6 +700,7 @@ function buildExpressionDependentReplacementChanges({ ObjectDiffContext, "version" | "currentUser" | "defaultPrivilegeState" >; + createdIds: ReadonlySet; addRelease: boolean; addRestore: boolean; }): Change[] | null { @@ -691,6 +710,7 @@ function buildExpressionDependentReplacementChanges({ mainCatalog, branchCatalog, diffContext, + createdIds, addRelease, addRestore, }); @@ -771,6 +791,7 @@ function buildColumnExpressionReplacementChanges({ mainCatalog, branchCatalog, diffContext, + createdIds, addRelease, addRestore, }: { @@ -781,6 +802,7 @@ function buildColumnExpressionReplacementChanges({ ObjectDiffContext, "version" | "currentUser" | "defaultPrivilegeState" >; + createdIds: ReadonlySet; addRelease: boolean; addRestore: boolean; }): Change[] | null { @@ -818,6 +840,9 @@ function buildColumnExpressionReplacementChanges({ branchColumn.default !== null; if (addRelease && canRecreateGeneratedColumn) { + const commentRestoreCovered = createdIds.has( + stableId.comment(dependentRaw), + ); // DROP DEFAULT is invalid for generated columns, while DROP EXPRESSION // turns the column into a regular column that SET EXPRESSION cannot // restore. Recreating the column releases the dependency without relying @@ -831,6 +856,14 @@ function buildColumnExpressionReplacementChanges({ table: branchTable, column: branchColumn, }), + ...(branchColumn.comment !== null && !commentRestoreCovered + ? [ + new CreateCommentOnColumn({ + table: branchTable, + column: branchColumn, + }), + ] + : []), ]; } From a58c429bd9f1f47e7b159a6db540ddb1bda15adf Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 16:21:57 +0800 Subject: [PATCH 19/78] test(pg-delta): cover retained generated column labels Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 89 +++++++++++++++++++ .../security-label-operations.test.ts | 78 +++++++++++++++- 2 files changed, 166 insertions(+), 1 deletion(-) 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 a3b40d6db..4a3937871 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -47,6 +47,7 @@ import { CreateCommentOnColumn } 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, type TableProps } from "./objects/table/table.model.ts"; import { CreateEnum } from "./objects/type/enum/changes/enum.create.ts"; import { DropEnum } from "./objects/type/enum/changes/enum.drop.ts"; @@ -1818,6 +1819,94 @@ describe("expandReplaceDependencies", () => { expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); }); + test("restores retained security labels without replacing the table when recreating a generated column", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const securityLabels = [{ provider: "dummy", label: "classified" }]; + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + security_labels: securityLabels, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + security_labels: securityLabels, + }); + const columnId = "column:public.items.value"; + const securityLabelId = + "securityLabel:column:public.items.value::provider:dummy"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: securityLabelId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof CreateSecurityLabelOnColumn, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + test("synthesizes a table check constraint replacement for an unchanged expression that depends on a replaced procedure", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/tests/integration/security-label-operations.test.ts b/packages/pg-delta/tests/integration/security-label-operations.test.ts index a8cfe3cec..be05a8974 100644 --- a/packages/pg-delta/tests/integration/security-label-operations.test.ts +++ b/packages/pg-delta/tests/integration/security-label-operations.test.ts @@ -1,4 +1,4 @@ -import { describe, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { POSTGRES_VERSIONS } from "../constants.ts"; import { shouldSkipDummySeclabelBuild } from "../postgres-alpine.ts"; import { withDb, withDbIsolated } from "../utils.ts"; @@ -91,6 +91,82 @@ for (const pgVersion of POSTGRES_VERSIONS) { }); }), ); + + test( + "retained label on recreated generated column", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: ` + ${DUMMY_PROVIDER_SETUP} + CREATE FUNCTION public.compute_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE public.invoices ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (public.compute_total(subtotal)) STORED + ); + + SECURITY LABEL FOR dummy ON COLUMN public.invoices.total + IS 'classified'; + `, + testSql: ` + ALTER TABLE public.invoices DROP COLUMN total; + + DROP FUNCTION public.compute_total(integer); + + CREATE FUNCTION public.compute_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE public.invoices + ADD COLUMN total integer GENERATED ALWAYS AS + (public.compute_total(subtotal)) STORED; + + SECURITY LABEL FOR dummy ON COLUMN public.invoices.total + IS 'classified'; + `, + assertSqlStatements: (sqlStatements) => { + expect( + sqlStatements.some((statement) => + statement.startsWith("DROP TABLE "), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith("CREATE TABLE "), + ), + ).toBe(false); + + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE public.invoices ADD COLUMN total", + ), + ); + const securityLabelIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "SECURITY LABEL FOR dummy ON COLUMN public.invoices.total", + ), + ); + + expect(addColumnIndex).toBeGreaterThanOrEqual(0); + expect(securityLabelIndex).toBeGreaterThan(addColumnIndex); + }, + }); + }), + ); }, ); From d99fc2e208b8660380f36b29e2dfcce139187031 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 16:22:04 +0800 Subject: [PATCH 20/78] fix(pg-delta): restore labels for generated column fallback Signed-off-by: xiaominghao --- .../src/core/expand-replace-dependencies.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 709442712..05727d112 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -33,6 +33,7 @@ import { } 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 { CreateSecurityLabelOnColumn } from "./objects/table/changes/table.security-label.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"; @@ -248,9 +249,9 @@ export function expandReplaceDependencies({ generatedColumnsRecreatedByExpressionFallback.has(refId) && isMetadataDependentStableId(dependentRaw) ) { - // Column comments are metadata for the recreated column itself. The - // drop/add fallback restores them directly, so walking the comment edge - // would incorrectly promote `comment:column:*` to the owning table. + // Column comments and security labels are metadata for the recreated + // column itself. The drop/add fallback restores them directly, so + // walking those edges must not promote metadata into object replacement. continue; } @@ -630,7 +631,9 @@ function isExpressionContainerStableId(stableId: string): boolean { } function isMetadataDependentStableId(stableId: string): boolean { - return stableId.startsWith("comment:"); + return ( + stableId.startsWith("comment:") || stableId.startsWith("securityLabel:") + ); } function shouldTraverseExpressionReplacementDependent({ @@ -864,6 +867,21 @@ function buildColumnExpressionReplacementChanges({ }), ] : []), + ...(branchColumn.security_labels ?? []) + .filter( + (securityLabel) => + !createdIds.has( + stableId.securityLabel(dependentRaw, securityLabel.provider), + ), + ) + .map( + (securityLabel) => + new CreateSecurityLabelOnColumn({ + table: branchTable, + column: branchColumn, + securityLabel, + }), + ), ]; } From 859cb87123aac80b0cffcb39238d5d617bdb9b50 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 16:59:00 +0800 Subject: [PATCH 21/78] test(pg-delta): cover generated column fallback metadata RED before the fix: - generated-column dependent index comment regression expected CreateCommentOnIndex but received 0. - generated-column retained column grant regression expected GrantTablePrivileges but received 0. - overloaded CHECK restore regression sorted ADD CONSTRAINT before DROP FUNCTION, so the old overload could be rebound. Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 106 +++++++++++++- .../src/core/sort/sort-changes.test.ts | 11 +- ...-signature-expression-dependencies.test.ts | 138 +++++++++++++++++- 3 files changed, 244 insertions(+), 11 deletions(-) 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 4a3937871..1ca6ccc8d 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -16,6 +16,7 @@ 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 { CreateCommentOnIndex } from "./objects/index/changes/index.comment.ts"; import { CreateIndex } from "./objects/index/changes/index.create.ts"; import { DropIndex } from "./objects/index/changes/index.drop.ts"; import { Index, type IndexProps } from "./objects/index/index.model.ts"; @@ -1631,8 +1632,10 @@ describe("expandReplaceDependencies", () => { ...branchTable.columns[0], ...generatedColumn, }; - const mainIndex = indexOnItemsValue(); - const branchIndex = indexOnItemsValue(); + const mainIndex = indexOnItemsValue({ comment: "retained index comment" }); + const branchIndex = indexOnItemsValue({ + comment: "retained index comment", + }); const mainView = viewOnItemsValue(); const branchView = viewOnItemsValue(); const columnId = "column:public.items.value"; @@ -1709,6 +1712,11 @@ describe("expandReplaceDependencies", () => { expect( expanded.changes.filter((change) => change instanceof CreateIndex), ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof CreateCommentOnIndex, + ), + ).toHaveLength(1); expect( expanded.changes.filter((change) => change instanceof DropView), ).toHaveLength(1); @@ -1819,6 +1827,100 @@ describe("expandReplaceDependencies", () => { expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); }); + test("restores retained column grants without replacing the table when recreating a generated column", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const retainedPrivileges = [ + { + grantee: "value_reader", + privilege: "SELECT", + grantable: false, + columns: ["value"], + }, + ]; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }), + privileges: retainedPrivileges, + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }), + privileges: retainedPrivileges, + }); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof GrantTablePrivileges, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + test("restores retained security labels without replacing the table when recreating a generated column", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); 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 3d2c70493..83d789043 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -562,8 +562,8 @@ describe("sortChanges", () => { expect(sorted.map(changeLabel)).toEqual([ `CreateProcedure:${branchProcedure.stableId}`, - "AlterTableAlterColumnSetDefault", `DropProcedure:${mainProcedure.stableId}`, + "AlterTableAlterColumnSetDefault", ]); }); @@ -643,17 +643,14 @@ describe("sortChanges", () => { ]); }); - test("orders unchanged table check replacement before dropping the old overloaded function", async () => { + test("drops the old overloaded function before restoring expressions that can still resolve to it", async () => { const mainProcedure = procedure(["integer"]); const branchProcedure = procedure(["bigint"]); const mainTable = table("items", [ checkConstraint("items_value_check", "public.normalize_value(id) > 0"), ]); const branchTable = table("items", [ - checkConstraint( - "items_value_check", - "public.normalize_value(id::bigint) > 0", - ), + checkConstraint("items_value_check", "public.normalize_value(id) > 0"), ]); const changes: Change[] = [ new DropProcedure({ procedure: mainProcedure }), @@ -687,8 +684,8 @@ describe("sortChanges", () => { expect(sorted.map(changeLabel)).toEqual([ "AlterTableDropConstraint:items.items_value_check", `CreateProcedure:${branchProcedure.stableId}`, - "AlterTableAddConstraint:items.items_value_check", `DropProcedure:${mainProcedure.stableId}`, + "AlterTableAddConstraint:items.items_value_check", ]); }); }); diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index 861e82f2d..ca31e0aee 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import dedent from "dedent"; import { POSTGRES_VERSIONS } from "../constants.ts"; -import { withDb } from "../utils.ts"; +import { withDb, withDbIsolated } from "../utils.ts"; import { roundtripFidelityTest } from "./roundtrip.ts"; function expectNoTableReplacement(sqlStatements: string[]) { @@ -678,8 +678,90 @@ for (const pgVersion of POSTGRES_VERSIONS) { ); test( - "unchanged generated column with retained dependents does not recreate table", + "unchanged check constraint for overloaded replacement drops old function before restore", withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.is_valid_score(value integer) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 0 + $function$; + + CREATE TABLE test_schema.score_labels ( + id integer NOT NULL, + value integer NOT NULL, + CONSTRAINT score_labels_value_check + CHECK (test_schema.is_valid_score(value)) + ); + + INSERT INTO test_schema.score_labels (id, value) + VALUES (1, 10); + `, + testSql: dedent` + ALTER TABLE test_schema.score_labels + DROP CONSTRAINT score_labels_value_check; + + CREATE FUNCTION test_schema.is_valid_score(value bigint) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value > 0 + $function$; + + DROP FUNCTION test_schema.is_valid_score(integer); + + ALTER TABLE test_schema.score_labels + ADD CONSTRAINT score_labels_value_check + CHECK (test_schema.is_valid_score(value)); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.score_labels DROP CONSTRAINT score_labels_value_check", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.is_valid_score", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.is_valid_score"), + ); + const addConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.score_labels ADD CONSTRAINT score_labels_value_check", + ), + ); + + expect(dropConstraintIndex).toBeGreaterThanOrEqual(0); + expect(createFunctionIndex).toBeGreaterThan(dropConstraintIndex); + expect(dropFunctionIndex).toBeGreaterThan(createFunctionIndex); + expect(addConstraintIndex).toBeGreaterThan(dropFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.score_labels", + 1, + ); + }), + ); + + test( + "unchanged generated column with retained dependents does not recreate table", + withDbIsolated(pgVersion, async (db) => { await roundtripFidelityTest({ mainSession: db.main, branchSession: db.branch, @@ -705,9 +787,26 @@ for (const pgVersion of POSTGRES_VERSIONS) { CREATE INDEX invoice_totals_total_idx ON test_schema.invoice_totals (total); + COMMENT ON INDEX test_schema.invoice_totals_total_idx + IS 'generated total lookup'; + CREATE VIEW test_schema.invoice_total_values AS SELECT id, total FROM test_schema.invoice_totals; + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM pg_catalog.pg_roles + WHERE rolname = 'invoice_total_reader' + ) THEN + CREATE ROLE invoice_total_reader; + END IF; + END + $$; + + GRANT SELECT (total) + ON TABLE test_schema.invoice_totals TO invoice_total_reader; + INSERT INTO test_schema.invoice_totals (id, subtotal) VALUES (1, 20); `, @@ -742,8 +841,14 @@ for (const pgVersion of POSTGRES_VERSIONS) { CREATE INDEX invoice_totals_total_idx ON test_schema.invoice_totals (total); + COMMENT ON INDEX test_schema.invoice_totals_total_idx + IS 'generated total lookup'; + CREATE VIEW test_schema.invoice_total_values AS SELECT id, total FROM test_schema.invoice_totals; + + GRANT SELECT (total) + ON TABLE test_schema.invoice_totals TO invoice_total_reader; `, assertSqlStatements: (sqlStatements) => { expectNoTableReplacement(sqlStatements); @@ -793,11 +898,22 @@ for (const pgVersion of POSTGRES_VERSIONS) { "CREATE INDEX invoice_totals_total_idx ON test_schema.invoice_totals", ), ); + const indexCommentIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "COMMENT ON INDEX test_schema.invoice_totals_total_idx", + ), + ); const createViewIndex = sqlStatements.findIndex((statement) => statement.startsWith( "CREATE VIEW test_schema.invoice_total_values", ), ); + const grantColumnIndex = sqlStatements.findIndex( + (statement) => + statement.includes("SELECT (total)") && + statement.includes("test_schema.invoice_totals") && + statement.includes("invoice_total_reader"), + ); expect(dropViewIndex).toBeGreaterThanOrEqual(0); expect(dropIndexIndex).toBeGreaterThanOrEqual(0); @@ -810,7 +926,9 @@ for (const pgVersion of POSTGRES_VERSIONS) { expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); expect(addConstraintIndex).toBeGreaterThan(addColumnIndex); expect(createIndexIndex).toBeGreaterThan(addColumnIndex); + expect(indexCommentIndex).toBeGreaterThan(createIndexIndex); expect(createViewIndex).toBeGreaterThan(addColumnIndex); + expect(grantColumnIndex).toBeGreaterThan(addColumnIndex); }, }); @@ -825,6 +943,22 @@ for (const pgVersion of POSTGRES_VERSIONS) { expect(rows[0]?.index_name).toBe( "test_schema.invoice_totals_total_idx", ); + const { rows: commentRows } = await db.main.query(dedent` + SELECT obj_description( + 'test_schema.invoice_totals_total_idx'::regclass, + 'pg_class' + ) AS comment + `); + expect(commentRows[0]?.comment).toBe("generated total lookup"); + const { rows: privilegeRows } = await db.main.query(dedent` + SELECT has_column_privilege( + 'invoice_total_reader', + 'test_schema.invoice_totals', + 'total', + 'SELECT' + ) AS has_privilege + `); + expect(privilegeRows[0]?.has_privilege).toBe(true); }), ); From f0d89c1ef6faced69b957832096f48a46d840781 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 16:59:08 +0800 Subject: [PATCH 22/78] fix(pg-delta): restore generated column fallback metadata Restore retained column grants and index comments when same-signature procedure replacement uses the generated-column drop/add fallback, and order expression restores after the old overload is dropped so PostgreSQL cannot bind restored expressions back to the old signature. Signed-off-by: xiaominghao --- .../src/core/expand-replace-dependencies.ts | 112 ++++++++++++++++++ .../src/core/sort/custom-constraints.ts | 7 +- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 05727d112..2e2901918 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -19,6 +19,7 @@ import { DropProcedure } from "./objects/procedure/changes/procedure.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 { CreateCommentOnIndex } from "./objects/index/changes/index.comment.ts"; import { AlterTableAddColumn, AlterTableAddConstraint, @@ -33,6 +34,7 @@ import { } 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 { CreateCompositeType } from "./objects/type/composite-type/changes/composite-type.create.ts"; import { DropCompositeType } from "./objects/type/composite-type/changes/composite-type.drop.ts"; @@ -277,6 +279,7 @@ export function expandReplaceDependencies({ branchCatalog, diffContext, createdIds, + existingChanges: changes, addRelease: !releaseCovered, addRestore: !restoreCovered, }); @@ -693,6 +696,7 @@ function buildExpressionDependentReplacementChanges({ branchCatalog, diffContext, createdIds, + existingChanges, addRelease, addRestore, }: { @@ -704,6 +708,7 @@ function buildExpressionDependentReplacementChanges({ "version" | "currentUser" | "defaultPrivilegeState" >; createdIds: ReadonlySet; + existingChanges: readonly Change[]; addRelease: boolean; addRestore: boolean; }): Change[] | null { @@ -714,6 +719,7 @@ function buildExpressionDependentReplacementChanges({ branchCatalog, diffContext, createdIds, + existingChanges, addRelease, addRestore, }); @@ -795,6 +801,7 @@ function buildColumnExpressionReplacementChanges({ branchCatalog, diffContext, createdIds, + existingChanges, addRelease, addRestore, }: { @@ -806,6 +813,7 @@ function buildColumnExpressionReplacementChanges({ "version" | "currentUser" | "defaultPrivilegeState" >; createdIds: ReadonlySet; + existingChanges: readonly Change[]; addRelease: boolean; addRestore: boolean; }): Change[] | null { @@ -882,6 +890,14 @@ function buildColumnExpressionReplacementChanges({ securityLabel, }), ), + // Column ACLs are stored on the dropped attribute. Unchanged grants do + // not appear in the normal diff, so the fallback must replay them. + ...buildRetainedColumnGrantChanges({ + table: branchTable, + columnName: branchColumn.name, + existingChanges, + diffContext, + }), ]; } @@ -966,6 +982,99 @@ function buildDomainDefaultReplacementChanges({ return changes; } +function buildRetainedColumnGrantChanges({ + table, + columnName, + existingChanges, + diffContext, +}: { + table: Catalog["tables"][string]; + columnName: string; + existingChanges: readonly Change[]; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): Change[] { + const grantsByKey = new Map< + string, + { + grantee: string; + grantable: boolean; + privileges: Set; + } + >(); + + for (const privilege of table.privileges) { + if (!privilege.columns?.includes(columnName)) continue; + if (privilege.grantee === table.owner) continue; + if ( + isColumnGrantCovered({ + existingChanges, + tableStableId: table.stableId, + grantee: privilege.grantee, + columnName, + privilege: privilege.privilege, + grantable: privilege.grantable, + }) + ) { + continue; + } + + const key = `${privilege.grantee}\0${privilege.grantable}`; + const group = grantsByKey.get(key) ?? { + grantee: privilege.grantee, + grantable: privilege.grantable, + privileges: new Set(), + }; + group.privileges.add(privilege.privilege); + grantsByKey.set(key, group); + } + + return [...grantsByKey.values()].map( + (group) => + new GrantTablePrivileges({ + table, + grantee: group.grantee, + privileges: [...group.privileges].map((privilege) => ({ + privilege, + grantable: group.grantable, + })), + columns: [columnName], + version: diffContext?.version, + }), + ); +} + +function isColumnGrantCovered({ + existingChanges, + tableStableId, + grantee, + columnName, + privilege, + grantable, +}: { + existingChanges: readonly Change[]; + tableStableId: string; + grantee: string; + columnName: string; + privilege: string; + grantable: boolean; +}): boolean { + return existingChanges.some( + (change) => + change instanceof GrantTablePrivileges && + change.table.stableId === tableStableId && + change.grantee === grantee && + change.columns?.includes(columnName) === true && + change.privileges.some( + (grantedPrivilege) => + grantedPrivilege.privilege === privilege && + grantedPrivilege.grantable === grantable, + ), + ); +} + function buildConstraintExpressionReplacementChanges({ dependentRaw, mainCatalog, @@ -1418,6 +1527,9 @@ function buildReplaceChanges( index: resolved.branch, indexableObject: resolved.branchIndexableObject, }), + ...(resolved.branch.comment !== null + ? [new CreateCommentOnIndex({ index: resolved.branch })] + : []), ] : []), ]; diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index dfbea055b..a37da4535 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -212,9 +212,12 @@ function generateProcedureSignatureReplacementConstraints( } for (const expressionUpdateIndex of expressionUpdateIndexes) { + // Restore expressions only after the old overload is gone; otherwise + // PostgreSQL can bind the recreated expression back to the exact old + // signature and make the following DROP FUNCTION fail. constraints.push({ - sourceChangeIndex: expressionUpdateIndex, - targetChangeIndex: drop.index, + sourceChangeIndex: drop.index, + targetChangeIndex: expressionUpdateIndex, source: "custom", }); } From 663b6302845c3d7243c3a2d8a4c1c475dae55c4a Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 17:38:29 +0800 Subject: [PATCH 23/78] test(pg-delta): cover routine replacement ordering edges RED before the fix: - rebuilt view restore sorted CREATE FUNCTION before DROP FUNCTION, allowing the view to bind the old overload. - defaulted old overload sorted CREATE FUNCTION before DROP FUNCTION, risking PostgreSQL default-argument ambiguity. - old argument domain drop sorted before DROP FUNCTION, so PostgreSQL could reject the domain drop while the old routine still depended on it. Signed-off-by: xiaominghao --- .../src/core/sort/sort-changes.test.ts | 104 +++++++++- ...-signature-expression-dependencies.test.ts | 185 +++++++++++++++++- 2 files changed, 282 insertions(+), 7 deletions(-) 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 83d789043..a65cfac3b 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -7,6 +7,7 @@ import { AlterDomainDropConstraint, AlterDomainDropDefault, } from "../objects/domain/changes/domain.alter.ts"; +import { DropDomain } from "../objects/domain/changes/domain.drop.ts"; import { Domain } from "../objects/domain/domain.model.ts"; import { CreateProcedure } from "../objects/procedure/changes/procedure.create.ts"; import { DropProcedure } from "../objects/procedure/changes/procedure.drop.ts"; @@ -15,6 +16,7 @@ import { AlterPublicationDropTables } from "../objects/publication/changes/publi import { Publication } from "../objects/publication/publication.model.ts"; import { AlterTableAddConstraint, + AlterTableAlterColumnDropDefault, AlterTableAlterColumnSetDefault, AlterTableAlterColumnType, AlterTableDropConstraint, @@ -216,10 +218,10 @@ function view(name: string, columns = [integerColumn("id", 1)]) { }); } -function domain(defaultValue: string | null) { +function domain(defaultValue: string | null, name = "score") { return new Domain({ schema: "public", - name: "score", + name, base_type: "int4", base_type_schema: "pg_catalog", base_type_str: "integer", @@ -311,6 +313,9 @@ function changeLabel(change: Change) { if (change instanceof AlterDomainAddConstraint) { return `${change.constructor.name}:${change.domain.name}.${change.constraint.name}`; } + if (change instanceof DropDomain) { + return `${change.constructor.name}:${change.domain.name}`; + } if (change instanceof AlterTableDropConstraint) { return `${change.constructor.name}:${change.table.name}.${change.constraint.name}`; } @@ -538,6 +543,10 @@ describe("sortChanges", () => { const changes: Change[] = [ new DropProcedure({ procedure: mainProcedure }), new CreateProcedure({ procedure: branchProcedure }), + new AlterTableAlterColumnDropDefault({ + table: branchTable, + column: branchColumn, + }), new AlterTableAlterColumnSetDefault({ table: branchTable, column: branchColumn, @@ -561,8 +570,9 @@ describe("sortChanges", () => { const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); expect(sorted.map(changeLabel)).toEqual([ - `CreateProcedure:${branchProcedure.stableId}`, + "AlterTableAlterColumnDropDefault", `DropProcedure:${mainProcedure.stableId}`, + `CreateProcedure:${branchProcedure.stableId}`, "AlterTableAlterColumnSetDefault", ]); }); @@ -683,9 +693,95 @@ describe("sortChanges", () => { expect(sorted.map(changeLabel)).toEqual([ "AlterTableDropConstraint:items.items_value_check", - `CreateProcedure:${branchProcedure.stableId}`, `DropProcedure:${mainProcedure.stableId}`, + `CreateProcedure:${branchProcedure.stableId}`, "AlterTableAddConstraint:items.items_value_check", ]); }); + + test("drops the old overloaded function before restoring rebuilt views that can still resolve to it", async () => { + const mainProcedure = procedure(["integer"]); + const branchProcedure = procedure(["bigint"]); + const mainView = view("score_view"); + const branchView = view("score_view"); + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new CreateView({ view: branchView, orReplace: true }), + new DropView({ view: mainView }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: mainView.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([ + { + dependent_stable_id: branchView.stableId, + referenced_stable_id: branchProcedure.stableId, + deptype: "n", + }, + ]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "DropView", + `DropProcedure:${mainProcedure.stableId}`, + `CreateProcedure:${branchProcedure.stableId}`, + "CreateView", + ]); + }); + + test("drops defaulted old overloads before creating shorter replacements", async () => { + const mainProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...procedure(["integer", "integer"]), + argument_default_count: 1, + argument_defaults: "DEFAULT 1", + }); + const branchProcedure = procedure(["integer"]); + const changes: Change[] = [ + new CreateProcedure({ procedure: branchProcedure }), + new DropProcedure({ procedure: mainProcedure }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + `DropProcedure:${mainProcedure.stableId}`, + `CreateProcedure:${branchProcedure.stableId}`, + ]); + }); + + test("drops old routines before dropping their old argument domains", async () => { + const oldDomain = domain(null, "old_score"); + const mainProcedure = procedure(["public.old_score"]); + const branchProcedure = procedure(["integer"]); + const changes: Change[] = [ + new DropDomain({ domain: oldDomain }), + new CreateProcedure({ procedure: branchProcedure }), + new DropProcedure({ procedure: mainProcedure }), + ]; + const mainCatalog = await catalogWithDepends([ + { + dependent_stable_id: mainProcedure.stableId, + referenced_stable_id: oldDomain.stableId, + deptype: "n", + }, + ]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + `DropProcedure:${mainProcedure.stableId}`, + "DropDomain:old_score", + `CreateProcedure:${branchProcedure.stableId}`, + ]); + }); }); diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index ca31e0aee..d8fab0ff0 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -745,9 +745,9 @@ for (const pgVersion of POSTGRES_VERSIONS) { ); expect(dropConstraintIndex).toBeGreaterThanOrEqual(0); - expect(createFunctionIndex).toBeGreaterThan(dropConstraintIndex); - expect(dropFunctionIndex).toBeGreaterThan(createFunctionIndex); - expect(addConstraintIndex).toBeGreaterThan(dropFunctionIndex); + expect(dropFunctionIndex).toBeGreaterThan(dropConstraintIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addConstraintIndex).toBeGreaterThan(createFunctionIndex); }, }); @@ -1116,5 +1116,184 @@ for (const pgVersion of POSTGRES_VERSIONS) { ); }), ); + + test( + "rebuilt view for overloaded replacement drops old function before restore", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.score_label(value integer) + RETURNS text + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT 'old:' || value::text + $function$; + + CREATE TABLE test_schema.score_labels ( + id integer NOT NULL, + value integer NOT NULL + ); + + CREATE VIEW test_schema.score_label_view AS + SELECT id, test_schema.score_label(value) AS label + FROM test_schema.score_labels; + + INSERT INTO test_schema.score_labels (id, value) + VALUES (1, 10); + `, + testSql: dedent` + DROP VIEW test_schema.score_label_view; + + CREATE FUNCTION test_schema.score_label(value bigint) + RETURNS text + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT 'new:' || value::text + $function$; + + DROP FUNCTION test_schema.score_label(integer); + + CREATE VIEW test_schema.score_label_view AS + SELECT id, test_schema.score_label(value) AS label + FROM test_schema.score_labels; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropViewIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP VIEW test_schema.score_label_view"), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.score_label"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE FUNCTION test_schema.score_label"), + ); + const createViewIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith("CREATE") && + statement.includes("VIEW test_schema.score_label_view"), + ); + + expect(dropViewIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropViewIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(createViewIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.score_labels", + 1, + ); + }), + ); + + test( + "defaulted old overload drops before shorter replacement is created", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.normalize_score( + value integer, + scale integer DEFAULT 1 + ) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value * scale + $function$; + `, + testSql: dedent` + DROP FUNCTION test_schema.normalize_score(integer, integer); + + CREATE FUNCTION test_schema.normalize_score(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + `, + assertSqlStatements: (sqlStatements) => { + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.normalize_score"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.normalize_score", + ), + ); + + expect(dropFunctionIndex).toBeGreaterThanOrEqual(0); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + }, + }); + }), + ); + + test( + "old argument domain drops after the old overloaded function", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE DOMAIN test_schema.old_score AS integer; + + CREATE FUNCTION test_schema.normalize_score(value test_schema.old_score) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value::integer + $function$; + `, + testSql: dedent` + DROP FUNCTION test_schema.normalize_score(test_schema.old_score); + DROP DOMAIN test_schema.old_score; + + CREATE FUNCTION test_schema.normalize_score(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + `, + assertSqlStatements: (sqlStatements) => { + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.normalize_score"), + ); + const dropDomainIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP DOMAIN test_schema.old_score"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.normalize_score", + ), + ); + + expect(dropFunctionIndex).toBeGreaterThanOrEqual(0); + expect(dropDomainIndex).toBeGreaterThan(dropFunctionIndex); + expect(createFunctionIndex).toBeGreaterThan(dropDomainIndex); + }, + }); + }), + ); }); } From f152b4158c0b6c9e8012447e6a182c31aff4b610 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 17:38:48 +0800 Subject: [PATCH 24/78] fix(pg-delta): keep routine drops in drop phase Signed-off-by: xiaominghao --- .../src/core/sort/custom-constraints.ts | 85 ------------------- .../pg-delta/src/core/sort/sort-changes.ts | 65 ++------------ 2 files changed, 5 insertions(+), 145 deletions(-) diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index a37da4535..c1c5ecdc8 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -1,11 +1,5 @@ import type { Change } from "../change.types.ts"; import { getSchema } from "../change-utils.ts"; -import { - AlterDomainAddConstraint, - AlterDomainSetDefault, -} from "../objects/domain/changes/domain.alter.ts"; -import { CreateProcedure } from "../objects/procedure/changes/procedure.create.ts"; -import { DropProcedure } from "../objects/procedure/changes/procedure.drop.ts"; import { GrantRoleDefaultPrivileges, RevokeRoleDefaultPrivileges, @@ -15,7 +9,6 @@ import { AlterTableAlterColumnDropDefault, AlterTableAlterColumnDropIdentity, AlterTableAlterColumnSetDefault, - AlterTableAddConstraint, } from "../objects/table/changes/table.alter.ts"; import type { Constraint } from "./types.ts"; @@ -156,76 +149,6 @@ function generateDefaultPrivilegeConstraints(changes: Change[]): Constraint[] { return constraints; } -function generateProcedureSignatureReplacementConstraints( - changes: Change[], -): Constraint[] { - const constraints: Constraint[] = []; - const createProcedureIndexesByName = new Map(); - const createdProcedureIds = new Set(); - const dropProcedureIndexes: Array<{ - index: number; - id: string; - key: string; - }> = []; - const expressionUpdateIndexes: number[] = []; - - for (let i = 0; i < changes.length; i++) { - const change = changes[i]; - - if (change instanceof CreateProcedure) { - for (const id of change.creates ?? []) { - createdProcedureIds.add(id); - const key = parseProcedureSchemaName(id); - if (!key) continue; - const entries = createProcedureIndexesByName.get(key) ?? []; - entries.push(i); - createProcedureIndexesByName.set(key, entries); - } - } else if (change instanceof DropProcedure) { - for (const id of change.drops ?? []) { - const key = parseProcedureSchemaName(id); - if (key) { - dropProcedureIndexes.push({ index: i, id, key }); - } - } - } else if ( - change instanceof AlterTableAlterColumnSetDefault || - change instanceof AlterDomainSetDefault || - change instanceof AlterTableAddConstraint || - change instanceof AlterDomainAddConstraint - ) { - expressionUpdateIndexes.push(i); - } - } - - for (const drop of dropProcedureIndexes) { - if (createdProcedureIds.has(drop.id)) continue; - const createIndexes = createProcedureIndexesByName.get(drop.key) ?? []; - if (createIndexes.length === 0) continue; - - for (const createIndex of createIndexes) { - constraints.push({ - sourceChangeIndex: createIndex, - targetChangeIndex: drop.index, - source: "custom", - }); - } - - for (const expressionUpdateIndex of expressionUpdateIndexes) { - // Restore expressions only after the old overload is gone; otherwise - // PostgreSQL can bind the recreated expression back to the exact old - // signature and make the following DROP FUNCTION fail. - constraints.push({ - sourceChangeIndex: drop.index, - targetChangeIndex: expressionUpdateIndex, - source: "custom", - }); - } - } - - return constraints; -} - function generateIdentityTransitionConstraints( changes: Change[], ): Constraint[] { @@ -298,7 +221,6 @@ function generateIdentityTransitionConstraints( */ const customConstraintGenerators: ConstraintGenerator[] = [ generateDefaultPrivilegeConstraints, - generateProcedureSignatureReplacementConstraints, generateIdentityTransitionConstraints, ]; @@ -311,10 +233,3 @@ const customConstraintGenerators: ConstraintGenerator[] = [ export function generateCustomConstraints(changes: Change[]): Constraint[] { return customConstraintGenerators.flatMap((generate) => generate(changes)); } - -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); -} diff --git a/packages/pg-delta/src/core/sort/sort-changes.ts b/packages/pg-delta/src/core/sort/sort-changes.ts index 7a55b35da..6030678ee 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.ts @@ -14,7 +14,6 @@ import debug from "debug"; import type { Catalog } from "../catalog.model.ts"; import type { Change } from "../change.types.ts"; -import { DropProcedure } from "../objects/procedure/changes/procedure.drop.ts"; import { generateCustomConstraints } from "./custom-constraints.ts"; import { tryBreakCycleByChangeInjection } from "./cycle-breakers.ts"; import { printDebugGraph } from "./debug-visualization.ts"; @@ -92,19 +91,17 @@ function sortChangesByPhasedGraph( }, changeList: Change[], ): Change[] { - const createAfterDropProcedureIds = - findSignatureReplacedProcedureDropIds(changeList); const changesByPhase: Record = { drop: [], create_alter_object: [], }; - // Partition changes into execution phases + // Keep routine drops in the drop phase even for same-name signature + // replacements. Dependent expressions/views are released in this phase and + // restored in create/alter; moving the routine drop later breaks old + // dependency drops such as argument domains and defaulted overloads. for (const changeItem of changeList) { - const phase = getContextualExecutionPhase( - changeItem, - createAfterDropProcedureIds, - ); + const phase = getExecutionPhase(changeItem); changesByPhase[phase].push(changeItem); } @@ -125,58 +122,6 @@ function sortChangesByPhasedGraph( return [...sortedDropPhase, ...sortedCreateAlterPhase]; } -function getContextualExecutionPhase( - change: Change, - createAfterDropProcedureIds: ReadonlySet, -): Phase { - if ( - change instanceof DropProcedure && - change.drops.some((id) => createAfterDropProcedureIds.has(id)) - ) { - return "create_alter_object"; - } - - return getExecutionPhase(change); -} - -function findSignatureReplacedProcedureDropIds(changes: Change[]): Set { - const createdProcedureNames = new Set(); - const createdProcedureIds = new Set(); - for (const change of changes) { - if (change.objectType !== "procedure" || change.operation !== "create") { - continue; - } - for (const id of change.creates ?? []) { - createdProcedureIds.add(id); - const key = parseProcedureSchemaName(id); - if (key) createdProcedureNames.add(key); - } - } - - const ids = new Set(); - for (const change of changes) { - if (change.objectType !== "procedure" || change.operation !== "drop") { - continue; - } - for (const id of change.drops ?? []) { - if (createdProcedureIds.has(id)) continue; - const key = parseProcedureSchemaName(id); - if (key && createdProcedureNames.has(key)) { - ids.add(id); - } - } - } - - return ids; -} - -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); -} - /** * Normalize a cycle by rotating it to start with the smallest node index, so * cycles that loop through the same nodes in the same direction compare equal From 2f376f4a3df455586917f237c774fe1bfc6abac4 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 18:31:36 +0800 Subject: [PATCH 25/78] test(pg-delta): cover partition fallback review edges RED summary before the fix: - partition child generated-column fallback emitted child AlterTableDropColumn - partition-propagated inherited column drops emitted child AlterTableDropColumn - partition-cloned CHECK constraints emitted child AlterTableDropConstraint - recreated retained expression indexes omitted AlterIndexSetStatistics Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 273 +++++++++++++++++- ...-signature-expression-dependencies.test.ts | 219 +++++++++++++- 2 files changed, 489 insertions(+), 3 deletions(-) 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 1ca6ccc8d..0ad5da6b8 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -16,6 +16,7 @@ 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 { 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"; @@ -178,6 +179,22 @@ function tableWithDefault( }); } +function partitionTableWithDefault( + columnDefault: string | null, + columnOverrides: Partial = {}, +): Table { + const table = tableWithDefault(columnDefault, columnOverrides); + return new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...table, + name: "items_2026", + is_partition: true, + partition_bound: "FOR VALUES FROM (2026) TO (2027)", + parent_schema: "public", + parent_name: "items", + }); +} + function domainWithDefault(defaultValue: string | null): Domain { return new Domain({ schema: "public", @@ -1525,6 +1542,156 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("skips generated column fallback on partition children", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = partitionTableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const branchTable = partitionTableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const columnId = "column:public.items_2026.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(0); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + + test("skips child column DDL when partition-propagated drops remove inherited columns", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = partitionTableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...partitionTableWithDefault(null), + columns: [], + }); + const columnId = "column:public.items_2026.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(0); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + test("does not count generated SET EXPRESSION as release coverage for same-signature procedure replacement", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -1632,9 +1799,18 @@ describe("expandReplaceDependencies", () => { ...branchTable.columns[0], ...generatedColumn, }; - const mainIndex = indexOnItemsValue({ comment: "retained index comment" }); + const retainedExpressionIndex = { + index_expressions: "value + 1", + definition: "CREATE INDEX items_value_idx ON public.items ((value + 1))", + statistics_target: [100], + }; + const mainIndex = indexOnItemsValue({ + comment: "retained index comment", + ...retainedExpressionIndex, + }); const branchIndex = indexOnItemsValue({ comment: "retained index comment", + ...retainedExpressionIndex, }); const mainView = viewOnItemsValue(); const branchView = viewOnItemsValue(); @@ -1717,6 +1893,11 @@ describe("expandReplaceDependencies", () => { (change) => change instanceof CreateCommentOnIndex, ), ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterIndexSetStatistics, + ), + ).toHaveLength(1); expect( expanded.changes.filter((change) => change instanceof DropView), ).toHaveLength(1); @@ -2281,6 +2462,96 @@ describe("expandReplaceDependencies", () => { expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); }); + test("skips partition-cloned table check constraints during expression replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const cloneConstraint = { + is_partition_clone: true, + parent_constraint_schema: "public", + parent_constraint_name: "items_value_check", + parent_table_schema: "public", + parent_table_name: "items", + }; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithCheckConstraint("public.normalize_value(value) > 0", { + ...cloneConstraint, + }), + name: "items_2026", + is_partition: true, + partition_bound: "FOR VALUES FROM (2026) TO (2027)", + parent_schema: "public", + parent_name: "items", + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithCheckConstraint("public.normalize_value(value) > 0", { + ...cloneConstraint, + }), + name: "items_2026", + is_partition: true, + partition_bound: "FOR VALUES FROM (2026) TO (2027)", + parent_schema: "public", + parent_name: "items", + }); + const constraintId = "constraint:public.items_2026.items_value_check"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: constraintId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropConstraint, + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddConstraint, + ), + ).toHaveLength(0); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + test("synthesizes one table check constraint replacement when the expression depends on two replaced procedures", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainNormalize = procedureWithArgs(["integer"], "normalize_value"); diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index d8fab0ff0..697a584d2 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -785,7 +785,10 @@ for (const pgVersion of POSTGRES_VERSIONS) { ); CREATE INDEX invoice_totals_total_idx - ON test_schema.invoice_totals (total); + ON test_schema.invoice_totals ((total + 1)); + + ALTER INDEX test_schema.invoice_totals_total_idx + ALTER COLUMN 1 SET STATISTICS 100; COMMENT ON INDEX test_schema.invoice_totals_total_idx IS 'generated total lookup'; @@ -839,7 +842,10 @@ for (const pgVersion of POSTGRES_VERSIONS) { ADD CONSTRAINT invoice_totals_total_nonnegative CHECK (total >= 0); CREATE INDEX invoice_totals_total_idx - ON test_schema.invoice_totals (total); + ON test_schema.invoice_totals ((total + 1)); + + ALTER INDEX test_schema.invoice_totals_total_idx + ALTER COLUMN 1 SET STATISTICS 100; COMMENT ON INDEX test_schema.invoice_totals_total_idx IS 'generated total lookup'; @@ -903,6 +909,12 @@ for (const pgVersion of POSTGRES_VERSIONS) { "COMMENT ON INDEX test_schema.invoice_totals_total_idx", ), ); + const alterIndexStatisticsIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER INDEX test_schema.invoice_totals_total_idx ALTER COLUMN 1 SET STATISTICS 100", + ), + ); const createViewIndex = sqlStatements.findIndex((statement) => statement.startsWith( "CREATE VIEW test_schema.invoice_total_values", @@ -927,6 +939,7 @@ for (const pgVersion of POSTGRES_VERSIONS) { expect(addConstraintIndex).toBeGreaterThan(addColumnIndex); expect(createIndexIndex).toBeGreaterThan(addColumnIndex); expect(indexCommentIndex).toBeGreaterThan(createIndexIndex); + expect(alterIndexStatisticsIndex).toBeGreaterThan(createIndexIndex); expect(createViewIndex).toBeGreaterThan(addColumnIndex); expect(grantColumnIndex).toBeGreaterThan(addColumnIndex); }, @@ -950,6 +963,13 @@ for (const pgVersion of POSTGRES_VERSIONS) { ) AS comment `); expect(commentRows[0]?.comment).toBe("generated total lookup"); + const { rows: statisticsRows } = await db.main.query(dedent` + SELECT attstattarget + FROM pg_catalog.pg_attribute + WHERE attrelid = 'test_schema.invoice_totals_total_idx'::regclass + AND attnum = 1 + `); + expect(statisticsRows[0]?.attstattarget).toBe(100); const { rows: privilegeRows } = await db.main.query(dedent` SELECT has_column_privilege( 'invoice_total_reader', @@ -962,6 +982,201 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "unchanged generated partition column for replaced function argument name does not emit child DDL", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_partition_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.partitioned_invoice_totals ( + id integer NOT NULL, + period integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_partition_total(subtotal)) STORED, + CONSTRAINT partitioned_invoice_totals_total_nonnegative + CHECK (total >= 0) + ) PARTITION BY RANGE (period); + + CREATE TABLE test_schema.partitioned_invoice_totals_2026 + PARTITION OF test_schema.partitioned_invoice_totals + FOR VALUES FROM (2026) TO (2027); + + INSERT INTO test_schema.partitioned_invoice_totals + (id, period, subtotal) + VALUES (1, 2026, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.partitioned_invoice_totals + DROP CONSTRAINT partitioned_invoice_totals_total_nonnegative; + + ALTER TABLE test_schema.partitioned_invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_partition_total(integer); + + CREATE FUNCTION test_schema.compute_partition_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.partitioned_invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_partition_total(subtotal)) STORED; + + ALTER TABLE test_schema.partitioned_invoice_totals + ADD CONSTRAINT partitioned_invoice_totals_total_nonnegative + CHECK (total >= 0); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_invoice_totals_2026 DROP COLUMN total", + ), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_invoice_totals_2026 DROP CONSTRAINT partitioned_invoice_totals_total_nonnegative", + ), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_invoice_totals_2026 ADD CONSTRAINT partitioned_invoice_totals_total_nonnegative", + ), + ), + ).toBe(false); + + const parentDropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_partition_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_partition_total", + ), + ); + const parentAddColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_invoice_totals ADD COLUMN total", + ), + ); + + expect(parentDropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(parentDropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(parentAddColumnIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.partitioned_invoice_totals", + 1, + ); + }), + ); + + test( + "removed generated partition column for dropped function does not emit child DDL", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_archived_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.partitioned_archive_totals ( + id integer NOT NULL, + period integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_archived_total(subtotal)) STORED + ) PARTITION BY RANGE (period); + + CREATE TABLE test_schema.partitioned_archive_totals_2026 + PARTITION OF test_schema.partitioned_archive_totals + FOR VALUES FROM (2026) TO (2027); + + INSERT INTO test_schema.partitioned_archive_totals + (id, period, subtotal) + VALUES (1, 2026, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.partitioned_archive_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_archived_total(integer); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_archive_totals_2026 DROP COLUMN total", + ), + ), + ).toBe(false); + + const parentDropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_archive_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_archived_total", + ), + ); + + expect(parentDropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(parentDropColumnIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.partitioned_archive_totals", + 1, + ); + }), + ); + test( "domain default update for replaced function signature does not recreate domain or table", withDb(pgVersion, async (db) => { From 9d8d6bf299082c44fa8352f94de37fc38dc5e2fc Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 18:31:45 +0800 Subject: [PATCH 26/78] fix(pg-delta): handle partition fallback review edges - skip partition child column expression replacements covered by parent DDL - skip partition-cloned CHECK constraint replacements covered by parent constraints - replay retained expression-index statistics after replacement index creation Signed-off-by: xiaominghao --- .../src/core/expand-replace-dependencies.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 2e2901918..3b2365280 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -9,6 +9,8 @@ import { } 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 { CreateMaterializedView } from "./objects/materialized-view/changes/materialized-view.create.ts"; @@ -19,7 +21,6 @@ import { DropProcedure } from "./objects/procedure/changes/procedure.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 { CreateCommentOnIndex } from "./objects/index/changes/index.comment.ts"; import { AlterTableAddColumn, AlterTableAddConstraint, @@ -824,6 +825,11 @@ function buildColumnExpressionReplacementChanges({ const mainTable = mainCatalog.tables[tableId]; const branchTable = branchCatalog.tables[tableId]; if (!mainTable || !branchTable) return null; + // Partition child column DDL is propagated from the parent table. Treating the + // child pg_depend row as covered avoids emitting duplicate child ALTER TABLE. + if (mainTable.is_partition || branchTable.is_partition) { + return []; + } const mainColumn = mainTable.columns.find( (column) => column.name === columnRef.column, @@ -1186,6 +1192,14 @@ function buildTableConstraintReplacementChanges({ (constraint) => constraint.name === constraintRef.constraint, ); if (!mainConstraint) return null; + // PostgreSQL clones parent CHECK constraints onto partitions. The parent + // constraint replacement releases/restores the dependency for all children. + if ( + mainConstraint.is_partition_clone || + branchConstraint?.is_partition_clone + ) { + return []; + } const changes: Change[] = []; if (addRelease) { @@ -1530,6 +1544,9 @@ function buildReplaceChanges( ...(resolved.branch.comment !== null ? [new CreateCommentOnIndex({ index: resolved.branch })] : []), + // CREATE INDEX does not carry expression-index statistics, so + // replay retained targets after the replacement index exists. + ...buildRetainedIndexStatisticsChanges(resolved.branch), ] : []), ]; @@ -1581,6 +1598,21 @@ function buildReplaceChanges( } } +function buildRetainedIndexStatisticsChanges( + index: Catalog["indexes"][string], +): Change[] { + const columnTargets = index.statistics_target + .map((statistics, index) => ({ + columnNumber: index + 1, + statistics, + })) + .filter(({ statistics }) => statistics >= 0); + + return columnTargets.length > 0 + ? [new AlterIndexSetStatistics({ index, columnTargets })] + : []; +} + function buildCreateViewReplacementChanges( view: Catalog["views"][string], diffContext: From 52b29cc3c2d5d0599ebf2bbf3c85fddcf05f775f Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 19:05:14 +0800 Subject: [PATCH 27/78] test(pg-delta): cover generated column fallback dependents RED: PGDELTA_TEST_POSTGRES_VERSIONS=17 bun run test --test-name-pattern "unchanged generated column with retained dependents" tests/integration/function-signature-expression-dependencies.test.ts failed with dropTriggerIndex = -1, expected >= 0. The generated-column fallback did not emit DROP TRIGGER invoice_totals_total_trigger before dropping the regenerated column. Signed-off-by: xiaominghao --- ...-signature-expression-dependencies.test.ts | 155 +++++++++++++++++- 1 file changed, 151 insertions(+), 4 deletions(-) diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index 697a584d2..9694bd90b 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -780,10 +780,16 @@ for (const pgVersion of POSTGRES_VERSIONS) { id integer NOT NULL, subtotal integer NOT NULL, total integer GENERATED ALWAYS AS - (test_schema.compute_invoice_total(subtotal)) STORED, + (test_schema.compute_invoice_total(subtotal)) STORED NOT NULL, CONSTRAINT invoice_totals_total_nonnegative CHECK (total >= 0) ); + CREATE UNIQUE INDEX invoice_totals_total_identity_idx + ON test_schema.invoice_totals (total); + + ALTER TABLE test_schema.invoice_totals + REPLICA IDENTITY USING INDEX invoice_totals_total_identity_idx; + CREATE INDEX invoice_totals_total_idx ON test_schema.invoice_totals ((total + 1)); @@ -796,6 +802,39 @@ for (const pgVersion of POSTGRES_VERSIONS) { CREATE VIEW test_schema.invoice_total_values AS SELECT id, total FROM test_schema.invoice_totals; + CREATE TABLE test_schema.invoice_total_audit ( + invoice_id integer NOT NULL, + total integer NOT NULL + ); + + CREATE FUNCTION test_schema.record_invoice_total_update() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + INSERT INTO test_schema.invoice_total_audit (invoice_id, total) + VALUES (NEW.id, NEW.total); + RETURN NEW; + END + $function$; + + CREATE TRIGGER invoice_totals_total_trigger + AFTER UPDATE OF total ON test_schema.invoice_totals + FOR EACH ROW + WHEN (OLD.total IS DISTINCT FROM NEW.total) + EXECUTE FUNCTION test_schema.record_invoice_total_update(); + + CREATE TABLE test_schema.invoice_total_rule_audit ( + invoice_id integer NOT NULL, + total integer NOT NULL + ); + + CREATE RULE invoice_totals_total_rule AS + ON UPDATE TO test_schema.invoice_totals + DO ALSO INSERT INTO test_schema.invoice_total_rule_audit + (invoice_id, total) + VALUES (NEW.id, NEW.total); + DO $$ BEGIN IF NOT EXISTS ( @@ -816,8 +855,19 @@ for (const pgVersion of POSTGRES_VERSIONS) { testSql: dedent` DROP VIEW test_schema.invoice_total_values; + DROP TRIGGER invoice_totals_total_trigger + ON test_schema.invoice_totals; + + DROP RULE invoice_totals_total_rule + ON test_schema.invoice_totals; + DROP INDEX test_schema.invoice_totals_total_idx; + ALTER TABLE test_schema.invoice_totals + REPLICA IDENTITY DEFAULT; + + DROP INDEX test_schema.invoice_totals_total_identity_idx; + ALTER TABLE test_schema.invoice_totals DROP CONSTRAINT invoice_totals_total_nonnegative; @@ -836,11 +886,17 @@ for (const pgVersion of POSTGRES_VERSIONS) { ALTER TABLE test_schema.invoice_totals ADD COLUMN total integer GENERATED ALWAYS AS - (test_schema.compute_invoice_total(subtotal)) STORED; + (test_schema.compute_invoice_total(subtotal)) STORED NOT NULL; ALTER TABLE test_schema.invoice_totals ADD CONSTRAINT invoice_totals_total_nonnegative CHECK (total >= 0); + CREATE UNIQUE INDEX invoice_totals_total_identity_idx + ON test_schema.invoice_totals (total); + + ALTER TABLE test_schema.invoice_totals + REPLICA IDENTITY USING INDEX invoice_totals_total_identity_idx; + CREATE INDEX invoice_totals_total_idx ON test_schema.invoice_totals ((total + 1)); @@ -853,6 +909,18 @@ for (const pgVersion of POSTGRES_VERSIONS) { CREATE VIEW test_schema.invoice_total_values AS SELECT id, total FROM test_schema.invoice_totals; + CREATE TRIGGER invoice_totals_total_trigger + AFTER UPDATE OF total ON test_schema.invoice_totals + FOR EACH ROW + WHEN (OLD.total IS DISTINCT FROM NEW.total) + EXECUTE FUNCTION test_schema.record_invoice_total_update(); + + CREATE RULE invoice_totals_total_rule AS + ON UPDATE TO test_schema.invoice_totals + DO ALSO INSERT INTO test_schema.invoice_total_rule_audit + (invoice_id, total) + VALUES (NEW.id, NEW.total); + GRANT SELECT (total) ON TABLE test_schema.invoice_totals TO invoice_total_reader; `, @@ -869,11 +937,27 @@ for (const pgVersion of POSTGRES_VERSIONS) { "DROP INDEX test_schema.invoice_totals_total_idx", ), ); + const dropReplicaIdentityIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "DROP INDEX test_schema.invoice_totals_total_identity_idx", + ), + ); const dropConstraintIndex = sqlStatements.findIndex((statement) => statement.startsWith( "ALTER TABLE test_schema.invoice_totals DROP CONSTRAINT invoice_totals_total_nonnegative", ), ); + const dropTriggerIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP TRIGGER invoice_totals_total_trigger ON test_schema.invoice_totals", + ), + ); + const dropRuleIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP RULE invoice_totals_total_rule ON test_schema.invoice_totals", + ), + ); const dropColumnIndex = sqlStatements.findIndex((statement) => statement.startsWith( "ALTER TABLE test_schema.invoice_totals DROP COLUMN total", @@ -899,6 +983,18 @@ for (const pgVersion of POSTGRES_VERSIONS) { "ALTER TABLE test_schema.invoice_totals ADD CONSTRAINT invoice_totals_total_nonnegative", ), ); + const createReplicaIdentityIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "CREATE UNIQUE INDEX invoice_totals_total_identity_idx ON test_schema.invoice_totals", + ), + ); + const restoreReplicaIdentityIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals REPLICA IDENTITY USING INDEX invoice_totals_total_identity_idx", + ), + ); const createIndexIndex = sqlStatements.findIndex((statement) => statement.startsWith( "CREATE INDEX invoice_totals_total_idx ON test_schema.invoice_totals", @@ -920,6 +1016,14 @@ for (const pgVersion of POSTGRES_VERSIONS) { "CREATE VIEW test_schema.invoice_total_values", ), ); + const createTriggerIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE TRIGGER invoice_totals_total_trigger", + ), + ); + const createRuleIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE RULE invoice_totals_total_rule"), + ); const grantColumnIndex = sqlStatements.findIndex( (statement) => statement.includes("SELECT (total)") && @@ -929,18 +1033,30 @@ for (const pgVersion of POSTGRES_VERSIONS) { expect(dropViewIndex).toBeGreaterThanOrEqual(0); expect(dropIndexIndex).toBeGreaterThanOrEqual(0); + expect(dropReplicaIdentityIndex).toBeGreaterThanOrEqual(0); expect(dropConstraintIndex).toBeGreaterThanOrEqual(0); + expect(dropTriggerIndex).toBeGreaterThanOrEqual(0); + expect(dropRuleIndex).toBeGreaterThanOrEqual(0); expect(dropColumnIndex).toBeGreaterThan(dropViewIndex); expect(dropColumnIndex).toBeGreaterThan(dropIndexIndex); + expect(dropColumnIndex).toBeGreaterThan(dropReplicaIdentityIndex); expect(dropColumnIndex).toBeGreaterThan(dropConstraintIndex); + expect(dropColumnIndex).toBeGreaterThan(dropTriggerIndex); + expect(dropColumnIndex).toBeGreaterThan(dropRuleIndex); expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); expect(addConstraintIndex).toBeGreaterThan(addColumnIndex); + expect(createReplicaIdentityIndex).toBeGreaterThan(addColumnIndex); + expect(restoreReplicaIdentityIndex).toBeGreaterThan( + createReplicaIdentityIndex, + ); expect(createIndexIndex).toBeGreaterThan(addColumnIndex); expect(indexCommentIndex).toBeGreaterThan(createIndexIndex); expect(alterIndexStatisticsIndex).toBeGreaterThan(createIndexIndex); expect(createViewIndex).toBeGreaterThan(addColumnIndex); + expect(createTriggerIndex).toBeGreaterThan(addColumnIndex); + expect(createRuleIndex).toBeGreaterThan(addColumnIndex); expect(grantColumnIndex).toBeGreaterThan(addColumnIndex); }, }); @@ -970,9 +1086,40 @@ for (const pgVersion of POSTGRES_VERSIONS) { AND attnum = 1 `); expect(statisticsRows[0]?.attstattarget).toBe(100); + const { rows: replicaIdentityRows } = await db.main.query(dedent` + SELECT c.relreplident, i.indisreplident + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_index i ON i.indrelid = c.oid + JOIN pg_catalog.pg_class idx ON idx.oid = i.indexrelid + WHERE n.nspname = 'test_schema' + AND c.relname = 'invoice_totals' + AND idx.relname = 'invoice_totals_total_identity_idx' + `); + expect(replicaIdentityRows[0]).toMatchObject({ + relreplident: "i", + indisreplident: true, + }); + const { rows: triggerRows } = await db.main.query(dedent` + SELECT pg_get_triggerdef(oid) AS definition + FROM pg_catalog.pg_trigger + WHERE tgname = 'invoice_totals_total_trigger' + AND NOT tgisinternal + `); + expect(triggerRows[0]?.definition).toContain("UPDATE OF total"); + const { rows: ruleRows } = await db.main.query(dedent` + SELECT pg_get_ruledef(r.oid) AS definition + FROM pg_catalog.pg_rewrite r + JOIN pg_catalog.pg_class c ON c.oid = r.ev_class + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE r.rulename = 'invoice_totals_total_rule' + AND n.nspname = 'test_schema' + AND c.relname = 'invoice_totals' + `); + expect(ruleRows[0]?.definition).toContain("new.total"); const { rows: privilegeRows } = await db.main.query(dedent` - SELECT has_column_privilege( - 'invoice_total_reader', + SELECT has_column_privilege( + 'invoice_total_reader', 'test_schema.invoice_totals', 'total', 'SELECT' From 22c6bec665814d157180bf79115d9adfde243eb5 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 19:05:26 +0800 Subject: [PATCH 28/78] fix(pg-delta): restore generated column fallback dependents Recreate retained trigger and rule dependents discovered while walking regenerated columns, and replay replica identity when the rebuilt index remains the table replica identity. GREEN: PGDELTA_TEST_POSTGRES_VERSIONS=17 bun run test --test-name-pattern "unchanged generated column with retained dependents" tests/integration/function-signature-expression-dependencies.test.ts passed with 1 pass / 0 fail after the fix. Signed-off-by: xiaominghao --- .../src/core/expand-replace-dependencies.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 3b2365280..dbee4b4c8 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -21,6 +21,9 @@ import { DropProcedure } from "./objects/procedure/changes/procedure.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 { 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 { AlterTableAddColumn, AlterTableAddConstraint, @@ -28,6 +31,7 @@ import { AlterTableAlterColumnSetDefault, AlterTableDropColumn, AlterTableDropConstraint, + AlterTableSetReplicaIdentity, } from "./objects/table/changes/table.alter.ts"; import { CreateCommentOnColumn, @@ -37,6 +41,9 @@ 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 { CreateCommentOnTrigger } from "./objects/trigger/changes/trigger.comment.ts"; +import { CreateTrigger } from "./objects/trigger/changes/trigger.create.ts"; +import { DropTrigger } from "./objects/trigger/changes/trigger.drop.ts"; import { CreateCompositeType } from "./objects/type/composite-type/changes/composite-type.create.ts"; import { DropCompositeType } from "./objects/type/composite-type/changes/composite-type.drop.ts"; import { CreateEnum } from "./objects/type/enum/changes/enum.create.ts"; @@ -64,6 +71,18 @@ type ResolvedObject = main: Catalog["indexes"][string]; branch: Catalog["indexes"][string]; branchIndexableObject: Catalog["indexableObjects"][string] | undefined; + branchTable: Catalog["tables"][string] | undefined; + } + | { + kind: "trigger"; + main: Catalog["triggers"][string]; + branch: Catalog["triggers"][string]; + branchIndexableObject: Catalog["indexableObjects"][string] | undefined; + } + | { + kind: "rule"; + main: Catalog["rules"][string]; + branch: Catalog["rules"][string]; } | { kind: "materialized_view"; @@ -1396,10 +1415,32 @@ function resolveObjectForStableId( branch, branchIndexableObject: branchCatalog.indexableObjects[branch.tableStableId], + branchTable: branchCatalog.tables[branch.tableStableId], } : null; } + if (stableId.startsWith("trigger:")) { + const main = mainCatalog.triggers[stableId]; + const branch = branchCatalog.triggers[stableId]; + if (main && branch) { + const branchTableId = `table:${branch.schema}.${branch.table_name}`; + return { + kind: "trigger", + main, + branch, + branchIndexableObject: branchCatalog.indexableObjects[branchTableId], + }; + } + return null; + } + + if (stableId.startsWith("rule:")) { + const main = mainCatalog.rules[stableId]; + const branch = branchCatalog.rules[stableId]; + return main && branch ? { kind: "rule", main, branch } : null; + } + if (stableId.startsWith("procedure:")) { const main = mainCatalog.procedures[stableId]; const branch = branchCatalog.procedures[stableId]; @@ -1547,6 +1588,42 @@ function buildReplaceChanges( // CREATE INDEX does not carry expression-index statistics, so // replay retained targets after the replacement index exists. ...buildRetainedIndexStatisticsChanges(resolved.branch), + ...(resolved.branch.is_replica_identity && resolved.branchTable + ? [ + new AlterTableSetReplicaIdentity({ + table: resolved.branchTable, + mode: "i", + indexName: resolved.branch.name, + }), + ] + : []), + ] + : []), + ]; + 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 })] + : []), + ] + : []), + ]; + case "rule": + return [ + ...(addDrop ? [new DropRule({ rule: resolved.main })] : []), + ...(addCreate + ? [ + new CreateRule({ rule: resolved.branch }), + ...(resolved.branch.comment !== null + ? [new CreateCommentOnRule({ rule: resolved.branch })] + : []), ] : []), ]; From d53e36827fd27f3c1581bedab66ca5e14007c771 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 19:29:03 +0800 Subject: [PATCH 29/78] test(pg-delta): cover generated column publication fallback RED: PGDELTA_TEST_POSTGRES_VERSIONS=18 bun run test --test-name-pattern "unchanged generated column in publication column list" tests/integration/function-signature-expression-dependencies.test.ts failed before the fix because the generated migration released the publication column list after DROP COLUMN instead of before it. Signed-off-by: xiaominghao --- ...-signature-expression-dependencies.test.ts | 129 +++++++++++++++++- 1 file changed, 126 insertions(+), 3 deletions(-) diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index 9694bd90b..6a7d859e4 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -1115,11 +1115,11 @@ for (const pgVersion of POSTGRES_VERSIONS) { WHERE r.rulename = 'invoice_totals_total_rule' AND n.nspname = 'test_schema' AND c.relname = 'invoice_totals' - `); + `); expect(ruleRows[0]?.definition).toContain("new.total"); const { rows: privilegeRows } = await db.main.query(dedent` - SELECT has_column_privilege( - 'invoice_total_reader', + SELECT has_column_privilege( + 'invoice_total_reader', 'test_schema.invoice_totals', 'total', 'SELECT' @@ -1129,6 +1129,129 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test.skipIf(pgVersion < 18)( + "unchanged generated column in publication column list does not recreate table", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_publication_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.published_invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_publication_total(subtotal)) STORED + ); + + CREATE PUBLICATION invoice_total_pub + FOR TABLE test_schema.published_invoice_totals (id, total); + + INSERT INTO test_schema.published_invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + ALTER PUBLICATION invoice_total_pub + SET TABLE test_schema.published_invoice_totals (id); + + ALTER TABLE test_schema.published_invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_publication_total(integer); + + CREATE FUNCTION test_schema.compute_publication_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.published_invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_publication_total(subtotal)) STORED; + + ALTER PUBLICATION invoice_total_pub + SET TABLE test_schema.published_invoice_totals (id, total); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const releasePublicationIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER PUBLICATION invoice_total_pub DROP TABLE test_schema.published_invoice_totals", + ), + ); + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.published_invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_publication_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_publication_total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.published_invoice_totals ADD COLUMN total", + ), + ); + const restorePublicationIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER PUBLICATION invoice_total_pub ADD TABLE test_schema.published_invoice_totals (id, total)", + ), + ); + + expect(releasePublicationIndex).toBeGreaterThanOrEqual(0); + expect(dropColumnIndex).toBeGreaterThan(releasePublicationIndex); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); + expect(restorePublicationIndex).toBeGreaterThan(addColumnIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.published_invoice_totals", + 1, + ); + const { rows: publicationRows } = await db.main.query(dedent` + SELECT json_agg(att.attname ORDER BY cols.ord) AS columns + FROM pg_catalog.pg_publication p + JOIN pg_catalog.pg_publication_rel pr ON pr.prpubid = p.oid + JOIN pg_catalog.pg_class c ON c.oid = pr.prrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN unnest(pr.prattrs) WITH ORDINALITY AS cols(attnum, ord) + ON true + JOIN pg_catalog.pg_attribute att + ON att.attrelid = c.oid + AND att.attnum = cols.attnum + WHERE p.pubname = 'invoice_total_pub' + AND n.nspname = 'test_schema' + AND c.relname = 'published_invoice_totals' + `); + expect(publicationRows[0]?.columns).toEqual(["id", "total"]); + }), + ); + test( "unchanged generated partition column for replaced function argument name does not emit child DDL", withDb(pgVersion, async (db) => { From 65722d070cef45433065f774ad859a52c99d57ab Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 19:29:12 +0800 Subject: [PATCH 30/78] fix(pg-delta): restore generated column publication lists Restore publication table membership around generated-column replacement by emitting DROP TABLE before the dependent column is dropped and ADD TABLE with the branch column list after the column is recreated. GREEN: PGDELTA_TEST_POSTGRES_VERSIONS=18 bun run test --test-name-pattern "unchanged generated column in publication column list" tests/integration/function-signature-expression-dependencies.test.ts passed with 1 pass, 18 filtered out, 0 fail. Signed-off-by: xiaominghao --- .../src/core/expand-replace-dependencies.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index dbee4b4c8..ea417d65b 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -18,6 +18,11 @@ import { DropMaterializedView } from "./objects/materialized-view/changes/materi import { buildCreateMaterializedViewChanges } from "./objects/materialized-view/materialized-view.diff.ts"; import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts"; import { DropProcedure } from "./objects/procedure/changes/procedure.drop.ts"; +import { + AlterPublicationAddTables, + AlterPublicationDropTables, +} from "./objects/publication/changes/publication.alter.ts"; +import type { PublicationTableProps } from "./objects/publication/publication.model.ts"; import { CreateCommentOnRlsPolicy } from "./objects/rls-policy/changes/rls-policy.comment.ts"; import { CreateRlsPolicy } from "./objects/rls-policy/changes/rls-policy.create.ts"; import { DropRlsPolicy } from "./objects/rls-policy/changes/rls-policy.drop.ts"; @@ -336,6 +341,19 @@ export function expandReplaceDependencies({ } } + if ( + maybeAddColumnDependentPublicationReplacement({ + refId, + dependentRaw, + mainCatalog, + branchCatalog, + additions, + visitedTargets, + }) + ) { + continue; + } + if ( maybeAddColumnDependentConstraintReplacement({ refId, @@ -768,6 +786,42 @@ function buildExpressionDependentReplacementChanges({ return null; } +function maybeAddColumnDependentPublicationReplacement({ + refId, + dependentRaw, + mainCatalog, + branchCatalog, + additions, + visitedTargets, +}: { + refId: string; + dependentRaw: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + additions: Change[]; + visitedTargets: Set; +}): boolean { + if ( + !refId.startsWith("column:") || + !dependentRaw.startsWith("publication:") + ) { + return false; + } + if (visitedTargets.has(dependentRaw)) return true; + + const replacementChanges = buildPublicationColumnListReplacementChanges({ + columnId: refId, + publicationId: dependentRaw, + mainCatalog, + branchCatalog, + }); + if (!replacementChanges) return false; + + additions.push(...replacementChanges); + visitedTargets.add(dependentRaw); + return true; +} + function maybeAddColumnDependentConstraintReplacement({ refId, dependentRaw, @@ -972,6 +1026,60 @@ function buildColumnExpressionReplacementChanges({ return changes; } +function buildPublicationColumnListReplacementChanges({ + columnId, + publicationId, + mainCatalog, + branchCatalog, +}: { + columnId: string; + publicationId: string; + mainCatalog: Catalog; + branchCatalog: Catalog; +}): Change[] | null { + const columnRef = parseColumnStableId(columnId); + if (!columnRef) return null; + + const mainPublication = mainCatalog.publications[publicationId]; + const branchPublication = branchCatalog.publications[publicationId]; + if (!mainPublication || !branchPublication) return null; + + const mainTable = findPublicationTableForColumn( + mainPublication.tables, + columnRef, + ); + const branchTable = findPublicationTableForColumn( + branchPublication.tables, + columnRef, + ); + if (!mainTable || !branchTable) return null; + + return [ + new AlterPublicationDropTables({ + publication: mainPublication, + tables: [mainTable], + }), + new AlterPublicationAddTables({ + publication: branchPublication, + tables: [branchTable], + }), + ]; +} + +function findPublicationTableForColumn( + tables: readonly PublicationTableProps[], + columnRef: ColumnStableIdParts, +): PublicationTableProps | null { + return ( + tables.find( + (table) => + table.schema === columnRef.schema && + table.name === columnRef.table && + table.columns?.includes(columnRef.column), + ) ?? null + ); +} + function buildDomainDefaultReplacementChanges({ dependentRaw, mainCatalog, From c72e32118f291f6d4efea93a0f86f8600779973c Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 20:40:27 +0800 Subject: [PATCH 31/78] test(pg-delta): cover replacement metadata review cases RED: cd packages/pg-delta && ../../node_modules/.bin/bun run test src/core/expand-replace-dependencies.test.ts Observed 6 failing regression tests: local partition column defaults received 0 drop-default changes; retained generated-column dependents missed CLUSTER ON / trigger state / rule state replay; partition-cloned triggers were still emitted; publication row filters emitted 0 drop/add publication-table changes; aggregate replacements emitted 0 default drop/set changes; promoted dependent routines missed owner/comment/security-label/EXECUTE replay. Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 621 ++++++++++++++++++ 1 file changed, 621 insertions(+) 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 0ad5da6b8..fe9366612 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -2,6 +2,9 @@ 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 { CreateAggregate } from "./objects/aggregate/changes/aggregate.create.ts"; +import { DropAggregate } from "./objects/aggregate/changes/aggregate.drop.ts"; +import { Aggregate } from "./objects/aggregate/aggregate.model.ts"; import { DefaultPrivilegeState } from "./objects/base.default-privileges.ts"; import { AlterDomainAddConstraint, @@ -21,6 +24,11 @@ import { CreateCommentOnIndex } from "./objects/index/changes/index.comment.ts"; import { CreateIndex } from "./objects/index/changes/index.create.ts"; import { DropIndex } from "./objects/index/changes/index.drop.ts"; import { Index, type IndexProps } from "./objects/index/index.model.ts"; +import { + AlterPublicationAddTables, + AlterPublicationDropTables, +} from "./objects/publication/changes/publication.alter.ts"; +import { Publication } from "./objects/publication/publication.model.ts"; import { AlterRlsPolicySetUsingExpression, AlterRlsPolicySetWithCheckExpression, @@ -29,6 +37,7 @@ 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 { 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"; @@ -51,6 +60,7 @@ 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, type TableProps } from "./objects/table/table.model.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"; @@ -372,6 +382,125 @@ function viewOnItemsValue(): View { }); } +function aggregateWithArgs(argumentTypes: string[]): Aggregate { + return new Aggregate({ + schema: "public", + name: "total_value", + identity_arguments: argumentTypes.join(", "), + kind: "a", + aggkind: "n", + num_direct_args: 0, + return_type: "integer", + return_type_schema: "pg_catalog", + parallel_safety: "u", + is_strict: false, + transition_function: "public.sum_state", + state_data_type: "integer", + state_data_type_schema: "pg_catalog", + state_data_space: 0, + final_function: null, + final_function_extra_args: false, + final_function_modify: null, + combine_function: null, + serial_function: null, + deserial_function: null, + initial_condition: null, + moving_transition_function: null, + moving_inverse_function: null, + moving_state_data_type: null, + moving_state_data_type_schema: null, + moving_state_data_space: null, + moving_final_function: null, + moving_final_function_extra_args: null, + moving_final_function_modify: null, + moving_initial_condition: null, + sort_operator: null, + argument_count: argumentTypes.length, + argument_default_count: 0, + argument_names: null, + argument_types: argumentTypes, + all_argument_types: null, + argument_modes: null, + argument_defaults: null, + owner: "postgres", + comment: null, + privileges: [], + }); +} + +function triggerOnItemsValue(overrides: Partial = {}): Trigger { + return new Trigger({ + schema: "public", + name: "items_value_trigger", + table_name: "items", + table_relkind: "r", + function_schema: "public", + function_name: "touch_value", + trigger_type: 16, + enabled: "O", + is_internal: false, + deferrable: false, + initially_deferred: false, + argument_count: 0, + column_numbers: [1], + arguments: [], + when_condition: null, + old_table: null, + new_table: null, + is_partition_clone: false, + parent_trigger_name: null, + parent_table_schema: null, + parent_table_name: null, + is_on_partitioned_table: false, + owner: "postgres", + definition: + "CREATE TRIGGER items_value_trigger AFTER UPDATE OF value ON public.items FOR EACH ROW EXECUTE FUNCTION public.touch_value()", + comment: null, + ...overrides, + }); +} + +function ruleOnItemsValue(overrides: Partial = {}): Rule { + return new Rule({ + schema: "public", + name: "items_value_rule", + table_name: "items", + relation_kind: "r", + event: "UPDATE", + enabled: "O", + is_instead: false, + owner: "postgres", + definition: + "CREATE RULE items_value_rule AS ON UPDATE TO public.items DO ALSO SELECT new.value", + comment: null, + columns: ["value"], + ...overrides, + }); +} + +function publicationOnItemsValue(rowFilter: string | null): Publication { + return new Publication({ + name: "items_pub", + owner: "postgres", + comment: null, + all_tables: false, + publish_insert: true, + publish_update: true, + publish_delete: true, + publish_truncate: true, + publish_via_partition_root: false, + tables: [ + { + schema: "public", + name: "items", + columns: null, + row_filter: rowFilter, + }, + ], + schemas: [], + }); +} + describe("expandReplaceDependencies", () => { test("returns changes unchanged when there are no replace roots", async () => { const catalog = await createEmptyCatalog(160004, "u"); @@ -1692,6 +1821,66 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("releases a local partition column default that depends on a replaced procedure", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = partitionTableWithDefault("public.normalize_value(1)"); + const branchTable = partitionTableWithDefault("public.normalize_value(1)"); + const columnId = "column:public.items_2026.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + test("does not count generated SET EXPRESSION as release coverage for same-signature procedure replacement", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -1769,6 +1958,92 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("traverses retained dependents when generated column recreation is already covered", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainIndex = indexOnItemsValue(); + const branchIndex = indexOnItemsValue(); + const columnId = "column:public.items.value"; + const preExistingDropColumn = new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }); + const preExistingAddColumn = new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + preExistingDropColumn, + preExistingAddColumn, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainIndex.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes).toContain(preExistingDropColumn); + expect(expanded.changes).toContain(preExistingAddColumn); + expect( + expanded.changes.filter((change) => change instanceof DropIndex), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateIndex), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + test("promotes retained dependents of a recreated generated column", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -2190,6 +2465,352 @@ describe("expandReplaceDependencies", () => { expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); }); + test("replays retained index, trigger, and rule metadata after generated column fallback", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainIndex = indexOnItemsValue({ is_clustered: true }); + const branchIndex = indexOnItemsValue({ is_clustered: true }); + const mainTrigger = triggerOnItemsValue({ enabled: "D" }); + const branchTrigger = triggerOnItemsValue({ enabled: "D" }); + const mainRule = ruleOnItemsValue({ enabled: "R" }); + const branchRule = ruleOnItemsValue({ enabled: "R" }); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + triggers: { [mainTrigger.stableId]: mainTrigger }, + rules: { [mainRule.stableId]: mainRule }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainIndex.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainTrigger.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainRule.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + triggers: { [branchTrigger.stableId]: branchTrigger }, + rules: { [branchRule.stableId]: branchRule }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect(serialized).toContain( + "ALTER TABLE public.items CLUSTER ON items_value_idx", + ); + expect(serialized).toContain( + "ALTER TABLE public.items DISABLE TRIGGER items_value_trigger", + ); + expect(serialized).toContain( + "ALTER TABLE public.items ENABLE REPLICA RULE items_value_rule", + ); + }); + + test("skips partition-cloned trigger dependents during routine replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const partitionClone = { + schema: "public", + table_name: "items_2026", + is_partition_clone: true, + parent_trigger_name: "items_value_trigger", + parent_table_schema: "public", + parent_table_name: "items", + }; + const mainTrigger = triggerOnItemsValue(partitionClone); + const branchTrigger = triggerOnItemsValue(partitionClone); + const mainTable = partitionTableWithDefault(null); + const branchTable = partitionTableWithDefault(null); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + triggers: { [mainTrigger.stableId]: mainTrigger }, + depends: [ + { + dependent_stable_id: mainTrigger.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + triggers: { [branchTrigger.stableId]: branchTrigger }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.some((change) => change.objectType === "trigger"), + ).toBe(false); + }); + + test("releases retained publication row filters before routine replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainPublication = publicationOnItemsValue( + "(public.normalize_value(value) > 0)", + ); + const branchPublication = publicationOnItemsValue( + "(public.normalize_value(value) > 0)", + ); + const table = tableWithDefault(null); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { [table.stableId]: table }, + depends: [ + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { [table.stableId]: table }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationDropTables, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toHaveLength(1); + }); + + test("handles aggregate replacements as expression roots", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainAggregate = aggregateWithArgs(["integer"]); + const branchAggregate = aggregateWithArgs(["bigint"]); + const mainTable = tableWithDefault("public.total_value(value)"); + const branchTable = tableWithDefault("public.total_value(value::bigint)"); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropAggregate({ aggregate: mainAggregate }), + new CreateAggregate({ aggregate: branchAggregate }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + aggregates: { [mainAggregate.stableId]: mainAggregate }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainAggregate.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + aggregates: { [branchAggregate.stableId]: branchAggregate }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + + test("restores promoted routine metadata after dependent routine replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDependent = procedureWithArgs(["integer"], "uses_normalize"); + const branchDependent = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainDependent, + owner: "routine_owner", + comment: "dependent routine comment", + security_labels: [{ provider: "dummy", label: "routine label" }], + privileges: [ + { + grantee: "routine_executor", + privilege: "EXECUTE", + grantable: false, + }, + ], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [mainProcedure.stableId]: mainProcedure, + [mainDependent.stableId]: mainDependent, + }, + depends: [ + { + dependent_stable_id: mainDependent.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [branchProcedure.stableId]: branchProcedure, + [branchDependent.stableId]: branchDependent, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect(serialized).toContain( + "ALTER FUNCTION public.uses_normalize(integer) OWNER TO routine_owner", + ); + expect(serialized).toContain( + "COMMENT ON FUNCTION public.uses_normalize(integer) IS 'dependent routine comment'", + ); + expect(serialized).toContain( + "SECURITY LABEL FOR dummy ON FUNCTION public.uses_normalize(integer) IS 'routine label'", + ); + expect(serialized).toContain( + "GRANT EXECUTE ON FUNCTION public.uses_normalize(integer) TO routine_executor", + ); + }); + test("synthesizes a table check constraint replacement for an unchanged expression that depends on a replaced procedure", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); From 7ce59f9b8f4f576d34f55526fc8e018eeb867699 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 20:53:58 +0800 Subject: [PATCH 32/78] fix(pg-delta): restore replacement dependent metadata GREEN: replacement expansion now handles routine and aggregate expression roots, traverses covered generated-column recreations, releases local partition defaults and publication row filters, skips partition-cloned triggers, and replays retained trigger/rule/index/routine metadata. Validation: - bun run test --test-name-pattern "local partition|already covered|metadata after generated|partition-cloned trigger|publication row filters|aggregate replacements|promoted routine metadata" src/core/expand-replace-dependencies.test.ts - bun run test src/core/expand-replace-dependencies.test.ts - PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1 PGDELTA_TEST_POSTGRES_VERSIONS=17 bun run test tests/integration/function-signature-expression-dependencies.test.ts - bun run format-and-lint - bun run check-types - bun run knip Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 4 +- .../src/core/expand-replace-dependencies.ts | 301 +++++++++++++++--- .../core/objects/table/changes/table.alter.ts | 32 ++ .../objects/trigger/changes/trigger.alter.ts | 40 ++- ...-signature-expression-dependencies.test.ts | 232 +++++++++++++- 5 files changed, 561 insertions(+), 48 deletions(-) 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 fe9366612..cbd4f1f65 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -411,7 +411,7 @@ function aggregateWithArgs(argumentTypes: string[]): Aggregate { moving_state_data_type_schema: null, moving_state_data_space: null, moving_final_function: null, - moving_final_function_extra_args: null, + moving_final_function_extra_args: false, moving_final_function_modify: null, moving_initial_condition: null, sort_operator: null, @@ -2807,7 +2807,7 @@ describe("expandReplaceDependencies", () => { "SECURITY LABEL FOR dummy ON FUNCTION public.uses_normalize(integer) IS 'routine label'", ); expect(serialized).toContain( - "GRANT EXECUTE ON FUNCTION public.uses_normalize(integer) TO routine_executor", + "GRANT ALL ON FUNCTION public.uses_normalize(integer) TO routine_executor", ); }); diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index ea417d65b..d71e28164 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -1,5 +1,10 @@ import type { Catalog } from "./catalog.model.ts"; import type { Change } from "./change.types.ts"; +import { + diffPrivileges, + emitObjectPrivilegeChanges, + filterPublicBuiltInDefaults, +} from "./objects/base.privilege-diff.ts"; import type { ObjectDiffContext } from "./objects/diff-context.ts"; import { AlterDomainAddConstraint, @@ -16,8 +21,16 @@ import { DropIndex } from "./objects/index/changes/index.drop.ts"; import { CreateMaterializedView } from "./objects/materialized-view/changes/materialized-view.create.ts"; import { DropMaterializedView } from "./objects/materialized-view/changes/materialized-view.drop.ts"; import { buildCreateMaterializedViewChanges } from "./objects/materialized-view/materialized-view.diff.ts"; +import { AlterProcedureChangeOwner } from "./objects/procedure/changes/procedure.alter.ts"; +import { CreateCommentOnProcedure } from "./objects/procedure/changes/procedure.comment.ts"; import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts"; import { DropProcedure } from "./objects/procedure/changes/procedure.drop.ts"; +import { + GrantProcedurePrivileges, + RevokeGrantOptionProcedurePrivileges, + RevokeProcedurePrivileges, +} from "./objects/procedure/changes/procedure.privilege.ts"; +import { CreateSecurityLabelOnProcedure } from "./objects/procedure/changes/procedure.security-label.ts"; import { AlterPublicationAddTables, AlterPublicationDropTables, @@ -29,11 +42,13 @@ import { DropRlsPolicy } from "./objects/rls-policy/changes/rls-policy.drop.ts"; import { CreateCommentOnRule } from "./objects/rule/changes/rule.comment.ts"; import { CreateRule } from "./objects/rule/changes/rule.create.ts"; import { DropRule } from "./objects/rule/changes/rule.drop.ts"; +import { SetRuleEnabledState } from "./objects/rule/changes/rule.alter.ts"; import { AlterTableAddColumn, AlterTableAddConstraint, AlterTableAlterColumnDropDefault, AlterTableAlterColumnSetDefault, + AlterTableClusterOn, AlterTableDropColumn, AlterTableDropConstraint, AlterTableSetReplicaIdentity, @@ -46,6 +61,7 @@ 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 { SetTriggerEnabledState } from "./objects/trigger/changes/trigger.alter.ts"; import { CreateCommentOnTrigger } from "./objects/trigger/changes/trigger.comment.ts"; import { CreateTrigger } from "./objects/trigger/changes/trigger.create.ts"; import { DropTrigger } from "./objects/trigger/changes/trigger.drop.ts"; @@ -161,12 +177,12 @@ export function expandReplaceDependencies({ } const replaceRoots = new Set(); - const procedureReplacementRoots = new Set(); + const routineExpressionReplacementRoots = new Set(); for (const id of createdIds) { if (droppedIds.has(id)) { replaceRoots.add(id); - if (id.startsWith("procedure:")) { - procedureReplacementRoots.add(id); + if (isRoutineExpressionReplacementRoot(id)) { + routineExpressionReplacementRoots.add(id); } } } @@ -181,23 +197,22 @@ export function expandReplaceDependencies({ promotedRlsPolicyIds, }); - // Procedure stableIds are signature-qualified - // (`procedure:schema.name(argtypes)`), so a function whose parameter types - // change has different ids in `createdIds` and `droppedIds` and would not - // appear in the intersection above. Treat any dropped procedure whose - // `(schema, name)` matches a created procedure as a replace root so - // dependents referencing the old signature via pg_depend get promoted to - // DROP+CREATE. - const createdProcedureNames = new Set(); + // Procedure and aggregate stableIds are signature-qualified, so a routine + // whose parameter types change has different ids in `createdIds` and + // `droppedIds` and would not appear in the intersection above. Treat any + // dropped routine whose `(kind, schema, name)` matches a created routine as a + // replace root so dependents referencing the old signature via pg_depend get + // promoted or temporarily released. + const createdRoutineNames = new Set(); for (const id of createdIds) { - const key = parseProcedureSchemaName(id); - if (key) createdProcedureNames.add(key); + const key = parseRoutineSchemaName(id); + if (key) createdRoutineNames.add(key); } for (const id of droppedIds) { - const key = parseProcedureSchemaName(id); - if (key && createdProcedureNames.has(key)) { + const key = parseRoutineSchemaName(id); + if (key && createdRoutineNames.has(key)) { replaceRoots.add(id); - procedureReplacementRoots.add(id); + routineExpressionReplacementRoots.add(id); } } @@ -283,10 +298,10 @@ export function expandReplaceDependencies({ } if ( - shouldHandleProcedureExpressionDependent({ + shouldHandleRoutineExpressionDependent({ refId, dependentRaw, - procedureReplacementRoots, + routineExpressionReplacementRoots, }) ) { const releaseCovered = @@ -341,6 +356,20 @@ export function expandReplaceDependencies({ } } + if ( + maybeAddRoutineDependentPublicationReplacement({ + refId, + dependentRaw, + routineExpressionReplacementRoots, + mainCatalog, + branchCatalog, + additions, + visitedTargets, + }) + ) { + continue; + } + if ( maybeAddColumnDependentPublicationReplacement({ refId, @@ -380,7 +409,7 @@ export function expandReplaceDependencies({ refId, dependentRaw, expressionDependentCoverage, - procedureReplacementRoots, + routineExpressionReplacementRoots, }) ) { continue; @@ -626,7 +655,7 @@ function shouldSuppressCoveredExpressionDependent({ refId, dependentRaw, expressionDependentCoverage, - procedureReplacementRoots, + routineExpressionReplacementRoots, }: { refId: string; dependentRaw: string; @@ -634,31 +663,31 @@ function shouldSuppressCoveredExpressionDependent({ release: ReadonlySet; restore: ReadonlySet; }; - procedureReplacementRoots: ReadonlySet; + routineExpressionReplacementRoots: ReadonlySet; }): boolean { - // Procedure replacement can require expression containers to release their + // Routine replacement can require expression containers to release their // pg_depend edge before the old routine is dropped. When the original diff // already emitted that targeted expression change, promoting the normalized // table/domain owner to DROP+CREATE would be redundant and destructive. return ( - procedureReplacementRoots.has(refId) && + routineExpressionReplacementRoots.has(refId) && isExpressionContainerStableId(dependentRaw) && expressionDependentCoverage.release.has(dependentRaw) && expressionDependentCoverage.restore.has(dependentRaw) ); } -function shouldHandleProcedureExpressionDependent({ +function shouldHandleRoutineExpressionDependent({ refId, dependentRaw, - procedureReplacementRoots, + routineExpressionReplacementRoots, }: { refId: string; dependentRaw: string; - procedureReplacementRoots: ReadonlySet; + routineExpressionReplacementRoots: ReadonlySet; }): boolean { return ( - procedureReplacementRoots.has(refId) && + routineExpressionReplacementRoots.has(refId) && isExpressionContainerStableId(dependentRaw) ); } @@ -786,6 +815,43 @@ function buildExpressionDependentReplacementChanges({ return null; } +function maybeAddRoutineDependentPublicationReplacement({ + refId, + dependentRaw, + routineExpressionReplacementRoots, + mainCatalog, + branchCatalog, + additions, + visitedTargets, +}: { + refId: string; + dependentRaw: string; + routineExpressionReplacementRoots: ReadonlySet; + mainCatalog: Catalog; + branchCatalog: Catalog; + additions: Change[]; + visitedTargets: Set; +}): boolean { + if ( + !routineExpressionReplacementRoots.has(refId) || + !dependentRaw.startsWith("publication:") + ) { + return false; + } + if (visitedTargets.has(dependentRaw)) return true; + + const replacementChanges = buildPublicationRowFilterReplacementChanges({ + publicationId: dependentRaw, + mainCatalog, + branchCatalog, + }); + if (!replacementChanges) return false; + + additions.push(...replacementChanges); + visitedTargets.add(dependentRaw); + return true; +} + function maybeAddColumnDependentPublicationReplacement({ refId, dependentRaw, @@ -898,11 +964,6 @@ function buildColumnExpressionReplacementChanges({ const mainTable = mainCatalog.tables[tableId]; const branchTable = branchCatalog.tables[tableId]; if (!mainTable || !branchTable) return null; - // Partition child column DDL is propagated from the parent table. Treating the - // child pg_depend row as covered avoids emitting duplicate child ALTER TABLE. - if (mainTable.is_partition || branchTable.is_partition) { - return []; - } const mainColumn = mainTable.columns.find( (column) => column.name === columnRef.column, @@ -913,6 +974,11 @@ function buildColumnExpressionReplacementChanges({ (column) => column.name === columnRef.column, ); if (!branchColumn) { + // Partition child column drops are propagated from the parent table. + // Treating the child pg_depend row as covered avoids duplicate child DDL. + if (mainTable.is_partition || branchTable.is_partition) { + return []; + } // The branch removed this column. When the original diff already drops it, // that drop releases the pg_depend edge and there is no expression to // restore, so this dependent is handled without owner table replacement. @@ -923,6 +989,15 @@ function buildColumnExpressionReplacementChanges({ const generatedColumnInvolved = mainColumn.is_generated || branchColumn.is_generated; + // Generated partition child columns are managed by the parent table DDL. + // Local non-generated defaults on partitions are not parent-propagated and + // still need explicit DROP/SET DEFAULT around routine replacement. + if ( + (mainTable.is_partition || branchTable.is_partition) && + generatedColumnInvolved + ) { + return []; + } if (generatedColumnInvolved) { const canRecreateGeneratedColumn = mainColumn.is_generated && @@ -1066,6 +1141,51 @@ function buildPublicationColumnListReplacementChanges({ ]; } +function buildPublicationRowFilterReplacementChanges({ + publicationId, + mainCatalog, + branchCatalog, +}: { + publicationId: string; + mainCatalog: Catalog; + branchCatalog: Catalog; +}): Change[] | null { + const mainPublication = mainCatalog.publications[publicationId]; + const branchPublication = branchCatalog.publications[publicationId]; + if (!mainPublication || !branchPublication) return null; + + const mainTables = mainPublication.tables.filter( + (table) => table.row_filter !== null, + ); + if (mainTables.length === 0) return null; + + const mainTableKeys = new Set( + mainTables.map((table) => publicationTableKey(table)), + ); + const branchTables = branchPublication.tables.filter((table) => + mainTableKeys.has(publicationTableKey(table)), + ); + + return [ + new AlterPublicationDropTables({ + publication: mainPublication, + tables: mainTables, + }), + ...(branchTables.length > 0 + ? [ + new AlterPublicationAddTables({ + publication: branchPublication, + tables: branchTables, + }), + ] + : []), + ]; +} + +function publicationTableKey(table: PublicationTableProps): string { + return `${table.schema}.${table.name}`; +} + function findPublicationTableForColumn( tables: readonly PublicationTableProps[], columnRef: ColumnStableIdParts, @@ -1397,11 +1517,20 @@ function isOwnedSequenceColumnDependency( ); } -function parseProcedureSchemaName(stableId: string): string | null { - if (!stableId.startsWith("procedure:")) return null; +function isRoutineExpressionReplacementRoot(stableId: string): boolean { + return stableId.startsWith("procedure:") || stableId.startsWith("aggregate:"); +} + +function parseRoutineSchemaName(stableId: string): string | null { + const prefix = stableId.startsWith("procedure:") + ? "procedure:" + : stableId.startsWith("aggregate:") + ? "aggregate:" + : null; + if (prefix === null) return null; const paren = stableId.indexOf("("); if (paren === -1) return null; - return stableId.slice("procedure:".length, paren); + return `${prefix}${stableId.slice(prefix.length, paren)}`; } function normalizeDependentId( @@ -1696,6 +1825,14 @@ function buildReplaceChanges( // CREATE INDEX does not carry expression-index statistics, so // replay retained targets after the replacement index exists. ...buildRetainedIndexStatisticsChanges(resolved.branch), + ...(resolved.branch.is_clustered && resolved.branchTable + ? [ + new AlterTableClusterOn({ + table: resolved.branchTable, + indexName: resolved.branch.name, + }), + ] + : []), ...(resolved.branch.is_replica_identity && resolved.branchTable ? [ new AlterTableSetReplicaIdentity({ @@ -1709,6 +1846,12 @@ function buildReplaceChanges( : []), ]; case "trigger": + if ( + resolved.main.is_partition_clone || + resolved.branch.is_partition_clone + ) { + return null; + } return [ ...(addDrop ? [new DropTrigger({ trigger: resolved.main })] : []), ...(addCreate @@ -1720,6 +1863,9 @@ function buildReplaceChanges( ...(resolved.branch.comment !== null ? [new CreateCommentOnTrigger({ trigger: resolved.branch })] : []), + ...(resolved.branch.enabled !== "O" + ? [new SetTriggerEnabledState({ trigger: resolved.branch })] + : []), ] : []), ]; @@ -1732,6 +1878,9 @@ function buildReplaceChanges( ...(resolved.branch.comment !== null ? [new CreateCommentOnRule({ rule: resolved.branch })] : []), + ...(resolved.branch.enabled !== "O" + ? [new SetRuleEnabledState({ rule: resolved.branch })] + : []), ] : []), ]; @@ -1739,7 +1888,13 @@ function buildReplaceChanges( return [ ...(addDrop ? [new DropProcedure({ procedure: resolved.main })] : []), ...(addCreate - ? [new CreateProcedure({ procedure: resolved.branch })] + ? [ + new CreateProcedure({ procedure: resolved.branch }), + ...buildRetainedProcedureMetadataChanges({ + procedure: resolved.branch, + diffContext, + }), + ] : []), ]; case "rls_policy": @@ -1798,6 +1953,80 @@ function buildRetainedIndexStatisticsChanges( : []; } +function buildRetainedProcedureMetadataChanges({ + procedure, + diffContext, +}: { + procedure: Catalog["procedures"][string]; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): Change[] { + const changes: Change[] = []; + + if (diffContext && procedure.owner !== diffContext.currentUser) { + changes.push( + 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, + }), + ); + } + + if (!diffContext) return changes; + + const effectiveDefaults = + diffContext.defaultPrivilegeState.getEffectiveDefaults( + diffContext.currentUser, + "procedure", + procedure.schema ?? "", + ); + const creatorFilteredDefaults = + procedure.owner !== diffContext.currentUser + ? effectiveDefaults.filter((p) => p.grantee !== diffContext.currentUser) + : effectiveDefaults; + const desiredPrivileges = filterPublicBuiltInDefaults( + "procedure", + procedure.privileges, + ); + const privilegeResults = diffPrivileges( + filterPublicBuiltInDefaults("procedure", creatorFilteredDefaults), + desiredPrivileges, + procedure.owner, + ); + + changes.push( + ...(emitObjectPrivilegeChanges( + privilegeResults, + procedure, + procedure, + "procedure", + { + Grant: GrantProcedurePrivileges, + Revoke: RevokeProcedurePrivileges, + RevokeGrantOption: RevokeGrantOptionProcedurePrivileges, + }, + diffContext.version, + ) as Change[]), + ); + + return changes; +} + function buildCreateViewReplacementChanges( view: Catalog["views"][string], diffContext: 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..03a1ad52f 100644 --- a/packages/pg-delta/src/core/objects/table/changes/table.alter.ts +++ b/packages/pg-delta/src/core/objects/table/changes/table.alter.ts @@ -73,6 +73,7 @@ export type AlterTable = | AlterTableAlterColumnType | AlterTableAttachPartition | AlterTableChangeOwner + | AlterTableClusterOn | AlterTableDetachPartition | AlterTableDisableRowLevelSecurity | AlterTableDropColumn @@ -115,6 +116,37 @@ export class AlterTableChangeOwner extends AlterTableChange { } } +/** + * ALTER TABLE ... CLUSTER ON ... + */ +export class AlterTableClusterOn extends AlterTableChange { + public readonly table: Table; + public readonly indexName: string; + public readonly scope = "object" as const; + + constructor(props: { table: Table; indexName: string }) { + super(); + this.table = props.table; + this.indexName = props.indexName; + } + + get requires() { + return [ + this.table.stableId, + stableId.index(this.table.schema, this.table.name, this.indexName), + ]; + } + + serialize(_options?: SerializeOptions): string { + return [ + "ALTER TABLE", + `${this.table.schema}.${this.table.name}`, + "CLUSTER ON", + this.indexName, + ].join(" "); + } +} + /** * ALTER TABLE ... SET LOGGED */ 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..97772fe31 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 @@ -17,7 +17,7 @@ import { DropTrigger } from "./trigger.drop.ts"; * ``` */ -export type AlterTrigger = ReplaceTrigger; +export type AlterTrigger = ReplaceTrigger | SetTriggerEnabledState; /** * Replace a trigger by dropping and recreating it. @@ -72,3 +72,41 @@ export class ReplaceTrigger extends AlterTriggerChange { return createChange.serialize(); } } + +export class SetTriggerEnabledState extends AlterTriggerChange { + public readonly trigger: Trigger; + public readonly enabled: Trigger["enabled"]; + public readonly scope = "object" as const; + + constructor(props: { trigger: Trigger; enabled?: Trigger["enabled"] }) { + super(); + this.trigger = props.trigger; + this.enabled = props.enabled ?? props.trigger.enabled; + } + + get requires() { + return [this.trigger.stableId]; + } + + serialize(_options?: SerializeOptions): string { + const clause = clauseForState(this.enabled); + return `ALTER TABLE ${this.trigger.schema}.${this.trigger.table_name} ${clause} ${this.trigger.name}`; + } +} + +function clauseForState(state: Trigger["enabled"]) { + switch (state) { + case "O": + return "ENABLE TRIGGER"; + case "D": + return "DISABLE TRIGGER"; + case "R": + return "ENABLE REPLICA TRIGGER"; + case "A": + return "ENABLE ALWAYS TRIGGER"; + default: { + const _exhaustive: never = state; + return _exhaustive; + } + } +} diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index 6a7d859e4..47533c8df 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -208,6 +208,78 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "dependent view is recreated when aggregate signature changes", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE TABLE test_schema.aggregate_inputs ( + value integer NOT NULL + ); + + CREATE AGGREGATE test_schema.total_value(integer) ( + SFUNC = int4pl, + STYPE = integer, + INITCOND = '0' + ); + + CREATE VIEW test_schema.aggregate_totals AS + SELECT test_schema.total_value(value) AS total + FROM test_schema.aggregate_inputs; + + INSERT INTO test_schema.aggregate_inputs (value) VALUES (1); + `, + testSql: dedent` + DROP VIEW test_schema.aggregate_totals; + + DROP AGGREGATE test_schema.total_value(integer); + + CREATE AGGREGATE test_schema.total_value(bigint) ( + SFUNC = int8pl, + STYPE = bigint, + INITCOND = '0' + ); + + CREATE VIEW test_schema.aggregate_totals AS + SELECT test_schema.total_value(value::bigint) AS total + FROM test_schema.aggregate_inputs; + `, + assertSqlStatements: (sqlStatements) => { + const dropViewIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP VIEW test_schema.aggregate_totals"), + ); + const dropAggregateIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP AGGREGATE test_schema.total_value(integer)", + ), + ); + const createAggregateIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE AGGREGATE test_schema.total_value(bigint)", + ), + ); + const createViewIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE VIEW test_schema.aggregate_totals"), + ); + + expect(dropViewIndex).toBeGreaterThanOrEqual(0); + expect(dropAggregateIndex).toBeGreaterThan(dropViewIndex); + expect(createAggregateIndex).toBeGreaterThan(dropAggregateIndex); + expect(createViewIndex).toBeGreaterThan(createAggregateIndex); + }, + }); + + const { rows } = await db.main.query( + "SELECT total FROM test_schema.aggregate_totals", + ); + expect(rows[0]?.total).toBe(1n); + }), + ); + test( "removed check constraint for replaced function argument name does not recreate table", withDb(pgVersion, async (db) => { @@ -793,6 +865,9 @@ for (const pgVersion of POSTGRES_VERSIONS) { CREATE INDEX invoice_totals_total_idx ON test_schema.invoice_totals ((total + 1)); + ALTER TABLE test_schema.invoice_totals + CLUSTER ON invoice_totals_total_idx; + ALTER INDEX test_schema.invoice_totals_total_idx ALTER COLUMN 1 SET STATISTICS 100; @@ -824,6 +899,9 @@ for (const pgVersion of POSTGRES_VERSIONS) { WHEN (OLD.total IS DISTINCT FROM NEW.total) EXECUTE FUNCTION test_schema.record_invoice_total_update(); + ALTER TABLE test_schema.invoice_totals + DISABLE TRIGGER invoice_totals_total_trigger; + CREATE TABLE test_schema.invoice_total_rule_audit ( invoice_id integer NOT NULL, total integer NOT NULL @@ -835,6 +913,9 @@ for (const pgVersion of POSTGRES_VERSIONS) { (invoice_id, total) VALUES (NEW.id, NEW.total); + ALTER TABLE test_schema.invoice_totals + ENABLE REPLICA RULE invoice_totals_total_rule; + DO $$ BEGIN IF NOT EXISTS ( @@ -900,6 +981,9 @@ for (const pgVersion of POSTGRES_VERSIONS) { CREATE INDEX invoice_totals_total_idx ON test_schema.invoice_totals ((total + 1)); + ALTER TABLE test_schema.invoice_totals + CLUSTER ON invoice_totals_total_idx; + ALTER INDEX test_schema.invoice_totals_total_idx ALTER COLUMN 1 SET STATISTICS 100; @@ -915,12 +999,18 @@ for (const pgVersion of POSTGRES_VERSIONS) { WHEN (OLD.total IS DISTINCT FROM NEW.total) EXECUTE FUNCTION test_schema.record_invoice_total_update(); + ALTER TABLE test_schema.invoice_totals + DISABLE TRIGGER invoice_totals_total_trigger; + CREATE RULE invoice_totals_total_rule AS ON UPDATE TO test_schema.invoice_totals DO ALSO INSERT INTO test_schema.invoice_total_rule_audit (invoice_id, total) VALUES (NEW.id, NEW.total); + ALTER TABLE test_schema.invoice_totals + ENABLE REPLICA RULE invoice_totals_total_rule; + GRANT SELECT (total) ON TABLE test_schema.invoice_totals TO invoice_total_reader; `, @@ -1011,6 +1101,11 @@ for (const pgVersion of POSTGRES_VERSIONS) { "ALTER INDEX test_schema.invoice_totals_total_idx ALTER COLUMN 1 SET STATISTICS 100", ), ); + const restoreClusterIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals CLUSTER ON invoice_totals_total_idx", + ), + ); const createViewIndex = sqlStatements.findIndex((statement) => statement.startsWith( "CREATE VIEW test_schema.invoice_total_values", @@ -1021,9 +1116,21 @@ for (const pgVersion of POSTGRES_VERSIONS) { "CREATE TRIGGER invoice_totals_total_trigger", ), ); + const restoreTriggerEnabledIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DISABLE TRIGGER invoice_totals_total_trigger", + ), + ); const createRuleIndex = sqlStatements.findIndex((statement) => statement.startsWith("CREATE RULE invoice_totals_total_rule"), ); + const restoreRuleEnabledIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ENABLE REPLICA RULE invoice_totals_total_rule", + ), + ); const grantColumnIndex = sqlStatements.findIndex( (statement) => statement.includes("SELECT (total)") && @@ -1054,9 +1161,14 @@ for (const pgVersion of POSTGRES_VERSIONS) { expect(createIndexIndex).toBeGreaterThan(addColumnIndex); expect(indexCommentIndex).toBeGreaterThan(createIndexIndex); expect(alterIndexStatisticsIndex).toBeGreaterThan(createIndexIndex); + expect(restoreClusterIndex).toBeGreaterThan(createIndexIndex); expect(createViewIndex).toBeGreaterThan(addColumnIndex); expect(createTriggerIndex).toBeGreaterThan(addColumnIndex); + expect(restoreTriggerEnabledIndex).toBeGreaterThan( + createTriggerIndex, + ); expect(createRuleIndex).toBeGreaterThan(addColumnIndex); + expect(restoreRuleEnabledIndex).toBeGreaterThan(createRuleIndex); expect(grantColumnIndex).toBeGreaterThan(addColumnIndex); }, }); @@ -1086,6 +1198,15 @@ for (const pgVersion of POSTGRES_VERSIONS) { AND attnum = 1 `); expect(statisticsRows[0]?.attstattarget).toBe(100); + const { rows: clusterRows } = await db.main.query(dedent` + SELECT i.indisclustered + FROM pg_catalog.pg_index i + JOIN pg_catalog.pg_class idx ON idx.oid = i.indexrelid + JOIN pg_catalog.pg_namespace n ON n.oid = idx.relnamespace + WHERE n.nspname = 'test_schema' + AND idx.relname = 'invoice_totals_total_idx' + `); + expect(clusterRows[0]?.indisclustered).toBe(true); const { rows: replicaIdentityRows } = await db.main.query(dedent` SELECT c.relreplident, i.indisreplident FROM pg_catalog.pg_class c @@ -1101,22 +1222,24 @@ for (const pgVersion of POSTGRES_VERSIONS) { indisreplident: true, }); const { rows: triggerRows } = await db.main.query(dedent` - SELECT pg_get_triggerdef(oid) AS definition - FROM pg_catalog.pg_trigger - WHERE tgname = 'invoice_totals_total_trigger' - AND NOT tgisinternal - `); + SELECT pg_get_triggerdef(oid) AS definition, tgenabled + FROM pg_catalog.pg_trigger + WHERE tgname = 'invoice_totals_total_trigger' + AND NOT tgisinternal + `); expect(triggerRows[0]?.definition).toContain("UPDATE OF total"); + expect(triggerRows[0]?.tgenabled).toBe("D"); const { rows: ruleRows } = await db.main.query(dedent` - SELECT pg_get_ruledef(r.oid) AS definition - FROM pg_catalog.pg_rewrite r - JOIN pg_catalog.pg_class c ON c.oid = r.ev_class + SELECT pg_get_ruledef(r.oid) AS definition, r.ev_enabled + FROM pg_catalog.pg_rewrite r + JOIN pg_catalog.pg_class c ON c.oid = r.ev_class JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE r.rulename = 'invoice_totals_total_rule' AND n.nspname = 'test_schema' AND c.relname = 'invoice_totals' - `); + `); expect(ruleRows[0]?.definition).toContain("new.total"); + expect(ruleRows[0]?.ev_enabled).toBe("R"); const { rows: privilegeRows } = await db.main.query(dedent` SELECT has_column_privilege( 'invoice_total_reader', @@ -1373,6 +1496,97 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "local partition column default for replaced function argument name is released on the child", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_partition_score(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.partitioned_scores ( + id integer NOT NULL, + period integer NOT NULL, + score integer + ) PARTITION BY RANGE (period); + + CREATE TABLE test_schema.partitioned_scores_2026 + PARTITION OF test_schema.partitioned_scores + FOR VALUES FROM (2026) TO (2027); + + ALTER TABLE test_schema.partitioned_scores_2026 + ALTER COLUMN score + SET DEFAULT test_schema.default_partition_score(1); + + INSERT INTO test_schema.partitioned_scores (id, period) + VALUES (1, 2026); + `, + testSql: dedent` + ALTER TABLE test_schema.partitioned_scores_2026 + ALTER COLUMN score DROP DEFAULT; + + DROP FUNCTION test_schema.default_partition_score(integer); + + CREATE FUNCTION test_schema.default_partition_score(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.partitioned_scores_2026 + ALTER COLUMN score + SET DEFAULT test_schema.default_partition_score(1); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropDefaultIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_scores_2026 ALTER COLUMN score DROP DEFAULT", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.default_partition_score", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.default_partition_score", + ), + ); + const restoreDefaultIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_scores_2026 ALTER COLUMN score SET DEFAULT", + ), + ); + + expect(dropDefaultIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropDefaultIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(restoreDefaultIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.partitioned_scores", + 1, + ); + }), + ); + test( "removed generated partition column for dropped function does not emit child DDL", withDb(pgVersion, async (db) => { From a1b006f4dd20dc68cc28c2cb7b1cd25c7f851da8 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 21:42:31 +0800 Subject: [PATCH 33/78] fix(pg-delta): merge publication replacement follow-ups Handle publication table-entry replacement per table and avoid duplicating row-filter rewrites already covered by existing publication drop/add changes. Also ignore PostgreSQL 18 generated-column NOT NULL bookkeeping constraints that pg-delta models on the column itself. RED: bun run test --test-name-pattern "does not duplicate publication table entries" src/core/expand-replace-dependencies.test.ts failed with DROP TABLE public.items, public.items before the table-entry de-duplication fix. Validation: - ../../node_modules/.bin/bun run test src/core/expand-replace-dependencies.test.ts -> 41 pass / 0 fail - PGDELTA_TEST_POSTGRES_VERSIONS=18 ../../node_modules/.bin/bun run test tests/integration/function-signature-expression-dependencies.test.ts -> 22 pass / 0 fail - ./node_modules/.bin/bun run format-and-lint -> pass - ./node_modules/.bin/bun run check-types -> pass - ./node_modules/.bin/bun run knip -> pass Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 328 ++++++++++++++++++ .../src/core/expand-replace-dependencies.ts | 274 +++++++++++++-- .../publication/changes/publication.alter.ts | 2 +- ...-signature-expression-dependencies.test.ts | 132 ++++++- 4 files changed, 705 insertions(+), 31 deletions(-) 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 cbd4f1f65..1dfa80f33 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -189,6 +189,18 @@ function tableWithDefault( }); } +function tableNamedWithDefault( + name: string, + columnDefault: string | null, + columnOverrides: Partial = {}, +): Table { + return new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault(columnDefault, columnOverrides), + name, + }); +} + function partitionTableWithDefault( columnDefault: string | null, columnOverrides: Partial = {}, @@ -501,6 +513,25 @@ function publicationOnItemsValue(rowFilter: string | null): Publication { }); } +function publicationOnTables( + tables: Publication["tables"], + name = "items_pub", +): Publication { + return new Publication({ + name, + owner: "postgres", + comment: null, + all_tables: false, + publish_insert: true, + publish_update: true, + publish_delete: true, + publish_truncate: true, + publish_via_partition_root: false, + tables, + schemas: [], + }); +} + describe("expandReplaceDependencies", () => { test("returns changes unchanged when there are no replace roots", async () => { const catalog = await createEmptyCatalog(160004, "u"); @@ -2619,6 +2650,140 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("refreshes each publication table entry when recreating generated columns", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainItems = tableNamedWithDefault( + "items", + "public.normalize_value(value)", + { is_generated: true }, + ); + const branchItems = tableNamedWithDefault( + "items", + "public.normalize_value(value)", + { is_generated: true }, + ); + const mainWidgets = tableNamedWithDefault( + "widgets", + "public.normalize_value(value)", + { is_generated: true }, + ); + const branchWidgets = tableNamedWithDefault( + "widgets", + "public.normalize_value(value)", + { is_generated: true }, + ); + const mainPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: ["value"], + row_filter: null, + }, + { + schema: "public", + name: "widgets", + columns: ["value"], + row_filter: null, + }, + ]); + const branchPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: ["value"], + row_filter: null, + }, + { + schema: "public", + name: "widgets", + columns: ["value"], + row_filter: null, + }, + ]); + const itemColumnId = "column:public.items.value"; + const widgetColumnId = "column:public.widgets.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { + [mainItems.stableId]: mainItems, + [mainWidgets.stableId]: mainWidgets, + }, + depends: [ + { + dependent_stable_id: itemColumnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: widgetColumnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: itemColumnId, + deptype: "n", + }, + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: widgetColumnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { + [branchItems.stableId]: branchItems, + [branchWidgets.stableId]: branchWidgets, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationDropTables, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toHaveLength(1); + expect(serialized).toContain( + "ALTER PUBLICATION items_pub DROP TABLE public.items, public.widgets", + ); + expect(serialized).toContain( + "ALTER PUBLICATION items_pub ADD TABLE public.items (value), TABLE public.widgets (value)", + ); + }); + test("releases retained publication row filters before routine replacement", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -2682,6 +2847,169 @@ describe("expandReplaceDependencies", () => { ).toHaveLength(1); }); + test("does not duplicate existing publication row filter replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainPublication = publicationOnItemsValue( + "(public.normalize_value(value) > 0)", + ); + const branchPublication = publicationOnItemsValue( + "(public.normalize_value(value) >= 0)", + ); + const table = tableWithDefault(null); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterPublicationDropTables({ + publication: mainPublication, + tables: mainPublication.tables, + }), + new AlterPublicationAddTables({ + publication: branchPublication, + tables: branchPublication.tables, + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { [table.stableId]: table }, + depends: [ + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { [table.stableId]: table }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationDropTables, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toHaveLength(1); + }); + + test("does not duplicate publication table entries when row filter and column list both need refresh", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: ["value"], + row_filter: "(public.normalize_value(value) > 0)", + }, + ]); + const branchPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: ["value"], + row_filter: "(public.normalize_value(value) > 0)", + }, + ]); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect( + serialized.filter((statement) => + statement.startsWith("ALTER PUBLICATION items_pub DROP TABLE"), + ), + ).toEqual(["ALTER PUBLICATION items_pub DROP TABLE public.items"]); + expect( + serialized.filter((statement) => + statement.startsWith("ALTER PUBLICATION items_pub ADD TABLE"), + ), + ).toEqual([ + "ALTER PUBLICATION items_pub ADD TABLE public.items (value) WHERE (public.normalize_value(value) > 0)", + ]); + }); + test("handles aggregate replacements as expression roots", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainAggregate = aggregateWithArgs(["integer"]); diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index d71e28164..a466287fb 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -296,6 +296,29 @@ export function expandReplaceDependencies({ // walking those edges must not promote metadata into object replacement. continue; } + if ( + generatedColumnsRecreatedByExpressionFallback.has(refId) && + isOwnerTableDependentForColumn(refId, dependentRaw) + ) { + // A table has catalog bookkeeping dependencies on its own columns. The + // generated-column fallback already drops and recreates the column, so + // that internal table->column edge must not promote the whole table. + continue; + } + if ( + generatedColumnsRecreatedByExpressionFallback.has(refId) && + isGeneratedColumnNotNullConstraintDependent({ + columnId: refId, + dependentId: dependentRaw, + mainCatalog, + branchCatalog, + }) + ) { + // PostgreSQL 18 stores NOT NULL as a pg_constraint dependency, but + // pg-delta models it on the column. Dropping/re-adding the generated + // column already releases and restores this edge. + continue; + } if ( shouldHandleRoutineExpressionDependent({ @@ -364,6 +387,7 @@ export function expandReplaceDependencies({ mainCatalog, branchCatalog, additions, + existingChanges: [...changes, ...additions], visitedTargets, }) ) { @@ -706,6 +730,68 @@ function isMetadataDependentStableId(stableId: string): boolean { ); } +function isOwnerTableDependentForColumn( + columnId: string, + dependentId: string, +): boolean { + const columnRef = parseColumnStableId(columnId); + if (!columnRef) return false; + + return dependentId === stableId.table(columnRef.schema, columnRef.table); +} + +function isGeneratedColumnNotNullConstraintDependent({ + columnId, + dependentId, + mainCatalog, + branchCatalog, +}: { + columnId: string; + dependentId: string; + mainCatalog: Catalog; + branchCatalog: Catalog; +}): boolean { + const columnRef = parseColumnStableId(columnId); + const constraintRef = parseConstraintStableId(dependentId); + if ( + !columnRef || + !constraintRef || + constraintRef.schema !== columnRef.schema || + constraintRef.owner !== columnRef.table + ) { + return false; + } + + const tableId = stableId.table(columnRef.schema, columnRef.table); + const mainTable = mainCatalog.tables[tableId]; + const branchTable = branchCatalog.tables[tableId]; + const mainColumn = mainTable?.columns.find( + (column) => column.name === columnRef.column, + ); + const branchColumn = branchTable?.columns.find( + (column) => column.name === columnRef.column, + ); + const modeledConstraint = Boolean( + mainTable?.constraints.some( + (constraint) => constraint.name === constraintRef.constraint, + ) || + branchTable?.constraints.some( + (constraint) => constraint.name === constraintRef.constraint, + ), + ); + const generatedColumnNotNull = Boolean( + (mainColumn?.is_generated && mainColumn.not_null) || + (branchColumn?.is_generated && branchColumn.not_null), + ); + + return ( + !modeledConstraint && + constraintRef.constraint === + `${columnRef.table}_${columnRef.column}_not_null` && + generatedColumnNotNull + ); +} + function shouldTraverseExpressionReplacementDependent({ dependentRaw, expressionReplacementChanges, @@ -822,6 +908,7 @@ function maybeAddRoutineDependentPublicationReplacement({ mainCatalog, branchCatalog, additions, + existingChanges, visitedTargets, }: { refId: string; @@ -830,6 +917,7 @@ function maybeAddRoutineDependentPublicationReplacement({ mainCatalog: Catalog; branchCatalog: Catalog; additions: Change[]; + existingChanges: readonly Change[]; visitedTargets: Set; }): boolean { if ( @@ -838,17 +926,19 @@ function maybeAddRoutineDependentPublicationReplacement({ ) { return false; } - if (visitedTargets.has(dependentRaw)) return true; + const visitKey = publicationRowFilterReplacementVisitKey(dependentRaw); + if (visitedTargets.has(visitKey)) return true; const replacementChanges = buildPublicationRowFilterReplacementChanges({ publicationId: dependentRaw, mainCatalog, branchCatalog, + existingChanges, }); if (!replacementChanges) return false; additions.push(...replacementChanges); - visitedTargets.add(dependentRaw); + visitedTargets.add(visitKey); return true; } @@ -873,18 +963,19 @@ function maybeAddColumnDependentPublicationReplacement({ ) { return false; } - if (visitedTargets.has(dependentRaw)) return true; - const replacementChanges = buildPublicationColumnListReplacementChanges({ + const replacement = buildPublicationColumnListReplacement({ columnId: refId, publicationId: dependentRaw, mainCatalog, branchCatalog, }); - if (!replacementChanges) return false; + if (!replacement) return false; - additions.push(...replacementChanges); - visitedTargets.add(dependentRaw); + if (visitedTargets.has(replacement.visitKey)) return true; + + appendPublicationColumnListReplacement(additions, replacement); + visitedTargets.add(replacement.visitKey); return true; } @@ -1101,7 +1192,7 @@ function buildColumnExpressionReplacementChanges({ return changes; } -function buildPublicationColumnListReplacementChanges({ +function buildPublicationColumnListReplacement({ columnId, publicationId, mainCatalog, @@ -1111,7 +1202,7 @@ function buildPublicationColumnListReplacementChanges({ publicationId: string; mainCatalog: Catalog; branchCatalog: Catalog; -}): Change[] | null { +}): PublicationColumnListReplacement | null { const columnRef = parseColumnStableId(columnId); if (!columnRef) return null; @@ -1129,26 +1220,88 @@ function buildPublicationColumnListReplacementChanges({ ); if (!mainTable || !branchTable) return null; - return [ - new AlterPublicationDropTables({ - publication: mainPublication, - tables: [mainTable], - }), - new AlterPublicationAddTables({ - publication: branchPublication, - tables: [branchTable], - }), - ]; + return { + mainPublication, + branchPublication, + mainTable, + branchTable, + visitKey: publicationTableReplacementVisitKey(publicationId, mainTable), + }; +} + +type PublicationColumnListReplacement = { + mainPublication: Catalog["publications"][string]; + branchPublication: Catalog["publications"][string]; + mainTable: PublicationTableProps; + branchTable: PublicationTableProps; + visitKey: string; +}; + +function appendPublicationColumnListReplacement( + additions: Change[], + replacement: PublicationColumnListReplacement, +): void { + const existingDrop = additions.find( + (change): change is AlterPublicationDropTables => + change instanceof AlterPublicationDropTables && + change.publication.stableId === replacement.mainPublication.stableId, + ); + if (existingDrop) { + if ( + !publicationTableChangeCovered({ + changes: [existingDrop], + publicationId: replacement.mainPublication.stableId, + table: replacement.mainTable, + changeType: "drop", + }) + ) { + existingDrop.tables.push(replacement.mainTable); + } + } else { + additions.push( + new AlterPublicationDropTables({ + publication: replacement.mainPublication, + tables: [replacement.mainTable], + }), + ); + } + + const existingAdd = additions.find( + (change): change is AlterPublicationAddTables => + change instanceof AlterPublicationAddTables && + change.publication.stableId === replacement.branchPublication.stableId, + ); + if (existingAdd) { + if ( + !publicationTableChangeCovered({ + changes: [existingAdd], + publicationId: replacement.branchPublication.stableId, + table: replacement.branchTable, + changeType: "add", + }) + ) { + existingAdd.tables.push(replacement.branchTable); + } + } else { + additions.push( + new AlterPublicationAddTables({ + publication: replacement.branchPublication, + tables: [replacement.branchTable], + }), + ); + } } function buildPublicationRowFilterReplacementChanges({ publicationId, mainCatalog, branchCatalog, + existingChanges, }: { publicationId: string; mainCatalog: Catalog; branchCatalog: Catalog; + existingChanges: readonly Change[]; }): Change[] | null { const mainPublication = mainCatalog.publications[publicationId]; const branchPublication = branchCatalog.publications[publicationId]; @@ -1165,27 +1318,98 @@ function buildPublicationRowFilterReplacementChanges({ const branchTables = branchPublication.tables.filter((table) => mainTableKeys.has(publicationTableKey(table)), ); + const branchTablesByKey = new Map( + branchTables.map((table) => [publicationTableKey(table), table]), + ); + const releaseTables = mainTables.filter( + (table) => + !publicationTableChangeCovered({ + changes: existingChanges, + publicationId, + table, + changeType: "drop", + }), + ); + const restoreTables = mainTables.flatMap((table) => { + const tableKey = publicationTableKey(table); + const branchTable = branchTablesByKey.get(tableKey); + if (!branchTable) return []; + return publicationTableChangeCovered({ + changes: existingChanges, + publicationId, + table: branchTable, + changeType: "add", + }) + ? [] + : [branchTable]; + }); + + if (releaseTables.length === 0 && restoreTables.length === 0) return []; return [ - new AlterPublicationDropTables({ - publication: mainPublication, - tables: mainTables, - }), - ...(branchTables.length > 0 + ...(releaseTables.length > 0 + ? [ + new AlterPublicationDropTables({ + publication: mainPublication, + tables: releaseTables, + }), + ] + : []), + ...(restoreTables.length > 0 ? [ new AlterPublicationAddTables({ publication: branchPublication, - tables: branchTables, + tables: restoreTables, }), ] : []), ]; } +function publicationRowFilterReplacementVisitKey( + publicationId: string, +): string { + return `publicationRowFilters:${publicationId}`; +} + +function publicationTableReplacementVisitKey( + publicationId: string, + table: PublicationTableProps, +): string { + return `publicationTable:${publicationId}:${publicationTableKey(table)}`; +} + function publicationTableKey(table: PublicationTableProps): string { return `${table.schema}.${table.name}`; } +function publicationTableChangeCovered({ + changes, + publicationId, + table, + changeType, +}: { + changes: readonly Change[]; + publicationId: string; + table: PublicationTableProps; + changeType: "add" | "drop"; +}): boolean { + const changeClass = + changeType === "add" + ? AlterPublicationAddTables + : AlterPublicationDropTables; + const tableStableId = stableId.table(table.schema, table.name); + + return changes.some((change) => { + if (!(change instanceof changeClass)) return false; + if (change.publication.stableId !== publicationId) return false; + + return changeType === "add" + ? change.requires.includes(tableStableId) + : change.drops.includes(tableStableId); + }); +} + function findPublicationTableForColumn( tables: readonly PublicationTableProps[], columnRef: ColumnStableIdParts, diff --git a/packages/pg-delta/src/core/objects/publication/changes/publication.alter.ts b/packages/pg-delta/src/core/objects/publication/changes/publication.alter.ts index a5649d138..57195c352 100644 --- a/packages/pg-delta/src/core/objects/publication/changes/publication.alter.ts +++ b/packages/pg-delta/src/core/objects/publication/changes/publication.alter.ts @@ -92,7 +92,7 @@ export class AlterPublicationSetList extends AlterPublicationChange { export class AlterPublicationAddTables extends AlterPublicationChange { public readonly publication: Publication; public readonly scope = "object" as const; - private readonly tables: PublicationTableProps[]; + public readonly tables: PublicationTableProps[]; constructor(props: { publication: Publication; diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index 47533c8df..bf70cd690 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -6,11 +6,12 @@ import { roundtripFidelityTest } from "./roundtrip.ts"; function expectNoTableReplacement(sqlStatements: string[]) { expect( - sqlStatements.some((statement) => statement.startsWith("DROP TABLE ")), - ).toBe(false); - expect( - sqlStatements.some((statement) => statement.startsWith("CREATE TABLE ")), - ).toBe(false); + sqlStatements.filter( + (statement) => + statement.startsWith("DROP TABLE ") || + statement.startsWith("CREATE TABLE "), + ), + ).toEqual([]); } async function expectTableRowCount( @@ -1375,6 +1376,127 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test.skipIf(pgVersion < 18)( + "multiple generated publication column lists are refreshed independently", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_shared_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.published_invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_shared_total(subtotal)) STORED + ); + + CREATE TABLE test_schema.published_order_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_shared_total(subtotal)) STORED + ); + + CREATE PUBLICATION shared_total_pub + FOR TABLE test_schema.published_invoice_totals (id, total), + test_schema.published_order_totals (id, total); + + INSERT INTO test_schema.published_invoice_totals (id, subtotal) + VALUES (1, 20); + INSERT INTO test_schema.published_order_totals (id, subtotal) + VALUES (1, 30); + `, + testSql: dedent` + ALTER PUBLICATION shared_total_pub + SET TABLE test_schema.published_invoice_totals (id), + test_schema.published_order_totals (id); + + ALTER TABLE test_schema.published_invoice_totals + DROP COLUMN total; + ALTER TABLE test_schema.published_order_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_shared_total(integer); + + CREATE FUNCTION test_schema.compute_shared_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.published_invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_shared_total(subtotal)) STORED; + ALTER TABLE test_schema.published_order_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_shared_total(subtotal)) STORED; + + ALTER PUBLICATION shared_total_pub + SET TABLE test_schema.published_invoice_totals (id, total), + test_schema.published_order_totals (id, total); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const publicationDropStatements = sqlStatements.filter( + (statement) => + statement.startsWith( + "ALTER PUBLICATION shared_total_pub DROP TABLE", + ), + ); + const publicationAddStatements = sqlStatements.filter((statement) => + statement.startsWith( + "ALTER PUBLICATION shared_total_pub ADD TABLE", + ), + ); + + expect(publicationDropStatements).toHaveLength(1); + expect(publicationAddStatements).toHaveLength(1); + expect(publicationDropStatements).toContain( + "ALTER PUBLICATION shared_total_pub DROP TABLE test_schema.published_invoice_totals, test_schema.published_order_totals", + ); + expect(publicationAddStatements).toContain( + "ALTER PUBLICATION shared_total_pub ADD TABLE test_schema.published_invoice_totals (id, total), TABLE test_schema.published_order_totals (id, total)", + ); + }, + }); + + for (const tableName of [ + "published_invoice_totals", + "published_order_totals", + ]) { + const { rows } = await db.main.query(dedent` + SELECT json_agg(att.attname ORDER BY cols.ord) AS columns + FROM pg_catalog.pg_publication p + JOIN pg_catalog.pg_publication_rel pr ON pr.prpubid = p.oid + JOIN pg_catalog.pg_class c ON c.oid = pr.prrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN unnest(pr.prattrs) WITH ORDINALITY AS cols(attnum, ord) + ON true + JOIN pg_catalog.pg_attribute att + ON att.attrelid = c.oid + AND att.attnum = cols.attnum + WHERE p.pubname = 'shared_total_pub' + AND n.nspname = 'test_schema' + AND c.relname = '${tableName}' + `); + expect(rows[0]?.columns).toEqual(["id", "total"]); + } + }), + ); + test( "unchanged generated partition column for replaced function argument name does not emit child DDL", withDb(pgVersion, async (db) => { From 5e161012a958e5beed154645e3a32db2bd776965 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 22:42:14 +0800 Subject: [PATCH 34/78] fix(pg-delta): preserve replacement dependents Recreate retained indexes for replaced tables and materialized views, including clustered materialized-view indexes that PostgreSQL drops with the relation. Avoid duplicating publication column-list release/restore changes when the original diff already includes the replacement pair. Recreate aggregate dependents of replaced routines and replay aggregate owner/comment/security-label/privilege metadata. RED evidence: focused unit coverage initially reported zero retained matview cluster changes, duplicate publication drop/add operations, and no aggregate drop/create replacement. Real catalog integration also showed the generated plan recreated the materialized view without recreating its retained index. Validation: npx --yes bun@1.3.14 run format-and-lint:fix; npx --yes bun@1.3.14 run check-types; cd packages/pg-delta && npx --yes bun@1.3.14 run test src/core/expand-replace-dependencies.test.ts; PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1 PGDELTA_TEST_POSTGRES_VERSIONS=17 npx --yes bun@1.3.14 run test --test-name-pattern 'clustered materialized view indexes survive|aggregate dependents are recreated' tests/integration/function-signature-expression-dependencies.test.ts; PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1 PGDELTA_TEST_POSTGRES_VERSIONS=18 npx --yes bun@1.3.14 run test --test-name-pattern 'unchanged generated column in publication column list' tests/integration/function-signature-expression-dependencies.test.ts; PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1 PGDELTA_TEST_POSTGRES_VERSIONS=17 npx --yes bun@1.3.14 run test tests/integration/function-signature-expression-dependencies.test.ts; npx --yes bun@1.3.14 run knip --fix; git diff --check. Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 283 +++++++++++++++ .../src/core/expand-replace-dependencies.ts | 337 +++++++++++++++--- .../changes/materialized-view.alter.ts | 40 +++ ...-signature-expression-dependencies.test.ts | 224 ++++++++++++ 4 files changed, 829 insertions(+), 55 deletions(-) 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 1dfa80f33..c0dfc1495 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -24,6 +24,11 @@ import { CreateCommentOnIndex } from "./objects/index/changes/index.comment.ts"; import { CreateIndex } from "./objects/index/changes/index.create.ts"; import { DropIndex } from "./objects/index/changes/index.drop.ts"; import { Index, type IndexProps } from "./objects/index/index.model.ts"; +import { AlterMaterializedViewClusterOn } from "./objects/materialized-view/changes/materialized-view.alter.ts"; +import { + MaterializedView, + type MaterializedViewProps, +} from "./objects/materialized-view/materialized-view.model.ts"; import { AlterPublicationAddTables, AlterPublicationDropTables, @@ -394,6 +399,51 @@ function viewOnItemsValue(): View { }); } +function materializedViewOnItemsValue( + overrides: Partial = {}, +): MaterializedView { + return new MaterializedView({ + schema: "public", + name: "items_value_mv", + definition: " SELECT value FROM public.items;", + row_security: false, + force_row_security: false, + has_indexes: false, + has_rules: false, + has_triggers: false, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + partition_bound: null, + options: null, + owner: "postgres", + comment: null, + columns: [ + { + name: "value", + position: 1, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ], + privileges: [], + ...overrides, + }); +} + function aggregateWithArgs(argumentTypes: string[]): Aggregate { return new Aggregate({ schema: "public", @@ -2589,6 +2639,95 @@ describe("expandReplaceDependencies", () => { ); }); + test("restores clustered indexes on promoted materialized views", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainMaterializedView = materializedViewOnItemsValue({ + definition: + " SELECT public.normalize_value(items.value) AS value FROM public.items;", + }); + const branchMaterializedView = materializedViewOnItemsValue({ + definition: + " SELECT public.normalize_value(items.value) AS value FROM public.items;", + }); + const mainIndex = indexOnItemsValue({ + table_name: "items_value_mv", + name: "items_value_mv_value_idx", + table_relkind: "m", + is_clustered: true, + definition: + "CREATE INDEX items_value_mv_value_idx ON public.items_value_mv (value)", + }); + const branchIndex = indexOnItemsValue({ + table_name: "items_value_mv", + name: "items_value_mv_value_idx", + table_relkind: "m", + is_clustered: true, + definition: + "CREATE INDEX items_value_mv_value_idx ON public.items_value_mv (value)", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + materializedViews: { + [mainMaterializedView.stableId]: mainMaterializedView, + }, + indexableObjects: { + [mainMaterializedView.stableId]: mainMaterializedView, + }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: mainMaterializedView.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + materializedViews: { + [branchMaterializedView.stableId]: branchMaterializedView, + }, + indexableObjects: { + [branchMaterializedView.stableId]: branchMaterializedView, + }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterMaterializedViewClusterOn, + ), + ).toHaveLength(1); + expect(serialized).toContain( + "ALTER MATERIALIZED VIEW public.items_value_mv CLUSTER ON items_value_mv_value_idx", + ); + }); + test("skips partition-cloned trigger dependents during routine replacement", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -2918,6 +3057,98 @@ describe("expandReplaceDependencies", () => { ).toHaveLength(1); }); + test("does not duplicate existing publication column list replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: ["value"], + row_filter: null, + }, + ]); + const branchPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: ["value"], + row_filter: null, + }, + ]); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterPublicationDropTables({ + publication: mainPublication, + tables: mainPublication.tables, + }), + new AlterPublicationAddTables({ + publication: branchPublication, + tables: branchPublication.tables, + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationDropTables, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toHaveLength(1); + }); + test("does not duplicate publication table entries when row filter and column list both need refresh", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -3064,6 +3295,58 @@ describe("expandReplaceDependencies", () => { ); }); + test("recreates retained aggregates that depend on replaced routines", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"], "sum_state"); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.sum_state(renamed integer) RETURNS integer", + }); + const mainAggregate = aggregateWithArgs(["integer"]); + const branchAggregate = aggregateWithArgs(["integer"]); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + aggregates: { [mainAggregate.stableId]: mainAggregate }, + depends: [ + { + dependent_stable_id: mainAggregate.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + aggregates: { [branchAggregate.stableId]: branchAggregate }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter((change) => change instanceof DropAggregate), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateAggregate), + ).toHaveLength(1); + }); + test("restores promoted routine metadata after dependent routine replacement", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index a466287fb..a64889e44 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -6,6 +6,16 @@ import { filterPublicBuiltInDefaults, } from "./objects/base.privilege-diff.ts"; import type { ObjectDiffContext } from "./objects/diff-context.ts"; +import { AlterAggregateChangeOwner } from "./objects/aggregate/changes/aggregate.alter.ts"; +import { CreateCommentOnAggregate } from "./objects/aggregate/changes/aggregate.comment.ts"; +import { CreateAggregate } from "./objects/aggregate/changes/aggregate.create.ts"; +import { DropAggregate } from "./objects/aggregate/changes/aggregate.drop.ts"; +import { + GrantAggregatePrivileges, + RevokeAggregatePrivileges, + RevokeGrantOptionAggregatePrivileges, +} from "./objects/aggregate/changes/aggregate.privilege.ts"; +import { CreateSecurityLabelOnAggregate } from "./objects/aggregate/changes/aggregate.security-label.ts"; import { AlterDomainAddConstraint, AlterDomainDropConstraint, @@ -18,6 +28,7 @@ import { AlterIndexSetStatistics } from "./objects/index/changes/index.alter.ts" import { CreateCommentOnIndex } from "./objects/index/changes/index.comment.ts"; import { CreateIndex } from "./objects/index/changes/index.create.ts"; import { DropIndex } from "./objects/index/changes/index.drop.ts"; +import { AlterMaterializedViewClusterOn } from "./objects/materialized-view/changes/materialized-view.alter.ts"; import { CreateMaterializedView } from "./objects/materialized-view/changes/materialized-view.create.ts"; import { DropMaterializedView } from "./objects/materialized-view/changes/materialized-view.drop.ts"; import { buildCreateMaterializedViewChanges } from "./objects/materialized-view/materialized-view.diff.ts"; @@ -92,6 +103,7 @@ type ResolvedObject = main: Catalog["indexes"][string]; branch: Catalog["indexes"][string]; branchIndexableObject: Catalog["indexableObjects"][string] | undefined; + branchMaterializedView: Catalog["materializedViews"][string] | undefined; branchTable: Catalog["tables"][string] | undefined; } | { @@ -115,6 +127,11 @@ type ResolvedObject = main: Catalog["procedures"][string]; branch: Catalog["procedures"][string]; } + | { + kind: "aggregate"; + main: Catalog["aggregates"][string]; + branch: Catalog["aggregates"][string]; + } | { kind: "rls_policy"; main: Catalog["rlsPolicies"][string]; @@ -401,6 +418,7 @@ export function expandReplaceDependencies({ mainCatalog, branchCatalog, additions, + existingChanges: [...changes, ...additions], visitedTargets, }) ) { @@ -482,7 +500,20 @@ export function expandReplaceDependencies({ }); if (!replacementChanges) continue; - additions.push(...replacementChanges); + const retainedIndexChanges = + resolved.kind === "table" || resolved.kind === "materialized_view" + ? buildRetainedIndexReplacementChanges({ + relationStableId: targetId, + mainCatalog, + branchCatalog, + createdIds, + droppedIds, + visitedTargets, + diffContext, + }) + : []; + + additions.push(...replacementChanges, ...retainedIndexChanges); if (resolved.kind === "rls_policy") { promotedRlsPolicyIds.add(targetId); } @@ -495,7 +526,7 @@ export function expandReplaceDependencies({ } // Track new creates/drops so we don't duplicate work for downstream dependents. - for (const change of replacementChanges) { + for (const change of [...replacementChanges, ...retainedIndexChanges]) { for (const id of change.creates ?? []) createdIds.add(id); for (const id of change.drops ?? []) droppedIds.add(id); } @@ -948,6 +979,7 @@ function maybeAddColumnDependentPublicationReplacement({ mainCatalog, branchCatalog, additions, + existingChanges, visitedTargets, }: { refId: string; @@ -955,6 +987,7 @@ function maybeAddColumnDependentPublicationReplacement({ mainCatalog: Catalog; branchCatalog: Catalog; additions: Change[]; + existingChanges: readonly Change[]; visitedTargets: Set; }): boolean { if ( @@ -974,7 +1007,25 @@ function maybeAddColumnDependentPublicationReplacement({ if (visitedTargets.has(replacement.visitKey)) return true; - appendPublicationColumnListReplacement(additions, replacement); + const addRelease = !publicationTableChangeCovered({ + changes: existingChanges, + publicationId: replacement.mainPublication.stableId, + table: replacement.mainTable, + changeType: "drop", + }); + const addRestore = !publicationTableChangeCovered({ + changes: existingChanges, + publicationId: replacement.branchPublication.stableId, + table: replacement.branchTable, + changeType: "add", + }); + + if (addRelease || addRestore) { + appendPublicationColumnListReplacement(additions, replacement, { + addRelease, + addRestore, + }); + } visitedTargets.add(replacement.visitKey); return true; } @@ -1240,55 +1291,60 @@ type PublicationColumnListReplacement = { function appendPublicationColumnListReplacement( additions: Change[], replacement: PublicationColumnListReplacement, + options: { addRelease: boolean; addRestore: boolean }, ): void { - const existingDrop = additions.find( - (change): change is AlterPublicationDropTables => - change instanceof AlterPublicationDropTables && - change.publication.stableId === replacement.mainPublication.stableId, - ); - if (existingDrop) { - if ( - !publicationTableChangeCovered({ - changes: [existingDrop], - publicationId: replacement.mainPublication.stableId, - table: replacement.mainTable, - changeType: "drop", - }) - ) { - existingDrop.tables.push(replacement.mainTable); - } - } else { - additions.push( - new AlterPublicationDropTables({ - publication: replacement.mainPublication, - tables: [replacement.mainTable], - }), + if (options.addRelease) { + const existingDrop = additions.find( + (change): change is AlterPublicationDropTables => + change instanceof AlterPublicationDropTables && + change.publication.stableId === replacement.mainPublication.stableId, ); + if (existingDrop) { + if ( + !publicationTableChangeCovered({ + changes: [existingDrop], + publicationId: replacement.mainPublication.stableId, + table: replacement.mainTable, + changeType: "drop", + }) + ) { + existingDrop.tables.push(replacement.mainTable); + } + } else { + additions.push( + new AlterPublicationDropTables({ + publication: replacement.mainPublication, + tables: [replacement.mainTable], + }), + ); + } } - const existingAdd = additions.find( - (change): change is AlterPublicationAddTables => - change instanceof AlterPublicationAddTables && - change.publication.stableId === replacement.branchPublication.stableId, - ); - if (existingAdd) { - if ( - !publicationTableChangeCovered({ - changes: [existingAdd], - publicationId: replacement.branchPublication.stableId, - table: replacement.branchTable, - changeType: "add", - }) - ) { - existingAdd.tables.push(replacement.branchTable); - } - } else { - additions.push( - new AlterPublicationAddTables({ - publication: replacement.branchPublication, - tables: [replacement.branchTable], - }), + if (options.addRestore) { + const existingAdd = additions.find( + (change): change is AlterPublicationAddTables => + change instanceof AlterPublicationAddTables && + change.publication.stableId === replacement.branchPublication.stableId, ); + if (existingAdd) { + if ( + !publicationTableChangeCovered({ + changes: [existingAdd], + publicationId: replacement.branchPublication.stableId, + table: replacement.branchTable, + changeType: "add", + }) + ) { + existingAdd.tables.push(replacement.branchTable); + } + } else { + additions.push( + new AlterPublicationAddTables({ + publication: replacement.branchPublication, + tables: [replacement.branchTable], + }), + ); + } } } @@ -1876,6 +1932,8 @@ function resolveObjectForStableId( branch, branchIndexableObject: branchCatalog.indexableObjects[branch.tableStableId], + branchMaterializedView: + branchCatalog.materializedViews[branch.tableStableId], branchTable: branchCatalog.tables[branch.tableStableId], } : null; @@ -1908,6 +1966,12 @@ function resolveObjectForStableId( 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]; @@ -1947,6 +2011,89 @@ function resolveObjectForStableId( return null; } +function buildRetainedIndexReplacementChanges({ + relationStableId, + mainCatalog, + branchCatalog, + createdIds, + droppedIds, + visitedTargets, + diffContext, +}: { + relationStableId: string; + mainCatalog: Catalog; + branchCatalog: Catalog; + createdIds: ReadonlySet; + droppedIds: ReadonlySet; + visitedTargets: Set; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): Change[] { + const changes: Change[] = []; + // Dropping a table or materialized view also drops its indexes. A retained + // relation replacement therefore has to recreate retained indexes from the + // branch catalog, even when pg_depend does not expose a standalone + // index -> relation edge for the graph walk. + const branchIndexes = Object.values(branchCatalog.indexes) + .filter((index) => index.tableStableId === relationStableId) + .sort((a, b) => a.stableId.localeCompare(b.stableId)); + + for (const branchIndex of branchIndexes) { + const indexId = branchIndex.stableId; + if (visitedTargets.has(indexId)) continue; + if (!mainCatalog.indexes[indexId]) continue; + if (createdIds.has(indexId) && droppedIds.has(indexId)) continue; + + const resolved = resolveObjectForStableId( + indexId, + mainCatalog, + branchCatalog, + ); + if (!resolved || resolved.kind !== "index") continue; + + const addDrop = !droppedIds.has(indexId); + const addCreate = !createdIds.has(indexId); + const replacementChanges = buildReplaceChanges(resolved, { + addDrop, + addCreate, + diffContext, + }); + if (!replacementChanges) continue; + + changes.push(...replacementChanges); + visitedTargets.add(indexId); + } + + return changes; +} + +function buildRetainedClusterChange( + resolved: Extract, +): Change[] { + if (!resolved.branch.is_clustered) return []; + if (resolved.branch.table_relkind === "m") { + return resolved.branchMaterializedView + ? [ + new AlterMaterializedViewClusterOn({ + materializedView: resolved.branchMaterializedView, + indexName: resolved.branch.name, + }), + ] + : []; + } + + return resolved.branchTable + ? [ + new AlterTableClusterOn({ + table: resolved.branchTable, + indexName: resolved.branch.name, + }), + ] + : []; +} + function buildReplaceChanges( resolved: ResolvedObject, options: { @@ -2049,14 +2196,7 @@ function buildReplaceChanges( // CREATE INDEX does not carry expression-index statistics, so // replay retained targets after the replacement index exists. ...buildRetainedIndexStatisticsChanges(resolved.branch), - ...(resolved.branch.is_clustered && resolved.branchTable - ? [ - new AlterTableClusterOn({ - table: resolved.branchTable, - indexName: resolved.branch.name, - }), - ] - : []), + ...buildRetainedClusterChange(resolved), ...(resolved.branch.is_replica_identity && resolved.branchTable ? [ new AlterTableSetReplicaIdentity({ @@ -2121,6 +2261,19 @@ function buildReplaceChanges( ] : []), ]; + case "aggregate": + return [ + ...(addDrop ? [new DropAggregate({ aggregate: resolved.main })] : []), + ...(addCreate + ? [ + new CreateAggregate({ aggregate: resolved.branch }), + ...buildRetainedAggregateMetadataChanges({ + aggregate: resolved.branch, + diffContext, + }), + ] + : []), + ]; case "rls_policy": return [ ...(addDrop ? [new DropRlsPolicy({ policy: resolved.main })] : []), @@ -2251,6 +2404,80 @@ function buildRetainedProcedureMetadataChanges({ return changes; } +function buildRetainedAggregateMetadataChanges({ + aggregate, + diffContext, +}: { + aggregate: Catalog["aggregates"][string]; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): Change[] { + const changes: Change[] = []; + + if (diffContext && aggregate.owner !== diffContext.currentUser) { + changes.push( + new AlterAggregateChangeOwner({ + aggregate, + owner: aggregate.owner, + }), + ); + } + + if (aggregate.comment !== null) { + changes.push(new CreateCommentOnAggregate({ aggregate })); + } + + for (const securityLabel of aggregate.security_labels) { + changes.push( + new CreateSecurityLabelOnAggregate({ + aggregate, + securityLabel, + }), + ); + } + + if (!diffContext) return changes; + + const effectiveDefaults = + diffContext.defaultPrivilegeState.getEffectiveDefaults( + diffContext.currentUser, + "aggregate", + aggregate.schema ?? "", + ); + const creatorFilteredDefaults = + aggregate.owner !== diffContext.currentUser + ? effectiveDefaults.filter((p) => p.grantee !== diffContext.currentUser) + : effectiveDefaults; + const desiredPrivileges = filterPublicBuiltInDefaults( + "aggregate", + aggregate.privileges, + ); + const privilegeResults = diffPrivileges( + filterPublicBuiltInDefaults("aggregate", creatorFilteredDefaults), + desiredPrivileges, + aggregate.owner, + ); + + changes.push( + ...(emitObjectPrivilegeChanges( + privilegeResults, + aggregate, + aggregate, + "aggregate", + { + Grant: GrantAggregatePrivileges, + Revoke: RevokeAggregatePrivileges, + RevokeGrantOption: RevokeGrantOptionAggregatePrivileges, + }, + diffContext.version, + ) as Change[]), + ); + + return changes; +} + function buildCreateViewReplacementChanges( view: Catalog["views"][string], diffContext: diff --git a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.ts b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.ts index 53fd1cd8e..ae0ab4c0c 100644 --- a/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.ts +++ b/packages/pg-delta/src/core/objects/materialized-view/changes/materialized-view.alter.ts @@ -1,4 +1,5 @@ import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; +import { stableId } from "../../utils.ts"; import type { MaterializedView } from "../materialized-view.model.ts"; import { AlterMaterializedViewChange } from "./materialized-view.base.ts"; @@ -34,6 +35,7 @@ import { AlterMaterializedViewChange } from "./materialized-view.base.ts"; export type AlterMaterializedView = | AlterMaterializedViewChangeOwner + | AlterMaterializedViewClusterOn | AlterMaterializedViewSetStorageParams; /** @@ -64,6 +66,44 @@ export class AlterMaterializedViewChangeOwner extends AlterMaterializedViewChang } } +/** + * ALTER MATERIALIZED VIEW ... CLUSTER ON ... + */ +export class AlterMaterializedViewClusterOn extends AlterMaterializedViewChange { + public readonly materializedView: MaterializedView; + public readonly indexName: string; + public readonly scope = "object" as const; + + constructor(props: { + materializedView: MaterializedView; + indexName: string; + }) { + super(); + this.materializedView = props.materializedView; + this.indexName = props.indexName; + } + + get requires() { + return [ + this.materializedView.stableId, + stableId.index( + this.materializedView.schema, + this.materializedView.name, + this.indexName, + ), + ]; + } + + serialize(_options?: SerializeOptions): string { + return [ + "ALTER MATERIALIZED VIEW", + `${this.materializedView.schema}.${this.materializedView.name}`, + "CLUSTER ON", + this.indexName, + ].join(" "); + } +} + /** * ALTER MATERIALIZED VIEW ... SET/RESET ( storage_parameter ... ) * Accepts main and branch, computes differences, and emits RESET then SET statements. diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index bf70cd690..8ef096adf 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -1310,6 +1310,17 @@ for (const pgVersion of POSTGRES_VERSIONS) { assertSqlStatements: (sqlStatements) => { expectNoTableReplacement(sqlStatements); + const publicationDropStatements = sqlStatements.filter( + (statement) => + statement.startsWith( + "ALTER PUBLICATION invoice_total_pub DROP TABLE", + ), + ); + const publicationAddStatements = sqlStatements.filter((statement) => + statement.startsWith( + "ALTER PUBLICATION invoice_total_pub ADD TABLE", + ), + ); const releasePublicationIndex = sqlStatements.findIndex( (statement) => statement.startsWith( @@ -1343,6 +1354,8 @@ for (const pgVersion of POSTGRES_VERSIONS) { ), ); + expect(publicationDropStatements).toHaveLength(1); + expect(publicationAddStatements).toHaveLength(1); expect(releasePublicationIndex).toBeGreaterThanOrEqual(0); expect(dropColumnIndex).toBeGreaterThan(releasePublicationIndex); expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); @@ -1497,6 +1510,217 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "clustered materialized view indexes survive dependent function replacement", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_mv_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.mv_inputs ( + id integer NOT NULL, + subtotal integer NOT NULL + ); + + CREATE MATERIALIZED VIEW test_schema.invoice_total_mv AS + SELECT + id, + test_schema.compute_mv_total(subtotal) AS total + FROM test_schema.mv_inputs; + + CREATE INDEX invoice_total_mv_total_idx + ON test_schema.invoice_total_mv (total); + + ALTER MATERIALIZED VIEW test_schema.invoice_total_mv + CLUSTER ON invoice_total_mv_total_idx; + + INSERT INTO test_schema.mv_inputs (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + DROP INDEX test_schema.invoice_total_mv_total_idx; + + DROP MATERIALIZED VIEW test_schema.invoice_total_mv; + + DROP FUNCTION test_schema.compute_mv_total(integer); + + CREATE FUNCTION test_schema.compute_mv_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + CREATE MATERIALIZED VIEW test_schema.invoice_total_mv AS + SELECT + id, + test_schema.compute_mv_total(subtotal) AS total + FROM test_schema.mv_inputs; + + CREATE INDEX invoice_total_mv_total_idx + ON test_schema.invoice_total_mv (total); + + ALTER MATERIALIZED VIEW test_schema.invoice_total_mv + CLUSTER ON invoice_total_mv_total_idx; + `, + assertSqlStatements: (sqlStatements) => { + const dropIndexIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP INDEX test_schema.invoice_total_mv_total_idx", + ), + ); + const dropMaterializedViewIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "DROP MATERIALIZED VIEW test_schema.invoice_total_mv", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_mv_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_mv_total", + ), + ); + const createMaterializedViewIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "CREATE MATERIALIZED VIEW test_schema.invoice_total_mv", + ), + ); + const createIndexIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE INDEX invoice_total_mv_total_idx ON test_schema.invoice_total_mv", + ), + ); + const restoreClusterIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER MATERIALIZED VIEW test_schema.invoice_total_mv CLUSTER ON invoice_total_mv_total_idx", + ), + ); + + expect(dropMaterializedViewIndex).toBeGreaterThanOrEqual(0); + if (dropIndexIndex >= 0) { + expect(dropMaterializedViewIndex).toBeGreaterThan(dropIndexIndex); + } + expect(dropFunctionIndex).toBeGreaterThan( + dropMaterializedViewIndex, + ); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(createMaterializedViewIndex).toBeGreaterThan( + createFunctionIndex, + ); + expect(createIndexIndex).toBeGreaterThan( + createMaterializedViewIndex, + ); + expect(restoreClusterIndex).toBeGreaterThan(createIndexIndex); + }, + }); + + const { rows } = await db.main.query(dedent` + SELECT i.indisclustered + FROM pg_catalog.pg_index i + JOIN pg_catalog.pg_class idx ON idx.oid = i.indexrelid + JOIN pg_catalog.pg_namespace n ON n.oid = idx.relnamespace + WHERE n.nspname = 'test_schema' + AND idx.relname = 'invoice_total_mv_total_idx' + `); + expect(rows[0]?.indisclustered).toBe(true); + }), + ); + + test( + "aggregate dependents are recreated before replacing support functions", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.sum_state(state integer, value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT state + value + $function$; + + CREATE AGGREGATE test_schema.total_amount(integer) ( + SFUNC = test_schema.sum_state, + STYPE = integer, + INITCOND = '0' + ); + + CREATE TABLE test_schema.aggregate_inputs ( + value integer NOT NULL + ); + + INSERT INTO test_schema.aggregate_inputs (value) + VALUES (1), (2); + `, + testSql: dedent` + DROP AGGREGATE test_schema.total_amount(integer); + + DROP FUNCTION test_schema.sum_state(integer, integer); + + CREATE FUNCTION test_schema.sum_state(current_total integer, input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT current_total + input + $function$; + + CREATE AGGREGATE test_schema.total_amount(integer) ( + SFUNC = test_schema.sum_state, + STYPE = integer, + INITCOND = '0' + ); + `, + assertSqlStatements: (sqlStatements) => { + const dropAggregateIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP AGGREGATE test_schema.total_amount"), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("DROP FUNCTION test_schema.sum_state"), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE FUNCTION test_schema.sum_state"), + ); + const createAggregateIndex = sqlStatements.findIndex((statement) => + statement.startsWith("CREATE AGGREGATE test_schema.total_amount"), + ); + + expect(dropAggregateIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropAggregateIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(createAggregateIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + + const { rows } = await db.main.query(dedent` + SELECT test_schema.total_amount(value) AS total + FROM test_schema.aggregate_inputs + `); + expect(rows[0]?.total).toBe(3); + }), + ); + test( "unchanged generated partition column for replaced function argument name does not emit child DDL", withDb(pgVersion, async (db) => { From c81a7b3afdab85180b5747b1f889b6fbc3cbfd56 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Thu, 11 Jun 2026 23:13:02 +0800 Subject: [PATCH 35/78] fix(pg-delta): refresh generated column dependents Signed-off-by: xiaominghao --- .changeset/green-pandas-repair.md | 5 + .../core/expand-replace-dependencies.test.ts | 389 +++++++++++++++++- .../src/core/expand-replace-dependencies.ts | 128 +++++- ...-signature-expression-dependencies.test.ts | 121 ++++++ 4 files changed, 630 insertions(+), 13 deletions(-) create mode 100644 .changeset/green-pandas-repair.md diff --git a/.changeset/green-pandas-repair.md b/.changeset/green-pandas-repair.md new file mode 100644 index 000000000..b6a0d68b7 --- /dev/null +++ b/.changeset/green-pandas-repair.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Refresh generated-column expression dependents more precisely during routine replacement, including covered column recreations, child-specific partition expressions, and publication row filters without explicit column lists. diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts index c0dfc1495..1a2d4ddcc 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -42,6 +42,8 @@ import { CreateCommentOnRlsPolicy } from "./objects/rls-policy/changes/rls-polic import { CreateRlsPolicy } from "./objects/rls-policy/changes/rls-policy.create.ts"; import { DropRlsPolicy } from "./objects/rls-policy/changes/rls-policy.drop.ts"; import { RlsPolicy } from "./objects/rls-policy/rls-policy.model.ts"; +import { CreateRule } from "./objects/rule/changes/rule.create.ts"; +import { DropRule } from "./objects/rule/changes/rule.drop.ts"; import { Rule } from "./objects/rule/rule.model.ts"; import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts"; import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts"; @@ -65,6 +67,8 @@ 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, type TableProps } from "./objects/table/table.model.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"; @@ -1752,7 +1756,7 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); - test("skips generated column fallback on partition children", async () => { + test("skips child generated column fallback when parent recreation covers inherited partition expressions", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); const branchProcedure = new Procedure({ @@ -1762,13 +1766,22 @@ describe("expandReplaceDependencies", () => { definition: "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", }); - const mainTable = partitionTableWithDefault( + const mainParentTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchParentTable = tableWithDefault( "public.normalize_value(value)", { is_generated: true, }, ); - const branchTable = partitionTableWithDefault( + const mainPartition = partitionTableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const branchPartition = partitionTableWithDefault( "public.normalize_value(value)", { is_generated: true, @@ -1779,12 +1792,23 @@ describe("expandReplaceDependencies", () => { const changes: Change[] = [ new DropProcedure({ procedure: mainProcedure }), new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainParentTable, + column: mainParentTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchParentTable, + column: branchParentTable.columns[0], + }), ]; const mainCatalog = new Catalog({ // oxlint-disable-next-line typescript/no-misused-spread ...baseline, procedures: { [mainProcedure.stableId]: mainProcedure }, - tables: { [mainTable.stableId]: mainTable }, + tables: { + [mainParentTable.stableId]: mainParentTable, + [mainPartition.stableId]: mainPartition, + }, depends: [ { dependent_stable_id: columnId, @@ -1797,7 +1821,10 @@ describe("expandReplaceDependencies", () => { // oxlint-disable-next-line typescript/no-misused-spread ...baseline, procedures: { [branchProcedure.stableId]: branchProcedure }, - tables: { [branchTable.stableId]: branchTable }, + tables: { + [branchParentTable.stableId]: branchParentTable, + [branchPartition.stableId]: branchPartition, + }, depends: [], }); @@ -1814,12 +1841,16 @@ describe("expandReplaceDependencies", () => { expect( expanded.changes.filter( - (change) => change instanceof AlterTableDropColumn, + (change) => + change instanceof AlterTableDropColumn && + change.table.name === "items_2026", ), ).toHaveLength(0); expect( expanded.changes.filter( - (change) => change instanceof AlterTableAddColumn, + (change) => + change instanceof AlterTableAddColumn && + change.table.name === "items_2026", ), ).toHaveLength(0); expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( @@ -2125,6 +2156,208 @@ describe("expandReplaceDependencies", () => { ); }); + test("traverses retained view, trigger, and rule dependents when generated column recreation is already covered", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainView = viewOnItemsValue(); + const branchView = viewOnItemsValue(); + const mainTrigger = triggerOnItemsValue(); + const branchTrigger = triggerOnItemsValue(); + const mainRule = ruleOnItemsValue(); + const branchRule = ruleOnItemsValue(); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + views: { [mainView.stableId]: mainView }, + triggers: { [mainTrigger.stableId]: mainTrigger }, + rules: { [mainRule.stableId]: mainRule }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainView.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainTrigger.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainRule.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + views: { [branchView.stableId]: branchView }, + triggers: { [branchTrigger.stableId]: branchTrigger }, + rules: { [branchRule.stableId]: branchRule }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter((change) => change instanceof DropView), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateView), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof DropTrigger), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateTrigger), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof DropRule), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateRule), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + + test("does not promote the owning table while traversing covered generated column recreation", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainIndex = indexOnItemsValue(); + const branchIndex = indexOnItemsValue(); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainIndex.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: columnId, + deptype: "a", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter((change) => change instanceof DropIndex), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateIndex), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + test("promotes retained dependents of a recreated generated column", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -2789,6 +3022,81 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("releases child-specific generated partition expressions when no parent DDL covers them", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = partitionTableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const branchTable = partitionTableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const columnId = "column:public.items_2026.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + test("refreshes each publication table entry when recreating generated columns", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -2986,6 +3294,73 @@ describe("expandReplaceDependencies", () => { ).toHaveLength(1); }); + test("refreshes publication row filters that depend on recreated generated columns without column lists", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainPublication = publicationOnItemsValue("(value > 0)"); + const branchPublication = publicationOnItemsValue("(value > 0)"); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect(serialized).toContain( + "ALTER PUBLICATION items_pub DROP TABLE public.items", + ); + expect(serialized).toContain( + "ALTER PUBLICATION items_pub ADD TABLE public.items WHERE (value > 0)", + ); + }); + test("does not duplicate existing publication row filter replacements", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index a64889e44..c76bb91b6 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -286,7 +286,8 @@ 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 generatedColumnsRecreatedByExpressionFallback = new Set(); + const generatedColumnsRecreatedByExpressionFallback = + collectCoveredGeneratedColumnRecreations(changes); while (queue.length > 0) { const refId = queue.shift() as string; @@ -349,11 +350,15 @@ export function expandReplaceDependencies({ const restoreCovered = expressionDependentCoverage.restore.has(dependentRaw); if (releaseCovered && restoreCovered) { + if (generatedColumnsRecreatedByExpressionFallback.has(dependentRaw)) { + queueRefForTraversal(dependentRaw, visitedRefs, queue); + } continue; } const expressionReplacementChanges = buildExpressionDependentReplacementChanges({ + refId, dependentRaw, mainCatalog, branchCatalog, @@ -706,6 +711,36 @@ function collectExpressionDependentCoverage( return { release, restore }; } +function collectCoveredGeneratedColumnRecreations( + changes: readonly Change[], +): Set { + const release = new Set(); + const restore = new Set(); + + for (const change of changes) { + if (change instanceof AlterTableDropColumn && change.column.is_generated) { + release.add( + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ), + ); + } + if (change instanceof AlterTableAddColumn && change.column.is_generated) { + restore.add( + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ), + ); + } + } + + return new Set([...release].filter((columnId) => restore.has(columnId))); +} + function shouldSuppressCoveredExpressionDependent({ refId, dependentRaw, @@ -875,6 +910,7 @@ function removeSupersededGeneratedColumnSetExpressions( } function buildExpressionDependentReplacementChanges({ + refId, dependentRaw, mainCatalog, branchCatalog, @@ -884,6 +920,7 @@ function buildExpressionDependentReplacementChanges({ addRelease, addRestore, }: { + refId: string; dependentRaw: string; mainCatalog: Catalog; branchCatalog: Catalog; @@ -898,6 +935,7 @@ function buildExpressionDependentReplacementChanges({ }): Change[] | null { if (dependentRaw.startsWith("column:")) { return buildColumnExpressionReplacementChanges({ + refId, dependentRaw, mainCatalog, branchCatalog, @@ -1078,6 +1116,7 @@ function maybeAddColumnDependentConstraintReplacement({ } function buildColumnExpressionReplacementChanges({ + refId, dependentRaw, mainCatalog, branchCatalog, @@ -1087,6 +1126,7 @@ function buildColumnExpressionReplacementChanges({ addRelease, addRestore, }: { + refId: string; dependentRaw: string; mainCatalog: Catalog; branchCatalog: Catalog; @@ -1131,12 +1171,20 @@ function buildColumnExpressionReplacementChanges({ const generatedColumnInvolved = mainColumn.is_generated || branchColumn.is_generated; - // Generated partition child columns are managed by the parent table DDL. - // Local non-generated defaults on partitions are not parent-propagated and - // still need explicit DROP/SET DEFAULT around routine replacement. + // Generated partition child columns are parent-managed only when the parent + // column is also being recreated. Child-specific generation expressions keep + // their own pg_depend edge and must release it locally. if ( (mainTable.is_partition || branchTable.is_partition) && - generatedColumnInvolved + generatedColumnInvolved && + isPartitionGeneratedColumnCoveredByParentRecreation({ + mainTable, + branchTable, + columnName: columnRef.column, + refId, + mainCatalog, + existingChanges, + }) ) { return []; } @@ -1475,11 +1523,79 @@ function findPublicationTableForColumn( (table) => table.schema === columnRef.schema && table.name === columnRef.table && - table.columns?.includes(columnRef.column), + (table.columns?.includes(columnRef.column) || + table.row_filter !== null), ) ?? null ); } +function isPartitionGeneratedColumnCoveredByParentRecreation({ + mainTable, + branchTable, + columnName, + refId, + mainCatalog, + existingChanges, +}: { + mainTable: Catalog["tables"][string]; + branchTable: Catalog["tables"][string]; + columnName: string; + refId: string; + mainCatalog: Catalog; + existingChanges: readonly Change[]; +}): boolean { + const mainParentColumnId = + mainTable.parent_schema && mainTable.parent_name + ? stableId.column( + mainTable.parent_schema, + mainTable.parent_name, + columnName, + ) + : null; + const branchParentColumnId = + branchTable.parent_schema && branchTable.parent_name + ? stableId.column( + branchTable.parent_schema, + branchTable.parent_name, + columnName, + ) + : null; + if (!mainParentColumnId || !branchParentColumnId) return false; + + if ( + mainCatalog.depends.some( + (dep) => + dep.dependent_stable_id === mainParentColumnId && + dep.referenced_stable_id === refId, + ) + ) { + return true; + } + + let releaseCovered = false; + let restoreCovered = false; + for (const change of existingChanges) { + if (change instanceof AlterTableDropColumn) { + releaseCovered ||= + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ) === mainParentColumnId; + } + if (change instanceof AlterTableAddColumn) { + restoreCovered ||= + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ) === branchParentColumnId; + } + } + + return releaseCovered && restoreCovered; +} + function buildDomainDefaultReplacementChanges({ dependentRaw, mainCatalog, diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index 8ef096adf..213804084 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -1510,6 +1510,127 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "generated publication row filters without column lists are refreshed before column recreation", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_filtered_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.filtered_invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_filtered_total(subtotal)) STORED + ); + + CREATE PUBLICATION filtered_total_pub + FOR TABLE test_schema.filtered_invoice_totals + WHERE (total > 0); + + INSERT INTO test_schema.filtered_invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + ALTER PUBLICATION filtered_total_pub + DROP TABLE test_schema.filtered_invoice_totals; + + ALTER TABLE test_schema.filtered_invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_filtered_total(integer); + + CREATE FUNCTION test_schema.compute_filtered_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.filtered_invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_filtered_total(subtotal)) STORED; + + ALTER PUBLICATION filtered_total_pub + ADD TABLE test_schema.filtered_invoice_totals + WHERE (total > 0); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const publicationDropIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER PUBLICATION filtered_total_pub DROP TABLE test_schema.filtered_invoice_totals", + ), + ); + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.filtered_invoice_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_filtered_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_filtered_total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.filtered_invoice_totals ADD COLUMN total", + ), + ); + const publicationAddIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER PUBLICATION filtered_total_pub ADD TABLE test_schema.filtered_invoice_totals WHERE (total > 0)", + ), + ); + + expect(publicationDropIndex).toBeGreaterThanOrEqual(0); + expect(dropColumnIndex).toBeGreaterThan(publicationDropIndex); + expect(dropFunctionIndex).toBeGreaterThan(dropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); + expect(publicationAddIndex).toBeGreaterThan(addColumnIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.filtered_invoice_totals", + 1, + ); + const { rows } = await db.main.query(dedent` + SELECT + pg_get_expr(pr.prqual, pr.prrelid) AS row_filter, + pr.prattrs IS NULL AS all_columns + FROM pg_catalog.pg_publication p + JOIN pg_catalog.pg_publication_rel pr ON pr.prpubid = p.oid + JOIN pg_catalog.pg_class c ON c.oid = pr.prrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE p.pubname = 'filtered_total_pub' + AND n.nspname = 'test_schema' + AND c.relname = 'filtered_invoice_totals' + `); + expect(rows[0]?.row_filter).toBe("(total > 0)"); + expect(rows[0]?.all_columns).toBe(true); + }), + ); + test( "clustered materialized view indexes survive dependent function replacement", withDb(pgVersion, async (db) => { From 5e6fd3aff17e2886160b4a93deb8bafee8342611 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Fri, 12 Jun 2026 00:17:07 +0800 Subject: [PATCH 36/78] fix(pg-delta): preserve partition generated expressions Preserve child-specific generated partition expressions when parent generated column recreation releases routine dependencies. Signed-off-by: xiaominghao --- .../fix-partition-generated-expression.md | 5 + .../core/expand-replace-dependencies.test.ts | 116 ++++++++++++++ .../src/core/expand-replace-dependencies.ts | 149 ++++++++++++++++-- .../core/objects/table/changes/table.alter.ts | 14 +- ...-signature-expression-dependencies.test.ts | 123 +++++++++++++++ 5 files changed, 391 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-partition-generated-expression.md diff --git a/.changeset/fix-partition-generated-expression.md b/.changeset/fix-partition-generated-expression.md new file mode 100644 index 000000000..c8410824f --- /dev/null +++ b/.changeset/fix-partition-generated-expression.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Preserve child-specific generated partition expressions during routine replacement. diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts index 1a2d4ddcc..e76c69724 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -1861,6 +1861,122 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("restores child-specific generated partition expression despite parent dependency coverage", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainParentTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchParentTable = tableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const mainPartition = partitionTableWithDefault( + "public.normalize_value(value + 1)", + { + is_generated: true, + }, + ); + const branchPartition = partitionTableWithDefault( + "public.normalize_value(value + 1)", + { + is_generated: true, + }, + ); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainParentTable, + column: mainParentTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchParentTable, + column: branchParentTable.columns[0], + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { + [mainParentTable.stableId]: mainParentTable, + [mainPartition.stableId]: mainPartition, + }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items_2026.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { + [branchParentTable.stableId]: branchParentTable, + [branchPartition.stableId]: branchPartition, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableDropColumn && + change.table.name === "items_2026", + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAddColumn && + change.table.name === "items_2026", + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAlterColumnSetDefault && + change.table.name === "items_2026", + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + test("skips child column DDL when partition-propagated drops remove inherited columns", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index c76bb91b6..e97f80d55 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -72,6 +72,7 @@ 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 type { TableProps } from "./objects/table/table.model.ts"; import { SetTriggerEnabledState } from "./objects/trigger/changes/trigger.alter.ts"; import { CreateCommentOnTrigger } from "./objects/trigger/changes/trigger.comment.ts"; import { CreateTrigger } from "./objects/trigger/changes/trigger.create.ts"; @@ -1172,23 +1173,51 @@ function buildColumnExpressionReplacementChanges({ const generatedColumnInvolved = mainColumn.is_generated || branchColumn.is_generated; // Generated partition child columns are parent-managed only when the parent - // column is also being recreated. Child-specific generation expressions keep - // their own pg_depend edge and must release it locally. + // column is also being recreated and the child expression matches the parent. + // Child-specific generation expressions need their own restore after the + // parent recreation propagates the parent expression. if ( (mainTable.is_partition || branchTable.is_partition) && generatedColumnInvolved && - isPartitionGeneratedColumnCoveredByParentRecreation({ + isPartitionGeneratedColumnInheritedFromParent({ mainTable, branchTable, columnName: columnRef.column, refId, mainCatalog, + branchCatalog, existingChanges, }) ) { return []; } if (generatedColumnInvolved) { + const parentRecreationCoverage = + mainTable.is_partition || branchTable.is_partition + ? getPartitionGeneratedColumnParentRecreationCoverage({ + mainTable, + branchTable, + columnName: columnRef.column, + refId, + mainCatalog, + existingChanges, + }) + : null; + if (parentRecreationCoverage?.release && parentRecreationCoverage.restore) { + if ((diffContext?.version ?? 0) >= 170000) { + return addRestore + ? [ + new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchColumn, + }), + ] + : []; + } + + return null; + } + const canRecreateGeneratedColumn = mainColumn.is_generated && branchColumn.is_generated && @@ -1529,12 +1558,13 @@ function findPublicationTableForColumn( ); } -function isPartitionGeneratedColumnCoveredByParentRecreation({ +function isPartitionGeneratedColumnInheritedFromParent({ mainTable, branchTable, columnName, refId, mainCatalog, + branchCatalog, existingChanges, }: { mainTable: Catalog["tables"][string]; @@ -1542,8 +1572,48 @@ function isPartitionGeneratedColumnCoveredByParentRecreation({ columnName: string; refId: string; mainCatalog: Catalog; + branchCatalog: Catalog; existingChanges: readonly Change[]; }): boolean { + if ( + !partitionGeneratedColumnExpressionsMatchParent({ + mainTable, + branchTable, + columnName, + mainCatalog, + branchCatalog, + }) + ) { + return false; + } + + const coverage = getPartitionGeneratedColumnParentRecreationCoverage({ + mainTable, + branchTable, + columnName, + refId, + mainCatalog, + existingChanges, + }); + + return Boolean(coverage?.release && coverage.restore); +} + +function getPartitionGeneratedColumnParentRecreationCoverage({ + mainTable, + branchTable, + columnName, + refId, + mainCatalog, + existingChanges, +}: { + mainTable: Catalog["tables"][string]; + branchTable: Catalog["tables"][string]; + columnName: string; + refId: string; + mainCatalog: Catalog; + existingChanges: readonly Change[]; +}): { release: boolean; restore: boolean } | null { const mainParentColumnId = mainTable.parent_schema && mainTable.parent_name ? stableId.column( @@ -1560,17 +1630,13 @@ function isPartitionGeneratedColumnCoveredByParentRecreation({ columnName, ) : null; - if (!mainParentColumnId || !branchParentColumnId) return false; + if (!mainParentColumnId || !branchParentColumnId) return null; - if ( - mainCatalog.depends.some( - (dep) => - dep.dependent_stable_id === mainParentColumnId && - dep.referenced_stable_id === refId, - ) - ) { - return true; - } + const parentHasRoutineDependency = mainCatalog.depends.some( + (dep) => + dep.dependent_stable_id === mainParentColumnId && + dep.referenced_stable_id === refId, + ); let releaseCovered = false; let restoreCovered = false; @@ -1593,7 +1659,60 @@ function isPartitionGeneratedColumnCoveredByParentRecreation({ } } - return releaseCovered && restoreCovered; + return { + release: parentHasRoutineDependency || releaseCovered, + restore: parentHasRoutineDependency || restoreCovered, + }; +} + +function partitionGeneratedColumnExpressionsMatchParent({ + mainTable, + branchTable, + columnName, + mainCatalog, + branchCatalog, +}: { + mainTable: Catalog["tables"][string]; + branchTable: Catalog["tables"][string]; + columnName: string; + mainCatalog: Catalog; + branchCatalog: Catalog; +}): boolean { + const mainColumn = findColumn(mainTable, columnName); + const branchColumn = findColumn(branchTable, columnName); + const mainParentColumn = findParentColumn(mainTable, columnName, mainCatalog); + const branchParentColumn = findParentColumn( + branchTable, + columnName, + branchCatalog, + ); + + return ( + Boolean(mainColumn?.is_generated) && + Boolean(branchColumn?.is_generated) && + Boolean(mainParentColumn?.is_generated) && + Boolean(branchParentColumn?.is_generated) && + mainColumn?.default === mainParentColumn?.default && + branchColumn?.default === branchParentColumn?.default + ); +} + +function findParentColumn( + table: Catalog["tables"][string], + columnName: string, + catalog: Catalog, +): TableProps["columns"][number] | undefined { + if (!table.parent_schema || !table.parent_name) return undefined; + const parentTable = + catalog.tables[stableId.table(table.parent_schema, table.parent_name)]; + return parentTable ? findColumn(parentTable, columnName) : undefined; +} + +function findColumn( + table: Catalog["tables"][string], + columnName: string, +): TableProps["columns"][number] | undefined { + return table.columns.find((column) => column.name === columnName); } function buildDomainDefaultReplacementChanges({ 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 03a1ad52f..7e6cd7a55 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 @@ -734,9 +734,21 @@ export class AlterTableAlterColumnSetDefault extends AlterTableChange { } get requires() { - return [ + const requirements = [ stableId.column(this.table.schema, this.table.name, this.column.name), ]; + + if (this.table.parent_schema && this.table.parent_name) { + requirements.push( + stableId.column( + this.table.parent_schema, + this.table.parent_name, + this.column.name, + ), + ); + } + + return requirements; } serialize(_options?: SerializeOptions): string { diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index 213804084..c713494e5 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -1963,6 +1963,129 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test.skipIf(pgVersion < 17)( + "child-specific generated partition expression is restored after parent recreation", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_partition_adjusted_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.partitioned_adjusted_totals ( + id integer NOT NULL, + period integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_partition_adjusted_total(subtotal)) STORED + ) PARTITION BY RANGE (period); + + CREATE TABLE test_schema.partitioned_adjusted_totals_2026 + PARTITION OF test_schema.partitioned_adjusted_totals ( + total GENERATED ALWAYS AS + (test_schema.compute_partition_adjusted_total(subtotal + 1)) STORED + ) + FOR VALUES FROM (2026) TO (2027); + + INSERT INTO test_schema.partitioned_adjusted_totals + (id, period, subtotal) + VALUES (1, 2026, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.partitioned_adjusted_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_partition_adjusted_total(integer); + + CREATE FUNCTION test_schema.compute_partition_adjusted_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.partitioned_adjusted_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_partition_adjusted_total(subtotal)) STORED; + + ALTER TABLE test_schema.partitioned_adjusted_totals_2026 + ALTER COLUMN total + SET EXPRESSION AS + (test_schema.compute_partition_adjusted_total(subtotal + 1)); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_adjusted_totals_2026 DROP COLUMN total", + ), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_adjusted_totals_2026 ADD COLUMN total", + ), + ), + ).toBe(false); + + const parentDropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_adjusted_totals DROP COLUMN total", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_partition_adjusted_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_partition_adjusted_total", + ), + ); + const parentAddColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_adjusted_totals ADD COLUMN total", + ), + ); + const childSetExpressionIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_adjusted_totals_2026 ALTER COLUMN total SET EXPRESSION", + ), + ); + + expect(parentDropColumnIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(parentDropColumnIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(parentAddColumnIndex).toBeGreaterThan(createFunctionIndex); + expect(childSetExpressionIndex).toBeGreaterThan( + parentAddColumnIndex, + ); + }, + }); + + const { rows } = await db.main.query(dedent` + SELECT total + FROM test_schema.partitioned_adjusted_totals + WHERE id = 1 + `); + expect(rows[0]?.total).toBe(22); + }), + ); + test( "local partition column default for replaced function argument name is released on the child", withDb(pgVersion, async (db) => { From 2d3545ce51be915eb76c7a7703585abcbf75cf70 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 01:44:10 +0800 Subject: [PATCH 37/78] test(pg-delta): cover materialized view index ordering Signed-off-by: xiaominghao --- .../index/changes/index.create.test.ts | 58 +++++++++++++ .../src/core/sort/sort-changes.test.ts | 84 +++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/packages/pg-delta/src/core/objects/index/changes/index.create.test.ts b/packages/pg-delta/src/core/objects/index/changes/index.create.test.ts index 17a36fd91..836ac3519 100644 --- a/packages/pg-delta/src/core/objects/index/changes/index.create.test.ts +++ b/packages/pg-delta/src/core/objects/index/changes/index.create.test.ts @@ -66,4 +66,62 @@ describe("index", () => { "CREATE INDEX test_index ON public.test_table (id)", ); }); + + test("requires materialized view stable id for materialized view indexes", () => { + const index = new Index({ + schema: "public", + table_name: "test_matview", + name: "test_matview_idx", + storage_params: [], + statistics_target: [0], + index_type: "btree", + tablespace: null, + is_unique: false, + is_primary: false, + is_exclusion: false, + is_owned_by_constraint: false, + nulls_not_distinct: false, + immediate: true, + is_clustered: false, + is_replica_identity: false, + key_columns: [1], + column_collations: [], + operator_classes: [], + column_options: [], + index_expressions: null, + partial_predicate: null, + table_relkind: "m", + is_partitioned_index: false, + is_index_partition: false, + parent_index_name: null, + definition: "CREATE INDEX test_matview_idx ON public.test_matview (id)", + comment: null, + owner: "test", + }); + + const columns: ColumnProps[] = [ + { + name: "id", + position: 1, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ]; + const change = new CreateIndex({ index, indexableObject: { columns } }); + + expect(change.requires).toContain("materializedView:public.test_matview"); + expect(change.requires).not.toContain("table:public.test_matview"); + }); }); diff --git a/packages/pg-delta/src/core/sort/sort-changes.test.ts b/packages/pg-delta/src/core/sort/sort-changes.test.ts index a65cfac3b..2634cdd40 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -9,6 +9,10 @@ import { } from "../objects/domain/changes/domain.alter.ts"; import { DropDomain } from "../objects/domain/changes/domain.drop.ts"; import { Domain } from "../objects/domain/domain.model.ts"; +import { CreateIndex } from "../objects/index/changes/index.create.ts"; +import { Index } from "../objects/index/index.model.ts"; +import { CreateMaterializedView } from "../objects/materialized-view/changes/materialized-view.create.ts"; +import { MaterializedView } from "../objects/materialized-view/materialized-view.model.ts"; import { CreateProcedure } from "../objects/procedure/changes/procedure.create.ts"; import { DropProcedure } from "../objects/procedure/changes/procedure.drop.ts"; import { Procedure } from "../objects/procedure/procedure.model.ts"; @@ -218,6 +222,62 @@ function view(name: string, columns = [integerColumn("id", 1)]) { }); } +function materializedView(name: string) { + return new MaterializedView({ + schema: "public", + name, + definition: "SELECT id FROM public.users", + row_security: false, + force_row_security: false, + has_indexes: true, + has_rules: false, + has_triggers: false, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + options: null, + partition_bound: null, + owner: "postgres", + comment: null, + columns: [integerColumn("id", 1)], + privileges: [], + }); +} + +function indexOnMaterializedView(viewName: string, indexName: string) { + return new Index({ + schema: "public", + table_name: viewName, + name: indexName, + storage_params: [], + statistics_target: [0], + index_type: "btree", + tablespace: null, + is_unique: false, + is_primary: false, + is_exclusion: false, + nulls_not_distinct: false, + immediate: true, + is_clustered: false, + is_replica_identity: false, + key_columns: [1], + column_collations: [], + operator_classes: [], + column_options: [], + index_expressions: null, + partial_predicate: null, + is_owned_by_constraint: false, + table_relkind: "m", + is_partitioned_index: false, + is_index_partition: false, + parent_index_name: null, + definition: `CREATE INDEX ${indexName} ON public.${viewName} (id)`, + comment: null, + owner: "postgres", + }); +} + function domain(defaultValue: string | null, name = "score") { return new Domain({ schema: "public", @@ -332,6 +392,30 @@ function changeLabel(change: Change) { } describe("sortChanges", () => { + test("orders materialized view indexes after recreated materialized views", async () => { + const branchMaterializedView = materializedView("user_ids_mv"); + const branchIndex = indexOnMaterializedView( + "user_ids_mv", + "user_ids_mv_id_idx", + ); + const changes: Change[] = [ + new CreateIndex({ + index: branchIndex, + indexableObject: branchMaterializedView, + }), + new CreateMaterializedView({ materializedView: branchMaterializedView }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.map(changeLabel)).toEqual([ + "CreateMaterializedView", + "CreateIndex", + ]); + }); + test("orders dependent view drop before drop-phase column type rewrite", async () => { const branchTable = table("users"); const mainColumn = { From f30bd4b3fcdd07bf118fade7d0d50a667d775028 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 01:44:16 +0800 Subject: [PATCH 38/78] fix(pg-delta): order materialized view indexes after recreation RED: PG17 focused tests failed before the fix because sortChanges kept CreateIndex before CreateMaterializedView, and CreateIndex.requires contained table:public.test_matview instead of materializedView:public.test_matview. GREEN: PG17 focused tests now pass with 13 pass / 0 fail for index.create.test.ts and sort-changes.test.ts, and the broader related unit set passes with 62 pass / 0 fail. Signed-off-by: xiaominghao --- .../pg-delta/src/core/objects/index/changes/index.create.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pg-delta/src/core/objects/index/changes/index.create.ts b/packages/pg-delta/src/core/objects/index/changes/index.create.ts index ab1efbafc..2702a023c 100644 --- a/packages/pg-delta/src/core/objects/index/changes/index.create.ts +++ b/packages/pg-delta/src/core/objects/index/changes/index.create.ts @@ -43,8 +43,8 @@ export class CreateIndex extends CreateIndexChange { // Schema dependency dependencies.add(stableId.schema(this.index.schema)); - // Table dependency - dependencies.add(stableId.table(this.index.schema, this.index.table_name)); + // Relation dependency + dependencies.add(this.index.tableStableId); // Owner dependency dependencies.add(stableId.role(this.index.owner)); From aa333b3684550ad0299aa7abdfbd476cd6fa7667 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 11:16:25 +0800 Subject: [PATCH 39/78] test(pg-delta): cover generated column replacement review cases RED: focused unit regressions failed before the fix because child partition generated-column replacement emitted child AlterTableDropColumn and covered generated-column recreation emitted zero CreateCommentOnColumn/CreateSecurityLabelOnColumn restores. The regular-to-generated traversal probe already passed and is kept as a guard for the reviewed path. RED: the PG17 child-specific partition integration case also exposed a deeper roundtrip gap after table replacement: the child partition still needed ALTER COLUMN SET EXPRESSION to converge after the new function was created. --- .../core/expand-replace-dependencies.test.ts | 220 +++++++++++++++++- ...-signature-expression-dependencies.test.ts | 185 +++++++++++++++ 2 files changed, 399 insertions(+), 6 deletions(-) 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 e76c69724..8add30b13 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -2272,6 +2272,90 @@ describe("expandReplaceDependencies", () => { ); }); + test("traverses retained dependents when a regular column is recreated as generated", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)"); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const mainIndex = indexOnItemsValue(); + const branchIndex = indexOnItemsValue(); + const columnId = "column:public.items.value"; + const preExistingDropColumn = new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }); + const preExistingAddColumn = new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + preExistingDropColumn, + preExistingAddColumn, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainIndex.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes).toContain(preExistingDropColumn); + expect(expanded.changes).toContain(preExistingAddColumn); + expect( + expanded.changes.filter((change) => change instanceof DropIndex), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateIndex), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + test("traverses retained view, trigger, and rule dependents when generated column recreation is already covered", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -2895,6 +2979,106 @@ describe("expandReplaceDependencies", () => { expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); }); + test("restores retained metadata when generated column recreation is already covered", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const securityLabels = [{ provider: "dummy", label: "classified" }]; + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + comment: "computed value", + security_labels: securityLabels, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + comment: "computed value", + security_labels: securityLabels, + }); + const columnId = "column:public.items.value"; + const commentId = `comment:${columnId}`; + const securityLabelId = + "securityLabel:column:public.items.value::provider:dummy"; + const preExistingDropColumn = new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }); + const preExistingAddColumn = new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + preExistingDropColumn, + preExistingAddColumn, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: commentId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: securityLabelId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes).toContain(preExistingDropColumn); + expect(expanded.changes).toContain(preExistingAddColumn); + expect( + expanded.changes.filter( + (change) => change instanceof CreateCommentOnColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof CreateSecurityLabelOnColumn, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + test("replays retained index, trigger, and rule metadata after generated column fallback", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -3138,8 +3322,8 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); - test("releases child-specific generated partition expressions when no parent DDL covers them", async () => { - const baseline = await createEmptyCatalog(150000, "postgres"); + test("replaces child partitions instead of dropping generated columns on them", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); const branchProcedure = new Procedure({ // oxlint-disable-next-line typescript/no-misused-spread @@ -3192,7 +3376,7 @@ describe("expandReplaceDependencies", () => { mainCatalog, branchCatalog, diffContext: { - version: 150000, + version: 170000, currentUser: "postgres", defaultPrivilegeState: new DefaultPrivilegeState({}), }, @@ -3200,16 +3384,40 @@ describe("expandReplaceDependencies", () => { expect( expanded.changes.filter( - (change) => change instanceof AlterTableDropColumn, + (change) => + change instanceof AlterTableDropColumn && + change.table.name === "items_2026", + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAddColumn && + change.table.name === "items_2026", + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => + change instanceof DropTable && change.table.name === "items_2026", ), ).toHaveLength(1); expect( expanded.changes.filter( - (change) => change instanceof AlterTableAddColumn, + (change) => + change instanceof CreateTable && change.table.name === "items_2026", + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAlterColumnSetDefault && + change.table.name === "items_2026" && + change.column.name === "value", ), ).toHaveLength(1); expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( - false, + true, ); }); diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index c713494e5..caf753c45 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -750,6 +750,91 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test.skipIf(pgVersion >= 17)( + "covered generated column recreation restores retained comments before PostgreSQL 17", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_commented_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.commented_invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_commented_total(subtotal)) STORED + ); + + COMMENT ON COLUMN test_schema.commented_invoice_totals.total + IS 'computed invoice total'; + + INSERT INTO test_schema.commented_invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.commented_invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_commented_total(integer); + + CREATE FUNCTION test_schema.compute_commented_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.commented_invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_commented_total(subtotal + 1)) STORED; + + COMMENT ON COLUMN test_schema.commented_invoice_totals.total + IS 'computed invoice total'; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.commented_invoice_totals ADD COLUMN total", + ), + ); + const commentIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "COMMENT ON COLUMN test_schema.commented_invoice_totals.total", + ), + ); + + expect(addColumnIndex).toBeGreaterThanOrEqual(0); + expect(commentIndex).toBeGreaterThan(addColumnIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.commented_invoice_totals", + 1, + ); + const { rows } = await db.main.query(dedent` + SELECT col_description(attrelid, attnum) AS comment + FROM pg_catalog.pg_attribute + WHERE attrelid = 'test_schema.commented_invoice_totals'::regclass + AND attname = 'total' + `); + expect(rows[0]?.comment).toBe("computed invoice total"); + }), + ); + test( "unchanged check constraint for overloaded replacement drops old function before restore", withDb(pgVersion, async (db) => { @@ -1963,6 +2048,106 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test.skipIf(pgVersion < 17)( + "child-specific generated partition expression without parent recreation avoids child column DDL", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_child_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.partitioned_child_only_totals ( + id integer NOT NULL, + period integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS (subtotal + 1) STORED + ) PARTITION BY RANGE (period); + + CREATE TABLE test_schema.partitioned_child_only_totals_2026 + PARTITION OF test_schema.partitioned_child_only_totals ( + total GENERATED ALWAYS AS + (test_schema.compute_child_total(subtotal)) STORED + ) + FOR VALUES FROM (2026) TO (2027); + `, + testSql: dedent` + DROP TABLE test_schema.partitioned_child_only_totals_2026; + + DROP FUNCTION test_schema.compute_child_total(integer); + + CREATE FUNCTION test_schema.compute_child_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + CREATE TABLE test_schema.partitioned_child_only_totals_2026 + PARTITION OF test_schema.partitioned_child_only_totals ( + total GENERATED ALWAYS AS + (test_schema.compute_child_total(subtotal)) STORED + ) + FOR VALUES FROM (2026) TO (2027); + `, + assertSqlStatements: (sqlStatements) => { + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_child_only_totals_2026 DROP COLUMN total", + ), + ), + ).toBe(false); + expect( + sqlStatements.some((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_child_only_totals_2026 ADD COLUMN total", + ), + ), + ).toBe(false); + + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.compute_child_total", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.compute_child_total", + ), + ); + + expect(dropFunctionIndex).toBeGreaterThanOrEqual(0); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + + const childDropTableIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP TABLE test_schema.partitioned_child_only_totals_2026", + ), + ); + const childCreateTableIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE TABLE test_schema.partitioned_child_only_totals_2026", + ), + ); + + expect(childDropTableIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(childDropTableIndex); + expect(childCreateTableIndex).toBeGreaterThan(createFunctionIndex); + }, + }); + }), + ); + test.skipIf(pgVersion < 17)( "child-specific generated partition expression is restored after parent recreation", withDb(pgVersion, async (db) => { From 43306076d806469de2dc23523d22669107982c9f Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 11:31:48 +0800 Subject: [PATCH 40/78] fix(pg-delta): cover generated column replacement edges --- .../src/core/expand-replace-dependencies.ts | 168 +++++++++++++++++- 1 file changed, 164 insertions(+), 4 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index e97f80d55..cf6a59652 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -311,8 +311,16 @@ export function expandReplaceDependencies({ isMetadataDependentStableId(dependentRaw) ) { // Column comments and security labels are metadata for the recreated - // column itself. The drop/add fallback restores them directly, so - // walking those edges must not promote metadata into object replacement. + // column itself. The drop/add fallback restores them directly; when the + // drop/add pair came from the original diff, restore retained metadata + // here instead of promoting metadata into object replacement. + maybeAddRecreatedColumnMetadataRestore({ + columnId: refId, + dependentRaw, + branchCatalog, + additions, + createdIds, + }); continue; } if ( @@ -518,8 +526,21 @@ export function expandReplaceDependencies({ diffContext, }) : []; + const partitionGeneratedColumnRestoreChanges = + resolved.kind === "table" + ? buildPartitionGeneratedColumnTableReplacementRestoreChanges({ + dependentRaw, + branchCatalog, + diffContext, + addCreate, + }) + : []; - additions.push(...replacementChanges, ...retainedIndexChanges); + additions.push( + ...replacementChanges, + ...retainedIndexChanges, + ...partitionGeneratedColumnRestoreChanges, + ); if (resolved.kind === "rls_policy") { promotedRlsPolicyIds.add(targetId); } @@ -532,7 +553,11 @@ export function expandReplaceDependencies({ } // Track new creates/drops so we don't duplicate work for downstream dependents. - for (const change of [...replacementChanges, ...retainedIndexChanges]) { + for (const change of [ + ...replacementChanges, + ...retainedIndexChanges, + ...partitionGeneratedColumnRestoreChanges, + ]) { for (const id of change.creates ?? []) createdIds.add(id); for (const id of change.drops ?? []) droppedIds.add(id); } @@ -859,6 +884,83 @@ function isGeneratedColumnNotNullConstraintDependent({ ); } +function maybeAddRecreatedColumnMetadataRestore({ + columnId, + dependentRaw, + branchCatalog, + additions, + createdIds, +}: { + columnId: string; + dependentRaw: string; + branchCatalog: Catalog; + additions: Change[]; + createdIds: Set; +}): void { + if (createdIds.has(dependentRaw)) { + return; + } + + const columnRef = parseColumnStableId(columnId); + if (!columnRef) { + return; + } + + const branchTable = + branchCatalog.tables[stableId.table(columnRef.schema, columnRef.table)]; + const branchColumn = branchTable?.columns.find( + (column) => column.name === columnRef.column, + ); + if (!branchTable || !branchColumn) { + return; + } + + if (dependentRaw === stableId.comment(columnId)) { + if (branchColumn.comment !== null) { + const change = new CreateCommentOnColumn({ + table: branchTable, + column: branchColumn, + }); + additions.push(change); + for (const id of change.creates ?? []) createdIds.add(id); + } + return; + } + + const provider = parseSecurityLabelProvider(dependentRaw, columnId); + if (!provider) { + return; + } + + const securityLabel = branchColumn.security_labels?.find( + (label) => label.provider === provider, + ); + if (!securityLabel) { + return; + } + + const change = new CreateSecurityLabelOnColumn({ + table: branchTable, + column: branchColumn, + securityLabel, + }); + additions.push(change); + for (const id of change.creates ?? []) createdIds.add(id); +} + +function parseSecurityLabelProvider( + dependentRaw: string, + columnId: string, +): string | null { + const prefix = stableId.securityLabel(columnId, ""); + if (!dependentRaw.startsWith(prefix)) { + return null; + } + + const provider = dependentRaw.slice(prefix.length); + return provider.length > 0 ? provider : null; +} + function shouldTraverseExpressionReplacementDependent({ dependentRaw, expressionReplacementChanges, @@ -1218,6 +1320,10 @@ function buildColumnExpressionReplacementChanges({ return null; } + if (mainTable.is_partition || branchTable.is_partition) { + return null; + } + const canRecreateGeneratedColumn = mainColumn.is_generated && branchColumn.is_generated && @@ -1320,6 +1426,60 @@ function buildColumnExpressionReplacementChanges({ return changes; } +function buildPartitionGeneratedColumnTableReplacementRestoreChanges({ + dependentRaw, + branchCatalog, + diffContext, + addCreate, +}: { + dependentRaw: string; + branchCatalog: Catalog; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; + addCreate: boolean; +}): Change[] { + if (!addCreate || (diffContext?.version ?? 0) < 170000) { + return []; + } + + const columnRef = parseColumnStableId(dependentRaw); + if (!columnRef) { + return []; + } + + const branchTable = + branchCatalog.tables[stableId.table(columnRef.schema, columnRef.table)]; + if (!branchTable?.is_partition) { + return []; + } + + const branchColumn = branchTable.columns.find( + (column) => column.name === columnRef.column, + ); + if ( + !branchColumn?.is_generated || + branchColumn.default === null || + partitionGeneratedColumnExpressionsMatchParent({ + mainTable: branchTable, + branchTable, + columnName: branchColumn.name, + mainCatalog: branchCatalog, + branchCatalog, + }) + ) { + return []; + } + + return [ + new AlterTableAlterColumnSetDefault({ + table: branchTable, + column: branchColumn, + }), + ]; +} + function buildPublicationColumnListReplacement({ columnId, publicationId, From f7c205e1127144f3dedabbaec59f7e8b329f9699 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 12:15:03 +0800 Subject: [PATCH 41/78] test(pg-delta): cover latest replacement review edges RED: focused review regressions failed before the fix because partition CreateTable omitted child-specific generated expressions and expansion still emitted a child SET EXPRESSION restore, root materialized-view replace roots emitted zero retained DropIndex/CreateIndex changes, and aggregate addDrop-only replacement emitted zero retained owner/comment/security-label/EXECUTE-grant metadata restores. --- .../core/expand-replace-dependencies.test.ts | 196 +++++++++++++++++- .../table/changes/table.create.test.ts | 35 ++++ ...-signature-expression-dependencies.test.ts | 10 + .../materialized-view-operations.test.ts | 84 ++++++++ 4 files changed, 319 insertions(+), 6 deletions(-) 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 8add30b13..6ae479aaf 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -2,8 +2,12 @@ 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 { @@ -25,6 +29,8 @@ import { CreateIndex } from "./objects/index/changes/index.create.ts"; import { DropIndex } from "./objects/index/changes/index.drop.ts"; import { Index, type IndexProps } from "./objects/index/index.model.ts"; import { AlterMaterializedViewClusterOn } from "./objects/materialized-view/changes/materialized-view.alter.ts"; +import { CreateMaterializedView } from "./objects/materialized-view/changes/materialized-view.create.ts"; +import { DropMaterializedView } from "./objects/materialized-view/changes/materialized-view.drop.ts"; import { MaterializedView, type MaterializedViewProps, @@ -448,7 +454,10 @@ function materializedViewOnItemsValue( }); } -function aggregateWithArgs(argumentTypes: string[]): Aggregate { +function aggregateWithArgs( + argumentTypes: string[], + overrides: Partial[0]> = {}, +): Aggregate { return new Aggregate({ schema: "public", name: "total_value", @@ -491,6 +500,7 @@ function aggregateWithArgs(argumentTypes: string[]): Aggregate { owner: "postgres", comment: null, privileges: [], + ...overrides, }); } @@ -3261,6 +3271,80 @@ describe("expandReplaceDependencies", () => { ); }); + test("restores retained indexes when materialized views are already replace roots", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainMaterializedView = materializedViewOnItemsValue({ + definition: " SELECT items.value AS value FROM public.items;", + }); + const branchMaterializedView = materializedViewOnItemsValue({ + definition: " SELECT items.value + 1 AS value FROM public.items;", + }); + const mainIndex = indexOnItemsValue({ + table_name: "items_value_mv", + name: "items_value_mv_value_idx", + table_relkind: "m", + is_clustered: true, + definition: + "CREATE INDEX items_value_mv_value_idx ON public.items_value_mv (value)", + }); + const branchIndex = indexOnItemsValue({ + table_name: "items_value_mv", + name: "items_value_mv_value_idx", + table_relkind: "m", + is_clustered: true, + definition: + "CREATE INDEX items_value_mv_value_idx ON public.items_value_mv (value)", + }); + + const changes: Change[] = [ + new DropMaterializedView({ materializedView: mainMaterializedView }), + new CreateMaterializedView({ + materializedView: branchMaterializedView, + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + materializedViews: { + [mainMaterializedView.stableId]: mainMaterializedView, + }, + indexableObjects: { + [mainMaterializedView.stableId]: mainMaterializedView, + }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + materializedViews: { + [branchMaterializedView.stableId]: branchMaterializedView, + }, + indexableObjects: { + [branchMaterializedView.stableId]: branchMaterializedView, + }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect( + expanded.changes.filter((change) => change instanceof DropIndex), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateIndex), + ).toHaveLength(1); + expect(serialized).toContain( + "ALTER MATERIALIZED VIEW public.items_value_mv CLUSTER ON items_value_mv_value_idx", + ); + }); + test("skips partition-cloned trigger dependents during routine replacement", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -3381,6 +3465,7 @@ describe("expandReplaceDependencies", () => { defaultPrivilegeState: new DefaultPrivilegeState({}), }, }); + const serialized = expanded.changes.map((change) => change.serialize()); expect( expanded.changes.filter( @@ -3410,12 +3495,12 @@ describe("expandReplaceDependencies", () => { ).toHaveLength(1); expect( expanded.changes.filter( - (change) => - change instanceof AlterTableAlterColumnSetDefault && - change.table.name === "items_2026" && - change.column.name === "value", + (change) => change instanceof AlterTableAlterColumnSetDefault, ), - ).toHaveLength(1); + ).toHaveLength(0); + expect(serialized).toContain( + "CREATE TABLE public.items_2026 PARTITION OF public.items (value GENERATED ALWAYS AS (public.normalize_value(value)) STORED) FOR VALUES FROM (2026) TO (2027)", + ); expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( true, ); @@ -4046,6 +4131,105 @@ describe("expandReplaceDependencies", () => { ).toHaveLength(1); }); + test("restores aggregate metadata when an existing aggregate create is converted from orReplace", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"], "sum_state"); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.sum_state(renamed integer) RETURNS integer", + }); + const mainAggregate = aggregateWithArgs(["integer"]); + const branchAggregate = aggregateWithArgs(["integer"], { + owner: "aggregate_owner", + comment: "aggregate comment", + security_labels: [{ provider: "dummy", label: "aggregate label" }], + privileges: [ + { + grantee: "aggregate_executor", + privilege: "EXECUTE", + grantable: false, + }, + ], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new CreateAggregate({ aggregate: branchAggregate, orReplace: true }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + aggregates: { [mainAggregate.stableId]: mainAggregate }, + depends: [ + { + dependent_stable_id: mainAggregate.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + aggregates: { [branchAggregate.stableId]: branchAggregate }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect( + expanded.changes.filter((change) => change instanceof DropAggregate), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterAggregateChangeOwner, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof CreateCommentOnAggregate, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof CreateSecurityLabelOnAggregate, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof GrantAggregatePrivileges, + ), + ).toHaveLength(1); + expect(serialized).toContain( + "ALTER AGGREGATE public.total_value(integer) OWNER TO aggregate_owner", + ); + expect(serialized).toContain( + "COMMENT ON AGGREGATE public.total_value(integer) IS 'aggregate comment'", + ); + expect(serialized).toContain( + "SECURITY LABEL FOR dummy ON AGGREGATE public.total_value(integer) IS 'aggregate label'", + ); + expect(serialized).toContain( + "GRANT ALL ON FUNCTION public.total_value(integer) TO aggregate_executor", + ); + }); + test("restores promoted routine metadata after dependent routine replacement", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/src/core/objects/table/changes/table.create.test.ts b/packages/pg-delta/src/core/objects/table/changes/table.create.test.ts index b7e60e8bd..feb79009d 100644 --- a/packages/pg-delta/src/core/objects/table/changes/table.create.test.ts +++ b/packages/pg-delta/src/core/objects/table/changes/table.create.test.ts @@ -34,6 +34,41 @@ describe.concurrent("table.create", () => { expect(change.serialize()).toBe("CREATE TABLE public.t ()"); }); + test("partition create preserves child-specific generated expressions", async () => { + const t = new Table({ + ...base, + name: "t_2026", + is_partition: true, + parent_schema: "public", + parent_name: "parent", + partition_bound: "FOR VALUES FROM (2026) TO (2027)", + columns: [ + { + name: "total", + position: 1, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: true, + collation: null, + default: "public.compute_child_total(subtotal)", + comment: null, + }, + ], + }); + const change = new CreateTable({ table: t }); + expect(change.serialize()).toBe( + "CREATE TABLE public.t_2026 PARTITION OF public.parent (total GENERATED ALWAYS AS (public.compute_child_total(subtotal)) STORED) FOR VALUES FROM (2026) TO (2027)", + ); + }); + test("TEMPORARY with columns, inherits and options", async () => { const t = new Table({ ...base, diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index caf753c45..e37965835 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -2139,10 +2139,20 @@ for (const pgVersion of POSTGRES_VERSIONS) { "CREATE TABLE test_schema.partitioned_child_only_totals_2026", ), ); + const childSetExpressionIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_child_only_totals_2026 ALTER COLUMN total SET EXPRESSION", + ), + ); expect(childDropTableIndex).toBeGreaterThanOrEqual(0); expect(dropFunctionIndex).toBeGreaterThan(childDropTableIndex); expect(childCreateTableIndex).toBeGreaterThan(createFunctionIndex); + expect(sqlStatements[childCreateTableIndex]).toContain( + "total GENERATED ALWAYS AS (test_schema.compute_child_total(subtotal)) STORED", + ); + expect(childSetExpressionIndex).toBe(-1); }, }); }), diff --git a/packages/pg-delta/tests/integration/materialized-view-operations.test.ts b/packages/pg-delta/tests/integration/materialized-view-operations.test.ts index 5583f6be1..dfa30508e 100644 --- a/packages/pg-delta/tests/integration/materialized-view-operations.test.ts +++ b/packages/pg-delta/tests/integration/materialized-view-operations.test.ts @@ -97,6 +97,90 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "replace materialized view definition preserves retained indexes", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE TABLE test_schema.mv_inputs ( + id integer NOT NULL, + subtotal integer NOT NULL + ); + + CREATE MATERIALIZED VIEW test_schema.invoice_total_mv AS + SELECT id, subtotal AS total + FROM test_schema.mv_inputs; + + CREATE INDEX invoice_total_mv_total_idx + ON test_schema.invoice_total_mv (total); + + ALTER MATERIALIZED VIEW test_schema.invoice_total_mv + CLUSTER ON invoice_total_mv_total_idx; + `, + testSql: dedent` + DROP MATERIALIZED VIEW test_schema.invoice_total_mv; + + CREATE MATERIALIZED VIEW test_schema.invoice_total_mv AS + SELECT id, subtotal + 1 AS total + FROM test_schema.mv_inputs; + + CREATE INDEX invoice_total_mv_total_idx + ON test_schema.invoice_total_mv (total); + + ALTER MATERIALIZED VIEW test_schema.invoice_total_mv + CLUSTER ON invoice_total_mv_total_idx; + `, + assertSqlStatements: (statements) => { + const dropIndexIndex = statements.findIndex((statement) => + statement.startsWith( + "DROP INDEX test_schema.invoice_total_mv_total_idx", + ), + ); + const dropMatviewIndex = statements.findIndex((statement) => + statement.startsWith( + "DROP MATERIALIZED VIEW test_schema.invoice_total_mv", + ), + ); + const createMatviewIndex = statements.findIndex((statement) => + statement.startsWith( + "CREATE MATERIALIZED VIEW test_schema.invoice_total_mv", + ), + ); + const createIndexIndex = statements.findIndex((statement) => + statement.startsWith( + "CREATE INDEX invoice_total_mv_total_idx ON test_schema.invoice_total_mv", + ), + ); + const restoreClusterIndex = statements.findIndex((statement) => + statement.startsWith( + "ALTER MATERIALIZED VIEW test_schema.invoice_total_mv CLUSTER ON invoice_total_mv_total_idx", + ), + ); + + expect(dropIndexIndex).toBeGreaterThanOrEqual(0); + expect(dropMatviewIndex).toBeGreaterThan(dropIndexIndex); + expect(createMatviewIndex).toBeGreaterThan(dropMatviewIndex); + expect(createIndexIndex).toBeGreaterThan(createMatviewIndex); + expect(restoreClusterIndex).toBeGreaterThan(createIndexIndex); + }, + }); + + const { rows } = await db.main.query(dedent` + SELECT i.indisclustered + FROM pg_catalog.pg_index i + JOIN pg_catalog.pg_class idx ON idx.oid = i.indexrelid + JOIN pg_catalog.pg_namespace n ON n.oid = idx.relnamespace + WHERE n.nspname = 'test_schema' + AND idx.relname = 'invoice_total_mv_total_idx' + `); + expect(rows[0]?.indisclustered).toBe(true); + }), + ); + test( "replace materialized view with dependent index and view", withDb(pgVersion, async (db) => { From 4967f26115529491f3f0f102faa022273fe2cb9e Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 12:15:08 +0800 Subject: [PATCH 42/78] fix(pg-delta): preserve replacement root dependents --- .../src/core/expand-replace-dependencies.ts | 150 ++++++++++-------- .../objects/table/changes/table.create.ts | 8 + 2 files changed, 95 insertions(+), 63 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index cf6a59652..4dc31328b 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -289,6 +289,17 @@ export function expandReplaceDependencies({ const tablesReplacedByExpansion = new Set(); const generatedColumnsRecreatedByExpressionFallback = collectCoveredGeneratedColumnRecreations(changes); + const rootRetainedIndexChanges = buildRootRetainedIndexReplacementChanges({ + replaceRoots, + mainCatalog, + branchCatalog, + createdIds, + droppedIds, + visitedTargets, + diffContext, + }); + additions.push(...rootRetainedIndexChanges); + recordChangeIds(rootRetainedIndexChanges, createdIds, droppedIds); while (queue.length > 0) { const refId = queue.shift() as string; @@ -526,20 +537,18 @@ export function expandReplaceDependencies({ diffContext, }) : []; - const partitionGeneratedColumnRestoreChanges = - resolved.kind === "table" - ? buildPartitionGeneratedColumnTableReplacementRestoreChanges({ - dependentRaw, - branchCatalog, + const aggregateMetadataRestoreChanges = + resolved.kind === "aggregate" && addDrop && !addCreate + ? buildRetainedAggregateMetadataChanges({ + aggregate: resolved.branch, diffContext, - addCreate, - }) + }).filter((change) => !isCreateAlreadyCovered(change, createdIds)) : []; additions.push( ...replacementChanges, ...retainedIndexChanges, - ...partitionGeneratedColumnRestoreChanges, + ...aggregateMetadataRestoreChanges, ); if (resolved.kind === "rls_policy") { promotedRlsPolicyIds.add(targetId); @@ -556,7 +565,7 @@ export function expandReplaceDependencies({ for (const change of [ ...replacementChanges, ...retainedIndexChanges, - ...partitionGeneratedColumnRestoreChanges, + ...aggregateMetadataRestoreChanges, ]) { for (const id of change.creates ?? []) createdIds.add(id); for (const id of change.drops ?? []) droppedIds.add(id); @@ -1426,60 +1435,6 @@ function buildColumnExpressionReplacementChanges({ return changes; } -function buildPartitionGeneratedColumnTableReplacementRestoreChanges({ - dependentRaw, - branchCatalog, - diffContext, - addCreate, -}: { - dependentRaw: string; - branchCatalog: Catalog; - diffContext?: Pick< - ObjectDiffContext, - "version" | "currentUser" | "defaultPrivilegeState" - >; - addCreate: boolean; -}): Change[] { - if (!addCreate || (diffContext?.version ?? 0) < 170000) { - return []; - } - - const columnRef = parseColumnStableId(dependentRaw); - if (!columnRef) { - return []; - } - - const branchTable = - branchCatalog.tables[stableId.table(columnRef.schema, columnRef.table)]; - if (!branchTable?.is_partition) { - return []; - } - - const branchColumn = branchTable.columns.find( - (column) => column.name === columnRef.column, - ); - if ( - !branchColumn?.is_generated || - branchColumn.default === null || - partitionGeneratedColumnExpressionsMatchParent({ - mainTable: branchTable, - branchTable, - columnName: branchColumn.name, - mainCatalog: branchCatalog, - branchCatalog, - }) - ) { - return []; - } - - return [ - new AlterTableAlterColumnSetDefault({ - table: branchTable, - column: branchColumn, - }), - ]; -} - function buildPublicationColumnListReplacement({ columnId, publicationId, @@ -2464,6 +2419,75 @@ function buildRetainedIndexReplacementChanges({ return changes; } +function buildRootRetainedIndexReplacementChanges({ + replaceRoots, + mainCatalog, + branchCatalog, + createdIds, + droppedIds, + visitedTargets, + diffContext, +}: { + replaceRoots: ReadonlySet; + mainCatalog: Catalog; + branchCatalog: Catalog; + createdIds: ReadonlySet; + droppedIds: ReadonlySet; + visitedTargets: Set; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): Change[] { + const changes: Change[] = []; + for (const relationStableId of [...replaceRoots].sort()) { + if ( + !( + mainCatalog.tables[relationStableId] && + branchCatalog.tables[relationStableId] + ) && + !( + mainCatalog.materializedViews[relationStableId] && + branchCatalog.materializedViews[relationStableId] + ) + ) { + continue; + } + + changes.push( + ...buildRetainedIndexReplacementChanges({ + relationStableId, + mainCatalog, + branchCatalog, + createdIds, + droppedIds, + visitedTargets, + diffContext, + }), + ); + } + return changes; +} + +function recordChangeIds( + changes: readonly Change[], + createdIds: Set, + droppedIds: Set, +): void { + for (const change of changes) { + for (const id of change.creates ?? []) createdIds.add(id); + for (const id of change.drops ?? []) droppedIds.add(id); + } +} + +function isCreateAlreadyCovered( + change: Change, + createdIds: ReadonlySet, +): boolean { + const creates = change.creates ?? []; + return creates.length > 0 && creates.every((id) => createdIds.has(id)); +} + function buildRetainedClusterChange( resolved: Extract, ): Change[] { diff --git a/packages/pg-delta/src/core/objects/table/changes/table.create.ts b/packages/pg-delta/src/core/objects/table/changes/table.create.ts index 4f2260d59..1caa12efd 100644 --- a/packages/pg-delta/src/core/objects/table/changes/table.create.ts +++ b/packages/pg-delta/src/core/objects/table/changes/table.create.ts @@ -115,10 +115,18 @@ export class CreateTable extends CreateTableChange { this.table.parent_name && this.table.partition_bound ) { + const columnOverrides = this.table.columns + .filter((col) => col.is_generated && col.default) + .map( + (col) => `${col.name} GENERATED ALWAYS AS (${col.default}) STORED`, + ); return [ ...parts, "PARTITION OF", `${this.table.parent_schema}.${this.table.parent_name}`, + ...(columnOverrides.length > 0 + ? [`(${columnOverrides.join(", ")})`] + : []), this.table.partition_bound, ].join(" "); } From 8f4363572578242f16887f8402566eb68f51875f Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 12:46:36 +0800 Subject: [PATCH 43/78] test(pg-delta): cover expression restore review regressions RED evidence before the fix:\n- focused unit regressions failed because AlterTableAlterColumnSetDefault was missing for both the FK requirement and parent partition default cases.\n- focused PG17 integration regressions failed with restoreDefaultIndex=-1 and parentRestoreDefaultIndex=-1, proving generated SQL dropped defaults without restoring them. --- .../core/expand-replace-dependencies.test.ts | 201 ++++++++++++++++ ...-signature-expression-dependencies.test.ts | 218 ++++++++++++++++++ 2 files changed, 419 insertions(+) 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 6ae479aaf..715b25981 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -1695,6 +1695,114 @@ describe("expandReplaceDependencies", () => { expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); }); + test("does not count unrelated constraint column requirements as column default restore coverage", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(1)"); + const branchTable = tableWithDefault("public.normalize_value(1)"); + const referencedTable = tableNamedWithDefault("parents", null, { + name: "id", + not_null: true, + }); + const addForeignKey = new AlterTableAddConstraint({ + table: branchTable, + constraint: { + name: "items_value_fkey", + constraint_type: "f", + deferrable: false, + initially_deferred: false, + validated: true, + is_local: true, + no_inherit: false, + is_temporal: false, + is_partition_clone: false, + parent_constraint_schema: null, + parent_constraint_name: null, + parent_table_schema: null, + parent_table_name: null, + key_columns: ["value"], + foreign_key_columns: ["id"], + foreign_key_table: "parents", + foreign_key_schema: "public", + foreign_key_table_is_partition: false, + foreign_key_parent_schema: null, + foreign_key_parent_table: null, + foreign_key_effective_schema: "public", + foreign_key_effective_table: "parents", + on_update: "a", + on_delete: "a", + match_type: "s", + check_expression: null, + owner: "postgres", + definition: "FOREIGN KEY (value) REFERENCES public.parents(id)", + comment: null, + }, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + addForeignKey, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { + [mainTable.stableId]: mainTable, + [referencedTable.stableId]: referencedTable, + }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { + [branchTable.stableId]: branchTable, + [referencedTable.stableId]: referencedTable, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(addForeignKey); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + test("synthesizes a generated column fallback before PostgreSQL 17 for an unchanged expression that depends on a same-signature procedure replacement", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -2119,6 +2227,99 @@ describe("expandReplaceDependencies", () => { ); }); + test("does not count child partition default changes as parent default restore coverage", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainParentTable = tableWithDefault("public.normalize_value(1)"); + const branchParentTable = tableWithDefault("public.normalize_value(1)"); + const mainPartition = partitionTableWithDefault( + "public.normalize_value(2)", + ); + const branchPartition = partitionTableWithDefault( + "public.normalize_value(3)", + ); + const childSetDefault = new AlterTableAlterColumnSetDefault({ + table: branchPartition, + column: branchPartition.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + childSetDefault, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { + [mainParentTable.stableId]: mainParentTable, + [mainPartition.stableId]: mainPartition, + }, + depends: [ + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items_2026.value", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { + [branchParentTable.stableId]: branchParentTable, + [branchPartition.stableId]: branchPartition, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(childSetDefault); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAlterColumnDropDefault && + change.table.name === "items", + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAlterColumnSetDefault && + change.table.name === "items", + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => + change instanceof AlterTableAlterColumnSetDefault && + change.table.name === "items_2026", + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + test("does not count generated SET EXPRESSION as release coverage for same-signature procedure replacement", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index e37965835..133c3f321 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -420,6 +420,103 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "unchanged column default with added foreign key for replaced function argument name is restored", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_form_value(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.form_value_parents ( + id integer PRIMARY KEY + ); + + CREATE TABLE test_schema.form_values ( + id integer NOT NULL, + value integer DEFAULT test_schema.default_form_value(1) + ); + + INSERT INTO test_schema.form_value_parents (id) VALUES (2); + INSERT INTO test_schema.form_values (id) VALUES (1); + `, + testSql: dedent` + ALTER TABLE test_schema.form_values + ALTER COLUMN value DROP DEFAULT; + + DROP FUNCTION test_schema.default_form_value(integer); + + CREATE FUNCTION test_schema.default_form_value(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.form_values + ALTER COLUMN value + SET DEFAULT test_schema.default_form_value(1); + + ALTER TABLE test_schema.form_values + ADD CONSTRAINT form_values_value_fkey + FOREIGN KEY (value) REFERENCES test_schema.form_value_parents(id); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropDefaultIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.form_values ALTER COLUMN value DROP DEFAULT", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.default_form_value", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.default_form_value", + ), + ); + const restoreDefaultIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.form_values ALTER COLUMN value SET DEFAULT", + ), + ); + const addForeignKeyIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.form_values ADD CONSTRAINT form_values_value_fkey", + ), + ); + + expect(dropDefaultIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan(dropDefaultIndex); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(restoreDefaultIndex).toBeGreaterThan(createFunctionIndex); + expect(addForeignKeyIndex).toBeGreaterThanOrEqual(0); + }, + }); + + const { rows } = await db.main.query( + "SELECT pg_get_expr(adbin, adrelid) AS expression FROM pg_attrdef WHERE adrelid = 'test_schema.form_values'::regclass AND adnum = 2", + ); + expect(rows[0]?.expression).toContain( + "test_schema.default_form_value(1)", + ); + }), + ); + test.skipIf(pgVersion < 17)( "generated column update for replaced function signature does not recreate table", withDb(pgVersion, async (db) => { @@ -2372,6 +2469,127 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "parent partition default is restored when only child default changes for replaced function argument name", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.default_partition_score(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.partitioned_scores_with_parent_default ( + id integer NOT NULL, + period integer NOT NULL, + score integer DEFAULT test_schema.default_partition_score(1) + ) PARTITION BY RANGE (period); + + CREATE TABLE test_schema.partitioned_scores_with_parent_default_2026 + PARTITION OF test_schema.partitioned_scores_with_parent_default + FOR VALUES FROM (2026) TO (2027); + + ALTER TABLE test_schema.partitioned_scores_with_parent_default_2026 + ALTER COLUMN score + SET DEFAULT test_schema.default_partition_score(2); + + INSERT INTO test_schema.partitioned_scores_with_parent_default + (id, period) + VALUES (1, 2026); + `, + testSql: dedent` + ALTER TABLE test_schema.partitioned_scores_with_parent_default_2026 + ALTER COLUMN score DROP DEFAULT; + + ALTER TABLE test_schema.partitioned_scores_with_parent_default + ALTER COLUMN score DROP DEFAULT; + + DROP FUNCTION test_schema.default_partition_score(integer); + + CREATE FUNCTION test_schema.default_partition_score(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.partitioned_scores_with_parent_default + ALTER COLUMN score + SET DEFAULT test_schema.default_partition_score(1); + + ALTER TABLE test_schema.partitioned_scores_with_parent_default_2026 + ALTER COLUMN score + SET DEFAULT test_schema.default_partition_score(3); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const parentDropDefaultIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_scores_with_parent_default ALTER COLUMN score DROP DEFAULT", + ), + ); + const childDropDefaultIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_scores_with_parent_default_2026 ALTER COLUMN score DROP DEFAULT", + ), + ); + const dropFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "DROP FUNCTION test_schema.default_partition_score", + ), + ); + const createFunctionIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "CREATE FUNCTION test_schema.default_partition_score", + ), + ); + const parentRestoreDefaultIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_scores_with_parent_default ALTER COLUMN score SET DEFAULT", + ), + ); + const childRestoreDefaultIndex = sqlStatements.findIndex( + (statement) => + statement.startsWith( + "ALTER TABLE test_schema.partitioned_scores_with_parent_default_2026 ALTER COLUMN score SET DEFAULT", + ), + ); + + expect(parentDropDefaultIndex).toBeGreaterThanOrEqual(0); + expect(childDropDefaultIndex).toBeGreaterThanOrEqual(0); + expect(dropFunctionIndex).toBeGreaterThan( + Math.max(parentDropDefaultIndex, childDropDefaultIndex), + ); + expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); + expect(parentRestoreDefaultIndex).toBeGreaterThan( + createFunctionIndex, + ); + expect(childRestoreDefaultIndex).toBeGreaterThan( + createFunctionIndex, + ); + }, + }); + + const { rows } = await db.main.query( + "SELECT pg_get_expr(adbin, adrelid) AS expression FROM pg_attrdef WHERE adrelid = 'test_schema.partitioned_scores_with_parent_default'::regclass AND adnum = 3", + ); + expect(rows[0]?.expression).toContain( + "test_schema.default_partition_score(1)", + ); + }), + ); + test( "removed generated partition column for dropped function does not emit child DDL", withDb(pgVersion, async (db) => { From 1b26ee1ef73cc4f0c14cae9aff037c3fab6f767b Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 12:54:41 +0800 Subject: [PATCH 44/78] fix(pg-delta): avoid false default restore coverage Do not count AlterTableAddConstraint column requirements as expression restore coverage. For table SET DEFAULT / SET EXPRESSION, mark only the altered column as restored so child partition defaults do not cover parent defaults. Add a custom sort constraint so parent partition default restores run before child default restores.\n\nRED evidence from 118e862 before the fix:\n- focused unit regressions failed with AlterTableAlterColumnSetDefault length 0 for the FK requirement and parent partition default cases.\n- focused PG17 integration regressions failed with restoreDefaultIndex=-1 and parentRestoreDefaultIndex=-1.\n\nGREEN validation after the fix:\n- focused unit regressions: 2 pass / 0 fail.\n- focused PG17 integration regressions: 2 pass / 0 fail.\n- sort guard: 1 pass / 0 fail.\n- expand-replace-dependencies.test.ts: 55 pass / 0 fail.\n- sort-changes.test.ts: 12 pass / 0 fail.\n- PG17 function-signature-expression-dependencies.test.ts: 27 pass / 3 skip / 0 fail. --- .../src/core/expand-replace-dependencies.ts | 20 ++++----- .../src/core/sort/custom-constraints.ts | 21 ++++++++++ .../src/core/sort/sort-changes.test.ts | 41 +++++++++++++++++++ 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 4dc31328b..7605fa89a 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -719,7 +719,6 @@ function collectExpressionDependentCoverage( } if ( - change instanceof AlterTableAddConstraint || change instanceof AlterDomainSetDefault || change instanceof AlterDomainAddConstraint ) { @@ -731,15 +730,16 @@ function collectExpressionDependentCoverage( } if (change instanceof AlterTableAlterColumnSetDefault) { - for (const id of change.requires ?? []) { - if (isExpressionContainerStableId(id)) { - // SET EXPRESSION installs the branch expression in the create/alter - // phase; it does not release the old pg_depend edge before DROP - // FUNCTION runs, so generated columns still need a drop-phase - // fallback when a replaced procedure keeps the same stable id. - restore.add(id); - } - } + // SET DEFAULT / SET EXPRESSION restores only the column being altered. + // Partition child changes may require the parent column for ordering, but + // that parent requirement is not itself parent expression restore coverage. + restore.add( + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ), + ); } } diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index c1c5ecdc8..1dd1b1eb1 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -157,6 +157,10 @@ function generateIdentityTransitionConstraints( const dropIdentityByColumn = new Map(); const addIdentityByColumn = new Map(); const setDefaultByColumn = new Map(); + const setDefaultChanges: Array<{ + index: number; + change: AlterTableAlterColumnSetDefault; + }> = []; for (let i = 0; i < changes.length; i++) { const change = changes[i]; @@ -182,6 +186,7 @@ function generateIdentityTransitionConstraints( const entries = setDefaultByColumn.get(columnKey) ?? []; entries.push(i); setDefaultByColumn.set(columnKey, entries); + setDefaultChanges.push({ index: i, change }); } } @@ -213,6 +218,22 @@ function generateIdentityTransitionConstraints( } } + for (const { index: targetIndex, change } of setDefaultChanges) { + if (!change.table.parent_schema || !change.table.parent_name) continue; + + const parentColumnKey = `${change.table.parent_schema}.${change.table.parent_name}.${change.column.name}`; + const parentSetDefaultIndexes = + setDefaultByColumn.get(parentColumnKey) ?? []; + for (const sourceIndex of parentSetDefaultIndexes) { + if (sourceIndex === targetIndex) continue; + constraints.push({ + sourceChangeIndex: sourceIndex, + targetChangeIndex: targetIndex, + source: "custom", + }); + } + } + return constraints; } 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 2634cdd40..37a0390d3 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -661,6 +661,47 @@ describe("sortChanges", () => { ]); }); + test("orders parent partition default restore before child default restore", async () => { + const parentTable = table("partitioned_scores"); + const childTable = new Table({ + ...baseTableProps, + name: "partitioned_scores_2026", + columns: [ + { ...integerColumn("id", 1), not_null: true }, + integerColumn("post_id", 2), + integerColumn("lab_id", 3), + ], + constraints: [], + is_partition: true, + parent_schema: "public", + parent_name: "partitioned_scores", + partition_bound: "FOR VALUES FROM (2026) TO (2027)", + }); + const parentSetDefault = new AlterTableAlterColumnSetDefault({ + table: parentTable, + column: { + ...integerColumn("lab_id", 3), + default: "public.normalize_value(1)", + }, + }); + const childSetDefault = new AlterTableAlterColumnSetDefault({ + table: childTable, + column: { + ...integerColumn("lab_id", 3), + default: "public.normalize_value(3)", + }, + }); + const changes: Change[] = [childSetDefault, parentSetDefault]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + + expect(sorted.indexOf(parentSetDefault)).toBeLessThan( + sorted.indexOf(childSetDefault), + ); + }); + test("orders domain default removal before dropping the referenced function", async () => { const mainProcedure = procedure(["integer"]); const mainDomain = domain("public.normalize_value(1)"); From 201b4aec8f57310bb17e002198b40a9891d7fb66 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 13:23:36 +0800 Subject: [PATCH 45/78] test(pg-delta): cover procedure metadata restore on injected drops RED evidence before the fix:\n- focused expandReplaceDependencies regression failed because serialized changes contained the injected dependent DropProcedure and existing CreateProcedure(orReplace), but no ALTER FUNCTION OWNER metadata restore.\n- The same output also lacked retained comment, security label, and EXECUTE grant restores. --- .../core/expand-replace-dependencies.test.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) 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 715b25981..6703c354e 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -4506,6 +4506,91 @@ describe("expandReplaceDependencies", () => { ); }); + test("restores procedure metadata when an existing procedure create is converted from orReplace", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDependent = procedureWithArgs(["integer"], "uses_normalize"); + const branchDependent = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainDependent, + source_code: "SELECT public.normalize_value(arg1::bigint)", + definition: + "CREATE FUNCTION public.uses_normalize(arg1 integer) RETURNS integer", + owner: "routine_owner", + comment: "dependent routine comment", + security_labels: [{ provider: "dummy", label: "routine label" }], + privileges: [ + { + grantee: "routine_executor", + privilege: "EXECUTE", + grantable: false, + }, + ], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new CreateProcedure({ procedure: branchDependent, orReplace: true }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [mainProcedure.stableId]: mainProcedure, + [mainDependent.stableId]: mainDependent, + }, + depends: [ + { + dependent_stable_id: mainDependent.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [branchProcedure.stableId]: branchProcedure, + [branchDependent.stableId]: branchDependent, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const serialized = expanded.changes.map((change) => change.serialize()); + + expect(serialized).toContain( + "DROP FUNCTION public.uses_normalize(arg1 integer)", + ); + expect(serialized).toContain( + "CREATE OR REPLACE FUNCTION public.uses_normalize(arg1 integer) RETURNS integer", + ); + expect(serialized).toContain( + "ALTER FUNCTION public.uses_normalize(integer) OWNER TO routine_owner", + ); + expect(serialized).toContain( + "COMMENT ON FUNCTION public.uses_normalize(integer) IS 'dependent routine comment'", + ); + expect(serialized).toContain( + "SECURITY LABEL FOR dummy ON FUNCTION public.uses_normalize(integer) IS 'routine label'", + ); + expect(serialized).toContain( + "GRANT ALL ON FUNCTION public.uses_normalize(integer) TO routine_executor", + ); + }); + test("synthesizes a table check constraint replacement for an unchanged expression that depends on a replaced procedure", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); From fab366c39de0dd912e359bed38e2f1d96a245ea7 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 13:25:35 +0800 Subject: [PATCH 46/78] fix(pg-delta): restore procedure metadata after injected drops Replay retained procedure metadata when dependency expansion injects only the DropProcedure side for a dependent procedure that already has a CreateProcedure(orReplace) from the normal diff. This mirrors the aggregate addDrop-only path and filters metadata creates that are already covered.\n\nRED evidence from 40f3b68 before the fix:\n- focused expandReplaceDependencies regression failed with injected DropProcedure and existing CreateProcedure(orReplace) present, but missing ALTER FUNCTION OWNER metadata restore.\n- The same serialized output lacked retained comment, security label, and EXECUTE grant restores.\n\nGREEN validation after the fix:\n- focused procedure metadata regression: 1 pass / 0 fail.\n- expand-replace-dependencies.test.ts: 56 pass / 0 fail.\n- format-and-lint:fix, check-types, knip --fix, and git diff --check pass. --- .../pg-delta/src/core/expand-replace-dependencies.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 7605fa89a..c6d1245a1 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -537,6 +537,13 @@ export function expandReplaceDependencies({ diffContext, }) : []; + const procedureMetadataRestoreChanges = + resolved.kind === "procedure" && addDrop && !addCreate + ? buildRetainedProcedureMetadataChanges({ + procedure: resolved.branch, + diffContext, + }).filter((change) => !isCreateAlreadyCovered(change, createdIds)) + : []; const aggregateMetadataRestoreChanges = resolved.kind === "aggregate" && addDrop && !addCreate ? buildRetainedAggregateMetadataChanges({ @@ -548,6 +555,7 @@ export function expandReplaceDependencies({ additions.push( ...replacementChanges, ...retainedIndexChanges, + ...procedureMetadataRestoreChanges, ...aggregateMetadataRestoreChanges, ); if (resolved.kind === "rls_policy") { @@ -565,6 +573,7 @@ export function expandReplaceDependencies({ for (const change of [ ...replacementChanges, ...retainedIndexChanges, + ...procedureMetadataRestoreChanges, ...aggregateMetadataRestoreChanges, ]) { for (const id of change.creates ?? []) createdIds.add(id); From 9560e7107b88a1711b3d417687d5fcbb6ca41764 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 13:58:52 +0800 Subject: [PATCH 47/78] test(pg-delta): cover promoted table replay regressions --- .../core/expand-replace-dependencies.test.ts | 378 +++++++++++++++++- 1 file changed, 376 insertions(+), 2 deletions(-) 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 6703c354e..85837a6a3 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -51,6 +51,7 @@ import { RlsPolicy } from "./objects/rls-policy/rls-policy.model.ts"; import { CreateRule } from "./objects/rule/changes/rule.create.ts"; import { DropRule } from "./objects/rule/changes/rule.drop.ts"; import { Rule } from "./objects/rule/rule.model.ts"; +import { AlterSequenceSetOwnedBy } from "./objects/sequence/changes/sequence.alter.ts"; import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts"; import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts"; import { diffSequences } from "./objects/sequence/sequence.diff.ts"; @@ -64,14 +65,21 @@ import { AlterTableDropColumn, AlterTableDropConstraint, AlterTableEnableRowLevelSecurity, + AlterTableForceRowLevelSecurity, AlterTableSetReplicaIdentity, AlterTableValidateConstraint, } from "./objects/table/changes/table.alter.ts"; -import { CreateCommentOnColumn } from "./objects/table/changes/table.comment.ts"; +import { + CreateCommentOnColumn, + CreateCommentOnTable, +} from "./objects/table/changes/table.comment.ts"; import { CreateTable } from "./objects/table/changes/table.create.ts"; import { DropTable } from "./objects/table/changes/table.drop.ts"; import { GrantTablePrivileges } from "./objects/table/changes/table.privilege.ts"; -import { CreateSecurityLabelOnColumn } from "./objects/table/changes/table.security-label.ts"; +import { + CreateSecurityLabelOnColumn, + CreateSecurityLabelOnTable, +} from "./objects/table/changes/table.security-label.ts"; import { Table, type TableProps } from "./objects/table/table.model.ts"; import { CreateTrigger } from "./objects/trigger/changes/trigger.create.ts"; import { DropTrigger } from "./objects/trigger/changes/trigger.drop.ts"; @@ -367,6 +375,28 @@ function indexOnItemsValue(overrides: Partial = {}): Index { }); } +function sequenceOwnedByItemsValue(): Sequence { + return new Sequence({ + schema: "public", + name: "items_value_seq", + data_type: "bigint", + start_value: 1, + minimum_value: BigInt(1), + maximum_value: BigInt("9223372036854775807"), + increment: 1, + cycle_option: false, + cache_size: 1, + persistence: "p", + owned_by_schema: "public", + owned_by_table: "items", + owned_by_column: "value", + comment: null, + privileges: [], + owner: "postgres", + security_labels: [], + }); +} + function viewOnItemsValue(): View { return new View({ schema: "public", @@ -4591,6 +4621,350 @@ describe("expandReplaceDependencies", () => { ); }); + test("replays retained table metadata for promoted table replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const tableSecurityLabels = [{ provider: "dummy", label: "table label" }]; + const columnSecurityLabels = [{ provider: "dummy", label: "column label" }]; + const retainedPrivileges = [ + { + grantee: "table_reader", + privilege: "SELECT", + grantable: false, + }, + ]; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault("public.normalize_value(value)", { + comment: "value column", + security_labels: columnSecurityLabels, + }), + owner: "app_owner", + row_security: true, + force_row_security: true, + replica_identity: "f", + comment: "retained table comment", + privileges: retainedPrivileges, + security_labels: tableSecurityLabels, + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainTable, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + true, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableChangeOwner, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableEnableRowLevelSecurity, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableForceRowLevelSecurity, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableSetReplicaIdentity, + ), + ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof CreateCommentOnTable), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof CreateCommentOnColumn, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof CreateSecurityLabelOnTable, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof CreateSecurityLabelOnColumn, + ), + ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof GrantTablePrivileges), + ).toBe(true); + }); + + test("replays retained owned sequences for promoted table replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = tableWithDefault(columnDefault); + const branchTable = tableWithDefault(columnDefault); + const mainSequence = sequenceOwnedByItemsValue(); + const branchSequence = sequenceOwnedByItemsValue(); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + true, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof CreateSequence), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterSequenceSetOwnedBy, + ), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toBe(true); + }); + + test("does not count domain constraints as domain default restores", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainDomain = domainWithDefault("public.normalize_value(1)"); + const branchDomain = new Domain({ + // oxlint-disable-next-line typescript/no-misused-spread + ...domainWithConstraint("VALUE > 0"), + default_bin: mainDomain.default_bin, + default_value: mainDomain.default_value, + }); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + const addConstraint = new AlterDomainAddConstraint({ + domain: branchDomain, + constraint: branchDomain.constraints[0] as Domain["constraints"][number], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + addConstraint, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: mainDomain.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(addConstraint); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainDropDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainSetDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + expect( + expanded.changes.some((change) => change instanceof CreateDomain), + ).toBe(false); + }); + + test("replays retained publication membership for promoted table replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithDefault("public.normalize_value(value)"); + const branchTable = tableWithDefault("public.normalize_value(value)"); + const mainPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: null, + row_filter: null, + }, + ]); + const branchPublication = publicationOnTables([ + { + schema: "public", + name: "items", + columns: null, + row_filter: null, + }, + ]); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + publications: { [mainPublication.stableId]: mainPublication }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + publications: { [branchPublication.stableId]: branchPublication }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + true, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toBe(true); + }); + test("synthesizes a table check constraint replacement for an unchanged expression that depends on a replaced procedure", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); From 515878ec5594e8d0bc99f4a6feaea7e32358c6fa Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 14:07:14 +0800 Subject: [PATCH 48/78] fix(pg-delta): replay promoted table dependents --- .../src/core/expand-replace-dependencies.ts | 241 +++++++++++++++--- 1 file changed, 212 insertions(+), 29 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index c6d1245a1..25c1ea735 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -54,6 +54,9 @@ import { CreateCommentOnRule } from "./objects/rule/changes/rule.comment.ts"; import { CreateRule } from "./objects/rule/changes/rule.create.ts"; import { DropRule } from "./objects/rule/changes/rule.drop.ts"; import { SetRuleEnabledState } from "./objects/rule/changes/rule.alter.ts"; +import { AlterSequenceSetOwnedBy } from "./objects/sequence/changes/sequence.alter.ts"; +import { diffSequences } from "./objects/sequence/sequence.diff.ts"; +import { Sequence } from "./objects/sequence/sequence.model.ts"; import { AlterTableAddColumn, AlterTableAddConstraint, @@ -72,6 +75,7 @@ import { CreateTable } from "./objects/table/changes/table.create.ts"; import { DropTable } from "./objects/table/changes/table.drop.ts"; import { GrantTablePrivileges } from "./objects/table/changes/table.privilege.ts"; import { CreateSecurityLabelOnColumn } from "./objects/table/changes/table.security-label.ts"; +import { diffTables } from "./objects/table/table.diff.ts"; import type { TableProps } from "./objects/table/table.model.ts"; import { SetTriggerEnabledState } from "./objects/trigger/changes/trigger.alter.ts"; import { CreateCommentOnTrigger } from "./objects/trigger/changes/trigger.comment.ts"; @@ -537,6 +541,25 @@ export function expandReplaceDependencies({ diffContext, }) : []; + const retainedOwnedSequenceChanges = + resolved.kind === "table" && addDrop && addCreate + ? buildRetainedOwnedSequenceReplacementChanges({ + table: resolved.branch, + mainCatalog, + branchCatalog, + createdIds, + diffContext, + }) + : []; + const retainedPublicationMembershipChanges = + resolved.kind === "table" && addDrop && addCreate + ? buildRetainedPublicationMembershipChanges({ + table: resolved.branch, + mainCatalog, + branchCatalog, + existingChanges: [...changes, ...additions], + }) + : []; const procedureMetadataRestoreChanges = resolved.kind === "procedure" && addDrop && !addCreate ? buildRetainedProcedureMetadataChanges({ @@ -555,6 +578,8 @@ export function expandReplaceDependencies({ additions.push( ...replacementChanges, ...retainedIndexChanges, + ...retainedOwnedSequenceChanges, + ...retainedPublicationMembershipChanges, ...procedureMetadataRestoreChanges, ...aggregateMetadataRestoreChanges, ); @@ -573,6 +598,8 @@ export function expandReplaceDependencies({ for (const change of [ ...replacementChanges, ...retainedIndexChanges, + ...retainedOwnedSequenceChanges, + ...retainedPublicationMembershipChanges, ...procedureMetadataRestoreChanges, ...aggregateMetadataRestoreChanges, ]) { @@ -727,10 +754,7 @@ function collectExpressionDependentCoverage( } } - if ( - change instanceof AlterDomainSetDefault || - change instanceof AlterDomainAddConstraint - ) { + if (change instanceof AlterDomainSetDefault) { for (const id of change.requires ?? []) { if (isExpressionContainerStableId(id)) { restore.add(id); @@ -2478,6 +2502,152 @@ function buildRootRetainedIndexReplacementChanges({ return changes; } +function buildRetainedOwnedSequenceReplacementChanges({ + table, + mainCatalog, + branchCatalog, + createdIds, + diffContext, +}: { + table: Catalog["tables"][string]; + mainCatalog: Catalog; + branchCatalog: Catalog; + createdIds: ReadonlySet; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): Change[] { + if (!diffContext) return []; + + const changes: Change[] = []; + const branchSequences = Object.values(branchCatalog.sequences) + .filter( + (sequence) => + sequence.owned_by_schema === table.schema && + sequence.owned_by_table === table.name && + sequence.owned_by_column !== null, + ) + .sort((a, b) => a.stableId.localeCompare(b.stableId)); + + for (const branchSequence of branchSequences) { + if (!mainCatalog.sequences[branchSequence.stableId]) continue; + if (createdIds.has(branchSequence.stableId)) continue; + const ownedByColumn = branchSequence.owned_by_column; + if (ownedByColumn === null) continue; + + const unownedSequence = new Sequence({ + schema: branchSequence.schema, + name: branchSequence.name, + data_type: branchSequence.data_type, + start_value: branchSequence.start_value, + minimum_value: branchSequence.minimum_value, + maximum_value: branchSequence.maximum_value, + increment: branchSequence.increment, + cycle_option: branchSequence.cycle_option, + cache_size: branchSequence.cache_size, + persistence: branchSequence.persistence, + owned_by_schema: null, + owned_by_table: null, + owned_by_column: null, + comment: branchSequence.comment, + privileges: branchSequence.privileges, + owner: branchSequence.owner, + security_labels: branchSequence.security_labels, + }); + changes.push( + ...(diffSequences( + diffContext, + {}, + { [unownedSequence.stableId]: unownedSequence }, + branchCatalog.tables, + mainCatalog.tables, + ) as Change[]), + ); + + changes.push( + new AlterSequenceSetOwnedBy({ + sequence: branchSequence, + ownedBy: { + schema: table.schema, + table: table.name, + column: ownedByColumn, + }, + }), + ); + + const ownedColumn = table.columns.find( + (column) => column.name === ownedByColumn, + ); + if (ownedColumn && ownedColumn.default !== null) { + changes.push( + new AlterTableAlterColumnSetDefault({ + table, + column: ownedColumn, + }), + ); + } + } + + return changes; +} + +function buildRetainedPublicationMembershipChanges({ + table, + mainCatalog, + branchCatalog, + existingChanges, +}: { + table: Catalog["tables"][string]; + mainCatalog: Catalog; + branchCatalog: Catalog; + existingChanges: readonly Change[]; +}): Change[] { + const changes: Change[] = []; + + for (const branchPublication of Object.values( + branchCatalog.publications, + ).sort((a, b) => a.stableId.localeCompare(b.stableId))) { + const mainPublication = + mainCatalog.publications[branchPublication.stableId]; + if (!mainPublication) continue; + + const branchTable = branchPublication.tables.find( + (publicationTable) => + publicationTable.schema === table.schema && + publicationTable.name === table.name, + ); + if (!branchTable) continue; + + const retainedInMain = mainPublication.tables.some( + (publicationTable) => + publicationTable.schema === branchTable.schema && + publicationTable.name === branchTable.name, + ); + if (!retainedInMain) continue; + + if ( + publicationTableChangeCovered({ + changes: [...existingChanges, ...changes], + publicationId: branchPublication.stableId, + table: branchTable, + changeType: "add", + }) + ) { + continue; + } + + changes.push( + new AlterPublicationAddTables({ + publication: branchPublication, + tables: [branchTable], + }), + ); + } + + return changes; +} + function recordChangeIds( changes: readonly Change[], createdIds: Set, @@ -2542,31 +2712,7 @@ function buildReplaceChanges( return [ ...(addDrop ? [new DropTable({ table: resolved.main })] : []), ...(addCreate - ? [ - new CreateTable({ table: resolved.branch }), - ...((resolved.branch.constraints ?? []) - .filter((c) => !c.is_partition_clone) - .flatMap((constraint) => { - const items: Change[] = [ - new AlterTableAddConstraint({ - table: resolved.branch, - constraint, - }), - ]; - if ( - constraint.comment !== null && - constraint.comment !== undefined - ) { - items.push( - new CreateCommentOnConstraint({ - table: resolved.branch, - constraint, - }), - ); - } - return items; - }) as Change[]), - ] + ? buildCreateTableReplacementChanges(resolved.branch, diffContext) : []), ]; case "view": @@ -2906,6 +3052,43 @@ function buildRetainedAggregateMetadataChanges({ return changes; } +function buildCreateTableReplacementChanges( + table: Catalog["tables"][string], + diffContext: + | Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + > + | undefined, +): Change[] { + if (diffContext) { + return diffTables(diffContext, {}, { [table.stableId]: table }) as Change[]; + } + + return [ + new CreateTable({ table }), + ...((table.constraints ?? []) + .filter((constraint) => !constraint.is_partition_clone) + .flatMap((constraint) => { + const items: Change[] = [ + new AlterTableAddConstraint({ + table, + constraint, + }), + ]; + if (constraint.comment !== null && constraint.comment !== undefined) { + items.push( + new CreateCommentOnConstraint({ + table, + constraint, + }), + ); + } + return items; + }) as Change[]), + ]; +} + function buildCreateViewReplacementChanges( view: Catalog["views"][string], diffContext: From d0aaa20246cc9e00e51a61c7660f157f1eb99c63 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 14:40:07 +0800 Subject: [PATCH 49/78] test(pg-delta): cover promoted table replay edge cases --- .../core/expand-replace-dependencies.test.ts | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) 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 85837a6a3..6883606d7 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -39,6 +39,8 @@ import { AlterPublicationAddTables, AlterPublicationDropTables, } from "./objects/publication/changes/publication.alter.ts"; +import { CreatePublication } from "./objects/publication/changes/publication.create.ts"; +import { DropPublication } from "./objects/publication/changes/publication.drop.ts"; import { Publication } from "./objects/publication/publication.model.ts"; import { AlterRlsPolicySetUsingExpression, @@ -62,6 +64,7 @@ import { AlterTableAlterColumnDropDefault, AlterTableAlterColumnSetDefault, AlterTableChangeOwner, + AlterTableClusterOn, AlterTableDropColumn, AlterTableDropConstraint, AlterTableEnableRowLevelSecurity, @@ -90,6 +93,7 @@ import { Enum } from "./objects/type/enum/enum.model.ts"; import { CreateView } from "./objects/view/changes/view.create.ts"; import { DropView } from "./objects/view/changes/view.drop.ts"; import { View } from "./objects/view/view.model.ts"; +import { sortChanges } from "./sort/sort-changes.ts"; function mockChange(overrides: { creates?: string[]; @@ -341,6 +345,54 @@ function tableWithCheckConstraint( }); } +function tableWithUniqueConstraint( + tableOverrides: Partial = {}, + constraintOverrides: Partial< + NonNullable[number] + > = {}, +): Table { + const table = tableWithDefault("public.normalize_value(value)"); + return new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...table, + constraints: [ + { + name: "items_value_key", + constraint_type: "u", + deferrable: false, + initially_deferred: false, + validated: true, + is_local: true, + no_inherit: false, + is_temporal: false, + is_partition_clone: false, + parent_constraint_schema: null, + parent_constraint_name: null, + parent_table_schema: null, + parent_table_name: null, + key_columns: ["value"], + foreign_key_columns: null, + foreign_key_table: null, + foreign_key_schema: null, + foreign_key_table_is_partition: null, + foreign_key_parent_schema: null, + foreign_key_parent_table: null, + foreign_key_effective_schema: null, + foreign_key_effective_table: null, + on_update: null, + on_delete: null, + match_type: null, + check_expression: null, + owner: "postgres", + definition: "UNIQUE (value)", + comment: null, + ...constraintOverrides, + }, + ], + ...tableOverrides, + }); +} + function indexOnItemsValue(overrides: Partial = {}): Index { return new Index({ schema: "public", @@ -375,6 +427,20 @@ function indexOnItemsValue(overrides: Partial = {}): Index { }); } +function constraintBackedIndexOnItemsValue( + overrides: Partial = {}, +): Index { + return indexOnItemsValue({ + name: "items_value_key", + is_unique: true, + is_owned_by_constraint: true, + key_columns: [1], + index_expressions: null, + definition: "CREATE UNIQUE INDEX items_value_key ON public.items (value)", + ...overrides, + }); +} + function sequenceOwnedByItemsValue(): Sequence { return new Sequence({ schema: "public", @@ -4965,6 +5031,261 @@ describe("expandReplaceDependencies", () => { ).toBe(true); }); + test("orders constraint-backed replica identity after promoted table constraints", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithUniqueConstraint({ + replica_identity: "i", + replica_identity_index: "items_value_key", + }); + const branchTable = tableWithUniqueConstraint({ + replica_identity: "i", + replica_identity_index: "items_value_key", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const addConstraint = expanded.changes.find( + (change) => change instanceof AlterTableAddConstraint, + ); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addConstraintIndex = sorted.findIndex( + (change) => change instanceof AlterTableAddConstraint, + ); + const replicaIdentityIndex = sorted.findIndex( + (change) => change instanceof AlterTableSetReplicaIdentity, + ); + + expect(addConstraint).toBeInstanceOf(AlterTableAddConstraint); + expect(addConstraint?.creates).toContain( + "index:public.items.items_value_key", + ); + expect(replicaIdentityIndex).toBeGreaterThan(addConstraintIndex); + }); + + test("treats publication recreation as row-filter replacement coverage", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithDefault("public.normalize_value(value)"); + const branchTable = tableWithDefault("public.normalize_value(value)"); + const mainPublication = publicationOnItemsValue( + "(public.normalize_value(value) > 0)", + ); + const branchPublication = publicationOnItemsValue( + "(public.normalize_value(value) > 0)", + ); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new DropPublication({ publication: mainPublication }), + new CreatePublication({ publication: branchPublication }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + publications: { [mainPublication.stableId]: mainPublication }, + depends: [ + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + publications: { [branchPublication.stableId]: branchPublication }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationDropTables, + ), + ).toHaveLength(0); + expect( + expanded.changes.filter( + (change) => change instanceof AlterPublicationAddTables, + ), + ).toHaveLength(0); + }); + + test("keeps owned sequence metadata for promoted table cycle filtering", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = tableWithDefault(columnDefault); + const branchTable = tableWithDefault(columnDefault); + const mainSequence = sequenceOwnedByItemsValue(); + const branchSequence = sequenceOwnedByItemsValue(); + const sequenceId = "sequence:public.items_value_seq"; + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: sequenceId, + deptype: "n", + }, + { + dependent_stable_id: sequenceId, + referenced_stable_id: columnId, + deptype: "a", + }, + ], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const createSequence = expanded.changes.find( + (change): change is CreateSequence => change instanceof CreateSequence, + ); + + expect(createSequence?.sequence.owned_by_schema).toBe("public"); + expect(createSequence?.sequence.owned_by_table).toBe("items"); + expect(createSequence?.sequence.owned_by_column).toBe("value"); + expect(() => + sortChanges({ mainCatalog, branchCatalog }, expanded.changes), + ).not.toThrow(); + }); + + test("restores clustering for retained constraint-backed indexes on promoted tables", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithUniqueConstraint(); + const branchTable = tableWithUniqueConstraint(); + const mainIndex = constraintBackedIndexOnItemsValue({ is_clustered: true }); + const branchIndex = constraintBackedIndexOnItemsValue({ + is_clustered: true, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.some((change) => change instanceof AlterTableClusterOn), + ).toBe(true); + }); + test("synthesizes a table check constraint replacement for an unchanged expression that depends on a replaced procedure", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); From d059903186a4457a69fbb816cb823edf299dd166 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 14:48:23 +0800 Subject: [PATCH 50/78] fix(pg-delta): preserve promoted table replay edges --- .../src/core/expand-replace-dependencies.ts | 69 ++++++++++--------- .../core/objects/table/changes/table.alter.ts | 16 ++++- 2 files changed, 50 insertions(+), 35 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 25c1ea735..82fca71da 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -46,6 +46,8 @@ import { AlterPublicationAddTables, AlterPublicationDropTables, } from "./objects/publication/changes/publication.alter.ts"; +import { CreatePublication } from "./objects/publication/changes/publication.create.ts"; +import { DropPublication } from "./objects/publication/changes/publication.drop.ts"; import type { PublicationTableProps } from "./objects/publication/publication.model.ts"; import { CreateCommentOnRlsPolicy } from "./objects/rls-policy/changes/rls-policy.comment.ts"; import { CreateRlsPolicy } from "./objects/rls-policy/changes/rls-policy.create.ts"; @@ -54,9 +56,7 @@ import { CreateCommentOnRule } from "./objects/rule/changes/rule.comment.ts"; import { CreateRule } from "./objects/rule/changes/rule.create.ts"; import { DropRule } from "./objects/rule/changes/rule.drop.ts"; import { SetRuleEnabledState } from "./objects/rule/changes/rule.alter.ts"; -import { AlterSequenceSetOwnedBy } from "./objects/sequence/changes/sequence.alter.ts"; import { diffSequences } from "./objects/sequence/sequence.diff.ts"; -import { Sequence } from "./objects/sequence/sequence.model.ts"; import { AlterTableAddColumn, AlterTableAddConstraint, @@ -1682,6 +1682,20 @@ function publicationTableChangeCovered({ const tableStableId = stableId.table(table.schema, table.name); return changes.some((change) => { + if ( + changeType === "add" && + change instanceof CreatePublication && + change.publication.stableId === publicationId + ) { + return change.requires.includes(tableStableId); + } + if ( + changeType === "drop" && + change instanceof DropPublication && + change.publication.stableId === publicationId + ) { + return true; + } if (!(change instanceof changeClass)) return false; if (change.publication.stableId !== publicationId) return false; @@ -2443,7 +2457,15 @@ function buildRetainedIndexReplacementChanges({ addCreate, diffContext, }); - if (!replacementChanges) continue; + if (!replacementChanges) { + const retainedConstraintBackedIndexChanges = + buildRetainedConstraintBackedIndexMetadataChanges(resolved); + if (retainedConstraintBackedIndexChanges.length > 0) { + changes.push(...retainedConstraintBackedIndexChanges); + visitedTargets.add(indexId); + } + continue; + } changes.push(...replacementChanges); visitedTargets.add(indexId); @@ -2536,46 +2558,16 @@ function buildRetainedOwnedSequenceReplacementChanges({ const ownedByColumn = branchSequence.owned_by_column; if (ownedByColumn === null) continue; - const unownedSequence = new Sequence({ - schema: branchSequence.schema, - name: branchSequence.name, - data_type: branchSequence.data_type, - start_value: branchSequence.start_value, - minimum_value: branchSequence.minimum_value, - maximum_value: branchSequence.maximum_value, - increment: branchSequence.increment, - cycle_option: branchSequence.cycle_option, - cache_size: branchSequence.cache_size, - persistence: branchSequence.persistence, - owned_by_schema: null, - owned_by_table: null, - owned_by_column: null, - comment: branchSequence.comment, - privileges: branchSequence.privileges, - owner: branchSequence.owner, - security_labels: branchSequence.security_labels, - }); changes.push( ...(diffSequences( diffContext, {}, - { [unownedSequence.stableId]: unownedSequence }, + { [branchSequence.stableId]: branchSequence }, branchCatalog.tables, mainCatalog.tables, ) as Change[]), ); - changes.push( - new AlterSequenceSetOwnedBy({ - sequence: branchSequence, - ownedBy: { - schema: table.schema, - table: table.name, - column: ownedByColumn, - }, - }), - ); - const ownedColumn = table.columns.find( (column) => column.name === ownedByColumn, ); @@ -2692,6 +2684,15 @@ function buildRetainedClusterChange( : []; } +function buildRetainedConstraintBackedIndexMetadataChanges( + resolved: Extract, +): Change[] { + if (!(resolved.branch.is_owned_by_constraint || resolved.branch.is_primary)) { + return []; + } + return buildRetainedClusterChange(resolved); +} + function buildReplaceChanges( resolved: ResolvedObject, options: { 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 7e6cd7a55..909c3f075 100644 --- a/packages/pg-delta/src/core/objects/table/changes/table.alter.ts +++ b/packages/pg-delta/src/core/objects/table/changes/table.alter.ts @@ -370,13 +370,27 @@ export class AlterTableAddConstraint extends AlterTableChange { } get creates() { - return [ + const creates: string[] = [ stableId.constraint( this.table.schema, this.table.name, this.constraint.name, ), ]; + if ( + this.constraint.constraint_type === "p" || + this.constraint.constraint_type === "u" || + this.constraint.constraint_type === "x" + ) { + creates.push( + stableId.index( + this.table.schema, + this.table.name, + this.constraint.name, + ), + ); + } + return creates; } get requires() { From 18d5c5cf669b14e2b8a8737e05e0fd8caa7b2c93 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 16:01:09 +0800 Subject: [PATCH 51/78] test(pg-delta): cover promoted table metadata replay gaps RED evidence before the fix:\n- focused unit regression failed because covered generated-column recreation emitted 0 GrantTablePrivileges for the retained generated-column grant.\n- focused unit regression for retained constraint-backed indexes emitted clustering restore, but no CreateCommentOnIndex for the retained index comment metadata.\n\nSigned-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 149 +++++++++++++++++- 1 file changed, 148 insertions(+), 1 deletion(-) 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 6883606d7..e8b81c308 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -3198,6 +3198,122 @@ describe("expandReplaceDependencies", () => { expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); }); + test("replays retained grants only for covered generated column recreation", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const retainedPrivileges = [ + { + grantee: "value_reader", + privilege: "SELECT", + grantable: false, + columns: ["value"], + }, + { + grantee: "other_reader", + privilege: "SELECT", + grantable: false, + columns: ["other_value"], + }, + ]; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }), + columns: [ + ...tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }).columns, + { + name: "other_value", + position: 2, + data_type: "integer", + data_type_str: "integer", + is_custom_type: false, + custom_type_type: null, + custom_type_category: null, + custom_type_schema: null, + custom_type_name: null, + not_null: false, + is_identity: false, + is_identity_always: false, + is_generated: false, + collation: null, + default: null, + comment: null, + }, + ], + privileges: retainedPrivileges, + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainTable, + privileges: retainedPrivileges, + }); + const valueColumn = mainTable.columns[0]; + const branchValueColumn = branchTable.columns[0]; + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainTable, + column: valueColumn, + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchValueColumn, + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const grants = expanded.changes.filter( + (change): change is GrantTablePrivileges => + change instanceof GrantTablePrivileges, + ); + + expect(grants).toHaveLength(1); + expect(grants[0]?.grantee).toBe("value_reader"); + expect(grants[0]?.columns).toEqual(["value"]); + }); + test("restores retained security labels without replacing the table when recreating a generated column", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -5232,7 +5348,7 @@ describe("expandReplaceDependencies", () => { ).not.toThrow(); }); - test("restores clustering for retained constraint-backed indexes on promoted tables", async () => { + test("replays metadata for retained constraint-backed indexes on promoted tables", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); const branchProcedure = procedureWithArgs(["bigint"]); @@ -5241,6 +5357,8 @@ describe("expandReplaceDependencies", () => { const mainIndex = constraintBackedIndexOnItemsValue({ is_clustered: true }); const branchIndex = constraintBackedIndexOnItemsValue({ is_clustered: true, + comment: "retained unique index", + statistics_target: [100], }); const changes: Change[] = [ @@ -5284,6 +5402,35 @@ describe("expandReplaceDependencies", () => { expect( expanded.changes.some((change) => change instanceof AlterTableClusterOn), ).toBe(true); + expect( + expanded.changes.some((change) => change instanceof CreateCommentOnIndex), + ).toBe(true); + expect( + expanded.changes.some( + (change) => change instanceof AlterIndexSetStatistics, + ), + ).toBe(true); + + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addConstraintIndex = sorted.findIndex( + (change) => change instanceof AlterTableAddConstraint, + ); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnIndex, + ); + const statisticsIndex = sorted.findIndex( + (change) => change instanceof AlterIndexSetStatistics, + ); + const clusterIndex = sorted.findIndex( + (change) => change instanceof AlterTableClusterOn, + ); + + expect(commentIndex).toBeGreaterThan(addConstraintIndex); + expect(statisticsIndex).toBeGreaterThan(addConstraintIndex); + expect(clusterIndex).toBeGreaterThan(addConstraintIndex); }); test("synthesizes a table check constraint replacement for an unchanged expression that depends on a replaced procedure", async () => { From d540f9d76a0bc64f2b96d38e59206a88476a8f60 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 16:01:20 +0800 Subject: [PATCH 52/78] fix(pg-delta): replay promoted table metadata gaps Replay retained column grants for generated columns whose DROP/ADD recreation is already covered by the original diff, limited to the affected covered column.\n\nReplay retained comment and statistics metadata for constraint-backed indexes recreated by table constraint DDL, alongside the existing cluster restore. These metadata changes require the index stable id, so sorting keeps them after the constraint-backed index is recreated.\n\nRED evidence from de660ed:\n- covered generated-column recreation produced 0 retained GrantTablePrivileges.\n- retained constraint-backed index replay produced no CreateCommentOnIndex.\n\nGREEN validation:\n- focused unit regressions: 3 pass / 0 fail.\n- expand-replace-dependencies.test.ts: 65 pass / 0 fail.\n- PG15 covered generated-column integration case: 1 pass / 0 fail after retry.\n- format-and-lint:fix, check-types, knip --fix, and git diff --check passed.\n\nSigned-off-by: xiaominghao --- .../src/core/expand-replace-dependencies.ts | 87 ++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 82fca71da..cb5e9c7bf 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -293,6 +293,18 @@ export function expandReplaceDependencies({ const tablesReplacedByExpansion = new Set(); const generatedColumnsRecreatedByExpressionFallback = collectCoveredGeneratedColumnRecreations(changes); + const generatedColumnGrantRestoresAdded = new Set(); + for (const columnId of generatedColumnsRecreatedByExpressionFallback) { + maybeAddCoveredGeneratedColumnGrantRestores({ + columnId, + generatedColumnsRecreatedByExpressionFallback, + generatedColumnGrantRestoresAdded, + branchCatalog, + additions, + existingChanges: [...changes, ...additions], + diffContext, + }); + } const rootRetainedIndexChanges = buildRootRetainedIndexReplacementChanges({ replaceRoots, mainCatalog, @@ -307,6 +319,15 @@ export function expandReplaceDependencies({ while (queue.length > 0) { const refId = queue.shift() as string; + maybeAddCoveredGeneratedColumnGrantRestores({ + columnId: refId, + generatedColumnsRecreatedByExpressionFallback, + generatedColumnGrantRestoresAdded, + branchCatalog, + additions, + existingChanges: [...changes, ...additions], + diffContext, + }); const dependents = dependentsByReferenced.get(refId); if (!dependents) continue; @@ -375,6 +396,15 @@ export function expandReplaceDependencies({ expressionDependentCoverage.restore.has(dependentRaw); if (releaseCovered && restoreCovered) { if (generatedColumnsRecreatedByExpressionFallback.has(dependentRaw)) { + maybeAddCoveredGeneratedColumnGrantRestores({ + columnId: dependentRaw, + generatedColumnsRecreatedByExpressionFallback, + generatedColumnGrantRestoresAdded, + branchCatalog, + additions, + existingChanges: [...changes, ...additions], + diffContext, + }); queueRefForTraversal(dependentRaw, visitedRefs, queue); } continue; @@ -990,6 +1020,55 @@ function maybeAddRecreatedColumnMetadataRestore({ for (const id of change.creates ?? []) createdIds.add(id); } +function maybeAddCoveredGeneratedColumnGrantRestores({ + columnId, + generatedColumnsRecreatedByExpressionFallback, + generatedColumnGrantRestoresAdded, + branchCatalog, + additions, + existingChanges, + diffContext, +}: { + columnId: string; + generatedColumnsRecreatedByExpressionFallback: ReadonlySet; + generatedColumnGrantRestoresAdded: Set; + branchCatalog: Catalog; + additions: Change[]; + existingChanges: readonly Change[]; + diffContext?: Pick< + ObjectDiffContext, + "version" | "currentUser" | "defaultPrivilegeState" + >; +}): void { + if ( + !generatedColumnsRecreatedByExpressionFallback.has(columnId) || + generatedColumnGrantRestoresAdded.has(columnId) + ) { + return; + } + + const columnRef = parseColumnStableId(columnId); + if (!columnRef) { + return; + } + + const branchTable = + branchCatalog.tables[stableId.table(columnRef.schema, columnRef.table)]; + if (!branchTable) { + return; + } + + generatedColumnGrantRestoresAdded.add(columnId); + additions.push( + ...buildRetainedColumnGrantChanges({ + table: branchTable, + columnName: columnRef.column, + existingChanges, + diffContext, + }), + ); +} + function parseSecurityLabelProvider( dependentRaw: string, columnId: string, @@ -2690,7 +2769,13 @@ function buildRetainedConstraintBackedIndexMetadataChanges( if (!(resolved.branch.is_owned_by_constraint || resolved.branch.is_primary)) { return []; } - return buildRetainedClusterChange(resolved); + return [ + ...(resolved.branch.comment !== null + ? [new CreateCommentOnIndex({ index: resolved.branch })] + : []), + ...buildRetainedIndexStatisticsChanges(resolved.branch), + ...buildRetainedClusterChange(resolved), + ]; } function buildReplaceChanges( From 00b829b81bb33afcc17fe6843e1e2677de495a8b Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 17:04:03 +0800 Subject: [PATCH 53/78] test(pg-delta): cover promoted table replay review gaps RED evidence before the fix:\n\n- `bun run test src/core/expand-replace-dependencies.test.ts --test-name-pattern "owned sequence owners|targeted constraint replacement"` failed because the retained owned sequence replay emitted no `ALTER SEQUENCE ... OWNER TO app_owner` and targeted constraint replacement emitted no retained backing-index comment/statistics/cluster metadata.\n- `bun run test src/core/post-diff-normalization.test.ts --test-name-pattern "owner restores"` failed because both table owner restores remained instead of keeping only the replacement replay. --- .../core/expand-replace-dependencies.test.ts | 182 ++++++++++++++++++ .../src/core/post-diff-normalization.test.ts | 39 ++++ ...-signature-expression-dependencies.test.ts | 118 ++++++++++++ 3 files changed, 339 insertions(+) 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 e8b81c308..eed9b1eb5 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -4989,6 +4989,83 @@ describe("expandReplaceDependencies", () => { ).toBe(true); }); + test("restores retained owned sequence owners before reattaching ownership on promoted tables", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault(columnDefault), + owner: "app_owner", + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainTable, + }); + const mainSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...sequenceOwnedByItemsValue(), + owner: "app_owner", + }); + const branchSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainSequence, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sortedSql = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ).map((change) => change.serialize()); + const ownerIndex = sortedSql.indexOf( + "ALTER SEQUENCE public.items_value_seq OWNER TO app_owner", + ); + const ownedByIndex = sortedSql.indexOf( + "ALTER SEQUENCE public.items_value_seq OWNED BY public.items.value", + ); + + expect(ownerIndex).toBeGreaterThan(-1); + expect(ownedByIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeLessThan(ownedByIndex); + }); + test("does not count domain constraints as domain default restores", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -5433,6 +5510,111 @@ describe("expandReplaceDependencies", () => { expect(clusterIndex).toBeGreaterThan(addConstraintIndex); }); + test("replays metadata for retained constraint-backed indexes after targeted constraint replacement", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const generatedColumnTable = tableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const mainTable = tableWithUniqueConstraint({ + columns: generatedColumnTable.columns, + replica_identity: "i", + replica_identity_index: "items_value_key", + }); + const branchTable = tableWithUniqueConstraint({ + columns: generatedColumnTable.columns, + replica_identity: "i", + replica_identity_index: "items_value_key", + }); + const mainIndex = constraintBackedIndexOnItemsValue({ is_clustered: true }); + const branchIndex = constraintBackedIndexOnItemsValue({ + is_clustered: true, + is_replica_identity: true, + comment: "retained unique index", + statistics_target: [100], + }); + const columnId = "column:public.items.value"; + const constraintId = "constraint:public.items.items_value_key"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: constraintId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addConstraintIndex = sorted.findIndex( + (change) => change instanceof AlterTableAddConstraint, + ); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnIndex, + ); + const statisticsIndex = sorted.findIndex( + (change) => change instanceof AlterIndexSetStatistics, + ); + const clusterIndex = sorted.findIndex( + (change) => change instanceof AlterTableClusterOn, + ); + const replicaIdentityIndex = sorted.findIndex( + (change) => change instanceof AlterTableSetReplicaIdentity, + ); + + expect(addConstraintIndex).toBeGreaterThan(-1); + expect(commentIndex).toBeGreaterThan(addConstraintIndex); + expect(statisticsIndex).toBeGreaterThan(addConstraintIndex); + expect(clusterIndex).toBeGreaterThan(addConstraintIndex); + expect(replicaIdentityIndex).toBeGreaterThan(addConstraintIndex); + }); + test("synthesizes a table check constraint replacement for an unchanged expression that depends on a replaced procedure", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/src/core/post-diff-normalization.test.ts b/packages/pg-delta/src/core/post-diff-normalization.test.ts index 906c084fa..84bd84200 100644 --- a/packages/pg-delta/src/core/post-diff-normalization.test.ts +++ b/packages/pg-delta/src/core/post-diff-normalization.test.ts @@ -181,6 +181,45 @@ describe("normalizePostDiffChanges", () => { expect(normalized).toContain(preExistingGrant); }); + test("dedupes table owner restores for replaced tables with last replacement replay winning", () => { + const mainTable = new Table({ + ...baseTableProps, + name: "items", + columns: [integerColumn("id", 1)], + owner: "postgres", + }); + const branchTable = new Table({ + ...baseTableProps, + name: "items", + columns: [integerColumn("id", 1)], + owner: "app_owner", + }); + + const preExistingOwnerRestore = new AlterTableChangeOwner({ + table: mainTable, + owner: "app_owner", + }); + const replacementOwnerRestore = new AlterTableChangeOwner({ + table: branchTable, + owner: "app_owner", + }); + const normalized = normalizePostDiffChanges({ + changes: [ + preExistingOwnerRestore, + new DropTable({ table: mainTable }), + new CreateTable({ table: branchTable }), + replacementOwnerRestore, + ], + replacedTableIds: new Set([mainTable.stableId]), + }); + const ownerRestores = normalized.filter( + (change): change is AlterTableChangeOwner => + change instanceof AlterTableChangeOwner, + ); + + expect(ownerRestores).toEqual([replacementOwnerRestore]); + }); + test("dedupes duplicate constraint Add/Validate/Comment on replaced tables keeping last occurrence", async () => { const branchChildren = new Table({ ...baseTableProps, diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index 133c3f321..0b8d4f792 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -1946,6 +1946,124 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "constraint-backed generated column index metadata survives dependent function replacement", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_invoice_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED NOT NULL, + CONSTRAINT invoice_totals_total_key UNIQUE (total) + ); + + COMMENT ON INDEX test_schema.invoice_totals_total_key + IS 'retained unique total lookup'; + + ALTER TABLE test_schema.invoice_totals + CLUSTER ON invoice_totals_total_key; + + ALTER TABLE test_schema.invoice_totals + REPLICA IDENTITY USING INDEX invoice_totals_total_key; + + INSERT INTO test_schema.invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + CREATE FUNCTION test_schema.compute_invoice_total(input bigint) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input::integer + 1 + $function$; + + ALTER TABLE test_schema.invoice_totals + DROP COLUMN total; + + ALTER TABLE test_schema.invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal::bigint)) STORED NOT NULL; + + ALTER TABLE test_schema.invoice_totals + ADD CONSTRAINT invoice_totals_total_key UNIQUE (total); + + COMMENT ON INDEX test_schema.invoice_totals_total_key + IS 'retained unique total lookup'; + + ALTER TABLE test_schema.invoice_totals + CLUSTER ON invoice_totals_total_key; + + ALTER TABLE test_schema.invoice_totals + REPLICA IDENTITY USING INDEX invoice_totals_total_key; + + DROP FUNCTION test_schema.compute_invoice_total(integer); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const addConstraintIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD CONSTRAINT invoice_totals_total_key", + ), + ); + const commentIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "COMMENT ON INDEX test_schema.invoice_totals_total_key IS 'retained unique total lookup'", + ), + ); + const clusterIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals CLUSTER ON invoice_totals_total_key", + ), + ); + const replicaIdentityIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals REPLICA IDENTITY USING INDEX invoice_totals_total_key", + ), + ); + + expect(addConstraintIndex).toBeGreaterThanOrEqual(0); + expect(commentIndex).toBeGreaterThan(addConstraintIndex); + expect(clusterIndex).toBeGreaterThan(addConstraintIndex); + expect(replicaIdentityIndex).toBeGreaterThan(addConstraintIndex); + }, + }); + + const { rows } = await db.main.query(dedent` + SELECT + d.description, + i.indisclustered, + i.indisreplident + FROM pg_catalog.pg_class idx + JOIN pg_catalog.pg_namespace n ON n.oid = idx.relnamespace + JOIN pg_catalog.pg_index i ON i.indexrelid = idx.oid + LEFT JOIN pg_catalog.pg_description d + ON d.objoid = idx.oid + AND d.objsubid = 0 + WHERE n.nspname = 'test_schema' + AND idx.relname = 'invoice_totals_total_key' + `); + expect(rows[0]?.description).toBe("retained unique total lookup"); + expect(rows[0]?.indisclustered).toBe(true); + expect(rows[0]?.indisreplident).toBe(true); + }), + ); + test( "aggregate dependents are recreated before replacing support functions", withDb(pgVersion, async (db) => { From 9d3090ee65e275a51e0eb56b29fedd1abfb490d5 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 17:04:13 +0800 Subject: [PATCH 54/78] fix(pg-delta): replay promoted table replacement metadata gaps GREEN validation after the fix:\n\n- `bun run test src/core/expand-replace-dependencies.test.ts --test-name-pattern "owned sequence owners|targeted constraint replacement"` -> 2 pass / 0 fail.\n- `bun run test src/core/post-diff-normalization.test.ts --test-name-pattern "owner restores"` -> 1 pass / 0 fail.\n- Full related unit reruns: `expand-replace-dependencies.test.ts` -> 67 pass / 0 fail; post-diff normalization + sequence files -> 30 pass / 0 fail.\n- PG17 focused integration for constraint-backed generated-column index metadata -> 1 pass / 0 fail with 10s connect timeout override. --- .../src/core/expand-replace-dependencies.ts | 39 +++++++++++++++++++ .../sequence/changes/sequence.alter.ts | 37 +++++++++++++++++- .../core/objects/sequence/sequence.diff.ts | 18 +++++++++ .../src/core/post-diff-normalization.ts | 35 ++++++++++++++++- 4 files changed, 127 insertions(+), 2 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index cb5e9c7bf..ead6562f2 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -2230,11 +2230,41 @@ function buildTableConstraintReplacementChanges({ }), ); } + changes.push( + ...buildRetainedConstraintBackedIndexMetadataChangesForConstraint({ + constraintRef, + mainCatalog, + branchCatalog, + }), + ); } return changes; } +function buildRetainedConstraintBackedIndexMetadataChangesForConstraint({ + constraintRef, + mainCatalog, + branchCatalog, +}: { + constraintRef: ConstraintStableIdParts; + mainCatalog: Catalog; + branchCatalog: Catalog; +}): Change[] { + const indexId = stableId.index( + constraintRef.schema, + constraintRef.owner, + constraintRef.constraint, + ); + const resolved = resolveObjectForStableId( + indexId, + mainCatalog, + branchCatalog, + ); + if (!resolved || resolved.kind !== "index") return []; + return buildRetainedConstraintBackedIndexMetadataChanges(resolved); +} + function isOwnedSequenceColumnDependency( referencedId: string, dependentId: string, @@ -2775,6 +2805,15 @@ function buildRetainedConstraintBackedIndexMetadataChanges( : []), ...buildRetainedIndexStatisticsChanges(resolved.branch), ...buildRetainedClusterChange(resolved), + ...(resolved.branch.is_replica_identity && resolved.branchTable + ? [ + new AlterTableSetReplicaIdentity({ + table: resolved.branchTable, + mode: "i", + indexName: resolved.branch.name, + }), + ] + : []), ]; } diff --git a/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.ts b/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.ts index b18c2e5a1..5a1ae7e1d 100644 --- a/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.ts +++ b/packages/pg-delta/src/core/objects/sequence/changes/sequence.alter.ts @@ -17,7 +17,42 @@ import { AlterSequenceChange } from "./sequence.base.ts"; * ``` */ -export type AlterSequence = AlterSequenceSetOptions | AlterSequenceSetOwnedBy; +export type AlterSequence = + | AlterSequenceChangeOwner + | AlterSequenceSetOptions + | AlterSequenceSetOwnedBy; + +/** + * ALTER SEQUENCE ... OWNER TO ... + */ +export class AlterSequenceChangeOwner extends AlterSequenceChange { + public readonly sequence: Sequence; + public readonly owner: string; + public readonly scope = "object" as const; + + constructor(props: { sequence: Sequence; owner: string }) { + super(); + this.sequence = props.sequence; + this.owner = props.owner; + } + + get creates() { + return []; + } + + get requires() { + return [this.sequence.stableId, stableId.role(this.owner)]; + } + + serialize(_options?: SerializeOptions): string { + return [ + "ALTER SEQUENCE", + `${this.sequence.schema}.${this.sequence.name}`, + "OWNER TO", + this.owner, + ].join(" "); + } +} /** * ALTER SEQUENCE ... OWNED BY ... | OWNED BY NONE diff --git a/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts b/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts index f8f6b4d97..c2799402b 100644 --- a/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts +++ b/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts @@ -9,6 +9,7 @@ import { AlterTableAlterColumnSetDefault } from "../table/changes/table.alter.ts import type { Table } from "../table/table.model.ts"; import { hasNonAlterableChanges } from "../utils.ts"; import { + AlterSequenceChangeOwner, AlterSequenceSetOptions, AlterSequenceSetOwnedBy, } from "./changes/sequence.alter.ts"; @@ -61,6 +62,14 @@ export function diffSequences( for (const sequenceId of created) { const createdSeq = branch[sequenceId]; changes.push(new CreateSequence({ sequence: createdSeq })); + if (createdSeq.owner !== ctx.currentUser) { + changes.push( + new AlterSequenceChangeOwner({ + sequence: createdSeq, + owner: createdSeq.owner, + }), + ); + } if (createdSeq.comment !== null) { changes.push(new CreateCommentOnSequence({ sequence: createdSeq })); } @@ -311,6 +320,15 @@ export function diffSequences( mainSequence.owned_by_table !== branchSequence.owned_by_table || mainSequence.owned_by_column !== branchSequence.owned_by_column; + if (mainSequence.owner !== branchSequence.owner) { + changes.push( + new AlterSequenceChangeOwner({ + sequence: mainSequence, + owner: branchSequence.owner, + }), + ); + } + if (ownedByChanged) { const ownedBy = branchSequence.owned_by_schema && diff --git a/packages/pg-delta/src/core/post-diff-normalization.ts b/packages/pg-delta/src/core/post-diff-normalization.ts index 5854041d6..49df43027 100644 --- a/packages/pg-delta/src/core/post-diff-normalization.ts +++ b/packages/pg-delta/src/core/post-diff-normalization.ts @@ -4,6 +4,7 @@ import { DropIndex } from "./objects/index/changes/index.drop.ts"; import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts"; import { AlterTableAddConstraint, + AlterTableChangeOwner, AlterTableDropColumn, AlterTableDropConstraint, AlterTableSetReplicaIdentity, @@ -127,6 +128,34 @@ function dropReplacedTableDuplicateConstraintChanges( return mutated ? reversedKept.reverse() : changes; } +function dropReplacedTableDuplicateOwnerChanges( + changes: Change[], + replacedTableIds: ReadonlySet, +): Change[] { + if (replacedTableIds.size === 0) return changes; + + const seen = new Set(); + const reversedKept: Change[] = []; + let mutated = false; + + for (let i = changes.length - 1; i >= 0; i--) { + const change = changes[i] as Change; + if ( + change instanceof AlterTableChangeOwner && + replacedTableIds.has(change.table.stableId) + ) { + if (seen.has(change.table.stableId)) { + mutated = true; + continue; + } + seen.add(change.table.stableId); + } + reversedKept.push(change); + } + + return mutated ? reversedKept.reverse() : changes; +} + /** * Re-emit `ALTER TABLE ... REPLICA IDENTITY USING INDEX ` after any * `DropIndex(idx) + CreateIndex(idx)` pair where `idx` is the replica-identity @@ -283,10 +312,14 @@ export function normalizePostDiffChanges({ branchTables, ); - const dedupedChanges = dropReplacedTableDuplicateConstraintChanges( + const dedupedConstraintChanges = dropReplacedTableDuplicateConstraintChanges( restoredChanges, replacedTableIds, ); + const dedupedChanges = dropReplacedTableDuplicateOwnerChanges( + dedupedConstraintChanges, + replacedTableIds, + ); if (replacedTableIds.size === 0) return dedupedChanges; From 6f8c674a8d1d2bf58b48952567e7b016cffea9b4 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 18:12:30 +0800 Subject: [PATCH 55/78] test(pg-delta): cover owner and constraint replay ordering RED evidence before the fix: - defers promoted table owner restore: expected ownerIndex > addConstraintIndex; received ownerIndex=4, addConstraintIndex=5. - orders promoted table and owned sequence owner restores: expected tableOwnerIndex > addConstraintIndex; received tableOwnerIndex=8, addConstraintIndex=9. - traverses foreign keys that reference recreated column constraints: expected one item_refs_item_value_fkey drop, received 0. - PG17 retained unique generated column FK integration added for end-to-end replay ordering. --- .../core/expand-replace-dependencies.test.ts | 309 +++++++++++++++++- ...-signature-expression-dependencies.test.ts | 119 +++++++ 2 files changed, 427 insertions(+), 1 deletion(-) 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 eed9b1eb5..93171b5a6 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -53,7 +53,10 @@ import { RlsPolicy } from "./objects/rls-policy/rls-policy.model.ts"; import { CreateRule } from "./objects/rule/changes/rule.create.ts"; import { DropRule } from "./objects/rule/changes/rule.drop.ts"; import { Rule } from "./objects/rule/rule.model.ts"; -import { AlterSequenceSetOwnedBy } from "./objects/sequence/changes/sequence.alter.ts"; +import { + AlterSequenceChangeOwner, + AlterSequenceSetOwnedBy, +} from "./objects/sequence/changes/sequence.alter.ts"; import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts"; import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts"; import { diffSequences } from "./objects/sequence/sequence.diff.ts"; @@ -4989,6 +4992,63 @@ describe("expandReplaceDependencies", () => { ).toBe(true); }); + test("defers promoted table owner restore until after table-local replay", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithUniqueConstraint({ owner: "app_owner" }); + const branchTable = tableWithUniqueConstraint({ owner: "app_owner" }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addConstraintIndex = sorted.findIndex( + (change) => change instanceof AlterTableAddConstraint, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterTableChangeOwner, + ); + + expect(addConstraintIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(addConstraintIndex); + }); + test("restores retained owned sequence owners before reattaching ownership on promoted tables", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -5066,6 +5126,253 @@ describe("expandReplaceDependencies", () => { expect(ownerIndex).toBeLessThan(ownedByIndex); }); + test("orders promoted table and owned sequence owner restores before OWNED BY", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithUniqueConstraint({ + columns: [ + { + ...tableWithDefault(columnDefault).columns[0], + not_null: true, + }, + ], + owner: "app_owner", + }), + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainTable, + }); + const mainSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...sequenceOwnedByItemsValue(), + owner: "app_owner", + }); + const branchSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainSequence, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addConstraintIndex = sorted.findIndex( + (change) => change instanceof AlterTableAddConstraint, + ); + const tableOwnerIndex = sorted.findIndex( + (change) => change instanceof AlterTableChangeOwner, + ); + const sequenceOwnerIndex = sorted.findIndex( + (change) => change instanceof AlterSequenceChangeOwner, + ); + const ownedByIndex = sorted.findIndex( + (change) => change instanceof AlterSequenceSetOwnedBy, + ); + + expect(addConstraintIndex).toBeGreaterThan(-1); + expect(tableOwnerIndex).toBeGreaterThan(addConstraintIndex); + expect(sequenceOwnerIndex).toBeGreaterThan(-1); + expect(ownedByIndex).toBeGreaterThan(sequenceOwnerIndex); + expect(ownedByIndex).toBeGreaterThan(tableOwnerIndex); + }); + + test("traverses foreign keys that reference recreated column constraints", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const generatedColumnTable = tableWithDefault( + "public.normalize_value(value)", + { + is_generated: true, + }, + ); + const mainTable = tableWithUniqueConstraint({ + columns: generatedColumnTable.columns, + }); + const branchTable = tableWithUniqueConstraint({ + columns: generatedColumnTable.columns, + }); + const mainReferencingTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableNamedWithDefault("item_refs", null, { + name: "item_value", + }), + constraints: [ + { + name: "item_refs_item_value_fkey", + constraint_type: "f", + deferrable: false, + initially_deferred: false, + validated: true, + is_local: true, + no_inherit: false, + is_temporal: false, + is_partition_clone: false, + parent_constraint_schema: null, + parent_constraint_name: null, + parent_table_schema: null, + parent_table_name: null, + key_columns: ["item_value"], + foreign_key_columns: ["value"], + foreign_key_table: "items", + foreign_key_schema: "public", + foreign_key_table_is_partition: false, + foreign_key_parent_schema: null, + foreign_key_parent_table: null, + foreign_key_effective_schema: "public", + foreign_key_effective_table: "items", + on_update: "a", + on_delete: "a", + match_type: "s", + check_expression: null, + owner: "postgres", + definition: "FOREIGN KEY (item_value) REFERENCES public.items(value)", + comment: null, + }, + ], + }); + const branchReferencingTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainReferencingTable, + }); + const columnId = "column:public.items.value"; + const uniqueConstraintId = "constraint:public.items.items_value_key"; + const foreignKeyId = + "constraint:public.item_refs.item_refs_item_value_fkey"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { + [mainTable.stableId]: mainTable, + [mainReferencingTable.stableId]: mainReferencingTable, + }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: uniqueConstraintId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: foreignKeyId, + referenced_stable_id: uniqueConstraintId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { + [branchTable.stableId]: branchTable, + [branchReferencingTable.stableId]: branchReferencingTable, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const dropConstraints = expanded.changes.filter( + (change): change is AlterTableDropConstraint => + change instanceof AlterTableDropConstraint, + ); + const addConstraints = expanded.changes.filter( + (change): change is AlterTableAddConstraint => + change instanceof AlterTableAddConstraint, + ); + + expect( + dropConstraints.filter( + (change) => change.constraint.name === "items_value_key", + ), + ).toHaveLength(1); + expect( + addConstraints.filter( + (change) => change.constraint.name === "items_value_key", + ), + ).toHaveLength(1); + expect( + dropConstraints.filter( + (change) => change.constraint.name === "item_refs_item_value_fkey", + ), + ).toHaveLength(1); + expect( + addConstraints.filter( + (change) => change.constraint.name === "item_refs_item_value_fkey", + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + test("does not count domain constraints as domain default restores", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index 0b8d4f792..707ad0287 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -1435,6 +1435,125 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "retained unique generated column referenced by foreign key is recreated safely", + withDbIsolated(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_invoice_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED, + CONSTRAINT invoice_totals_total_key UNIQUE (total) + ); + + CREATE TABLE test_schema.invoice_total_refs ( + total integer NOT NULL, + CONSTRAINT invoice_total_refs_total_fkey + FOREIGN KEY (total) + REFERENCES test_schema.invoice_totals(total) + ); + + INSERT INTO test_schema.invoice_totals (id, subtotal) + VALUES (1, 20); + INSERT INTO test_schema.invoice_total_refs (total) + VALUES (21); + `, + testSql: dedent` + ALTER TABLE test_schema.invoice_total_refs + DROP CONSTRAINT invoice_total_refs_total_fkey; + + ALTER TABLE test_schema.invoice_totals + DROP CONSTRAINT invoice_totals_total_key; + + ALTER TABLE test_schema.invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_invoice_total(integer); + + CREATE FUNCTION test_schema.compute_invoice_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_invoice_total(subtotal)) STORED; + + ALTER TABLE test_schema.invoice_totals + ADD CONSTRAINT invoice_totals_total_key UNIQUE (total); + + ALTER TABLE test_schema.invoice_total_refs + ADD CONSTRAINT invoice_total_refs_total_fkey + FOREIGN KEY (total) + REFERENCES test_schema.invoice_totals(total); + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const dropForeignKeyIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_total_refs DROP CONSTRAINT invoice_total_refs_total_fkey", + ), + ); + const dropUniqueIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP CONSTRAINT invoice_totals_total_key", + ), + ); + const dropColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals DROP COLUMN total", + ), + ); + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD COLUMN total", + ), + ); + const addUniqueIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_totals ADD CONSTRAINT invoice_totals_total_key", + ), + ); + const addForeignKeyIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.invoice_total_refs ADD CONSTRAINT invoice_total_refs_total_fkey", + ), + ); + + expect(dropForeignKeyIndex).toBeGreaterThanOrEqual(0); + expect(dropUniqueIndex).toBeGreaterThan(dropForeignKeyIndex); + expect(dropColumnIndex).toBeGreaterThan(dropUniqueIndex); + expect(addColumnIndex).toBeGreaterThan(dropColumnIndex); + expect(addUniqueIndex).toBeGreaterThan(addColumnIndex); + expect(addForeignKeyIndex).toBeGreaterThan(addUniqueIndex); + }, + }); + + const { rows } = await db.main.query( + "SELECT count(*) FROM test_schema.invoice_total_refs WHERE total = 21", + ); + expect(Number(rows[0]?.count)).toBe(1); + }), + ); + test.skipIf(pgVersion < 18)( "unchanged generated column in publication column list does not recreate table", withDb(pgVersion, async (db) => { From a495fab7398364edb3cee69db88f379484e46235 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 18:12:39 +0800 Subject: [PATCH 56/78] fix(pg-delta): stabilize promoted replay ordering Fixes the PR #291 review follow-ups by: - ordering table owner restores after table-local replay DDL and before owned-sequence attachment via explicit sort constraints; - ordering sequence owner restores before ALTER SEQUENCE ... OWNED BY; - continuing traversal from recreated constraints so FK dependents are also released/restored without duplicate constraint changes. Validation after the fix: - focused owner and FK unit regressions pass. - expand-replace-dependencies.test.ts: 70 pass / 0 fail. - related sort/post-diff/sequence unit subset: 37 pass / 0 fail after PG17-limited retry; initial unrestricted run failed in testcontainers PG15 healthcheck before assertions. - PG17 FK integration passed twice, with testcontainers retries (attempt 6, then attempt 4). - format-and-lint:fix, check-types, knip --fix, and git diff --check pass. --- .../src/core/expand-replace-dependencies.ts | 38 ++++- .../src/core/sort/custom-constraints.ts | 147 ++++++++++++++++++ 2 files changed, 181 insertions(+), 4 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index ead6562f2..7b9f447a9 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -62,6 +62,7 @@ import { AlterTableAddConstraint, AlterTableAlterColumnDropDefault, AlterTableAlterColumnSetDefault, + AlterTableChangeOwner, AlterTableClusterOn, AlterTableDropColumn, AlterTableDropConstraint, @@ -493,6 +494,8 @@ export function expandReplaceDependencies({ additions, createdIds, droppedIds, + visitedRefs, + queue, visitedTargets, }) ) { @@ -1300,6 +1303,8 @@ function maybeAddColumnDependentConstraintReplacement({ additions, createdIds, droppedIds, + visitedRefs, + queue, visitedTargets, }: { refId: string; @@ -1309,16 +1314,27 @@ function maybeAddColumnDependentConstraintReplacement({ additions: Change[]; createdIds: Set; droppedIds: Set; + visitedRefs: Set; + queue: string[]; visitedTargets: Set; }): boolean { - if (!refId.startsWith("column:") || !dependentRaw.startsWith("constraint:")) { + if ( + !(refId.startsWith("column:") || refId.startsWith("constraint:")) || + !dependentRaw.startsWith("constraint:") + ) { return false; } - if (visitedTargets.has(dependentRaw)) return true; + if (visitedTargets.has(dependentRaw)) { + queueRefForTraversal(dependentRaw, visitedRefs, queue); + return true; + } const addRelease = !droppedIds.has(dependentRaw); const addRestore = !createdIds.has(dependentRaw); - if (!addRelease && !addRestore) return true; + if (!addRelease && !addRestore) { + queueRefForTraversal(dependentRaw, visitedRefs, queue); + return true; + } const constraintReplacementChanges = buildConstraintExpressionReplacementChanges({ @@ -1332,6 +1348,7 @@ function maybeAddColumnDependentConstraintReplacement({ additions.push(...constraintReplacementChanges); visitedTargets.add(dependentRaw); + queueRefForTraversal(dependentRaw, visitedRefs, queue); for (const change of constraintReplacementChanges) { for (const id of change.creates ?? []) createdIds.add(id); for (const id of change.drops ?? []) droppedIds.add(id); @@ -3187,7 +3204,9 @@ function buildCreateTableReplacementChanges( | undefined, ): Change[] { if (diffContext) { - return diffTables(diffContext, {}, { [table.stableId]: table }) as Change[]; + return moveTableOwnerRestoresAfterReplay( + diffTables(diffContext, {}, { [table.stableId]: table }) as Change[], + ); } return [ @@ -3214,6 +3233,17 @@ function buildCreateTableReplacementChanges( ]; } +function moveTableOwnerRestoresAfterReplay(changes: Change[]): Change[] { + const ownerRestores = changes.filter( + (change) => change instanceof AlterTableChangeOwner, + ); + if (ownerRestores.length === 0) return changes; + return [ + ...changes.filter((change) => !(change instanceof AlterTableChangeOwner)), + ...ownerRestores, + ]; +} + function buildCreateViewReplacementChanges( view: Catalog["views"][string], diffContext: diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index 1dd1b1eb1..1108ee240 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -4,11 +4,16 @@ import { GrantRoleDefaultPrivileges, RevokeRoleDefaultPrivileges, } from "../objects/role/changes/role.privilege.ts"; +import { + AlterSequenceChangeOwner, + AlterSequenceSetOwnedBy, +} from "../objects/sequence/changes/sequence.alter.ts"; import { AlterTableAlterColumnAddIdentity, AlterTableAlterColumnDropDefault, AlterTableAlterColumnDropIdentity, AlterTableAlterColumnSetDefault, + AlterTableChangeOwner, } from "../objects/table/changes/table.alter.ts"; import type { Constraint } from "./types.ts"; @@ -237,12 +242,154 @@ function generateIdentityTransitionConstraints( return constraints; } +function parseTableStableIdFromStableId(id: string): string | null { + if (id.startsWith("table:")) { + return id; + } + const parseSubEntity = (prefix: string) => { + if (!id.startsWith(prefix)) return null; + const parts = id.slice(prefix.length).split("."); + if (parts.length < 2) return null; + return `table:${parts[0]}.${parts[1]}`; + }; + return ( + parseSubEntity("column:") ?? + parseSubEntity("constraint:") ?? + parseSubEntity("index:") + ); +} + +function getRelatedTableStableIds(change: Change): Set { + const tableIds = new Set(); + if ( + "table" in change && + change.table && + typeof change.table === "object" && + "stableId" in change.table && + typeof change.table.stableId === "string" + ) { + tableIds.add(change.table.stableId); + } + if ( + "index" in change && + change.index && + typeof change.index === "object" && + "tableStableId" in change.index && + typeof change.index.tableStableId === "string" + ) { + tableIds.add(change.index.tableStableId); + } + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + const tableId = parseTableStableIdFromStableId(id); + if (tableId) tableIds.add(tableId); + } + return tableIds; +} + +function generateTableOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; tableId: string }> = []; + const changesByTable = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if ( + change.objectType !== "sequence" && + !(change instanceof AlterTableChangeOwner) + ) { + const tableIds = getRelatedTableStableIds(change); + for (const tableId of tableIds) { + const indexes = changesByTable.get(tableId) ?? []; + indexes.push(index); + changesByTable.set(tableId, indexes); + } + } + if (change instanceof AlterTableChangeOwner) { + ownerChanges.push({ index, tableId: change.table.stableId }); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = changesByTable.get(ownerChange.tableId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + +function generateOwnedSequenceAttachmentConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const tableOwnerChanges = new Map(); + const sequenceOwnerChanges = new Map(); + const ownedByChanges: Array<{ + index: number; + sequenceId: string; + tableId: string; + }> = []; + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (change instanceof AlterTableChangeOwner) { + const entries = tableOwnerChanges.get(change.table.stableId) ?? []; + entries.push(index); + tableOwnerChanges.set(change.table.stableId, entries); + } else if (change instanceof AlterSequenceChangeOwner) { + const entries = sequenceOwnerChanges.get(change.sequence.stableId) ?? []; + entries.push(index); + sequenceOwnerChanges.set(change.sequence.stableId, entries); + } else if ( + change instanceof AlterSequenceSetOwnedBy && + change.ownedBy !== null + ) { + ownedByChanges.push({ + index, + sequenceId: change.sequence.stableId, + tableId: `table:${change.ownedBy.schema}.${change.ownedBy.table}`, + }); + } + } + + for (const ownedByChange of ownedByChanges) { + for (const sourceChangeIndex of [ + ...(tableOwnerChanges.get(ownedByChange.tableId) ?? []), + ...(sequenceOwnerChanges.get(ownedByChange.sequenceId) ?? []), + ]) { + if (sourceChangeIndex === ownedByChange.index) continue; + constraints.push({ + sourceChangeIndex, + targetChangeIndex: ownedByChange.index, + source: "custom", + }); + } + } + + return constraints; +} + /** * All custom constraint generators. */ const customConstraintGenerators: ConstraintGenerator[] = [ generateDefaultPrivilegeConstraints, generateIdentityTransitionConstraints, + generateTableOwnerRestoreLastConstraints, + generateOwnedSequenceAttachmentConstraints, ]; /** From a5bbf99053ddf8d58d529f20020df78b0b78e2c2 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 19:01:42 +0800 Subject: [PATCH 57/78] test(pg-delta): cover replacement review ordering RED evidence before the fix: - focused materialized-view unit regression failed with ownerIndex=6 and clusterIndex=7, proving ALTER MATERIALIZED VIEW OWNER sorted before CLUSTER ON. - focused sort regression failed with sequenceOwnerIndex=0 and tableOwnerIndex=1, proving ALTER SEQUENCE OWNER sorted before the owning table OWNER change. - the removed domain CHECK constraint guard already passed on current code because AlterDomainDropConstraint exposes the constraint stable id through drops; it is kept as explicit coverage for the review thread. --- .../core/expand-replace-dependencies.test.ts | 182 +++++++++++++++++- .../src/core/sort/sort-changes.test.ts | 56 ++++++ 2 files changed, 237 insertions(+), 1 deletion(-) 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 93171b5a6..937e8fc57 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -28,7 +28,10 @@ import { CreateCommentOnIndex } from "./objects/index/changes/index.comment.ts"; import { CreateIndex } from "./objects/index/changes/index.create.ts"; import { DropIndex } from "./objects/index/changes/index.drop.ts"; import { Index, type IndexProps } from "./objects/index/index.model.ts"; -import { AlterMaterializedViewClusterOn } from "./objects/materialized-view/changes/materialized-view.alter.ts"; +import { + AlterMaterializedViewChangeOwner, + AlterMaterializedViewClusterOn, +} from "./objects/materialized-view/changes/materialized-view.alter.ts"; import { CreateMaterializedView } from "./objects/materialized-view/changes/materialized-view.create.ts"; import { DropMaterializedView } from "./objects/materialized-view/changes/materialized-view.drop.ts"; import { @@ -3687,6 +3690,105 @@ describe("expandReplaceDependencies", () => { ); }); + test("orders promoted materialized view clustering before owner restore", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainMaterializedView = materializedViewOnItemsValue({ + definition: + " SELECT public.normalize_value(items.value) AS value FROM public.items;", + owner: "app_owner", + }); + const branchMaterializedView = materializedViewOnItemsValue({ + definition: + " SELECT public.normalize_value(items.value) AS value FROM public.items;", + owner: "app_owner", + }); + const mainIndex = indexOnItemsValue({ + table_name: "items_value_mv", + name: "items_value_mv_value_idx", + table_relkind: "m", + is_clustered: true, + definition: + "CREATE INDEX items_value_mv_value_idx ON public.items_value_mv (value)", + }); + const branchIndex = indexOnItemsValue({ + table_name: "items_value_mv", + name: "items_value_mv_value_idx", + table_relkind: "m", + is_clustered: true, + definition: + "CREATE INDEX items_value_mv_value_idx ON public.items_value_mv (value)", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + materializedViews: { + [mainMaterializedView.stableId]: mainMaterializedView, + }, + indexableObjects: { + [mainMaterializedView.stableId]: mainMaterializedView, + }, + indexes: { [mainIndex.stableId]: mainIndex }, + depends: [ + { + dependent_stable_id: mainMaterializedView.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + materializedViews: { + [branchMaterializedView.stableId]: branchMaterializedView, + }, + indexableObjects: { + [branchMaterializedView.stableId]: branchMaterializedView, + }, + indexes: { [branchIndex.stableId]: branchIndex }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const clusterIndex = sorted.findIndex( + (change) => change instanceof AlterMaterializedViewClusterOn, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterMaterializedViewChangeOwner, + ); + + expect(clusterIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(clusterIndex); + }); + test("restores retained indexes when materialized views are already replace roots", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainMaterializedView = materializedViewOnItemsValue({ @@ -6544,6 +6646,84 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("does not duplicate a covered removed domain check constraint release", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainDomain = domainWithConstraint( + "public.normalize_value(VALUE) > 0", + ); + const branchDomain = domainWithDefault(null); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + const dropConstraint = new AlterDomainDropConstraint({ + domain: mainDomain, + constraint: mainDomain.constraints[0] as Domain["constraints"][number], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + dropConstraint, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: "constraint:public.item_value.item_value_check", + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(dropConstraint); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + }); + test("preserves NOT VALID domain check state when synthesizing a replacement", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); 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 37a0390d3..2e49667ad 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -18,11 +18,14 @@ 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 { AlterSequenceChangeOwner } from "../objects/sequence/changes/sequence.alter.ts"; +import { Sequence } from "../objects/sequence/sequence.model.ts"; import { AlterTableAddConstraint, AlterTableAlterColumnDropDefault, AlterTableAlterColumnSetDefault, AlterTableAlterColumnType, + AlterTableChangeOwner, AlterTableDropConstraint, } from "../objects/table/changes/table.alter.ts"; import { DropTable } from "../objects/table/changes/table.drop.ts"; @@ -278,6 +281,28 @@ function indexOnMaterializedView(viewName: string, indexName: string) { }); } +function sequenceOwnedBy(tableName: string, columnName: string) { + return new Sequence({ + schema: "public", + name: `${tableName}_${columnName}_seq`, + data_type: "bigint", + start_value: 1, + minimum_value: BigInt(1), + maximum_value: BigInt("9223372036854775807"), + increment: 1, + cycle_option: false, + cache_size: 1, + persistence: "p", + owned_by_schema: "public", + owned_by_table: tableName, + owned_by_column: columnName, + comment: null, + privileges: [], + owner: "old_owner", + security_labels: [], + }); +} + function domain(defaultValue: string | null, name = "score") { return new Domain({ schema: "public", @@ -392,6 +417,37 @@ function changeLabel(change: Change) { } describe("sortChanges", () => { + test("orders owned sequence owner after owning table owner", async () => { + const owningTable = new Table({ + ...baseTableProps, + name: "items", + columns: [{ ...integerColumn("id", 1), not_null: true }], + constraints: [], + owner: "old_owner", + }); + const ownedSequence = sequenceOwnedBy("items", "id"); + const changes: Change[] = [ + new AlterSequenceChangeOwner({ + sequence: ownedSequence, + owner: "app_owner", + }), + new AlterTableChangeOwner({ table: owningTable, owner: "app_owner" }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const tableOwnerIndex = sorted.findIndex( + (change) => change instanceof AlterTableChangeOwner, + ); + const sequenceOwnerIndex = sorted.findIndex( + (change) => change instanceof AlterSequenceChangeOwner, + ); + + expect(tableOwnerIndex).toBeGreaterThan(-1); + expect(sequenceOwnerIndex).toBeGreaterThan(tableOwnerIndex); + }); + test("orders materialized view indexes after recreated materialized views", async () => { const branchMaterializedView = materializedView("user_ids_mv"); const branchIndex = indexOnMaterializedView( From 0dfe1c5643cddd1b77c1afe6490e7964a75e6d28 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 19:02:27 +0800 Subject: [PATCH 58/78] fix(pg-delta): order replacement owner restores Count AlterDomainDropConstraint as explicit release coverage for routine-dependent domain CHECK constraints. Add materialized-view owner-last custom ordering so retained CLUSTER ON replay runs before OWNER TO. Order owned sequence owner changes after the owning table owner change, while keeping existing OWNED BY attachment ordering after both owners. GREEN validation after the fix: - focused materialized-view and domain guard tests: 2 pass / 0 fail. - focused owned sequence owner sort test: 1 pass / 0 fail. --- .../src/core/expand-replace-dependencies.ts | 1 + .../src/core/sort/custom-constraints.ts | 105 ++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 7b9f447a9..79154bd3f 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -778,6 +778,7 @@ function collectExpressionDependentCoverage( if ( change instanceof AlterTableAlterColumnDropDefault || change instanceof AlterTableDropConstraint || + change instanceof AlterDomainDropConstraint || change instanceof AlterDomainDropDefault ) { for (const id of change.requires ?? []) { diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index 1108ee240..b2ae459b6 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -4,6 +4,7 @@ import { GrantRoleDefaultPrivileges, RevokeRoleDefaultPrivileges, } from "../objects/role/changes/role.privilege.ts"; +import { AlterMaterializedViewChangeOwner } from "../objects/materialized-view/changes/materialized-view.alter.ts"; import { AlterSequenceChangeOwner, AlterSequenceSetOwnedBy, @@ -331,12 +332,96 @@ function generateTableOwnerRestoreLastConstraints( return constraints; } +function parseMaterializedViewStableIdFromStableId(id: string): string | null { + if (id.startsWith("materializedView:")) { + return id; + } + return null; +} + +function getRelatedMaterializedViewStableIds(change: Change): Set { + const materializedViewIds = new Set(); + if ( + "materializedView" in change && + change.materializedView && + typeof change.materializedView === "object" && + "stableId" in change.materializedView && + typeof change.materializedView.stableId === "string" + ) { + materializedViewIds.add(change.materializedView.stableId); + } + if ( + "index" in change && + change.index && + typeof change.index === "object" && + "tableStableId" in change.index && + typeof change.index.tableStableId === "string" && + change.index.tableStableId.startsWith("materializedView:") + ) { + materializedViewIds.add(change.index.tableStableId); + } + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + const materializedViewId = parseMaterializedViewStableIdFromStableId(id); + if (materializedViewId) materializedViewIds.add(materializedViewId); + } + return materializedViewIds; +} + +function generateMaterializedViewOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; materializedViewId: string }> = []; + const changesByMaterializedView = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (!(change instanceof AlterMaterializedViewChangeOwner)) { + const materializedViewIds = getRelatedMaterializedViewStableIds(change); + for (const materializedViewId of materializedViewIds) { + const indexes = changesByMaterializedView.get(materializedViewId) ?? []; + indexes.push(index); + changesByMaterializedView.set(materializedViewId, indexes); + } + } else { + ownerChanges.push({ + index, + materializedViewId: change.materializedView.stableId, + }); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = + changesByMaterializedView.get(ownerChange.materializedViewId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + function generateOwnedSequenceAttachmentConstraints( changes: Change[], ): Constraint[] { const constraints: Constraint[] = []; const tableOwnerChanges = new Map(); const sequenceOwnerChanges = new Map(); + const ownedSequenceOwnerChanges: Array<{ + index: number; + tableId: string; + }> = []; const ownedByChanges: Array<{ index: number; sequenceId: string; @@ -353,6 +438,12 @@ function generateOwnedSequenceAttachmentConstraints( const entries = sequenceOwnerChanges.get(change.sequence.stableId) ?? []; entries.push(index); sequenceOwnerChanges.set(change.sequence.stableId, entries); + if (change.sequence.owned_by_schema && change.sequence.owned_by_table) { + ownedSequenceOwnerChanges.push({ + index, + tableId: `table:${change.sequence.owned_by_schema}.${change.sequence.owned_by_table}`, + }); + } } else if ( change instanceof AlterSequenceSetOwnedBy && change.ownedBy !== null @@ -365,6 +456,19 @@ function generateOwnedSequenceAttachmentConstraints( } } + for (const sequenceOwnerChange of ownedSequenceOwnerChanges) { + for (const sourceChangeIndex of tableOwnerChanges.get( + sequenceOwnerChange.tableId, + ) ?? []) { + if (sourceChangeIndex === sequenceOwnerChange.index) continue; + constraints.push({ + sourceChangeIndex, + targetChangeIndex: sequenceOwnerChange.index, + source: "custom", + }); + } + } + for (const ownedByChange of ownedByChanges) { for (const sourceChangeIndex of [ ...(tableOwnerChanges.get(ownedByChange.tableId) ?? []), @@ -389,6 +493,7 @@ const customConstraintGenerators: ConstraintGenerator[] = [ generateDefaultPrivilegeConstraints, generateIdentityTransitionConstraints, generateTableOwnerRestoreLastConstraints, + generateMaterializedViewOwnerRestoreLastConstraints, generateOwnedSequenceAttachmentConstraints, ]; From 75783acc10e1505985029e51b64b3690977e5ab9 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 20:00:32 +0800 Subject: [PATCH 59/78] test(pg-delta): cover sequence owner replay ordering RED evidence before the fix: - sequence.diff focused regression failed with ownerIndex=0 and detachIndex=1, proving ALTER SEQUENCE OWNER was emitted before OWNED BY NONE for an existing owned sequence detach. - sort-changes focused regression failed with ownerIndex=1 and grantIndex=2, proving sequence owner restore sorted before sequence ACL replay. - PG17 integration coverage verifies generated SQL order for detach-before-owner and grant-before-owner, then applies the plan and checks final owners/ACL. --- .../objects/sequence/sequence.diff.test.ts | 34 ++++++ .../src/core/sort/sort-changes.test.ts | 47 ++++++++ .../integration/sequence-operations.test.ts | 110 +++++++++++++++++- 3 files changed, 190 insertions(+), 1 deletion(-) diff --git a/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts b/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts index 071067b86..62f18fd41 100644 --- a/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts +++ b/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts @@ -3,6 +3,7 @@ import { DefaultPrivilegeState } from "../base.default-privileges.ts"; import { AlterTableAlterColumnSetDefault } from "../table/changes/table.alter.ts"; import { Table } from "../table/table.model.ts"; import { + AlterSequenceChangeOwner, AlterSequenceSetOptions, AlterSequenceSetOwnedBy, } from "./changes/sequence.alter.ts"; @@ -71,6 +72,39 @@ describe.concurrent("sequence.diff", () => { expect(changes[0]).toBeInstanceOf(AlterSequenceSetOwnedBy); }); + test("detaches existing owned sequence before changing owner", () => { + const main = new Sequence({ + ...base, + owner: "old_owner", + owned_by_schema: "public", + owned_by_table: "old_items", + owned_by_column: "id", + }); + const branch = new Sequence({ + ...base, + owner: "new_owner", + owned_by_schema: null, + owned_by_table: null, + owned_by_column: null, + }); + + const changes = diffSequences( + testContext, + { [main.stableId]: main }, + { [branch.stableId]: branch }, + ); + const detachIndex = changes.findIndex( + (change) => + change instanceof AlterSequenceSetOwnedBy && change.ownedBy === null, + ); + const ownerIndex = changes.findIndex( + (change) => change instanceof AlterSequenceChangeOwner, + ); + + expect(detachIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(detachIndex); + }); + test("alter options via diff", () => { const main = new Sequence(base); const branch = new Sequence({ 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 2e49667ad..d0ce3a19f 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -19,6 +19,8 @@ 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 { AlterSequenceChangeOwner } from "../objects/sequence/changes/sequence.alter.ts"; +import { CreateSequence } from "../objects/sequence/changes/sequence.create.ts"; +import { GrantSequencePrivileges } from "../objects/sequence/changes/sequence.privilege.ts"; import { Sequence } from "../objects/sequence/sequence.model.ts"; import { AlterTableAddConstraint, @@ -448,6 +450,51 @@ describe("sortChanges", () => { expect(sequenceOwnerIndex).toBeGreaterThan(tableOwnerIndex); }); + test("orders sequence privilege replay before owner restore", async () => { + const sequence = new Sequence({ + schema: "public", + name: "items_id_seq", + data_type: "bigint", + start_value: 1, + minimum_value: BigInt(1), + maximum_value: BigInt("9223372036854775807"), + increment: 1, + cycle_option: false, + cache_size: 1, + persistence: "p", + owned_by_schema: null, + owned_by_table: null, + owned_by_column: null, + comment: null, + privileges: [], + owner: "app_owner", + security_labels: [], + }); + const changes: Change[] = [ + new CreateSequence({ sequence }), + new AlterSequenceChangeOwner({ sequence, owner: "app_owner" }), + new GrantSequencePrivileges({ + sequence, + grantee: "app_reader", + privileges: [{ privilege: "USAGE", grantable: false }], + version: 170000, + }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const grantIndex = sorted.findIndex( + (change) => change instanceof GrantSequencePrivileges, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterSequenceChangeOwner, + ); + + expect(grantIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(grantIndex); + }); + test("orders materialized view indexes after recreated materialized views", async () => { const branchMaterializedView = materializedView("user_ids_mv"); const branchIndex = indexOnMaterializedView( diff --git a/packages/pg-delta/tests/integration/sequence-operations.test.ts b/packages/pg-delta/tests/integration/sequence-operations.test.ts index 9519f00b5..11275d9fa 100644 --- a/packages/pg-delta/tests/integration/sequence-operations.test.ts +++ b/packages/pg-delta/tests/integration/sequence-operations.test.ts @@ -3,11 +3,12 @@ */ import { describe, expect, test } from "bun:test"; +import { applyPlan } from "../../src/core/plan/apply.ts"; import { createPlan } from "../../src/core/plan/create.ts"; +import { flattenPlanStatements } from "../../src/core/plan/render.ts"; import { POSTGRES_VERSIONS } from "../constants.ts"; import { withDb } from "../utils.ts"; import { roundtripFidelityTest } from "./roundtrip.ts"; -import { flattenPlanStatements } from "../../src/core/plan/render.ts"; for (const pgVersion of POSTGRES_VERSIONS) { describe(`sequence operations (pg${pgVersion})`, () => { @@ -93,6 +94,113 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "sequence owner restores run after detach and privilege replay", + withDb(pgVersion, async (db) => { + const initialSetup = ` + CREATE SCHEMA test_schema; + DO $$ + BEGIN + CREATE ROLE seq_review_old_owner; + EXCEPTION WHEN duplicate_object THEN NULL; + END + $$; + DO $$ + BEGIN + CREATE ROLE seq_review_new_owner; + EXCEPTION WHEN duplicate_object THEN NULL; + END + $$; + DO $$ + BEGIN + CREATE ROLE seq_review_reader; + EXCEPTION WHEN duplicate_object THEN NULL; + END + $$; + + CREATE SEQUENCE test_schema.existing_owned_seq; + CREATE TABLE test_schema.old_items ( + id bigint DEFAULT nextval('test_schema.existing_owned_seq'::regclass) + ); + ALTER SEQUENCE test_schema.existing_owned_seq OWNED BY test_schema.old_items.id; + ALTER TABLE test_schema.old_items OWNER TO seq_review_old_owner; + `; + const testSql = ` + ALTER SEQUENCE test_schema.existing_owned_seq OWNED BY NONE; + ALTER SEQUENCE test_schema.existing_owned_seq OWNER TO seq_review_new_owner; + + CREATE SEQUENCE test_schema.acl_seq; + GRANT USAGE ON SEQUENCE test_schema.acl_seq TO seq_review_reader; + ALTER SEQUENCE test_schema.acl_seq OWNER TO seq_review_new_owner; + `; + + await db.main.query(initialSetup); + await db.branch.query(initialSetup); + await db.branch.query(testSql); + + const planResult = await createPlan(db.main, db.branch); + expect(planResult).not.toBeNull(); + if (!planResult) return; + + const sqlStatements = flattenPlanStatements(planResult.plan); + const existingDetachIndex = sqlStatements.indexOf( + "ALTER SEQUENCE test_schema.existing_owned_seq OWNED BY NONE", + ); + const existingOwnerIndex = sqlStatements.indexOf( + "ALTER SEQUENCE test_schema.existing_owned_seq OWNER TO seq_review_new_owner", + ); + const createdGrantIndex = sqlStatements.indexOf( + "GRANT USAGE ON SEQUENCE test_schema.acl_seq TO seq_review_reader", + ); + const createdOwnerIndex = sqlStatements.indexOf( + "ALTER SEQUENCE test_schema.acl_seq OWNER TO seq_review_new_owner", + ); + + expect(existingDetachIndex).toBeGreaterThan(-1); + expect(existingOwnerIndex).toBeGreaterThan(existingDetachIndex); + expect(createdGrantIndex).toBeGreaterThan(-1); + expect(createdOwnerIndex).toBeGreaterThan(createdGrantIndex); + + const applyResult = await applyPlan( + planResult.plan, + db.main, + db.branch, + { + verifyPostApply: false, + }, + ); + expect(applyResult.status).toBe("applied"); + + const { rows } = await db.main.query<{ + existing_owner: string; + acl_owner: string; + reader_has_usage: boolean; + }>(` + SELECT + pg_get_userbyid(existing.relowner) AS existing_owner, + pg_get_userbyid(acl.relowner) AS acl_owner, + has_sequence_privilege( + 'seq_review_reader', + 'test_schema.acl_seq', + 'USAGE' + ) AS reader_has_usage + FROM pg_class existing + CROSS JOIN pg_class acl + JOIN pg_namespace existing_ns ON existing_ns.oid = existing.relnamespace + JOIN pg_namespace acl_ns ON acl_ns.oid = acl.relnamespace + WHERE existing_ns.nspname = 'test_schema' + AND existing.relname = 'existing_owned_seq' + AND acl_ns.nspname = 'test_schema' + AND acl.relname = 'acl_seq'; + `); + expect(rows[0]).toEqual({ + existing_owner: "seq_review_new_owner", + acl_owner: "seq_review_new_owner", + reader_has_usage: true, + }); + }), + ); + test( "sequence comments", withDb(pgVersion, async (db) => { From 3c4693686e2c2a588f91365987fa73831841dd54 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 20:00:42 +0800 Subject: [PATCH 60/78] fix(pg-delta): order sequence owner restores last Detach existing owned sequences before changing owner when OWNED BY is being removed or moved, so PostgreSQL no longer sees the sequence attached to the old owning table during ALTER SEQUENCE OWNER. Add sequence owner-last sort constraints for same-sequence metadata and privilege replay, while preserving the final OWNED BY attachment after owner restoration. RED evidence from 5d2fb6c before the fix: - sequence.diff focused regression failed with ownerIndex=0 and detachIndex=1. - sort-changes focused regression failed with ownerIndex=1 and grantIndex=2. GREEN validation after the fix: - PG17 focused unit regressions: 2 pass / 0 fail. - related unit files: 29 pass / 0 fail. - PG17 sequence owner integration: 1 pass / 0 fail. - format-and-lint:fix, check-types, knip --fix, and git diff --check passed. --- .../core/objects/sequence/sequence.diff.ts | 26 ++++++- .../src/core/sort/custom-constraints.ts | 73 +++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts b/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts index c2799402b..a09242c25 100644 --- a/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts +++ b/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts @@ -319,8 +319,24 @@ export function diffSequences( mainSequence.owned_by_schema !== branchSequence.owned_by_schema || mainSequence.owned_by_table !== branchSequence.owned_by_table || mainSequence.owned_by_column !== branchSequence.owned_by_column; + const ownerChanged = mainSequence.owner !== branchSequence.owner; + const mainOwnedBy = + mainSequence.owned_by_schema !== null || + mainSequence.owned_by_table !== null || + mainSequence.owned_by_column !== null; + const detachBeforeOwnerChange = + ownerChanged && ownedByChanged && mainOwnedBy; - if (mainSequence.owner !== branchSequence.owner) { + if (detachBeforeOwnerChange) { + changes.push( + new AlterSequenceSetOwnedBy({ + sequence: mainSequence, + ownedBy: null, + }), + ); + } + + if (ownerChanged) { changes.push( new AlterSequenceChangeOwner({ sequence: mainSequence, @@ -340,9 +356,11 @@ export function diffSequences( column: branchSequence.owned_by_column, } : null; - changes.push( - new AlterSequenceSetOwnedBy({ sequence: mainSequence, ownedBy }), - ); + if (!(detachBeforeOwnerChange && ownedBy === null)) { + changes.push( + new AlterSequenceSetOwnedBy({ sequence: mainSequence, ownedBy }), + ); + } } // COMMENT diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index b2ae459b6..157eb5536 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -412,6 +412,78 @@ function generateMaterializedViewOwnerRestoreLastConstraints( return constraints; } +function parseSequenceStableIdFromStableId(id: string): string | null { + if (id.startsWith("sequence:")) { + return id; + } + return null; +} + +function getRelatedSequenceStableIds(change: Change): Set { + const sequenceIds = new Set(); + if ( + "sequence" in change && + change.sequence && + typeof change.sequence === "object" && + "stableId" in change.sequence && + typeof change.sequence.stableId === "string" + ) { + sequenceIds.add(change.sequence.stableId); + } + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + const sequenceId = parseSequenceStableIdFromStableId(id); + if (sequenceId) sequenceIds.add(sequenceId); + } + return sequenceIds; +} + +function generateSequenceOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; sequenceId: string }> = []; + const changesBySequence = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (change instanceof AlterSequenceChangeOwner) { + ownerChanges.push({ + index, + sequenceId: change.sequence.stableId, + }); + continue; + } + if (change instanceof AlterSequenceSetOwnedBy && change.ownedBy !== null) { + continue; + } + const sequenceIds = getRelatedSequenceStableIds(change); + for (const sequenceId of sequenceIds) { + const indexes = changesBySequence.get(sequenceId) ?? []; + indexes.push(index); + changesBySequence.set(sequenceId, indexes); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = changesBySequence.get(ownerChange.sequenceId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + function generateOwnedSequenceAttachmentConstraints( changes: Change[], ): Constraint[] { @@ -494,6 +566,7 @@ const customConstraintGenerators: ConstraintGenerator[] = [ generateIdentityTransitionConstraints, generateTableOwnerRestoreLastConstraints, generateMaterializedViewOwnerRestoreLastConstraints, + generateSequenceOwnerRestoreLastConstraints, generateOwnedSequenceAttachmentConstraints, ]; From ae07b475608acb89e2f773c7b820c748b781f976 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 20:38:50 +0800 Subject: [PATCH 61/78] test(pg-delta): cover routine replacement review regressions RED evidence before the fix:\n- focused expand-replace-dependencies regression failed with AlterProcedureChangeOwner length 2, expected 1.\n- focused domain regression failed with AlterDomainDropDefault length 0, expected 1.\n- focused owner ordering regression failed with ownerIndex=5 before securityLabelIndex=7. --- .../core/expand-replace-dependencies.test.ts | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) 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 937e8fc57..40a1cc463 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -22,6 +22,7 @@ import { DropDomain } from "./objects/domain/changes/domain.drop.ts"; import { Domain } from "./objects/domain/domain.model.ts"; import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts"; import { DropProcedure } from "./objects/procedure/changes/procedure.drop.ts"; +import { AlterProcedureChangeOwner } from "./objects/procedure/changes/procedure.alter.ts"; import { Procedure } from "./objects/procedure/procedure.model.ts"; import { AlterIndexSetStatistics } from "./objects/index/changes/index.alter.ts"; import { CreateCommentOnIndex } from "./objects/index/changes/index.comment.ts"; @@ -4823,6 +4824,151 @@ describe("expandReplaceDependencies", () => { ); }); + test("de-duplicates superseded owner alters for promoted routine replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDependent = procedureWithArgs(["integer"], "uses_normalize"); + const branchDependent = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainDependent, + owner: "routine_owner", + }); + const ownerAlter = new AlterProcedureChangeOwner({ + procedure: mainDependent, + owner: branchDependent.owner, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ownerAlter, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [mainProcedure.stableId]: mainProcedure, + [mainDependent.stableId]: mainDependent, + }, + depends: [ + { + dependent_stable_id: mainDependent.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [branchProcedure.stableId]: branchProcedure, + [branchDependent.stableId]: branchDependent, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterProcedureChangeOwner, + ), + ).toHaveLength(1); + }); + + test("defers promoted routine owner restore until after routine metadata replay", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDependent = procedureWithArgs(["integer"], "uses_normalize"); + const branchDependent = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainDependent, + owner: "routine_owner", + comment: "dependent routine comment", + security_labels: [{ provider: "dummy", label: "routine label" }], + privileges: [ + { + grantee: "routine_executor", + privilege: "EXECUTE", + grantable: false, + }, + ], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [mainProcedure.stableId]: mainProcedure, + [mainDependent.stableId]: mainDependent, + }, + depends: [ + { + dependent_stable_id: mainDependent.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [branchProcedure.stableId]: branchProcedure, + [branchDependent.stableId]: branchDependent, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ).map((change) => change.serialize()); + + const ownerIndex = sorted.indexOf( + "ALTER FUNCTION public.uses_normalize(integer) OWNER TO routine_owner", + ); + const commentIndex = sorted.indexOf( + "COMMENT ON FUNCTION public.uses_normalize(integer) IS 'dependent routine comment'", + ); + const securityLabelIndex = sorted.indexOf( + "SECURITY LABEL FOR dummy ON FUNCTION public.uses_normalize(integer) IS 'routine label'", + ); + const grantIndex = sorted.indexOf( + "GRANT ALL ON FUNCTION public.uses_normalize(integer) TO routine_executor", + ); + + expect(ownerIndex).toBeGreaterThan(commentIndex); + expect(ownerIndex).toBeGreaterThan(securityLabelIndex); + expect(ownerIndex).toBeGreaterThan(grantIndex); + }); + test("restores procedure metadata when an existing procedure create is converted from orReplace", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -5564,6 +5710,97 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("does not count domain constraint drops as domain default releases", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainDomain = new Domain({ + // oxlint-disable-next-line typescript/no-misused-spread + ...domainWithConstraint("VALUE > 0"), + default_bin: "public.normalize_value(1)", + default_value: "public.normalize_value(1)", + }); + const branchDomain = domainWithDefault("public.normalize_value(1)"); + const tableUsingDomain = tableWithDefault(null, { + data_type: "item_value", + data_type_str: "public.item_value", + is_custom_type: true, + custom_type_type: "d", + custom_type_category: "N", + custom_type_schema: "public", + custom_type_name: "item_value", + }); + const dropConstraint = new AlterDomainDropConstraint({ + domain: mainDomain, + constraint: mainDomain.constraints[0] as Domain["constraints"][number], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + dropConstraint, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + domains: { [mainDomain.stableId]: mainDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [ + { + dependent_stable_id: mainDomain.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: "column:public.items.value", + referenced_stable_id: mainDomain.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + domains: { [branchDomain.stableId]: branchDomain }, + tables: { [tableUsingDomain.stableId]: tableUsingDomain }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).toContain(dropConstraint); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainDropConstraint, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainDropDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterDomainSetDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.some((change) => change instanceof DropDomain), + ).toBe(false); + }); + test("replays retained publication membership for promoted table replacements", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); From cc42f469b715a0318103fc98ed28c2390e2ff415 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 20:38:59 +0800 Subject: [PATCH 62/78] fix(pg-delta): de-duplicate promoted routine restores De-duplicate routine alters superseded by dependency-expansion recreation, avoid treating domain constraint drops as domain default release coverage, and order routine owner restores after metadata and ACL replay.\n\nGREEN verification:\n- npx --yes bun@1.3.14 run test src/core/expand-replace-dependencies.test.ts --test-name-pattern "de-duplicates superseded owner alters|defers promoted routine owner restore|does not count domain constraint drops" -> 3 pass / 0 fail.\n- npx --yes bun@1.3.14 run test src/core/expand-replace-dependencies.test.ts -> 75 pass / 0 fail.\n- npx --yes bun@1.3.14 run test src/core/sort/sort-changes.test.ts -> 14 pass / 0 fail.\n- npx --yes bun@1.3.14 run format-and-lint:fix -> pass.\n- npx --yes bun@1.3.14 run check-types -> pass.\n- npx --yes bun@1.3.14 run knip --fix -> exit 0, existing configuration hints only.\n- git diff --check -> pass. --- .../src/core/expand-replace-dependencies.ts | 68 +++++++++++++------ .../src/core/sort/custom-constraints.ts | 65 ++++++++++++++++++ 2 files changed, 113 insertions(+), 20 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 79154bd3f..6687489c3 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -292,6 +292,7 @@ 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 routinesReplacedByExpansion = new Set(); const generatedColumnsRecreatedByExpressionFallback = collectCoveredGeneratedColumnRecreations(changes); const generatedColumnGrantRestoresAdded = new Set(); @@ -619,6 +620,12 @@ export function expandReplaceDependencies({ if (resolved.kind === "rls_policy") { promotedRlsPolicyIds.add(targetId); } + if ( + (resolved.kind === "procedure" || resolved.kind === "aggregate") && + addDrop + ) { + routinesReplacedByExpansion.add(targetId); + } // If we added a DropTable(T) for an existing table, mark T so any // pre-existing object-scope AlterTable*(T) changes get dropped below — @@ -652,7 +659,10 @@ export function expandReplaceDependencies({ return { changes: [ ...removeSupersededGeneratedColumnSetExpressions( - removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds), + removeSupersededRoutineAlters( + removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds), + routinesReplacedByExpansion, + ), generatedColumnsRecreatedByExpressionFallback, ), ...additions, @@ -751,6 +761,23 @@ function removeSupersededRlsPolicyAlters( }); } +function removeSupersededRoutineAlters( + changes: Change[], + promotedRoutineIds: ReadonlySet, +): Change[] { + if (promotedRoutineIds.size === 0) return changes; + return changes.filter((change) => { + if (change.operation !== "alter") return true; + if (change.objectType === "procedure") { + return !promotedRoutineIds.has(change.procedure.stableId); + } + if (change.objectType === "aggregate") { + return !promotedRoutineIds.has(change.aggregate.stableId); + } + return true; + }); +} + interface ExpressionDependentCoverage { release: Set; restore: Set; @@ -778,7 +805,6 @@ function collectExpressionDependentCoverage( if ( change instanceof AlterTableAlterColumnDropDefault || change instanceof AlterTableDropConstraint || - change instanceof AlterDomainDropConstraint || change instanceof AlterDomainDropDefault ) { for (const id of change.requires ?? []) { @@ -3058,15 +3084,13 @@ function buildRetainedProcedureMetadataChanges({ >; }): Change[] { const changes: Change[] = []; - - if (diffContext && procedure.owner !== diffContext.currentUser) { - changes.push( - new AlterProcedureChangeOwner({ - procedure, - owner: procedure.owner, - }), - ); - } + const ownerRestore = + diffContext && procedure.owner !== diffContext.currentUser + ? new AlterProcedureChangeOwner({ + procedure, + owner: procedure.owner, + }) + : null; if (procedure.comment !== null) { changes.push(new CreateCommentOnProcedure({ procedure })); @@ -3117,6 +3141,9 @@ function buildRetainedProcedureMetadataChanges({ diffContext.version, ) as Change[]), ); + if (ownerRestore) { + changes.push(ownerRestore); + } return changes; } @@ -3132,15 +3159,13 @@ function buildRetainedAggregateMetadataChanges({ >; }): Change[] { const changes: Change[] = []; - - if (diffContext && aggregate.owner !== diffContext.currentUser) { - changes.push( - new AlterAggregateChangeOwner({ - aggregate, - owner: aggregate.owner, - }), - ); - } + const ownerRestore = + diffContext && aggregate.owner !== diffContext.currentUser + ? new AlterAggregateChangeOwner({ + aggregate, + owner: aggregate.owner, + }) + : null; if (aggregate.comment !== null) { changes.push(new CreateCommentOnAggregate({ aggregate })); @@ -3191,6 +3216,9 @@ function buildRetainedAggregateMetadataChanges({ diffContext.version, ) as Change[]), ); + if (ownerRestore) { + changes.push(ownerRestore); + } return changes; } diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index 157eb5536..4ede53926 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -1,5 +1,7 @@ import type { Change } from "../change.types.ts"; import { getSchema } from "../change-utils.ts"; +import { AlterAggregateChangeOwner } from "../objects/aggregate/changes/aggregate.alter.ts"; +import { AlterProcedureChangeOwner } from "../objects/procedure/changes/procedure.alter.ts"; import { GrantRoleDefaultPrivileges, RevokeRoleDefaultPrivileges, @@ -484,6 +486,68 @@ function generateSequenceOwnerRestoreLastConstraints( return constraints; } +function getRelatedRoutineStableIds(change: Change): Set { + const routineIds = new Set(); + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + if (id.startsWith("procedure:") || id.startsWith("aggregate:")) { + routineIds.add(id); + } + } + return routineIds; +} + +function generateRoutineOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; routineId: string }> = []; + const changesByRoutine = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (change instanceof AlterProcedureChangeOwner) { + ownerChanges.push({ + index, + routineId: change.procedure.stableId, + }); + continue; + } + if (change instanceof AlterAggregateChangeOwner) { + ownerChanges.push({ + index, + routineId: change.aggregate.stableId, + }); + continue; + } + + const routineIds = getRelatedRoutineStableIds(change); + for (const routineId of routineIds) { + const indexes = changesByRoutine.get(routineId) ?? []; + indexes.push(index); + changesByRoutine.set(routineId, indexes); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = changesByRoutine.get(ownerChange.routineId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + function generateOwnedSequenceAttachmentConstraints( changes: Change[], ): Constraint[] { @@ -567,6 +631,7 @@ const customConstraintGenerators: ConstraintGenerator[] = [ generateTableOwnerRestoreLastConstraints, generateMaterializedViewOwnerRestoreLastConstraints, generateSequenceOwnerRestoreLastConstraints, + generateRoutineOwnerRestoreLastConstraints, generateOwnedSequenceAttachmentConstraints, ]; From 0cd94b2e4e91d6aceb25c026759bdd297f7b7d9a Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 21:14:47 +0800 Subject: [PATCH 63/78] test(pg-delta): cover latest replacement replay review gaps --- .../core/expand-replace-dependencies.test.ts | 65 +++++++++++++++++++ .../objects/sequence/sequence.diff.test.ts | 36 +++++++++- .../trigger/changes/trigger.alter.test.ts | 39 ++++++++++- 3 files changed, 136 insertions(+), 4 deletions(-) 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 40a1cc463..d076e9539 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -5240,6 +5240,71 @@ describe("expandReplaceDependencies", () => { ).toBe(true); }); + test("replays sequences cascaded by main-side promoted table ownership", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = tableWithDefault(columnDefault); + const branchTable = tableWithDefault(columnDefault); + const mainSequence = sequenceOwnedByItemsValue(); + const branchSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainSequence, + owned_by_schema: null, + owned_by_table: null, + owned_by_column: null, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + const createSequences = expanded.changes.filter( + (change) => change instanceof CreateSequence, + ); + const ownedByChanges = expanded.changes.filter( + (change) => change instanceof AlterSequenceSetOwnedBy, + ); + + expect(createSequences).toHaveLength(1); + expect(ownedByChanges).toHaveLength(0); + }); + test("defers promoted table owner restore until after table-local replay", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts b/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts index 62f18fd41..0585d2f3f 100644 --- a/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts +++ b/packages/pg-delta/src/core/objects/sequence/sequence.diff.test.ts @@ -142,6 +142,35 @@ describe.concurrent("sequence.diff", () => { expect(changes[1]).toBeInstanceOf(CreateSequence); }); + test("restores owner after replacing a sequence", () => { + const main = new Sequence({ + ...base, + persistence: "u", + owner: "old_owner", + }); + const branch = new Sequence({ + ...base, + persistence: "p", + owner: "app_owner", + }); + + const changes = diffSequences( + testContext, + { [main.stableId]: main }, + { [branch.stableId]: branch }, + ); + + const createIndex = changes.findIndex( + (change) => change instanceof CreateSequence, + ); + const ownerIndex = changes.findIndex( + (change) => change instanceof AlterSequenceChangeOwner, + ); + + expect(createIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(createIndex); + }); + test("replacing an owned sequence re-emits the owning column default", () => { // Use `persistence` (UNLOGGED → LOGGED) to trigger the // non-alterable replace path: it's the only field still in @@ -211,11 +240,12 @@ describe.concurrent("sequence.diff", () => { { [branchTable.stableId]: branchTable }, ); - expect(changes).toHaveLength(4); + expect(changes).toHaveLength(5); expect(changes[0]).toBeInstanceOf(DropSequence); expect(changes[1]).toBeInstanceOf(CreateSequence); - expect(changes[2]).toBeInstanceOf(AlterSequenceSetOwnedBy); - expect(changes[3]).toBeInstanceOf(AlterTableAlterColumnSetDefault); + expect(changes[2]).toBeInstanceOf(AlterSequenceChangeOwner); + expect(changes[3]).toBeInstanceOf(AlterSequenceSetOwnedBy); + expect(changes[4]).toBeInstanceOf(AlterTableAlterColumnSetDefault); }); test("skip DROP SEQUENCE when owned by table being dropped", () => { diff --git a/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.test.ts b/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.test.ts index f50673090..19469c6b5 100644 --- a/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.test.ts +++ b/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts"; +import { stableId } from "../../utils.ts"; import { Trigger, type TriggerProps } from "../trigger.model.ts"; -import { ReplaceTrigger } from "./trigger.alter.ts"; +import { ReplaceTrigger, SetTriggerEnabledState } from "./trigger.alter.ts"; describe.concurrent("trigger", () => { describe("alter", () => { @@ -46,5 +47,41 @@ describe.concurrent("trigger", () => { "CREATE OR REPLACE TRIGGER test_trigger AFTER UPDATE ON public.test_table EXECUTE FUNCTION public.test_function()", ); }); + + test("enabled-state restore requires the owning table", () => { + const trigger = new Trigger({ + schema: "public", + name: "test_trigger", + table_name: "test_table", + table_relkind: "r", + function_schema: "public", + function_name: "test_function", + trigger_type: 1 << 4, + enabled: "D", + is_internal: false, + deferrable: false, + initially_deferred: false, + argument_count: 0, + column_numbers: null, + arguments: [], + when_condition: null, + old_table: null, + new_table: null, + is_partition_clone: false, + parent_trigger_name: null, + parent_table_schema: null, + parent_table_name: null, + is_on_partitioned_table: false, + owner: "test", + definition: + "CREATE TRIGGER test_trigger AFTER UPDATE ON public.test_table EXECUTE FUNCTION public.test_function()", + comment: null, + }); + + const change = new SetTriggerEnabledState({ trigger }); + + expect(change.requires).toContain(trigger.stableId); + expect(change.requires).toContain(stableId.table("public", "test_table")); + }); }); }); From 22f91c03ae6d9c622cbd25c942622fac161fdc3c Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 21:14:54 +0800 Subject: [PATCH 64/78] fix(pg-delta): replay latest replacement review gaps RED evidence before fix: - sequence.diff focused test failed with ownerIndex=-1 after CreateSequence. - expandReplaceDependencies focused test failed with createSequences length 0 for a sequence cascade-dropped by main-side OWNED BY. - trigger.alter focused test failed because SetTriggerEnabledState.requires only contained the trigger stable id. --- .../pg-delta/src/core/expand-replace-dependencies.ts | 12 +++++++----- .../src/core/objects/sequence/sequence.diff.ts | 8 ++++++++ .../core/objects/trigger/changes/trigger.alter.ts | 6 +++++- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 6687489c3..411ef1634 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -2696,7 +2696,7 @@ function buildRetainedOwnedSequenceReplacementChanges({ if (!diffContext) return []; const changes: Change[] = []; - const branchSequences = Object.values(branchCatalog.sequences) + const mainSequences = Object.values(mainCatalog.sequences) .filter( (sequence) => sequence.owned_by_schema === table.schema && @@ -2705,11 +2705,10 @@ function buildRetainedOwnedSequenceReplacementChanges({ ) .sort((a, b) => a.stableId.localeCompare(b.stableId)); - for (const branchSequence of branchSequences) { - if (!mainCatalog.sequences[branchSequence.stableId]) continue; + for (const mainSequence of mainSequences) { + const branchSequence = branchCatalog.sequences[mainSequence.stableId]; + if (!branchSequence) continue; if (createdIds.has(branchSequence.stableId)) continue; - const ownedByColumn = branchSequence.owned_by_column; - if (ownedByColumn === null) continue; changes.push( ...(diffSequences( @@ -2721,6 +2720,9 @@ function buildRetainedOwnedSequenceReplacementChanges({ ) as Change[]), ); + const ownedByColumn = branchSequence.owned_by_column; + if (ownedByColumn === null) continue; + const ownedColumn = table.columns.find( (column) => column.name === ownedByColumn, ); diff --git a/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts b/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts index a09242c25..075e9c466 100644 --- a/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts +++ b/packages/pg-delta/src/core/objects/sequence/sequence.diff.ts @@ -210,6 +210,14 @@ export function diffSequences( changes.push(new DropSequence({ sequence: mainSequence })); } changes.push(new CreateSequence({ sequence: branchSequence })); + if (branchSequence.owner !== ctx.currentUser) { + changes.push( + new AlterSequenceChangeOwner({ + sequence: branchSequence, + owner: branchSequence.owner, + }), + ); + } // Re-apply OWNED BY if present on branch if ( branchSequence.owned_by_schema !== null && 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 97772fe31..7800a8a82 100644 --- a/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.ts +++ b/packages/pg-delta/src/core/objects/trigger/changes/trigger.alter.ts @@ -1,6 +1,7 @@ import type { SerializeOptions } from "../../../integrations/serialize/serialize.types.ts"; import { quoteLiteral } from "../../base.change.ts"; import type { TableLikeObject } from "../../base.model.ts"; +import { stableId } from "../../utils.ts"; import type { Trigger } from "../trigger.model.ts"; import { AlterTriggerChange } from "./trigger.base.ts"; import { CreateTrigger } from "./trigger.create.ts"; @@ -85,7 +86,10 @@ export class SetTriggerEnabledState extends AlterTriggerChange { } get requires() { - return [this.trigger.stableId]; + return [ + this.trigger.stableId, + stableId.table(this.trigger.schema, this.trigger.table_name), + ]; } serialize(_options?: SerializeOptions): string { From d5c91afbe5d5da55a22268c559cb58438cc46a9d Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 21:36:25 +0800 Subject: [PATCH 65/78] test(pg-delta): cover promoted table add-column pruning RED evidence before the fix: - focused post-diff normalization regression failed because the normalized change list still contained the original AlterTableAddColumn for a table already promoted to DropTable/CreateTable. Signed-off-by: xiaominghao --- .../src/core/post-diff-normalization.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/pg-delta/src/core/post-diff-normalization.test.ts b/packages/pg-delta/src/core/post-diff-normalization.test.ts index 84bd84200..c53ac647f 100644 --- a/packages/pg-delta/src/core/post-diff-normalization.test.ts +++ b/packages/pg-delta/src/core/post-diff-normalization.test.ts @@ -10,6 +10,7 @@ import { type SequenceProps, } from "./objects/sequence/sequence.model.ts"; import { + AlterTableAddColumn, AlterTableAddConstraint, AlterTableChangeOwner, AlterTableDropColumn, @@ -220,6 +221,49 @@ describe("normalizePostDiffChanges", () => { expect(ownerRestores).toEqual([replacementOwnerRestore]); }); + test("prunes same-table add-column ALTERs for replaced tables only", () => { + const mainTable = new Table({ + ...baseTableProps, + name: "items", + columns: [integerColumn("id", 1)], + }); + const branchTable = new Table({ + ...baseTableProps, + name: "items", + columns: [integerColumn("id", 1), integerColumn("slug", 2)], + }); + const otherTable = new Table({ + ...baseTableProps, + name: "other_items", + columns: [integerColumn("id", 1), integerColumn("slug", 2)], + }); + + const replacedTableAddColumn = new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[1], + }); + const unrelatedAddColumn = new AlterTableAddColumn({ + table: otherTable, + column: otherTable.columns[1], + }); + + const normalized = normalizePostDiffChanges({ + changes: [ + new DropTable({ table: mainTable }), + new CreateTable({ table: branchTable }), + replacedTableAddColumn, + unrelatedAddColumn, + ], + replacedTableIds: new Set([mainTable.stableId]), + }); + + expect(normalized).not.toContain(replacedTableAddColumn); + expect(normalized).toContain(unrelatedAddColumn); + expect( + normalized.filter((change) => change instanceof AlterTableAddColumn), + ).toEqual([unrelatedAddColumn]); + }); + test("dedupes duplicate constraint Add/Validate/Comment on replaced tables keeping last occurrence", async () => { const branchChildren = new Table({ ...baseTableProps, From 8a18ae86a20d05de6bec004c89d8b890cb3a19b7 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 21:37:49 +0800 Subject: [PATCH 66/78] fix(pg-delta): prune promoted table add-column alters Drop same-table AlterTableAddColumn changes when dependency expansion has promoted that table to DropTable/CreateTable. The replacement CreateTable already includes the branch columns, so keeping the original ADD COLUMN can fail with column already exists after table recreation. RED evidence from 4a74cff: - focused post-diff normalization regression failed because the normalized change list still contained the original AlterTableAddColumn for a replaced table. GREEN verification: - npx --yes bun@1.3.14 run test src/core/post-diff-normalization.test.ts --test-name-pattern "prunes same-table add-column" -> 1 pass / 0 fail. - npx --yes bun@1.3.14 run test src/core/post-diff-normalization.test.ts -> 12 pass / 0 fail. - npx --yes bun@1.3.14 run test src/core/expand-replace-dependencies.test.ts --test-name-pattern "promoted table" -> 10 pass / 0 fail. - npx --yes bun@1.3.14 run format-and-lint:fix, check-types, knip --fix, and git diff --check passed. Signed-off-by: xiaominghao --- packages/pg-delta/src/core/post-diff-normalization.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/pg-delta/src/core/post-diff-normalization.ts b/packages/pg-delta/src/core/post-diff-normalization.ts index 49df43027..6b268908d 100644 --- a/packages/pg-delta/src/core/post-diff-normalization.ts +++ b/packages/pg-delta/src/core/post-diff-normalization.ts @@ -3,6 +3,7 @@ import { CreateIndex } from "./objects/index/changes/index.create.ts"; import { DropIndex } from "./objects/index/changes/index.drop.ts"; import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts"; import { + AlterTableAddColumn, AlterTableAddConstraint, AlterTableChangeOwner, AlterTableDropColumn, @@ -26,6 +27,7 @@ function isSupersededByTableReplacement( replacedTableIds: ReadonlySet, ): boolean { if ( + change instanceof AlterTableAddColumn || change instanceof AlterTableDropColumn || change instanceof AlterTableDropConstraint ) { @@ -272,11 +274,12 @@ function restoreReplicaIdentityAfterIndexReplace( * * Concretely, this pass: * - * - Prunes `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)` + * - Prunes structural table alters (`AlterTableAddColumn(T.*)`, + * `AlterTableDropColumn(T.*)`, `AlterTableDropConstraint(T.*)`) * changes that are made redundant by an expansion-emitted * `DropTable(T) + CreateTable(T)` pair. Without this, the apply phase - * would try to drop a column that no longer exists in the freshly - * recreated table. + * would try to add/drop a column or drop a constraint that is already + * represented by the freshly recreated table. * - Prunes `DropSequence(S)` changes when `S` is `OWNED BY` a column on a * table promoted to `DropTable + CreateTable` by the expander. The * `DROP TABLE` cascade drops the sequence at apply time; emitting an From 205f28400a7a544e676e287a61ee3f71594f19de Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 22:18:44 +0800 Subject: [PATCH 67/78] test(pg-delta): cover latest replacement review regressions RED evidence before fix: - post-diff identity-add pruning: focused test kept AlterTableAlterColumnAddIdentity for the replaced table. - routine grant replay: focused test found 0 GrantProcedurePrivileges after the original grant alter was pruned. - promoted trigger cleanup: focused test kept the stale ReplaceTrigger alongside DropTrigger/CreateTrigger. --- .../core/expand-replace-dependencies.test.ts | 155 ++++++++++++++++++ .../src/core/post-diff-normalization.test.ts | 58 +++++++ 2 files changed, 213 insertions(+) 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 d076e9539..aec1d9165 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -23,6 +23,7 @@ import { Domain } from "./objects/domain/domain.model.ts"; import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts"; import { DropProcedure } from "./objects/procedure/changes/procedure.drop.ts"; import { AlterProcedureChangeOwner } from "./objects/procedure/changes/procedure.alter.ts"; +import { GrantProcedurePrivileges } from "./objects/procedure/changes/procedure.privilege.ts"; import { Procedure } from "./objects/procedure/procedure.model.ts"; import { AlterIndexSetStatistics } from "./objects/index/changes/index.alter.ts"; import { CreateCommentOnIndex } from "./objects/index/changes/index.comment.ts"; @@ -91,6 +92,7 @@ import { CreateSecurityLabelOnTable, } from "./objects/table/changes/table.security-label.ts"; import { Table, type TableProps } from "./objects/table/table.model.ts"; +import { ReplaceTrigger } from "./objects/trigger/changes/trigger.alter.ts"; import { CreateTrigger } from "./objects/trigger/changes/trigger.create.ts"; import { DropTrigger } from "./objects/trigger/changes/trigger.drop.ts"; import { Trigger } from "./objects/trigger/trigger.model.ts"; @@ -3925,6 +3927,81 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("prunes superseded trigger alters for promoted trigger replacements", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault(null); + const branchTable = tableWithDefault(null); + const mainTrigger = triggerOnItemsValue({ + function_name: mainProcedure.name, + definition: + "CREATE TRIGGER items_value_trigger AFTER UPDATE OF value ON public.items FOR EACH ROW EXECUTE FUNCTION public.normalize_value()", + }); + const branchTrigger = triggerOnItemsValue({ + function_name: branchProcedure.name, + definition: + "CREATE TRIGGER items_value_trigger BEFORE UPDATE OF value ON public.items FOR EACH ROW EXECUTE FUNCTION public.normalize_value()", + }); + const originalReplaceTrigger = new ReplaceTrigger({ + trigger: branchTrigger, + indexableObject: branchTable, + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + originalReplaceTrigger, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + indexableObjects: { [mainTable.stableId]: mainTable }, + triggers: { [mainTrigger.stableId]: mainTrigger }, + depends: [ + { + dependent_stable_id: mainTrigger.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + indexableObjects: { [branchTable.stableId]: branchTable }, + triggers: { [branchTrigger.stableId]: branchTrigger }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect(expanded.changes).not.toContain(originalReplaceTrigger); + expect( + expanded.changes.filter((change) => change instanceof ReplaceTrigger), + ).toHaveLength(0); + expect( + expanded.changes.filter((change) => change instanceof DropTrigger), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateTrigger), + ).toHaveLength(1); + }); + test("replaces child partitions instead of dropping generated columns on them", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -4887,6 +4964,84 @@ describe("expandReplaceDependencies", () => { ).toHaveLength(1); }); + test("preserves new grants when injecting the drop side for an existing routine create", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainDependent = procedureWithArgs(["integer"], "uses_normalize"); + const branchDependent = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainDependent, + source_code: "SELECT public.normalize_value(arg1::bigint)", + definition: + "CREATE FUNCTION public.uses_normalize(arg1 integer) RETURNS integer", + privileges: [ + { + grantee: "routine_executor", + privilege: "EXECUTE", + grantable: false, + }, + ], + }); + const originalGrant = new GrantProcedurePrivileges({ + procedure: branchDependent, + grantee: "routine_executor", + privileges: [{ privilege: "EXECUTE", grantable: false }], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new CreateProcedure({ procedure: branchDependent, orReplace: true }), + originalGrant, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [mainProcedure.stableId]: mainProcedure, + [mainDependent.stableId]: mainDependent, + }, + depends: [ + { + dependent_stable_id: mainDependent.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { + [branchProcedure.stableId]: branchProcedure, + [branchDependent.stableId]: branchDependent, + }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes).not.toContain(originalGrant); + expect( + expanded.changes.filter( + (change) => change instanceof GrantProcedurePrivileges, + ), + ).toHaveLength(1); + expect(expanded.changes.map((change) => change.serialize())).toContain( + "GRANT ALL ON FUNCTION public.uses_normalize(integer) TO routine_executor", + ); + }); + test("defers promoted routine owner restore until after routine metadata replay", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/src/core/post-diff-normalization.test.ts b/packages/pg-delta/src/core/post-diff-normalization.test.ts index c53ac647f..929bd9932 100644 --- a/packages/pg-delta/src/core/post-diff-normalization.test.ts +++ b/packages/pg-delta/src/core/post-diff-normalization.test.ts @@ -12,6 +12,7 @@ import { import { AlterTableAddColumn, AlterTableAddConstraint, + AlterTableAlterColumnAddIdentity, AlterTableChangeOwner, AlterTableDropColumn, AlterTableDropConstraint, @@ -264,6 +265,63 @@ describe("normalizePostDiffChanges", () => { ).toEqual([unrelatedAddColumn]); }); + test("prunes same-table identity-add ALTERs for replaced tables only", () => { + const mainTable = new Table({ + ...baseTableProps, + name: "items", + columns: [integerColumn("id", 1)], + }); + const branchTable = new Table({ + ...baseTableProps, + name: "items", + columns: [ + { + ...integerColumn("id", 1), + is_identity: true, + is_identity_always: true, + }, + ], + }); + const otherTable = new Table({ + ...baseTableProps, + name: "other_items", + columns: [ + { + ...integerColumn("id", 1), + is_identity: true, + is_identity_always: true, + }, + ], + }); + + const replacedTableAddIdentity = new AlterTableAlterColumnAddIdentity({ + table: branchTable, + column: branchTable.columns[0], + }); + const unrelatedAddIdentity = new AlterTableAlterColumnAddIdentity({ + table: otherTable, + column: otherTable.columns[0], + }); + + const normalized = normalizePostDiffChanges({ + changes: [ + new DropTable({ table: mainTable }), + new CreateTable({ table: branchTable }), + replacedTableAddIdentity, + unrelatedAddIdentity, + ], + replacedTableIds: new Set([mainTable.stableId]), + }); + + expect(normalized).not.toContain(replacedTableAddIdentity); + expect(normalized).toContain(unrelatedAddIdentity); + expect( + normalized.filter( + (change) => change instanceof AlterTableAlterColumnAddIdentity, + ), + ).toEqual([unrelatedAddIdentity]); + }); + test("dedupes duplicate constraint Add/Validate/Comment on replaced tables keeping last occurrence", async () => { const branchChildren = new Table({ ...baseTableProps, From 079b558fc4fe8459d97ae98d3a97bac3933ea1c7 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 22:20:16 +0800 Subject: [PATCH 68/78] fix(pg-delta): prune latest replacement replay leftovers GREEN verification before commit: - npx --yes bun@1.3.14 run test src/core/post-diff-normalization.test.ts --test-name-pattern identity-add -> 1 pass / 0 fail. - npx --yes bun@1.3.14 run test src/core/expand-replace-dependencies.test.ts --test-name-pattern 'preserves new grants|prunes superseded trigger alters' -> 2 pass / 0 fail. - npx --yes bun@1.3.14 run test src/core/post-diff-normalization.test.ts -> 13 pass / 0 fail. - npx --yes bun@1.3.14 run test src/core/expand-replace-dependencies.test.ts -> 78 pass / 0 fail. --- .../src/core/expand-replace-dependencies.ts | 46 +++++++++++++++++-- .../src/core/post-diff-normalization.ts | 2 + 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 411ef1634..33cd0adf3 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -293,6 +293,7 @@ export function expandReplaceDependencies({ // associated drop-phase cycle with the catalog constraint→column edge. const tablesReplacedByExpansion = new Set(); const routinesReplacedByExpansion = new Set(); + const triggersReplacedByExpansion = new Set(); const generatedColumnsRecreatedByExpressionFallback = collectCoveredGeneratedColumnRecreations(changes); const generatedColumnGrantRestoresAdded = new Set(); @@ -599,14 +600,22 @@ export function expandReplaceDependencies({ ? buildRetainedProcedureMetadataChanges({ procedure: resolved.branch, diffContext, - }).filter((change) => !isCreateAlreadyCovered(change, createdIds)) + }).filter( + (change) => + !isCreateAlreadyCovered(change, createdIds) || + isRoutinePrivilegeReplay(change), + ) : []; const aggregateMetadataRestoreChanges = resolved.kind === "aggregate" && addDrop && !addCreate ? buildRetainedAggregateMetadataChanges({ aggregate: resolved.branch, diffContext, - }).filter((change) => !isCreateAlreadyCovered(change, createdIds)) + }).filter( + (change) => + !isCreateAlreadyCovered(change, createdIds) || + isRoutinePrivilegeReplay(change), + ) : []; additions.push( @@ -626,6 +635,9 @@ export function expandReplaceDependencies({ ) { routinesReplacedByExpansion.add(targetId); } + if (resolved.kind === "trigger" && addDrop) { + triggersReplacedByExpansion.add(targetId); + } // If we added a DropTable(T) for an existing table, mark T so any // pre-existing object-scope AlterTable*(T) changes get dropped below — @@ -659,9 +671,12 @@ export function expandReplaceDependencies({ return { changes: [ ...removeSupersededGeneratedColumnSetExpressions( - removeSupersededRoutineAlters( - removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds), - routinesReplacedByExpansion, + removeSupersededTriggerAlters( + removeSupersededRoutineAlters( + removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds), + routinesReplacedByExpansion, + ), + triggersReplacedByExpansion, ), generatedColumnsRecreatedByExpressionFallback, ), @@ -778,6 +793,27 @@ function removeSupersededRoutineAlters( }); } +function removeSupersededTriggerAlters( + changes: Change[], + promotedTriggerIds: ReadonlySet, +): Change[] { + if (promotedTriggerIds.size === 0) return changes; + return changes.filter((change) => { + if (change.objectType !== "trigger" || change.operation !== "alter") { + return true; + } + return !promotedTriggerIds.has(change.trigger.stableId); + }); +} + +function isRoutinePrivilegeReplay(change: Change): boolean { + return ( + change.operation === "alter" && + change.scope === "privilege" && + (change.objectType === "procedure" || change.objectType === "aggregate") + ); +} + interface ExpressionDependentCoverage { release: Set; restore: Set; diff --git a/packages/pg-delta/src/core/post-diff-normalization.ts b/packages/pg-delta/src/core/post-diff-normalization.ts index 6b268908d..1942d6173 100644 --- a/packages/pg-delta/src/core/post-diff-normalization.ts +++ b/packages/pg-delta/src/core/post-diff-normalization.ts @@ -5,6 +5,7 @@ import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts"; import { AlterTableAddColumn, AlterTableAddConstraint, + AlterTableAlterColumnAddIdentity, AlterTableChangeOwner, AlterTableDropColumn, AlterTableDropConstraint, @@ -28,6 +29,7 @@ function isSupersededByTableReplacement( ): boolean { if ( change instanceof AlterTableAddColumn || + change instanceof AlterTableAlterColumnAddIdentity || change instanceof AlterTableDropColumn || change instanceof AlterTableDropConstraint ) { From b88cb00d88c6bba02d420c57952e5419904bc13b Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 23:01:18 +0800 Subject: [PATCH 69/78] test(pg-delta): cover replacement follow-up ordering --- .../core/expand-replace-dependencies.test.ts | 104 ++++++++++++++++++ .../src/core/post-diff-normalization.test.ts | 58 ++++++++++ .../src/core/sort/sort-changes.test.ts | 54 +++++++++ 3 files changed, 216 insertions(+) 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 aec1d9165..dcdae9295 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -2672,6 +2672,110 @@ describe("expandReplaceDependencies", () => { ); }); + test("traverses retained view, trigger, and rule dependents when a generated column is recreated as regular", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)"); + const mainView = viewOnItemsValue(); + const branchView = viewOnItemsValue(); + const mainTrigger = triggerOnItemsValue(); + const branchTrigger = triggerOnItemsValue(); + const mainRule = ruleOnItemsValue(); + const branchRule = ruleOnItemsValue(); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + views: { [mainView.stableId]: mainView }, + triggers: { [mainTrigger.stableId]: mainTrigger }, + rules: { [mainRule.stableId]: mainRule }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: mainView.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainTrigger.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + { + dependent_stable_id: mainRule.stableId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + views: { [branchView.stableId]: branchView }, + triggers: { [branchTrigger.stableId]: branchTrigger }, + rules: { [branchRule.stableId]: branchRule }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter((change) => change instanceof DropView), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateView), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof DropTrigger), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateTrigger), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof DropRule), + ).toHaveLength(1); + expect( + expanded.changes.filter((change) => change instanceof CreateRule), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + }); + test("traverses retained view, trigger, and rule dependents when generated column recreation is already covered", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); diff --git a/packages/pg-delta/src/core/post-diff-normalization.test.ts b/packages/pg-delta/src/core/post-diff-normalization.test.ts index 929bd9932..3f9b2049b 100644 --- a/packages/pg-delta/src/core/post-diff-normalization.test.ts +++ b/packages/pg-delta/src/core/post-diff-normalization.test.ts @@ -13,6 +13,7 @@ import { AlterTableAddColumn, AlterTableAddConstraint, AlterTableAlterColumnAddIdentity, + AlterTableAlterColumnDropIdentity, AlterTableChangeOwner, AlterTableDropColumn, AlterTableDropConstraint, @@ -322,6 +323,63 @@ describe("normalizePostDiffChanges", () => { ).toEqual([unrelatedAddIdentity]); }); + test("prunes same-table identity-drop ALTERs for replaced tables only", () => { + const mainTable = new Table({ + ...baseTableProps, + name: "items", + columns: [ + { + ...integerColumn("id", 1), + is_identity: true, + is_identity_always: true, + }, + ], + }); + const branchTable = new Table({ + ...baseTableProps, + name: "items", + columns: [integerColumn("id", 1)], + }); + const otherTable = new Table({ + ...baseTableProps, + name: "other_items", + columns: [ + { + ...integerColumn("id", 1), + is_identity: true, + is_identity_always: true, + }, + ], + }); + + const replacedTableDropIdentity = new AlterTableAlterColumnDropIdentity({ + table: mainTable, + column: mainTable.columns[0], + }); + const unrelatedDropIdentity = new AlterTableAlterColumnDropIdentity({ + table: otherTable, + column: otherTable.columns[0], + }); + + const normalized = normalizePostDiffChanges({ + changes: [ + new DropTable({ table: mainTable }), + new CreateTable({ table: branchTable }), + replacedTableDropIdentity, + unrelatedDropIdentity, + ], + replacedTableIds: new Set([mainTable.stableId]), + }); + + expect(normalized).not.toContain(replacedTableDropIdentity); + expect(normalized).toContain(unrelatedDropIdentity); + expect( + normalized.filter( + (change) => change instanceof AlterTableAlterColumnDropIdentity, + ), + ).toEqual([unrelatedDropIdentity]); + }); + test("dedupes duplicate constraint Add/Validate/Comment on replaced tables keeping last occurrence", async () => { const branchChildren = new Table({ ...baseTableProps, diff --git a/packages/pg-delta/src/core/sort/sort-changes.test.ts b/packages/pg-delta/src/core/sort/sort-changes.test.ts index d0ce3a19f..aa7e6f406 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -32,6 +32,8 @@ import { } 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 { CreateCommentOnTrigger } from "../objects/trigger/changes/trigger.comment.ts"; +import { Trigger } from "../objects/trigger/trigger.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"; @@ -305,6 +307,36 @@ function sequenceOwnedBy(tableName: string, columnName: string) { }); } +function triggerOnTable(tableName: string) { + return new Trigger({ + schema: "public", + name: `${tableName}_audit_trigger`, + table_name: tableName, + table_relkind: "r", + function_schema: "public", + function_name: "audit_row", + trigger_type: 16, + enabled: "O", + is_internal: false, + deferrable: false, + initially_deferred: false, + argument_count: 0, + column_numbers: [], + arguments: [], + when_condition: null, + old_table: null, + new_table: null, + is_partition_clone: false, + parent_trigger_name: null, + parent_table_schema: null, + parent_table_name: null, + is_on_partitioned_table: false, + owner: "postgres", + definition: `CREATE TRIGGER ${tableName}_audit_trigger AFTER UPDATE ON public.${tableName} FOR EACH ROW EXECUTE FUNCTION public.audit_row()`, + comment: "retained trigger comment", + }); +} + function domain(defaultValue: string | null, name = "score") { return new Domain({ schema: "public", @@ -495,6 +527,28 @@ describe("sortChanges", () => { expect(ownerIndex).toBeGreaterThan(grantIndex); }); + test("orders trigger comment replay before table owner restore", async () => { + const owningTable = table("items"); + const trigger = triggerOnTable("items"); + const changes: Change[] = [ + new AlterTableChangeOwner({ table: owningTable, owner: "app_owner" }), + new CreateCommentOnTrigger({ trigger }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnTrigger, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterTableChangeOwner, + ); + + expect(commentIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(commentIndex); + }); + test("orders materialized view indexes after recreated materialized views", async () => { const branchMaterializedView = materializedView("user_ids_mv"); const branchIndex = indexOnMaterializedView( From 017e6ce783db2198baed70f88ce3d177a3b53202 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 23:01:23 +0800 Subject: [PATCH 70/78] fix(pg-delta): order table replacement replays safely --- .../src/core/expand-replace-dependencies.ts | 34 +++++++++++++++++++ .../src/core/post-diff-normalization.ts | 2 ++ .../src/core/sort/custom-constraints.ts | 12 ++++++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 33cd0adf3..23c8f6b44 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -296,6 +296,8 @@ export function expandReplaceDependencies({ const triggersReplacedByExpansion = new Set(); const generatedColumnsRecreatedByExpressionFallback = collectCoveredGeneratedColumnRecreations(changes); + const columnsRecreatedByOriginalDiff = + collectCoveredColumnRecreations(changes); const generatedColumnGrantRestoresAdded = new Set(); for (const columnId of generatedColumnsRecreatedByExpressionFallback) { maybeAddCoveredGeneratedColumnGrantRestores({ @@ -408,6 +410,8 @@ export function expandReplaceDependencies({ existingChanges: [...changes, ...additions], diffContext, }); + } + if (columnsRecreatedByOriginalDiff.has(dependentRaw)) { queueRefForTraversal(dependentRaw, visitedRefs, queue); } continue; @@ -905,6 +909,36 @@ function collectCoveredGeneratedColumnRecreations( return new Set([...release].filter((columnId) => restore.has(columnId))); } +function collectCoveredColumnRecreations( + changes: readonly Change[], +): Set { + const release = new Set(); + const restore = new Set(); + + for (const change of changes) { + if (change instanceof AlterTableDropColumn) { + release.add( + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ), + ); + } + if (change instanceof AlterTableAddColumn) { + restore.add( + stableId.column( + change.table.schema, + change.table.name, + change.column.name, + ), + ); + } + } + + return new Set([...release].filter((columnId) => restore.has(columnId))); +} + function shouldSuppressCoveredExpressionDependent({ refId, dependentRaw, diff --git a/packages/pg-delta/src/core/post-diff-normalization.ts b/packages/pg-delta/src/core/post-diff-normalization.ts index 1942d6173..92f100053 100644 --- a/packages/pg-delta/src/core/post-diff-normalization.ts +++ b/packages/pg-delta/src/core/post-diff-normalization.ts @@ -6,6 +6,7 @@ import { AlterTableAddColumn, AlterTableAddConstraint, AlterTableAlterColumnAddIdentity, + AlterTableAlterColumnDropIdentity, AlterTableChangeOwner, AlterTableDropColumn, AlterTableDropConstraint, @@ -30,6 +31,7 @@ function isSupersededByTableReplacement( if ( change instanceof AlterTableAddColumn || change instanceof AlterTableAlterColumnAddIdentity || + change instanceof AlterTableAlterColumnDropIdentity || change instanceof AlterTableDropColumn || change instanceof AlterTableDropConstraint ) { diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index 4ede53926..2d9183372 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -246,6 +246,13 @@ function generateIdentityTransitionConstraints( } function parseTableStableIdFromStableId(id: string): string | null { + if (id.startsWith("comment:")) { + return parseTableStableIdFromStableId(id.slice("comment:".length)); + } + if (id.startsWith("securityLabel:")) { + const objectId = id.slice("securityLabel:".length).split("::provider:")[0]; + return parseTableStableIdFromStableId(objectId); + } if (id.startsWith("table:")) { return id; } @@ -258,7 +265,10 @@ function parseTableStableIdFromStableId(id: string): string | null { return ( parseSubEntity("column:") ?? parseSubEntity("constraint:") ?? - parseSubEntity("index:") + parseSubEntity("index:") ?? + parseSubEntity("trigger:") ?? + parseSubEntity("rule:") ?? + parseSubEntity("rlsPolicy:") ); } From 7b84f6568aa1b91d301aba2182d7f40c29dceb35 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 23:41:05 +0800 Subject: [PATCH 71/78] test(pg-delta): cover latest replacement review regressions RED before the fix: - expandReplaceDependencies regular-to-generated grant replay expected 1 GrantTablePrivileges but received 0. - expandReplaceDependencies publication row-filter replay sorted AlterPublicationSetOwner at index 2 before AlterPublicationAddTables at index 3. - sortChanges view metadata replay sorted AlterViewChangeOwner before GrantViewPrivileges, owner index 2 vs grant index 3. - sortChanges publication table replay sorted AlterPublicationSetOwner before AlterPublicationAddTables, owner index 0 vs add index 1. --- .../core/expand-replace-dependencies.test.ts | 157 ++++++++++++++++++ .../src/core/sort/sort-changes.test.ts | 113 ++++++++++++- ...-signature-expression-dependencies.test.ts | 117 +++++++++++++ 3 files changed, 386 insertions(+), 1 deletion(-) 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 dcdae9295..39322e9b4 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -43,6 +43,7 @@ import { import { AlterPublicationAddTables, AlterPublicationDropTables, + AlterPublicationSetOwner, } from "./objects/publication/changes/publication.alter.ts"; import { CreatePublication } from "./objects/publication/changes/publication.create.ts"; import { DropPublication } from "./objects/publication/changes/publication.drop.ts"; @@ -3427,6 +3428,91 @@ describe("expandReplaceDependencies", () => { expect(grants[0]?.columns).toEqual(["value"]); }); + test("replays retained grants for covered regular-to-generated column recreation", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const retainedPrivileges = [ + { + grantee: "value_reader", + privilege: "SELECT", + grantable: false, + columns: ["value"], + }, + ]; + const mainTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault("public.normalize_value(value)"), + privileges: retainedPrivileges, + }); + const branchTable = new Table({ + // oxlint-disable-next-line typescript/no-misused-spread + ...tableWithDefault("public.normalize_value(value)", { + is_generated: true, + }), + privileges: retainedPrivileges, + }); + const columnId = "column:public.items.value"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const grants = expanded.changes.filter( + (change): change is GrantTablePrivileges => + change instanceof GrantTablePrivileges, + ); + + expect(grants).toHaveLength(1); + expect(grants[0]?.grantee).toBe("value_reader"); + expect(grants[0]?.columns).toEqual(["value"]); + }); + test("restores retained security labels without replacing the table when recreating a generated column", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -4403,6 +4489,77 @@ describe("expandReplaceDependencies", () => { ).toHaveLength(1); }); + test("orders replayed publication row filters before publication owner restore", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainPublication = publicationOnItemsValue( + "(public.normalize_value(value) > 0)", + ); + const branchPublication = new Publication({ + // oxlint-disable-next-line typescript/no-misused-spread + ...publicationOnItemsValue("(public.normalize_value(value) > 0)"), + owner: "app_owner", + }); + const table = tableWithDefault(null); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterPublicationSetOwner({ + publication: branchPublication, + owner: "app_owner", + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + publications: { [mainPublication.stableId]: mainPublication }, + tables: { [table.stableId]: table }, + depends: [ + { + dependent_stable_id: mainPublication.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + publications: { [branchPublication.stableId]: branchPublication }, + tables: { [table.stableId]: table }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + const sorted = sortChanges( + { mainCatalog, branchCatalog }, + expanded.changes, + ); + const addTablesIndex = sorted.findIndex( + (change) => change instanceof AlterPublicationAddTables, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterPublicationSetOwner, + ); + + expect(addTablesIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(addTablesIndex); + }); + test("refreshes publication row filters that depend on recreated generated columns without column lists", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); 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 aa7e6f406..3f4e0f6b2 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -16,7 +16,11 @@ import { MaterializedView } from "../objects/materialized-view/materialized-view 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 { + AlterPublicationAddTables, + AlterPublicationDropTables, + AlterPublicationSetOwner, +} from "../objects/publication/changes/publication.alter.ts"; import { Publication } from "../objects/publication/publication.model.ts"; import { AlterSequenceChangeOwner } from "../objects/sequence/changes/sequence.alter.ts"; import { CreateSequence } from "../objects/sequence/changes/sequence.create.ts"; @@ -34,8 +38,11 @@ import { DropTable } from "../objects/table/changes/table.drop.ts"; import { Table } from "../objects/table/table.model.ts"; import { CreateCommentOnTrigger } from "../objects/trigger/changes/trigger.comment.ts"; import { Trigger } from "../objects/trigger/trigger.model.ts"; +import { AlterViewChangeOwner } from "../objects/view/changes/view.alter.ts"; +import { CreateCommentOnView } from "../objects/view/changes/view.comment.ts"; import { CreateView } from "../objects/view/changes/view.create.ts"; import { DropView } from "../objects/view/changes/view.drop.ts"; +import { GrantViewPrivileges } from "../objects/view/changes/view.privilege.ts"; import { View } from "../objects/view/view.model.ts"; import { sortChanges } from "./sort-changes.ts"; @@ -549,6 +556,110 @@ describe("sortChanges", () => { expect(ownerIndex).toBeGreaterThan(commentIndex); }); + test("orders view metadata replay before view owner restore", async () => { + const retainedView = new View({ + schema: "public", + name: "active_users", + definition: "SELECT id FROM users", + row_security: false, + force_row_security: false, + has_indexes: false, + has_rules: true, + has_triggers: false, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + options: null, + partition_bound: null, + owner: "app_owner", + comment: "retained view comment", + columns: [integerColumn("id", 1)], + privileges: [ + { + grantee: "app_reader", + privilege: "SELECT", + grantable: false, + }, + ], + }); + const changes: Change[] = [ + new CreateView({ view: retainedView }), + new AlterViewChangeOwner({ view: retainedView, owner: "app_owner" }), + new CreateCommentOnView({ view: retainedView }), + new GrantViewPrivileges({ + view: retainedView, + grantee: "app_reader", + privileges: [{ privilege: "SELECT", grantable: false }], + version: 170000, + }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnView, + ); + const grantIndex = sorted.findIndex( + (change) => change instanceof GrantViewPrivileges, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterViewChangeOwner, + ); + + expect(commentIndex).toBeGreaterThan(-1); + expect(grantIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(commentIndex); + expect(ownerIndex).toBeGreaterThan(grantIndex); + }); + + test("orders publication table replay before publication owner restore", async () => { + const publication = new Publication({ + name: "items_pub", + owner: "app_owner", + comment: null, + all_tables: false, + publish_insert: true, + publish_update: true, + publish_delete: true, + publish_truncate: true, + publish_via_partition_root: false, + tables: [ + { + schema: "public", + name: "items", + columns: null, + row_filter: "(value > 0)", + }, + ], + schemas: [], + }); + const changes: Change[] = [ + new AlterPublicationSetOwner({ + publication, + owner: "app_owner", + }), + new AlterPublicationAddTables({ + publication, + tables: publication.tables, + }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const addTablesIndex = sorted.findIndex( + (change) => change instanceof AlterPublicationAddTables, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterPublicationSetOwner, + ); + + expect(addTablesIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(addTablesIndex); + }); + test("orders materialized view indexes after recreated materialized views", async () => { const branchMaterializedView = materializedView("user_ids_mv"); const branchIndex = indexOnMaterializedView( diff --git a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts index 707ad0287..91cf6a0ef 100644 --- a/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts +++ b/packages/pg-delta/tests/integration/function-signature-expression-dependencies.test.ts @@ -1554,6 +1554,106 @@ for (const pgVersion of POSTGRES_VERSIONS) { }), ); + test( + "regular-to-generated column recreation restores retained column grants", + withDb(pgVersion, async (db) => { + await roundtripFidelityTest({ + mainSession: db.main, + branchSession: db.branch, + initialSetup: dedent` + CREATE SCHEMA test_schema; + + CREATE FUNCTION test_schema.compute_regular_total(value integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT value + 1 + $function$; + + CREATE TABLE test_schema.regular_invoice_totals ( + id integer NOT NULL, + subtotal integer NOT NULL, + total integer DEFAULT test_schema.compute_regular_total(1) + ); + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT FROM pg_catalog.pg_roles + WHERE rolname = 'regular_total_reader' + ) THEN + CREATE ROLE regular_total_reader; + END IF; + END + $$; + + GRANT SELECT (total) + ON TABLE test_schema.regular_invoice_totals + TO regular_total_reader; + + INSERT INTO test_schema.regular_invoice_totals (id, subtotal) + VALUES (1, 20); + `, + testSql: dedent` + ALTER TABLE test_schema.regular_invoice_totals + DROP COLUMN total; + + DROP FUNCTION test_schema.compute_regular_total(integer); + + CREATE FUNCTION test_schema.compute_regular_total(input integer) + RETURNS integer + LANGUAGE sql + IMMUTABLE + AS $function$ + SELECT input + 1 + $function$; + + ALTER TABLE test_schema.regular_invoice_totals + ADD COLUMN total integer GENERATED ALWAYS AS + (test_schema.compute_regular_total(subtotal)) STORED; + + GRANT SELECT (total) + ON TABLE test_schema.regular_invoice_totals + TO regular_total_reader; + `, + assertSqlStatements: (sqlStatements) => { + expectNoTableReplacement(sqlStatements); + + const addColumnIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER TABLE test_schema.regular_invoice_totals ADD COLUMN total", + ), + ); + const grantColumnIndex = sqlStatements.findIndex( + (statement) => + statement.includes("SELECT (total)") && + statement.includes("test_schema.regular_invoice_totals") && + statement.includes("regular_total_reader"), + ); + + expect(addColumnIndex).toBeGreaterThanOrEqual(0); + expect(grantColumnIndex).toBeGreaterThan(addColumnIndex); + }, + }); + + await expectTableRowCount( + db.main.query.bind(db.main), + "test_schema.regular_invoice_totals", + 1, + ); + const { rows } = await db.main.query(dedent` + SELECT has_column_privilege( + 'regular_total_reader', + 'test_schema.regular_invoice_totals', + 'total', + 'SELECT' + ) AS can_select + `); + expect(rows[0]?.can_select).toBe(true); + }), + ); + test.skipIf(pgVersion < 18)( "unchanged generated column in publication column list does not recreate table", withDb(pgVersion, async (db) => { @@ -1843,6 +1943,8 @@ for (const pgVersion of POSTGRES_VERSIONS) { VALUES (1, 20); `, testSql: dedent` + CREATE ROLE filtered_pub_owner; + ALTER PUBLICATION filtered_total_pub DROP TABLE test_schema.filtered_invoice_totals; @@ -1866,6 +1968,9 @@ for (const pgVersion of POSTGRES_VERSIONS) { ALTER PUBLICATION filtered_total_pub ADD TABLE test_schema.filtered_invoice_totals WHERE (total > 0); + + ALTER PUBLICATION filtered_total_pub + OWNER TO filtered_pub_owner; `, assertSqlStatements: (sqlStatements) => { expectNoTableReplacement(sqlStatements); @@ -1900,6 +2005,11 @@ for (const pgVersion of POSTGRES_VERSIONS) { "ALTER PUBLICATION filtered_total_pub ADD TABLE test_schema.filtered_invoice_totals WHERE (total > 0)", ), ); + const publicationOwnerIndex = sqlStatements.findIndex((statement) => + statement.startsWith( + "ALTER PUBLICATION filtered_total_pub OWNER TO filtered_pub_owner", + ), + ); expect(publicationDropIndex).toBeGreaterThanOrEqual(0); expect(dropColumnIndex).toBeGreaterThan(publicationDropIndex); @@ -1907,6 +2017,7 @@ for (const pgVersion of POSTGRES_VERSIONS) { expect(createFunctionIndex).toBeGreaterThan(dropFunctionIndex); expect(addColumnIndex).toBeGreaterThan(createFunctionIndex); expect(publicationAddIndex).toBeGreaterThan(addColumnIndex); + expect(publicationOwnerIndex).toBeGreaterThan(publicationAddIndex); }, }); @@ -1929,6 +2040,12 @@ for (const pgVersion of POSTGRES_VERSIONS) { `); expect(rows[0]?.row_filter).toBe("(total > 0)"); expect(rows[0]?.all_columns).toBe(true); + const { rows: ownerRows } = await db.main.query(dedent` + SELECT pg_get_userbyid(p.pubowner) AS owner + FROM pg_catalog.pg_publication p + WHERE p.pubname = 'filtered_total_pub' + `); + expect(ownerRows[0]?.owner).toBe("filtered_pub_owner"); }), ); From 6945a5aeddc990f24821fc9c065b6069faefecfc Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sat, 13 Jun 2026 23:41:14 +0800 Subject: [PATCH 72/78] fix(pg-delta): restore replay before owner transfers Fixes the latest PR #291 review batch: - replay retained column grants for covered regular/generated status column recreations. - defer view owner restores until after retained view metadata and ACL replay. - defer publication owner restores until after replayed publication table membership. GREEN verification: - ./node_modules/.bin/bun run --cwd packages/pg-delta test src/core/expand-replace-dependencies.test.ts src/core/sort/sort-changes.test.ts -> 98 pass / 0 fail. - PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1 PGDELTA_TEST_POSTGRES_VERSIONS=17 TESTCONTAINERS_RYUK_DISABLED=true ./node_modules/.bin/bun run --cwd packages/pg-delta test tests/integration/function-signature-expression-dependencies.test.ts --test-name-pattern "regular-to-generated column recreation restores retained column grants|generated publication row filters without column lists" -> 2 pass / 0 fail. - ./node_modules/.bin/bun run format-and-lint:fix && ./node_modules/.bin/bun run check-types && ./node_modules/.bin/bun run knip --fix && git diff --check -> exit 0 (knip existing hints only). --- .../src/core/expand-replace-dependencies.ts | 18 ++- .../src/core/sort/custom-constraints.ts | 130 ++++++++++++++++++ 2 files changed, 141 insertions(+), 7 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 23c8f6b44..88933b741 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -298,11 +298,15 @@ export function expandReplaceDependencies({ collectCoveredGeneratedColumnRecreations(changes); const columnsRecreatedByOriginalDiff = collectCoveredColumnRecreations(changes); + const columnsEligibleForGrantRestores = new Set([ + ...generatedColumnsRecreatedByExpressionFallback, + ...columnsRecreatedByOriginalDiff, + ]); const generatedColumnGrantRestoresAdded = new Set(); for (const columnId of generatedColumnsRecreatedByExpressionFallback) { maybeAddCoveredGeneratedColumnGrantRestores({ columnId, - generatedColumnsRecreatedByExpressionFallback, + columnsEligibleForGrantRestores, generatedColumnGrantRestoresAdded, branchCatalog, additions, @@ -326,7 +330,7 @@ export function expandReplaceDependencies({ const refId = queue.shift() as string; maybeAddCoveredGeneratedColumnGrantRestores({ columnId: refId, - generatedColumnsRecreatedByExpressionFallback, + columnsEligibleForGrantRestores, generatedColumnGrantRestoresAdded, branchCatalog, additions, @@ -400,10 +404,10 @@ export function expandReplaceDependencies({ const restoreCovered = expressionDependentCoverage.restore.has(dependentRaw); if (releaseCovered && restoreCovered) { - if (generatedColumnsRecreatedByExpressionFallback.has(dependentRaw)) { + if (columnsEligibleForGrantRestores.has(dependentRaw)) { maybeAddCoveredGeneratedColumnGrantRestores({ columnId: dependentRaw, - generatedColumnsRecreatedByExpressionFallback, + columnsEligibleForGrantRestores, generatedColumnGrantRestoresAdded, branchCatalog, additions, @@ -1122,7 +1126,7 @@ function maybeAddRecreatedColumnMetadataRestore({ function maybeAddCoveredGeneratedColumnGrantRestores({ columnId, - generatedColumnsRecreatedByExpressionFallback, + columnsEligibleForGrantRestores, generatedColumnGrantRestoresAdded, branchCatalog, additions, @@ -1130,7 +1134,7 @@ function maybeAddCoveredGeneratedColumnGrantRestores({ diffContext, }: { columnId: string; - generatedColumnsRecreatedByExpressionFallback: ReadonlySet; + columnsEligibleForGrantRestores: ReadonlySet; generatedColumnGrantRestoresAdded: Set; branchCatalog: Catalog; additions: Change[]; @@ -1141,7 +1145,7 @@ function maybeAddCoveredGeneratedColumnGrantRestores({ >; }): void { if ( - !generatedColumnsRecreatedByExpressionFallback.has(columnId) || + !columnsEligibleForGrantRestores.has(columnId) || generatedColumnGrantRestoresAdded.has(columnId) ) { return; diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index 2d9183372..125770c38 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -2,6 +2,7 @@ import type { Change } from "../change.types.ts"; import { getSchema } from "../change-utils.ts"; import { AlterAggregateChangeOwner } from "../objects/aggregate/changes/aggregate.alter.ts"; import { AlterProcedureChangeOwner } from "../objects/procedure/changes/procedure.alter.ts"; +import { AlterPublicationSetOwner } from "../objects/publication/changes/publication.alter.ts"; import { GrantRoleDefaultPrivileges, RevokeRoleDefaultPrivileges, @@ -18,6 +19,7 @@ import { AlterTableAlterColumnSetDefault, AlterTableChangeOwner, } from "../objects/table/changes/table.alter.ts"; +import { AlterViewChangeOwner } from "../objects/view/changes/view.alter.ts"; import type { Constraint } from "./types.ts"; /** @@ -424,6 +426,67 @@ function generateMaterializedViewOwnerRestoreLastConstraints( return constraints; } +function getRelatedViewStableIds(change: Change): Set { + const viewIds = new Set(); + if ( + "view" in change && + change.view && + typeof change.view === "object" && + "stableId" in change.view && + typeof change.view.stableId === "string" + ) { + viewIds.add(change.view.stableId); + } + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + if (id.startsWith("view:")) { + viewIds.add(id); + } + } + return viewIds; +} + +function generateViewOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; viewId: string }> = []; + const changesByView = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (change instanceof AlterViewChangeOwner) { + ownerChanges.push({ index, viewId: change.view.stableId }); + continue; + } + + const viewIds = getRelatedViewStableIds(change); + for (const viewId of viewIds) { + const indexes = changesByView.get(viewId) ?? []; + indexes.push(index); + changesByView.set(viewId, indexes); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = changesByView.get(ownerChange.viewId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + function parseSequenceStableIdFromStableId(id: string): string | null { if (id.startsWith("sequence:")) { return id; @@ -558,6 +621,71 @@ function generateRoutineOwnerRestoreLastConstraints( return constraints; } +function getRelatedPublicationStableIds(change: Change): Set { + const publicationIds = new Set(); + if ( + "publication" in change && + change.publication && + typeof change.publication === "object" && + "stableId" in change.publication && + typeof change.publication.stableId === "string" + ) { + publicationIds.add(change.publication.stableId); + } + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + if (id.startsWith("publication:")) { + publicationIds.add(id); + } + } + return publicationIds; +} + +function generatePublicationOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; publicationId: string }> = []; + const changesByPublication = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (change instanceof AlterPublicationSetOwner) { + ownerChanges.push({ + index, + publicationId: change.publication.stableId, + }); + continue; + } + + const publicationIds = getRelatedPublicationStableIds(change); + for (const publicationId of publicationIds) { + const indexes = changesByPublication.get(publicationId) ?? []; + indexes.push(index); + changesByPublication.set(publicationId, indexes); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = + changesByPublication.get(ownerChange.publicationId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + function generateOwnedSequenceAttachmentConstraints( changes: Change[], ): Constraint[] { @@ -640,8 +768,10 @@ const customConstraintGenerators: ConstraintGenerator[] = [ generateIdentityTransitionConstraints, generateTableOwnerRestoreLastConstraints, generateMaterializedViewOwnerRestoreLastConstraints, + generateViewOwnerRestoreLastConstraints, generateSequenceOwnerRestoreLastConstraints, generateRoutineOwnerRestoreLastConstraints, + generatePublicationOwnerRestoreLastConstraints, generateOwnedSequenceAttachmentConstraints, ]; From e661550f1c32a3fae5fb72fc2e9970ee3ab70d01 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sun, 14 Jun 2026 00:19:18 +0800 Subject: [PATCH 73/78] test(pg-delta): cover view replay owner ordering RED evidence before the fix: - sortChanges view trigger replay failed with ownerIndex=2 before trigger commentIndex=3. - sortChanges view rule metadata replay failed with ownerIndex=2 before rule commentIndex=3. - expandReplaceDependencies promoted table replay kept two AlterSequenceChangeOwner changes, expected one. Signed-off-by: xiaominghao --- .../core/expand-replace-dependencies.test.ts | 65 +++++++++ .../src/core/sort/sort-changes.test.ts | 134 +++++++++++++++++- 2 files changed, 198 insertions(+), 1 deletion(-) 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 39322e9b4..9a6432fdd 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -5855,6 +5855,71 @@ describe("expandReplaceDependencies", () => { expect(ownerIndex).toBeLessThan(ownedByIndex); }); + test("prunes original owned sequence owner alters after promoted table replay", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = tableWithDefault(columnDefault); + const branchTable = tableWithDefault(columnDefault); + const mainSequence = sequenceOwnedByItemsValue(); + const branchSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainSequence, + owner: "app_owner", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterSequenceChangeOwner({ + sequence: branchSequence, + owner: "app_owner", + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const sequenceOwnerChanges = expanded.changes.filter( + (change) => change instanceof AlterSequenceChangeOwner, + ); + + expect(sequenceOwnerChanges).toHaveLength(1); + expect(sequenceOwnerChanges[0].sequence.stableId).toBe( + branchSequence.stableId, + ); + }); + test("orders promoted table and owned sequence owner restores before OWNED BY", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); 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 3f4e0f6b2..00ee376cb 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -22,6 +22,10 @@ import { AlterPublicationSetOwner, } from "../objects/publication/changes/publication.alter.ts"; import { Publication } from "../objects/publication/publication.model.ts"; +import { SetRuleEnabledState } from "../objects/rule/changes/rule.alter.ts"; +import { CreateCommentOnRule } from "../objects/rule/changes/rule.comment.ts"; +import { CreateRule } from "../objects/rule/changes/rule.create.ts"; +import { Rule } from "../objects/rule/rule.model.ts"; import { AlterSequenceChangeOwner } from "../objects/sequence/changes/sequence.alter.ts"; import { CreateSequence } from "../objects/sequence/changes/sequence.create.ts"; import { GrantSequencePrivileges } from "../objects/sequence/changes/sequence.privilege.ts"; @@ -36,7 +40,9 @@ import { } from "../objects/table/changes/table.alter.ts"; import { DropTable } from "../objects/table/changes/table.drop.ts"; import { Table } from "../objects/table/table.model.ts"; +import { SetTriggerEnabledState } from "../objects/trigger/changes/trigger.alter.ts"; import { CreateCommentOnTrigger } from "../objects/trigger/changes/trigger.comment.ts"; +import { CreateTrigger } from "../objects/trigger/changes/trigger.create.ts"; import { Trigger } from "../objects/trigger/trigger.model.ts"; import { AlterViewChangeOwner } from "../objects/view/changes/view.alter.ts"; import { CreateCommentOnView } from "../objects/view/changes/view.comment.ts"; @@ -314,7 +320,10 @@ function sequenceOwnedBy(tableName: string, columnName: string) { }); } -function triggerOnTable(tableName: string) { +function triggerOnTable( + tableName: string, + overrides: Partial[0]> = {}, +) { return new Trigger({ schema: "public", name: `${tableName}_audit_trigger`, @@ -341,6 +350,23 @@ function triggerOnTable(tableName: string) { owner: "postgres", definition: `CREATE TRIGGER ${tableName}_audit_trigger AFTER UPDATE ON public.${tableName} FOR EACH ROW EXECUTE FUNCTION public.audit_row()`, comment: "retained trigger comment", + ...overrides, + }); +} + +function ruleOnView(viewName: string) { + return new Rule({ + schema: "public", + name: `${viewName}_update_rule`, + table_name: viewName, + relation_kind: "v", + event: "UPDATE", + enabled: "R", + is_instead: true, + owner: "postgres", + definition: `CREATE RULE ${viewName}_update_rule AS ON UPDATE TO public.${viewName} DO INSTEAD NOTHING`, + comment: "retained rule comment", + columns: [], }); } @@ -614,6 +640,112 @@ describe("sortChanges", () => { expect(ownerIndex).toBeGreaterThan(grantIndex); }); + test("orders view trigger replay before view owner restore", async () => { + const retainedView = new View({ + schema: "public", + name: "active_users", + definition: "SELECT id FROM users", + row_security: false, + force_row_security: false, + has_indexes: false, + has_rules: false, + has_triggers: true, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + options: null, + partition_bound: null, + owner: "app_owner", + comment: null, + columns: [integerColumn("id", 1)], + privileges: [], + }); + const trigger = triggerOnTable("active_users", { + table_relkind: "v", + definition: + "CREATE TRIGGER active_users_audit_trigger INSTEAD OF UPDATE ON public.active_users FOR EACH ROW EXECUTE FUNCTION public.audit_row()", + enabled: "R", + }); + const changes: Change[] = [ + new AlterViewChangeOwner({ view: retainedView, owner: "app_owner" }), + new CreateTrigger({ trigger }), + new SetTriggerEnabledState({ trigger }), + new CreateCommentOnTrigger({ trigger }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const createTriggerIndex = sorted.findIndex( + (change) => change instanceof CreateTrigger, + ); + const enabledStateIndex = sorted.findIndex( + (change) => change instanceof SetTriggerEnabledState, + ); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnTrigger, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterViewChangeOwner, + ); + + expect(createTriggerIndex).toBeGreaterThan(-1); + expect(enabledStateIndex).toBeGreaterThan(-1); + expect(commentIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(createTriggerIndex); + expect(ownerIndex).toBeGreaterThan(enabledStateIndex); + expect(ownerIndex).toBeGreaterThan(commentIndex); + }); + + test("orders view rule metadata replay before view owner restore", async () => { + const retainedView = new View({ + schema: "public", + name: "active_users", + definition: "SELECT id FROM users", + row_security: false, + force_row_security: false, + has_indexes: false, + has_rules: true, + has_triggers: false, + has_subclasses: false, + is_populated: true, + replica_identity: "d", + is_partition: false, + options: null, + partition_bound: null, + owner: "app_owner", + comment: null, + columns: [integerColumn("id", 1)], + privileges: [], + }); + const rule = ruleOnView("active_users"); + const changes: Change[] = [ + new CreateRule({ rule }), + new AlterViewChangeOwner({ view: retainedView, owner: "app_owner" }), + new CreateCommentOnRule({ rule }), + new SetRuleEnabledState({ rule }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnRule, + ); + const enabledStateIndex = sorted.findIndex( + (change) => change instanceof SetRuleEnabledState, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterViewChangeOwner, + ); + + expect(commentIndex).toBeGreaterThan(-1); + expect(enabledStateIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(commentIndex); + expect(ownerIndex).toBeGreaterThan(enabledStateIndex); + }); + test("orders publication table replay before publication owner restore", async () => { const publication = new Publication({ name: "items_pub", From becb9edbddd0165fadc46e9129dc9bac30d83fd7 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Sun, 14 Jun 2026 00:19:30 +0800 Subject: [PATCH 74/78] fix(pg-delta): order view replay before owner restores Map view-owned trigger and rule replay changes back to the owning view for owner-last sort constraints, including trigger/rule metadata wrappers that otherwise only expose trigger: or rule: stable ids. Prune original AlterSequenceChangeOwner changes when promoted table replay recreates the same retained owned sequence and emits the replacement owner restore. RED evidence from 63c5bc2: - view trigger comment replay sorted after AlterViewChangeOwner. - view rule comment/enabled-state replay sorted after AlterViewChangeOwner. - promoted table sequence replay kept two AlterSequenceChangeOwner changes. GREEN verification: - ./node_modules/.bin/bun run --cwd packages/pg-delta test src/core/sort/sort-changes.test.ts --test-name-pattern "view trigger replay|view rule metadata" -> 2 pass / 0 fail. - ./node_modules/.bin/bun run --cwd packages/pg-delta test src/core/expand-replace-dependencies.test.ts --test-name-pattern "prunes original owned sequence owner alters" -> 1 pass / 0 fail. - ./node_modules/.bin/bun run --cwd packages/pg-delta test src/core/expand-replace-dependencies.test.ts src/core/sort/sort-changes.test.ts -> 101 pass / 0 fail. - PGDELTA_SKIP_DUMMY_SECLABEL_BUILD=1 PGDELTA_TEST_POSTGRES_VERSIONS=17 TESTCONTAINERS_RYUK_DISABLED=true ./node_modules/.bin/bun run --cwd packages/pg-delta test tests/integration/sequence-operations.test.ts --test-name-pattern "sequence owner restores run after detach and privilege replay" -> 1 pass / 0 fail. - ./node_modules/.bin/bun run format-and-lint:fix, check-types, knip --fix, and git diff --check passed. Signed-off-by: xiaominghao --- .../src/core/expand-replace-dependencies.ts | 32 ++++++++++++--- .../src/core/sort/custom-constraints.ts | 41 +++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 88933b741..0fdce18e6 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -56,6 +56,8 @@ import { CreateCommentOnRule } from "./objects/rule/changes/rule.comment.ts"; import { CreateRule } from "./objects/rule/changes/rule.create.ts"; import { DropRule } from "./objects/rule/changes/rule.drop.ts"; import { SetRuleEnabledState } from "./objects/rule/changes/rule.alter.ts"; +import { AlterSequenceChangeOwner } from "./objects/sequence/changes/sequence.alter.ts"; +import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts"; import { diffSequences } from "./objects/sequence/sequence.diff.ts"; import { AlterTableAddColumn, @@ -294,6 +296,7 @@ export function expandReplaceDependencies({ const tablesReplacedByExpansion = new Set(); const routinesReplacedByExpansion = new Set(); const triggersReplacedByExpansion = new Set(); + const sequencesRecreatedByPromotedTableReplay = new Set(); const generatedColumnsRecreatedByExpressionFallback = collectCoveredGeneratedColumnRecreations(changes); const columnsRecreatedByOriginalDiff = @@ -594,6 +597,11 @@ export function expandReplaceDependencies({ diffContext, }) : []; + for (const change of retainedOwnedSequenceChanges) { + if (change instanceof CreateSequence) { + sequencesRecreatedByPromotedTableReplay.add(change.sequence.stableId); + } + } const retainedPublicationMembershipChanges = resolved.kind === "table" && addDrop && addCreate ? buildRetainedPublicationMembershipChanges({ @@ -679,12 +687,15 @@ export function expandReplaceDependencies({ return { changes: [ ...removeSupersededGeneratedColumnSetExpressions( - removeSupersededTriggerAlters( - removeSupersededRoutineAlters( - removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds), - routinesReplacedByExpansion, + removeSupersededSequenceAlters( + removeSupersededTriggerAlters( + removeSupersededRoutineAlters( + removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds), + routinesReplacedByExpansion, + ), + triggersReplacedByExpansion, ), - triggersReplacedByExpansion, + sequencesRecreatedByPromotedTableReplay, ), generatedColumnsRecreatedByExpressionFallback, ), @@ -814,6 +825,17 @@ function removeSupersededTriggerAlters( }); } +function removeSupersededSequenceAlters( + changes: Change[], + recreatedSequenceIds: ReadonlySet, +): Change[] { + if (recreatedSequenceIds.size === 0) return changes; + return changes.filter((change) => { + if (!(change instanceof AlterSequenceChangeOwner)) return true; + return !recreatedSequenceIds.has(change.sequence.stableId); + }); +} + function isRoutinePrivilegeReplay(change: Change): boolean { return ( change.operation === "alter" && diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index 125770c38..22fe422d3 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -437,6 +437,29 @@ function getRelatedViewStableIds(change: Change): Set { ) { viewIds.add(change.view.stableId); } + if ( + "trigger" in change && + change.trigger && + typeof change.trigger === "object" && + "table_relkind" in change.trigger && + change.trigger.table_relkind === "v" && + "schema" in change.trigger && + typeof change.trigger.schema === "string" && + "table_name" in change.trigger && + typeof change.trigger.table_name === "string" + ) { + viewIds.add(`view:${change.trigger.schema}.${change.trigger.table_name}`); + } + if ( + "rule" in change && + change.rule && + typeof change.rule === "object" && + "relationStableId" in change.rule && + typeof change.rule.relationStableId === "string" && + change.rule.relationStableId.startsWith("view:") + ) { + viewIds.add(change.rule.relationStableId); + } for (const id of [ ...change.creates, ...change.drops, @@ -446,10 +469,28 @@ function getRelatedViewStableIds(change: Change): Set { if (id.startsWith("view:")) { viewIds.add(id); } + if (id.startsWith("comment:")) { + const viewId = parseViewStableIdFromStableId(id.slice("comment:".length)); + if (viewId) viewIds.add(viewId); + } + if (id.startsWith("securityLabel:")) { + const objectId = id + .slice("securityLabel:".length) + .split("::provider:")[0]; + const viewId = parseViewStableIdFromStableId(objectId); + if (viewId) viewIds.add(viewId); + } } return viewIds; } +function parseViewStableIdFromStableId(id: string): string | null { + if (id.startsWith("view:")) { + return id; + } + return null; +} + function generateViewOwnerRestoreLastConstraints( changes: Change[], ): Constraint[] { From 7bfd8cc7926b8145914de1d88463434fc37e1a24 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Wed, 1 Jul 2026 21:19:42 +0800 Subject: [PATCH 75/78] test(pg-delta): cover latest replacement review regressions --- .../core/expand-replace-dependencies.test.ts | 164 ++++++++++++++++++ .../src/core/sort/sort-changes.test.ts | 63 +++++++ 2 files changed, 227 insertions(+) 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 9a6432fdd..ea9f7d0a1 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -3513,6 +3513,90 @@ describe("expandReplaceDependencies", () => { expect(grants[0]?.columns).toEqual(["value"]); }); + test("restores retained comments for covered regular-to-generated column recreation", async () => { + const baseline = await createEmptyCatalog(150000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + comment: "computed value", + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + comment: "computed value", + }); + const columnId = "column:public.items.value"; + const commentId = `comment:${columnId}`; + const preExistingDropColumn = new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }); + const preExistingAddColumn = new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + preExistingDropColumn, + preExistingAddColumn, + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: commentId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 150000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect(expanded.changes).toContain(preExistingDropColumn); + expect(expanded.changes).toContain(preExistingAddColumn); + expect( + expanded.changes.filter( + (change) => change instanceof CreateCommentOnColumn, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + test("restores retained security labels without replacing the table when recreating a generated column", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -5656,6 +5740,86 @@ describe("expandReplaceDependencies", () => { ).toBe(true); }); + test("restores retained owned sequence defaults on the branch owning table", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = procedureWithArgs(["bigint"]); + const columnDefault = "nextval('public.items_value_seq'::regclass)"; + const mainTable = tableWithDefault(columnDefault); + const branchTable = tableWithDefault(columnDefault); + const mainArchiveTable = tableNamedWithDefault( + "archived_items", + columnDefault, + { name: "archived_value" }, + ); + const branchArchiveTable = tableNamedWithDefault( + "archived_items", + columnDefault, + { name: "archived_value" }, + ); + const mainSequence = sequenceOwnedByItemsValue(); + const branchSequence = new Sequence({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainSequence, + owned_by_table: "archived_items", + owned_by_column: "archived_value", + }); + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { + [mainTable.stableId]: mainTable, + [mainArchiveTable.stableId]: mainArchiveTable, + }, + sequences: { [mainSequence.stableId]: mainSequence }, + depends: [ + { + dependent_stable_id: mainTable.stableId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { + [branchTable.stableId]: branchTable, + [branchArchiveTable.stableId]: branchArchiveTable, + }, + sequences: { [branchSequence.stableId]: branchSequence }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 170000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + const setDefaultChanges = expanded.changes.filter( + (change): change is AlterTableAlterColumnSetDefault => + change instanceof AlterTableAlterColumnSetDefault, + ); + + expect( + setDefaultChanges.map( + (change) => `${change.table.name}.${change.column.name}`, + ), + ).toContain("archived_items.archived_value"); + }); + test("replays sequences cascaded by main-side promoted table ownership", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); 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 00ee376cb..b28a96023 100644 --- a/packages/pg-delta/src/core/sort/sort-changes.test.ts +++ b/packages/pg-delta/src/core/sort/sort-changes.test.ts @@ -4,8 +4,10 @@ import type { Change } from "../change.types.ts"; import type { PgDepend } from "../depend.ts"; import { AlterDomainAddConstraint, + AlterDomainChangeOwner, AlterDomainDropConstraint, AlterDomainDropDefault, + AlterDomainSetDefault, } from "../objects/domain/changes/domain.alter.ts"; import { DropDomain } from "../objects/domain/changes/domain.drop.ts"; import { Domain } from "../objects/domain/domain.model.ts"; @@ -582,6 +584,32 @@ describe("sortChanges", () => { expect(ownerIndex).toBeGreaterThan(commentIndex); }); + test("orders quoted-dot table trigger metadata before table owner restore", async () => { + const owningTable = table('"a.b"'); + const trigger = triggerOnTable('"a.b"', { + name: "audit_trigger", + definition: + 'CREATE TRIGGER audit_trigger AFTER UPDATE ON public."a.b" FOR EACH ROW EXECUTE FUNCTION public.audit_row()', + }); + const changes: Change[] = [ + new AlterTableChangeOwner({ table: owningTable, owner: "app_owner" }), + new CreateCommentOnTrigger({ trigger }), + ]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const commentIndex = sorted.findIndex( + (change) => change instanceof CreateCommentOnTrigger, + ); + const ownerIndex = sorted.findIndex( + (change) => change instanceof AlterTableChangeOwner, + ); + + expect(commentIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(commentIndex); + }); + test("orders view metadata replay before view owner restore", async () => { const retainedView = new View({ schema: "public", @@ -1126,6 +1154,41 @@ describe("sortChanges", () => { ]); }); + test("orders domain expression replay before domain owner restore", async () => { + const branchDomain = new Domain({ + // oxlint-disable-next-line typescript/no-misused-spread + ...domainWithConstraint("score_check", "VALUE > 0"), + default_bin: "public.normalize_value(1)", + default_value: "public.normalize_value(1)", + owner: "app_owner", + }); + const addConstraint = new AlterDomainAddConstraint({ + domain: branchDomain, + constraint: branchDomain.constraints[0], + }); + const setDefault = new AlterDomainSetDefault({ + domain: branchDomain, + defaultValue: "public.normalize_value(1)", + }); + const ownerRestore = new AlterDomainChangeOwner({ + domain: branchDomain, + owner: "app_owner", + }); + const changes: Change[] = [ownerRestore, setDefault, addConstraint]; + const mainCatalog = await catalogWithDepends([]); + const branchCatalog = await catalogWithDepends([]); + + const sorted = sortChanges({ mainCatalog, branchCatalog }, changes); + const setDefaultIndex = sorted.indexOf(setDefault); + const addConstraintIndex = sorted.indexOf(addConstraint); + const ownerIndex = sorted.indexOf(ownerRestore); + + expect(setDefaultIndex).toBeGreaterThan(-1); + expect(addConstraintIndex).toBeGreaterThan(-1); + expect(ownerIndex).toBeGreaterThan(setDefaultIndex); + expect(ownerIndex).toBeGreaterThan(addConstraintIndex); + }); + test("orders unchanged domain check replacement around same-signature procedure replacement", async () => { const mainProcedure = procedure(["integer"]); const branchProcedure = new Procedure({ From 9b6320e87897a703cefc84e1550252a1939cf3eb Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Wed, 1 Jul 2026 21:19:54 +0800 Subject: [PATCH 76/78] fix(pg-delta): address replacement review regressions RED evidence before this fix: - expandReplaceDependencies retained regular-to-generated comment test failed because DropTable was present: Expected false, Received true. - expandReplaceDependencies moved owned-sequence default test failed because set-default changes did not contain archived_items.archived_value: Received []. - sortChanges quoted-dot trigger metadata test failed because owner restore stayed before metadata: ownerIndex 0, commentIndex 1. - sortChanges domain expression replay test failed because owner restore stayed before domain replay: ownerIndex 0, setDefaultIndex 1. --- .changeset/yummy-jokes-sing.md | 2 + .../src/core/expand-replace-dependencies.ts | 21 +++- .../src/core/sort/custom-constraints.ts | 106 +++++++++++++++++- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/.changeset/yummy-jokes-sing.md b/.changeset/yummy-jokes-sing.md index c1de629d9..57fbee3a3 100644 --- a/.changeset/yummy-jokes-sing.md +++ b/.changeset/yummy-jokes-sing.md @@ -3,3 +3,5 @@ --- Recreate expression dependents directly during procedure replacement instead of replacing their owning table or domain. + +Also preserve retained column metadata for column recreations already covered by the original diff, restore defaults on the branch-side owner of replayed owned sequences, and keep domain/table owner restores after expression and metadata replay. diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 0fdce18e6..99e55f079 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -354,8 +354,11 @@ export function expandReplaceDependencies({ ) { continue; } + const columnRecreationCovered = + generatedColumnsRecreatedByExpressionFallback.has(refId) || + columnsRecreatedByOriginalDiff.has(refId); if ( - generatedColumnsRecreatedByExpressionFallback.has(refId) && + columnRecreationCovered && isMetadataDependentStableId(dependentRaw) ) { // Column comments and security labels are metadata for the recreated @@ -372,7 +375,7 @@ export function expandReplaceDependencies({ continue; } if ( - generatedColumnsRecreatedByExpressionFallback.has(refId) && + columnRecreationCovered && isOwnerTableDependentForColumn(refId, dependentRaw) ) { // A table has catalog bookkeeping dependencies on its own columns. The @@ -381,7 +384,7 @@ export function expandReplaceDependencies({ continue; } if ( - generatedColumnsRecreatedByExpressionFallback.has(refId) && + columnRecreationCovered && isGeneratedColumnNotNullConstraintDependent({ columnId: refId, dependentId: dependentRaw, @@ -2819,13 +2822,21 @@ function buildRetainedOwnedSequenceReplacementChanges({ const ownedByColumn = branchSequence.owned_by_column; if (ownedByColumn === null) continue; - const ownedColumn = table.columns.find( + const ownedBySchema = branchSequence.owned_by_schema; + const ownedByTable = branchSequence.owned_by_table; + if (ownedBySchema === null || ownedByTable === null) continue; + + const branchOwnedTable = + branchCatalog.tables[stableId.table(ownedBySchema, ownedByTable)]; + if (!branchOwnedTable) continue; + + const ownedColumn = branchOwnedTable.columns.find( (column) => column.name === ownedByColumn, ); if (ownedColumn && ownedColumn.default !== null) { changes.push( new AlterTableAlterColumnSetDefault({ - table, + table: branchOwnedTable, column: ownedColumn, }), ); diff --git a/packages/pg-delta/src/core/sort/custom-constraints.ts b/packages/pg-delta/src/core/sort/custom-constraints.ts index 22fe422d3..e550bc8a2 100644 --- a/packages/pg-delta/src/core/sort/custom-constraints.ts +++ b/packages/pg-delta/src/core/sort/custom-constraints.ts @@ -1,6 +1,7 @@ import type { Change } from "../change.types.ts"; import { getSchema } from "../change-utils.ts"; import { AlterAggregateChangeOwner } from "../objects/aggregate/changes/aggregate.alter.ts"; +import { AlterDomainChangeOwner } from "../objects/domain/changes/domain.alter.ts"; import { AlterProcedureChangeOwner } from "../objects/procedure/changes/procedure.alter.ts"; import { AlterPublicationSetOwner } from "../objects/publication/changes/publication.alter.ts"; import { @@ -260,7 +261,7 @@ function parseTableStableIdFromStableId(id: string): string | null { } const parseSubEntity = (prefix: string) => { if (!id.startsWith(prefix)) return null; - const parts = id.slice(prefix.length).split("."); + const parts = splitStableIdParts(id.slice(prefix.length)); if (parts.length < 2) return null; return `table:${parts[0]}.${parts[1]}`; }; @@ -274,6 +275,31 @@ function parseTableStableIdFromStableId(id: string): string | null { ); } +function splitStableIdParts(value: string): string[] { + const parts: string[] = []; + let start = 0; + let inQuotedIdentifier = false; + + for (let index = 0; index < value.length; index++) { + const char = value[index]; + if (char === '"') { + if (inQuotedIdentifier && value[index + 1] === '"') { + index++; + continue; + } + inQuotedIdentifier = !inQuotedIdentifier; + continue; + } + if (char === "." && !inQuotedIdentifier) { + parts.push(value.slice(start, index)); + start = index + 1; + } + } + + parts.push(value.slice(start)); + return parts; +} + function getRelatedTableStableIds(change: Change): Set { const tableIds = new Set(); if ( @@ -662,6 +688,83 @@ function generateRoutineOwnerRestoreLastConstraints( return constraints; } +function parseDomainStableIdFromStableId(id: string): string | null { + if (id.startsWith("comment:")) { + return parseDomainStableIdFromStableId(id.slice("comment:".length)); + } + if (id.startsWith("securityLabel:")) { + const objectId = id.slice("securityLabel:".length).split("::provider:")[0]; + return parseDomainStableIdFromStableId(objectId); + } + if (id.startsWith("domain:")) { + return id; + } + return null; +} + +function getRelatedDomainStableIds(change: Change): Set { + const domainIds = new Set(); + if ( + "domain" in change && + change.domain && + typeof change.domain === "object" && + "stableId" in change.domain && + typeof change.domain.stableId === "string" + ) { + domainIds.add(change.domain.stableId); + } + for (const id of [ + ...change.creates, + ...change.drops, + ...change.requires, + ...change.invalidates, + ]) { + const domainId = parseDomainStableIdFromStableId(id); + if (domainId) domainIds.add(domainId); + } + return domainIds; +} + +function generateDomainOwnerRestoreLastConstraints( + changes: Change[], +): Constraint[] { + const constraints: Constraint[] = []; + const ownerChanges: Array<{ index: number; domainId: string }> = []; + const changesByDomain = new Map(); + + for (let index = 0; index < changes.length; index++) { + const change = changes[index]; + if (change instanceof AlterDomainChangeOwner) { + ownerChanges.push({ + index, + domainId: change.domain.stableId, + }); + continue; + } + + const domainIds = getRelatedDomainStableIds(change); + for (const domainId of domainIds) { + const indexes = changesByDomain.get(domainId) ?? []; + indexes.push(index); + changesByDomain.set(domainId, indexes); + } + } + + for (const ownerChange of ownerChanges) { + const relatedIndexes = changesByDomain.get(ownerChange.domainId) ?? []; + for (const relatedIndex of relatedIndexes) { + if (relatedIndex === ownerChange.index) continue; + constraints.push({ + sourceChangeIndex: relatedIndex, + targetChangeIndex: ownerChange.index, + source: "custom", + }); + } + } + + return constraints; +} + function getRelatedPublicationStableIds(change: Change): Set { const publicationIds = new Set(); if ( @@ -812,6 +915,7 @@ const customConstraintGenerators: ConstraintGenerator[] = [ generateViewOwnerRestoreLastConstraints, generateSequenceOwnerRestoreLastConstraints, generateRoutineOwnerRestoreLastConstraints, + generateDomainOwnerRestoreLastConstraints, generatePublicationOwnerRestoreLastConstraints, generateOwnedSequenceAttachmentConstraints, ]; From 7a7a81aded08af60966441bd761a5b210fafd961 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Wed, 1 Jul 2026 21:57:37 +0800 Subject: [PATCH 77/78] test(pg-delta): cover latest procedure dependent regressions --- .../core/expand-replace-dependencies.test.ts | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) 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 ea9f7d0a1..6d68ccbdd 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.test.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.test.ts @@ -1728,6 +1728,75 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("synthesizes column default replacement for quoted-dot table stable ids", async () => { + const baseline = await createEmptyCatalog(170000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableNamedWithDefault( + '"a.b"', + "public.normalize_value(1)", + ); + const branchTable = tableNamedWithDefault( + '"a.b"', + "public.normalize_value(1)", + ); + const columnId = 'column:public."a.b".value'; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnDropDefault, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAlterColumnSetDefault, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + }); + test("does not replace a table when a replaced procedure only drops a dependent column default", async () => { const baseline = await createEmptyCatalog(170000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); @@ -2979,6 +3048,96 @@ describe("expandReplaceDependencies", () => { ).toBe(false); }); + test("does not promote the owning table for truncated generated not null constraints", async () => { + const baseline = await createEmptyCatalog(180000, "postgres"); + const mainProcedure = procedureWithArgs(["integer"]); + const branchProcedure = new Procedure({ + // oxlint-disable-next-line typescript/no-misused-spread + ...mainProcedure, + argument_names: ["renamed"], + definition: + "CREATE FUNCTION public.normalize_value(renamed integer) RETURNS integer", + }); + const mainTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + not_null: true, + }); + const branchTable = tableWithDefault("public.normalize_value(value)", { + is_generated: true, + not_null: true, + }); + const columnId = "column:public.items.value"; + const truncatedNotNullConstraintId = + "constraint:public.items.items_value_not_nul"; + + const changes: Change[] = [ + new DropProcedure({ procedure: mainProcedure }), + new CreateProcedure({ procedure: branchProcedure }), + new AlterTableDropColumn({ + table: mainTable, + column: mainTable.columns[0], + }), + new AlterTableAddColumn({ + table: branchTable, + column: branchTable.columns[0], + }), + ]; + const mainCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [mainProcedure.stableId]: mainProcedure }, + tables: { [mainTable.stableId]: mainTable }, + depends: [ + { + dependent_stable_id: columnId, + referenced_stable_id: mainProcedure.stableId, + deptype: "n", + }, + { + dependent_stable_id: truncatedNotNullConstraintId, + referenced_stable_id: columnId, + deptype: "n", + }, + ], + }); + const branchCatalog = new Catalog({ + // oxlint-disable-next-line typescript/no-misused-spread + ...baseline, + procedures: { [branchProcedure.stableId]: branchProcedure }, + tables: { [branchTable.stableId]: branchTable }, + depends: [], + }); + + const expanded = expandReplaceDependencies({ + changes, + mainCatalog, + branchCatalog, + diffContext: { + version: 180000, + currentUser: "postgres", + defaultPrivilegeState: new DefaultPrivilegeState({}), + }, + }); + + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableDropColumn, + ), + ).toHaveLength(1); + expect( + expanded.changes.filter( + (change) => change instanceof AlterTableAddColumn, + ), + ).toHaveLength(1); + expect(expanded.changes.some((change) => change instanceof DropTable)).toBe( + false, + ); + expect( + expanded.changes.some((change) => change instanceof CreateTable), + ).toBe(false); + expect(expanded.replacedTableIds.has(mainTable.stableId)).toBe(false); + }); + test("promotes retained dependents of a recreated generated column", async () => { const baseline = await createEmptyCatalog(150000, "postgres"); const mainProcedure = procedureWithArgs(["integer"]); From b5bc112f2931837421b6892852d36b17efa58b07 Mon Sep 17 00:00:00 2001 From: xiaominghao Date: Wed, 1 Jul 2026 21:57:47 +0800 Subject: [PATCH 78/78] fix(pg-delta): handle quoted procedure dependents RED evidence: - quoted-dot table stable id regression failed with AlterTableAlterColumnDropDefault length expected 1, received 0 - truncated generated NOT NULL constraint regression failed with DropTable expected false, received true --- .changeset/yummy-jokes-sing.md | 2 + .../src/core/expand-replace-dependencies.ts | 40 ++++++++++++++----- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/.changeset/yummy-jokes-sing.md b/.changeset/yummy-jokes-sing.md index 57fbee3a3..ab8de2c11 100644 --- a/.changeset/yummy-jokes-sing.md +++ b/.changeset/yummy-jokes-sing.md @@ -5,3 +5,5 @@ Recreate expression dependents directly during procedure replacement instead of replacing their owning table or domain. Also preserve retained column metadata for column recreations already covered by the original diff, restore defaults on the branch-side owner of replayed owned sequences, and keep domain/table owner restores after expression and metadata replay. + +Handle quoted-dot table stable IDs while restoring column defaults, and recognize truncated PostgreSQL 18 generated NOT NULL constraint names from catalog dependency edges. diff --git a/packages/pg-delta/src/core/expand-replace-dependencies.ts b/packages/pg-delta/src/core/expand-replace-dependencies.ts index 99e55f079..fb09aa9fd 100644 --- a/packages/pg-delta/src/core/expand-replace-dependencies.ts +++ b/packages/pg-delta/src/core/expand-replace-dependencies.ts @@ -1077,12 +1077,7 @@ function isGeneratedColumnNotNullConstraintDependent({ (branchColumn?.is_generated && branchColumn.not_null), ); - return ( - !modeledConstraint && - constraintRef.constraint === - `${columnRef.table}_${columnRef.column}_not_null` && - generatedColumnNotNull - ); + return !modeledConstraint && generatedColumnNotNull; } function maybeAddRecreatedColumnMetadataRestore({ @@ -2484,7 +2479,7 @@ function normalizeDependentId( } if (id.startsWith("column:")) { - const parts = id.slice("column:".length).split("."); + const parts = splitStableIdParts(id.slice("column:".length)); if (parts.length >= 2) { const [schema, table] = parts; return `table:${schema}.${table}`; @@ -2522,9 +2517,36 @@ interface ConstraintStableIdParts { constraint: string; } +function splitStableIdParts(value: string): string[] { + const parts: string[] = []; + let partStart = 0; + let inQuotedIdentifier = false; + + for (let index = 0; index < value.length; index++) { + const char = value[index]; + + if (char === '"') { + if (inQuotedIdentifier && value[index + 1] === '"') { + index++; + continue; + } + inQuotedIdentifier = !inQuotedIdentifier; + continue; + } + + if (char === "." && !inQuotedIdentifier) { + parts.push(value.slice(partStart, index)); + partStart = index + 1; + } + } + + parts.push(value.slice(partStart)); + return parts; +} + function parseColumnStableId(stableId: string): ColumnStableIdParts | null { if (!stableId.startsWith("column:")) return null; - const parts = stableId.slice("column:".length).split("."); + const parts = splitStableIdParts(stableId.slice("column:".length)); if (parts.length < 3) return null; const [schema, table, ...columnParts] = parts; return { @@ -2538,7 +2560,7 @@ function parseConstraintStableId( stableId: string, ): ConstraintStableIdParts | null { if (!stableId.startsWith("constraint:")) return null; - const parts = stableId.slice("constraint:".length).split("."); + const parts = splitStableIdParts(stableId.slice("constraint:".length)); if (parts.length < 3) return null; const [schema, owner, ...constraintParts] = parts; return {