From f565e90e68f182ab040503f60c6b81a8635995fc Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Tue, 23 Jun 2026 01:12:26 +0800 Subject: [PATCH] fix: skip partition-clone child triggers in dump (#472) A FOR EACH ROW trigger on a partitioned table causes PostgreSQL to clone the trigger onto every partition child, creating one extra pg_trigger row per partition with tgparentid != 0 (and tgisinternal = false). pg_dump never emits these clones; only the top-level trigger on the partitioned parent (tgparentid = 0) is dumpable. pgschema dump emitted all of them, producing a bogus CREATE OR REPLACE TRIGGER ... ON statement. This broke pgschema's own plan with "relation does not exist" when the child partition was excluded via .pgschemaignore, mirroring the #409/#460 internal per-partition FK constraint problem. Filter trigger rows with t.tgparentid = 0 in the inspector query, matching pg_dump and the existing FK-constraint treatment. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/dump/dump_integration_test.go | 7 +++ ir/queries/queries.sql | 5 ++ ir/queries/queries.sql.go | 5 ++ .../manifest.json | 11 ++++ .../pgdump.sql | 51 +++++++++++++++++++ .../pgschema.sql | 50 ++++++++++++++++++ .../issue_472_partition_clone_trigger/raw.sql | 28 ++++++++++ 7 files changed, 157 insertions(+) create mode 100644 testdata/dump/issue_472_partition_clone_trigger/manifest.json create mode 100644 testdata/dump/issue_472_partition_clone_trigger/pgdump.sql create mode 100644 testdata/dump/issue_472_partition_clone_trigger/pgschema.sql create mode 100644 testdata/dump/issue_472_partition_clone_trigger/raw.sql diff --git a/cmd/dump/dump_integration_test.go b/cmd/dump/dump_integration_test.go index da25f580..f4501ec4 100644 --- a/cmd/dump/dump_integration_test.go +++ b/cmd/dump/dump_integration_test.go @@ -165,6 +165,13 @@ func TestDumpCommand_Issue409PartitionedFK(t *testing.T) { runExactMatchTest(t, "issue_409_partitioned_fk") } +func TestDumpCommand_Issue472PartitionCloneTrigger(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + runExactMatchTest(t, "issue_472_partition_clone_trigger") +} + func TestDumpCommand_Issue421QuotedFKColumns(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") diff --git a/ir/queries/queries.sql b/ir/queries/queries.sql index 3f77c463..7d213674 100644 --- a/ir/queries/queries.sql +++ b/ir/queries/queries.sql @@ -1316,6 +1316,11 @@ LEFT JOIN LATERAL ( ) def ON true WHERE n.nspname = $1 AND NOT t.tgisinternal -- Exclude internal triggers + -- Skip partition-clone child triggers (tgparentid != 0) that PostgreSQL + -- automatically creates on every partition when a FOR EACH ROW trigger is + -- defined on a partitioned parent; pg_dump emits only the top-level trigger + -- on the parent (tgparentid = 0). + AND t.tgparentid = 0 ORDER BY n.nspname, c.relname, t.tgname; -- GetTypesForSchema retrieves all user-defined types for a specific schema diff --git a/ir/queries/queries.sql.go b/ir/queries/queries.sql.go index fb430d53..a29fd9a9 100644 --- a/ir/queries/queries.sql.go +++ b/ir/queries/queries.sql.go @@ -3182,6 +3182,11 @@ LEFT JOIN LATERAL ( ) def ON true WHERE n.nspname = $1 AND NOT t.tgisinternal -- Exclude internal triggers + -- Skip partition-clone child triggers (tgparentid != 0) that PostgreSQL + -- automatically creates on every partition when a FOR EACH ROW trigger is + -- defined on a partitioned parent; pg_dump emits only the top-level trigger + -- on the parent (tgparentid = 0). + AND t.tgparentid = 0 ORDER BY n.nspname, c.relname, t.tgname ` diff --git a/testdata/dump/issue_472_partition_clone_trigger/manifest.json b/testdata/dump/issue_472_partition_clone_trigger/manifest.json new file mode 100644 index 00000000..5fd72eae --- /dev/null +++ b/testdata/dump/issue_472_partition_clone_trigger/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "issue_472_partition_clone_trigger", + "description": "pgschema dump emits partition-clone child triggers (tgparentid != 0) that PostgreSQL creates automatically on every partition", + "source": "https://github.com/pgplex/pgschema/issues/472", + "notes": [ + "A FOR EACH ROW trigger on a partitioned table causes PostgreSQL to clone the trigger onto every partition child, creating one extra pg_trigger row per partition with tgparentid != 0 (and tgisinternal = false).", + "pg_dump never emits these clones; only the top-level trigger on the partitioned parent (tgparentid = 0) is dumpable.", + "The trigger query in ir/queries now filters rows with t.tgparentid = 0 to match pg_dump, mirroring the #409/#460 treatment of internal per-partition FK constraints.", + "Without the fix, dump produced a bogus CREATE OR REPLACE TRIGGER trg_rollup ... ON ledger_2026_06, which also left a dangling reference when the partition was excluded via .pgschemaignore." + ] +} diff --git a/testdata/dump/issue_472_partition_clone_trigger/pgdump.sql b/testdata/dump/issue_472_partition_clone_trigger/pgdump.sql new file mode 100644 index 00000000..9b7741d9 --- /dev/null +++ b/testdata/dump/issue_472_partition_clone_trigger/pgdump.sql @@ -0,0 +1,51 @@ +-- +-- PostgreSQL database dump +-- + +SET statement_timeout = 0; +SET lock_timeout = 0; +-- SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET check_function_bodies = false; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: ledger; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.ledger ( + id uuid NOT NULL, + amount bigint NOT NULL, + ts timestamp with time zone NOT NULL +) +PARTITION BY RANGE (ts); + +ALTER TABLE ONLY public.ledger + ADD CONSTRAINT ledger_pkey PRIMARY KEY (ts, id); + +-- +-- Name: ledger_2026_06; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.ledger_2026_06 PARTITION OF public.ledger +FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); + +-- +-- Name: tg_noop(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.tg_noop() RETURNS trigger + LANGUAGE plpgsql + AS $$ BEGIN RETURN NEW; END $$; + +-- +-- Name: ledger trg_rollup; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER trg_rollup AFTER INSERT ON public.ledger FOR EACH ROW EXECUTE FUNCTION public.tg_noop(); + +-- +-- PostgreSQL database dump complete +-- diff --git a/testdata/dump/issue_472_partition_clone_trigger/pgschema.sql b/testdata/dump/issue_472_partition_clone_trigger/pgschema.sql new file mode 100644 index 00000000..caf57aff --- /dev/null +++ b/testdata/dump/issue_472_partition_clone_trigger/pgschema.sql @@ -0,0 +1,50 @@ +-- +-- pgschema database dump +-- + +-- Dumped from database version PostgreSQL 18.0 +-- Dumped by pgschema version 1.11.0 + + +-- +-- Name: ledger; Type: TABLE; Schema: -; Owner: - +-- + +CREATE TABLE IF NOT EXISTS ledger ( + id uuid, + amount bigint NOT NULL, + ts timestamptz, + CONSTRAINT ledger_pkey PRIMARY KEY (ts, id) +) PARTITION BY RANGE (ts); + +-- +-- Name: ledger_2026_06; Type: TABLE; Schema: -; Owner: - +-- + +CREATE TABLE IF NOT EXISTS ledger_2026_06 ( + id uuid, + amount bigint NOT NULL, + ts timestamptz, + CONSTRAINT ledger_2026_06_pkey PRIMARY KEY (ts, id) +); + +-- +-- Name: tg_noop(); Type: FUNCTION; Schema: -; Owner: - +-- + +CREATE OR REPLACE FUNCTION tg_noop() +RETURNS trigger +LANGUAGE plpgsql +VOLATILE +AS $$ BEGIN RETURN NEW; END +$$; + +-- +-- Name: trg_rollup; Type: TRIGGER; Schema: -; Owner: - +-- + +CREATE OR REPLACE TRIGGER trg_rollup + AFTER INSERT ON ledger + FOR EACH ROW + EXECUTE FUNCTION tg_noop(); + diff --git a/testdata/dump/issue_472_partition_clone_trigger/raw.sql b/testdata/dump/issue_472_partition_clone_trigger/raw.sql new file mode 100644 index 00000000..66da855b --- /dev/null +++ b/testdata/dump/issue_472_partition_clone_trigger/raw.sql @@ -0,0 +1,28 @@ +-- +-- Test case for GitHub issue #472: partition-clone child triggers dumped +-- +-- When a FOR EACH ROW trigger is defined on a partitioned table, PostgreSQL +-- automatically clones it onto every partition child, creating one extra +-- pg_trigger row per partition with tgparentid != 0 (and tgisinternal = false). +-- These are internal artifacts: pg_dump never emits them, only the top-level +-- trigger on the partitioned parent (tgparentid = 0). +-- +-- pgschema dump used to emit all of them, producing a bogus +-- CREATE OR REPLACE TRIGGER trg_rollup ... ON ledger_2026_06 that also left a +-- dangling reference when the partition was excluded via .pgschemaignore. +-- + +CREATE TABLE ledger ( + id uuid NOT NULL, + amount bigint NOT NULL, + ts timestamptz NOT NULL, + PRIMARY KEY (ts, id) +) PARTITION BY RANGE (ts); + +CREATE TABLE ledger_2026_06 PARTITION OF ledger + FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); + +CREATE FUNCTION tg_noop() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; + +CREATE TRIGGER trg_rollup AFTER INSERT ON ledger + FOR EACH ROW EXECUTE FUNCTION tg_noop();