Skip to content

pg-delta: function replacement can over-recreate tables for default and check-constraint dependencies #280

Description

@xmh1011

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:

  1. Replacing the function signature seeds expandReplaceDependencies from the dropped/recreated procedure stable id.
  2. 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.
  3. The dependency walk reaches a column:* or constraint:* dependent.
  4. normalizeDependentId promotes those ids to the owning table:*.
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    supabase/pg-toolbelt🐛 BugDefects, regressions, or unintended behavior that need fixing

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions