Bug report
pg-delta can generate destructive DROP TABLE + CREATE TABLE migrations when replacing a function signature that is referenced only by a column default or a check constraint. The table itself does not need to be replaced; the dependent expression can be dropped/updated around the function replacement.
This was found during deep validation of current main.
- Affected package:
pg-delta
- Tested commit:
9f01826b00b64e7f9628844ecb6f69a07f53c1f8
- PostgreSQL version: 17.6 via integration container
- Runtime: Bun 1.3.14
- Docker: 28.3.2
- OS: macOS/Darwin arm64
- Command shape: focused PG17 integration probe using
createPlan
Scenario 1: function signature change referenced by a column default
Source state
CREATE SCHEMA probe_default;
CREATE FUNCTION probe_default.make_amount(value integer)
RETURNS integer
LANGUAGE sql
IMMUTABLE
AS $$ SELECT value $$;
CREATE TABLE probe_default.items (
id integer PRIMARY KEY,
amount integer DEFAULT probe_default.make_amount(1)
);
Target state
ALTER TABLE probe_default.items ALTER COLUMN amount DROP DEFAULT;
DROP FUNCTION probe_default.make_amount(integer);
CREATE FUNCTION probe_default.make_amount(value bigint)
RETURNS integer
LANGUAGE sql
IMMUTABLE
AS $$ SELECT value::integer $$;
ALTER TABLE probe_default.items
ALTER COLUMN amount SET DEFAULT probe_default.make_amount(1::bigint);
Actual generated SQL
SET check_function_bodies = false;
DROP TABLE probe_default.items;
DROP FUNCTION probe_default.make_amount(value integer);
CREATE FUNCTION probe_default.make_amount(value bigint)
RETURNS integer
LANGUAGE sql
IMMUTABLE
AS $function$ SELECT value::integer $function$;
CREATE TABLE probe_default.items (id integer NOT NULL, amount integer DEFAULT probe_default.make_amount((1)::bigint));
ALTER TABLE probe_default.items ALTER COLUMN amount SET DEFAULT probe_default.make_amount((1)::bigint);
ALTER TABLE probe_default.items ADD CONSTRAINT items_pkey PRIMARY KEY (id);
The DROP TABLE probe_default.items is not required and would destroy table data.
Expected SQL shape
SET check_function_bodies = false;
ALTER TABLE probe_default.items ALTER COLUMN amount DROP DEFAULT;
DROP FUNCTION probe_default.make_amount(value integer);
CREATE FUNCTION probe_default.make_amount(value bigint)
RETURNS integer
LANGUAGE sql
IMMUTABLE
AS $$ SELECT value::integer $$;
ALTER TABLE probe_default.items
ALTER COLUMN amount SET DEFAULT probe_default.make_amount(1::bigint);
Scenario 2: function signature change referenced by a check constraint
Source state
CREATE SCHEMA probe_constraint;
CREATE FUNCTION probe_constraint.is_valid_amount(value integer)
RETURNS boolean
LANGUAGE sql
IMMUTABLE
AS $$ SELECT value > 0 $$;
CREATE TABLE probe_constraint.items (
id integer PRIMARY KEY,
amount integer NOT NULL,
CONSTRAINT amount_is_valid CHECK (
probe_constraint.is_valid_amount(amount)
)
);
Target state
ALTER TABLE probe_constraint.items DROP CONSTRAINT amount_is_valid;
DROP FUNCTION probe_constraint.is_valid_amount(integer);
CREATE FUNCTION probe_constraint.is_valid_amount(value bigint)
RETURNS boolean
LANGUAGE sql
IMMUTABLE
AS $$ SELECT value > 0 $$;
ALTER TABLE probe_constraint.items
ADD CONSTRAINT amount_is_valid CHECK (
probe_constraint.is_valid_amount(amount::bigint)
);
Actual generated SQL
SET check_function_bodies = false;
DROP TABLE probe_constraint.items;
DROP FUNCTION probe_constraint.is_valid_amount(value integer);
CREATE FUNCTION probe_constraint.is_valid_amount(value bigint)
RETURNS boolean
LANGUAGE sql
IMMUTABLE
AS $function$ SELECT value > 0 $function$;
CREATE TABLE probe_constraint.items (id integer NOT NULL, amount integer NOT NULL);
ALTER TABLE probe_constraint.items ADD CONSTRAINT amount_is_valid CHECK (probe_constraint.is_valid_amount(amount::bigint));
ALTER TABLE probe_constraint.items ADD CONSTRAINT items_pkey PRIMARY KEY (id);
Again, the table replacement is unnecessary and destructive.
Root cause
This looks like an over-broad dependency promotion in the replacement cascade:
- Replacing the function signature seeds
expandReplaceDependencies from the dropped/recreated procedure stable id.
mainCatalog.depends correctly reports expression dependencies from PostgreSQL catalogs:
pg_attrdef default expression depends on the old function;
pg_constraint check expression depends on the old function.
- The dependency walk reaches a
column:* or constraint:* dependent.
normalizeDependentId promotes those ids to the owning table:*.
buildReplaceChanges then emits DropTable + CreateTable for the table.
That promotion is too destructive for expression dependents that PostgreSQL can update independently. In both repros, the table is only a container for the dependent expression; it does not need to be dropped.
Suggested fix direction
Treat expression dependents as targeted replacement/removal units before falling back to table replacement:
- For
pg_attrdef / column-default dependencies, ensure the migration emits ALTER TABLE ... ALTER COLUMN ... DROP DEFAULT before the old function is dropped, then ALTER TABLE ... ALTER COLUMN ... SET DEFAULT ... after the new function exists.
- For check constraints, reuse the existing table-constraint changes (
DROP CONSTRAINT, ADD CONSTRAINT) instead of promoting the owning table to DROP TABLE.
- Restrict
column:* / constraint:* -> table:* normalization to cases where the owning table truly has to be replaced, or only after checking whether an existing targeted column/default/constraint change already resolves the dependency.
- Add integration coverage that asserts the generated plan contains no
DROP TABLE for these cases and applies cleanly with existing rows preserved.
Local validation command
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 \
--test-name-pattern "column default does not replace the table" \
tests/integration/deep-scenario-probes.test.ts
The focused default and check-constraint probes both failed consistently after retries because the generated plan contained DROP TABLE.
Bug report
pg-deltacan generate destructiveDROP TABLE+CREATE TABLEmigrations when replacing a function signature that is referenced only by a column default or a check constraint. The table itself does not need to be replaced; the dependent expression can be dropped/updated around the function replacement.This was found during deep validation of current
main.pg-delta9f01826b00b64e7f9628844ecb6f69a07f53c1f8createPlanScenario 1: function signature change referenced by a column default
Source state
Target state
Actual generated SQL
The
DROP TABLE probe_default.itemsis not required and would destroy table data.Expected SQL shape
Scenario 2: function signature change referenced by a check constraint
Source state
Target state
Actual generated SQL
Again, the table replacement is unnecessary and destructive.
Root cause
This looks like an over-broad dependency promotion in the replacement cascade:
expandReplaceDependenciesfrom the dropped/recreated procedure stable id.mainCatalog.dependscorrectly reports expression dependencies from PostgreSQL catalogs:pg_attrdefdefault expression depends on the old function;pg_constraintcheck expression depends on the old function.column:*orconstraint:*dependent.normalizeDependentIdpromotes those ids to the owningtable:*.buildReplaceChangesthen emitsDropTable+CreateTablefor the table.That promotion is too destructive for expression dependents that PostgreSQL can update independently. In both repros, the table is only a container for the dependent expression; it does not need to be dropped.
Suggested fix direction
Treat expression dependents as targeted replacement/removal units before falling back to table replacement:
pg_attrdef/ column-default dependencies, ensure the migration emitsALTER TABLE ... ALTER COLUMN ... DROP DEFAULTbefore the old function is dropped, thenALTER TABLE ... ALTER COLUMN ... SET DEFAULT ...after the new function exists.DROP CONSTRAINT,ADD CONSTRAINT) instead of promoting the owning table toDROP TABLE.column:*/constraint:*->table:*normalization to cases where the owning table truly has to be replaced, or only after checking whether an existing targeted column/default/constraint change already resolves the dependency.DROP TABLEfor these cases and applies cleanly with existing rows preserved.Local validation command
The focused default and check-constraint probes both failed consistently after retries because the generated plan contained
DROP TABLE.