From 76b07f6cdc125cb10f2d5b229c07007efe5c8588 Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <pj@illuminatedcomputing.com>
Date: Thu, 3 Sep 2026 13:34:55 -0700
Subject: [PATCH v1] Fire leftover INSERT triggers on the table the leftovers
 go into

Since 7d13b03, FOR PORTION OF leftovers under plain table inheritance are
re-inserted into the child table they came from, but the statement-level
INSERT triggers for those inserts still belonged to the parent. This commit
fires triggers directly on the child table, which also requires updating the
transition table to match.

Changed fireBSTriggers, fireASTriggers, and ExecSetupTransitionCaptureState to
parameterize the result rel instead of assuming mtstate->rootResultRelInfo. Only
temporal leftovers use a non-root result rel right now.

We also need to avoid conversion when adding to the transition table tuplestore.
That is what tcs_original_insert_tuple is designed for, so we set it to
leftoverSlot for traditional-inheritance child tables.

Reported-by: Noah Misch <noah@leadboat.com>
Author: Paul A. Jungwirth <pj@illuminatedcomputing.com>
Backpatch-through: 19
---
 src/backend/executor/nodeModifyTable.c       |  54 ++++--
 src/test/regress/expected/for_portion_of.out | 192 +++++++++++++++++++
 src/test/regress/sql/for_portion_of.sql      | 126 ++++++++++++
 3 files changed, 356 insertions(+), 16 deletions(-)

diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 5681505d31c..ae55816d590 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -196,9 +196,13 @@ static TupleTableSlot *ExecMergeMatched(ModifyTableContext *context,
 static TupleTableSlot *ExecMergeNotMatched(ModifyTableContext *context,
 										   ResultRelInfo *resultRelInfo,
 										   bool canSetTag);
-static void ExecSetupTransitionCaptureState(ModifyTableState *mtstate, EState *estate);
-static void fireBSTriggers(ModifyTableState *node);
-static void fireASTriggers(ModifyTableState *node);
+static void ExecSetupTransitionCaptureState(ModifyTableState *mtstate,
+											EState *estate,
+											ResultRelInfo *targetRelInfo);
+static void fireBSTriggers(ModifyTableState *node,
+						   ResultRelInfo *resultRelInfo);
+static void fireASTriggers(ModifyTableState *node,
+						   ResultRelInfo *resultRelInfo);
 static void ExecInitForPortionOf(ModifyTableState *mtstate, EState *estate,
 								 ResultRelInfo *resultRelInfo);
 
@@ -1614,10 +1618,24 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 		 * diagnostic or the command tag, so we pass false for canSetTag.
 		 */
 		AfterTriggerBeginQuery();
-		ExecSetupTransitionCaptureState(mtstate, estate);
-		fireBSTriggers(mtstate);
+		ExecSetupTransitionCaptureState(mtstate, estate, resultRelInfo);
+
+		/*
+		 * When inserting directly into a child table (for traditional
+		 * inheritance), we must avoid converting to the root table's format.
+		 */
+		if (!partitionRouting && mtstate->mt_transition_capture != NULL)
+			mtstate->mt_transition_capture->tcs_original_insert_tuple =
+				leftoverSlot;
+
+		fireBSTriggers(mtstate, resultRelInfo);
 		ExecInsert(context, resultRelInfo, leftoverSlot, false, NULL, NULL);
-		fireASTriggers(mtstate);
+		fireASTriggers(mtstate, resultRelInfo);
+
+		/* Reset the transition state, as ExecCrossPartitionUpdate() does. */
+		if (mtstate->mt_transition_capture != NULL)
+			mtstate->mt_transition_capture->tcs_original_insert_tuple = NULL;
+
 		AfterTriggerEndQuery(estate);
 	}
 
@@ -4461,10 +4479,9 @@ ExecInitMergeTupleSlots(ModifyTableState *mtstate,
  * Process BEFORE EACH STATEMENT triggers
  */
 static void
-fireBSTriggers(ModifyTableState *node)
+fireBSTriggers(ModifyTableState *node, ResultRelInfo *resultRelInfo)
 {
 	ModifyTable *plan = (ModifyTable *) node->ps.plan;
-	ResultRelInfo *resultRelInfo = node->rootResultRelInfo;
 
 	switch (node->operation)
 	{
@@ -4498,10 +4515,9 @@ fireBSTriggers(ModifyTableState *node)
  * Process AFTER EACH STATEMENT triggers
  */
 static void
-fireASTriggers(ModifyTableState *node)
+fireASTriggers(ModifyTableState *node, ResultRelInfo *resultRelInfo)
 {
 	ModifyTable *plan = (ModifyTable *) node->ps.plan;
-	ResultRelInfo *resultRelInfo = node->rootResultRelInfo;
 
 	switch (node->operation)
 	{
@@ -4541,14 +4557,19 @@ fireASTriggers(ModifyTableState *node)
 /*
  * Set up the state needed for collecting transition tuples for AFTER
  * triggers.
+ *
+ * targetRelInfo is the relation whose transition tables we collect into.
+ * Normally this is the statement's target relation, but temporal leftovers are
+ * inserted directly into child tables (for traditional inheritance), so it
+ * needs to match.
  */
 static void
-ExecSetupTransitionCaptureState(ModifyTableState *mtstate, EState *estate)
+ExecSetupTransitionCaptureState(ModifyTableState *mtstate, EState *estate,
+								ResultRelInfo *targetRelInfo)
 {
 	ModifyTable *plan = (ModifyTable *) mtstate->ps.plan;
-	ResultRelInfo *targetRelInfo = mtstate->rootResultRelInfo;
 
-	/* Check for transition tables on the directly targeted relation. */
+	/* Check for transition tables on the given relation. */
 	mtstate->mt_transition_capture =
 		MakeTransitionCaptureState(targetRelInfo->ri_TrigDesc,
 								   RelationGetRelid(targetRelInfo->ri_RelationDesc),
@@ -4676,7 +4697,7 @@ ExecModifyTable(PlanState *pstate)
 	 */
 	if (node->fireBSTriggers)
 	{
-		fireBSTriggers(node);
+		fireBSTriggers(node, node->rootResultRelInfo);
 		node->fireBSTriggers = false;
 	}
 
@@ -5057,7 +5078,7 @@ ExecModifyTable(PlanState *pstate)
 	/*
 	 * We're done, but fire AFTER STATEMENT triggers before exiting.
 	 */
-	fireASTriggers(node);
+	fireASTriggers(node, node->rootResultRelInfo);
 
 	node->mt_done = true;
 
@@ -5302,7 +5323,8 @@ ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags)
 	 * valid trigger query context, so skip it in explain-only mode.
 	 */
 	if (!(eflags & EXEC_FLAG_EXPLAIN_ONLY))
-		ExecSetupTransitionCaptureState(mtstate, estate);
+		ExecSetupTransitionCaptureState(mtstate, estate,
+										mtstate->rootResultRelInfo);
 
 	/*
 	 * Open all the result relations and initialize the ResultRelInfo structs.
diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out
index 64789d1777b..95c3cd0d25d 100644
--- a/src/test/regress/expected/for_portion_of.out
+++ b/src/test/regress/expected/for_portion_of.out
@@ -2502,8 +2502,188 @@ SELECT * FROM ONLY fpo_inh_parent ORDER BY valid_at;
 ----+----------+------
 (0 rows)
 
+-- Statement-level INSERT triggers for the leftovers must fire on the table
+-- the leftovers are actually inserted into, i.e. the child, just as they
+-- would for a plain INSERT into that child.
+CREATE FUNCTION fpo_inh_stmt_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '% % % on %', TG_NAME, TG_WHEN, TG_OP, TG_TABLE_NAME;
+  RETURN NULL;
+END;
+$$;
+CREATE TRIGGER fpo_inh_parent_bs BEFORE INSERT ON fpo_inh_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_inh_parent_as AFTER INSERT ON fpo_inh_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_inh_child_bs BEFORE INSERT ON fpo_inh_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_inh_child_as AFTER INSERT ON fpo_inh_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+-- control: a plain INSERT into the child fires only the child's triggers
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+-- One firing per leftover, all of them on the child.
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+   tableoid    |  id   |        valid_at         | name  
+---------------+-------+-------------------------+-------
+ fpo_inh_child | [1,2) | [2018-01-01,2018-04-01) | one
+ fpo_inh_child | [1,2) | [2018-04-01,2018-10-01) | one^1
+ fpo_inh_child | [1,2) | [2018-10-01,2019-01-01) | one
+(3 rows)
+
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+DELETE FROM fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01';
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+   tableoid    |  id   |        valid_at         | name 
+---------------+-------+-------------------------+------
+ fpo_inh_child | [1,2) | [2018-01-01,2018-04-01) | one
+ fpo_inh_child | [1,2) | [2018-10-01,2019-01-01) | one
+(2 rows)
+
+-- The transition table for those inserts belongs to the child too, and holds
+-- the child's rows in the child's own format, including its extra columns.
+CREATE FUNCTION fpo_inh_transition_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '% on %: %', TG_NAME, TG_TABLE_NAME,
+    (SELECT string_agg(new_table::text, ', ' ORDER BY new_table::text)
+       FROM new_table);
+  RETURN NULL;
+END;
+$$;
+CREATE TRIGGER fpo_inh_parent_tt AFTER INSERT ON fpo_inh_parent
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_transition_trigger();
+CREATE TRIGGER fpo_inh_child_tt AFTER INSERT ON fpo_inh_child
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_transition_trigger();
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+-- control: a plain INSERT into the child reports description = 'initial'
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_tt on fpo_inh_child: ("[1,2)","[2018-01-01,2019-01-01)",one,initial)
+-- The leftovers must report it too: they are child rows and they keep it.
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_tt on fpo_inh_child: ("[1,2)","[2018-01-01,2018-04-01)",one,initial)
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_tt on fpo_inh_child: ("[1,2)","[2018-10-01,2019-01-01)",one,initial)
+SELECT tableoid::regclass, * FROM fpo_inh_child ORDER BY valid_at;
+   tableoid    |  id   |        valid_at         | name  | description 
+---------------+-------+-------------------------+-------+-------------
+ fpo_inh_child | [1,2) | [2018-01-01,2018-04-01) | one   | initial
+ fpo_inh_child | [1,2) | [2018-04-01,2018-10-01) | one^1 | initial
+ fpo_inh_child | [1,2) | [2018-10-01,2019-01-01) | one   | initial
+(3 rows)
+
+-- DELETE inserts its leftovers the same way.
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_tt on fpo_inh_child: ("[1,2)","[2018-01-01,2019-01-01)",one,initial)
+DELETE FROM fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01';
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_tt on fpo_inh_child: ("[1,2)","[2018-01-01,2018-04-01)",one,initial)
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_tt on fpo_inh_child: ("[1,2)","[2018-10-01,2019-01-01)",one,initial)
+SELECT tableoid::regclass, * FROM fpo_inh_child ORDER BY valid_at;
+   tableoid    |  id   |        valid_at         | name | description 
+---------------+-------+-------------------------+------+-------------
+ fpo_inh_child | [1,2) | [2018-01-01,2018-04-01) | one  | initial
+ fpo_inh_child | [1,2) | [2018-10-01,2019-01-01) | one  | initial
+(2 rows)
+
 DROP TABLE fpo_inh_parent CASCADE;
 NOTICE:  drop cascades to table fpo_inh_child
+-- By contrast, leftovers in a partitioned table are inserted through the
+-- root to get tuple routing, so the root is where their statement-level
+-- triggers belong, exactly as for a plain INSERT into the root.
+CREATE TABLE fpo_part_parent (
+  id int4range,
+  valid_at daterange,
+  name text
+) PARTITION BY RANGE (id);
+CREATE TABLE fpo_part_child PARTITION OF fpo_part_parent
+  FOR VALUES FROM ('[1,1]') TO ('[9,9]');
+CREATE TRIGGER fpo_part_parent_bs BEFORE INSERT ON fpo_part_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_part_parent_as AFTER INSERT ON fpo_part_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_part_child_bs BEFORE INSERT ON fpo_part_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_part_child_as AFTER INSERT ON fpo_part_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+-- and so does their transition table, which still receives the rows converted
+-- to the root's format.
+CREATE FUNCTION fpo_part_transition_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '% on %: %', TG_NAME, TG_TABLE_NAME,
+    (SELECT string_agg(new_table::text, ', ' ORDER BY new_table::text)
+       FROM new_table);
+  RETURN NULL;
+END;
+$$;
+CREATE TRIGGER fpo_part_parent_tt AFTER INSERT ON fpo_part_parent
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_part_transition_trigger();
+INSERT INTO fpo_part_parent VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one');
+NOTICE:  fpo_part_parent_bs BEFORE INSERT on fpo_part_parent
+NOTICE:  fpo_part_parent_as AFTER INSERT on fpo_part_parent
+NOTICE:  fpo_part_parent_tt on fpo_part_parent: ("[1,2)","[2018-01-01,2019-01-01)",one)
+UPDATE fpo_part_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+NOTICE:  fpo_part_parent_bs BEFORE INSERT on fpo_part_parent
+NOTICE:  fpo_part_parent_as AFTER INSERT on fpo_part_parent
+NOTICE:  fpo_part_parent_tt on fpo_part_parent: ("[1,2)","[2018-01-01,2018-04-01)",one)
+NOTICE:  fpo_part_parent_bs BEFORE INSERT on fpo_part_parent
+NOTICE:  fpo_part_parent_as AFTER INSERT on fpo_part_parent
+NOTICE:  fpo_part_parent_tt on fpo_part_parent: ("[1,2)","[2018-10-01,2019-01-01)",one)
+SELECT tableoid::regclass, * FROM fpo_part_parent ORDER BY valid_at;
+    tableoid    |  id   |        valid_at         | name  
+----------------+-------+-------------------------+-------
+ fpo_part_child | [1,2) | [2018-01-01,2018-04-01) | one
+ fpo_part_child | [1,2) | [2018-04-01,2018-10-01) | one^1
+ fpo_part_child | [1,2) | [2018-10-01,2019-01-01) | one
+(3 rows)
+
+DROP FUNCTION fpo_part_transition_trigger();
+ERROR:  cannot drop function fpo_part_transition_trigger() because other objects depend on it
+DETAIL:  trigger fpo_part_parent_tt on table fpo_part_parent depends on function fpo_part_transition_trigger()
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+DROP TABLE fpo_part_parent;
+DROP FUNCTION fpo_inh_stmt_trigger();
 -- UPDATE FOR PORTION OF with multiple inheritance
 -- Leftover rows must stay in the child table, even if the range column's
 -- attnum differs between the target parent and child.
@@ -2517,6 +2697,11 @@ CREATE TABLE other_parent (
   note text
 );
 CREATE TABLE mi_child () INHERITS (other_parent, temporal_parent);
+-- The leftovers' transition table must use the child's format even when the
+-- child's columns are in a different order from the parent's.
+CREATE TRIGGER mi_child_tt AFTER INSERT ON mi_child
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_transition_trigger();
 -- attnum of the range column is different in temporal_parent and mi_child
 SELECT attnum, attname
   FROM pg_attribute
@@ -2546,9 +2731,12 @@ SELECT attnum, attname
 
 INSERT INTO mi_child (prefix, note, id, valid_at, name) VALUES
   ('pfx', 'memo', 1, daterange('2000-01-01', '2010-01-01'), 'old');
+NOTICE:  mi_child_tt on mi_child: (pfx,memo,1,"[2000-01-01,2010-01-01)",old)
 UPDATE temporal_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
   SET name = 'new'
   WHERE id = 1;
+NOTICE:  mi_child_tt on mi_child: (pfx,memo,1,"[2000-01-01,2001-01-01)",old)
+NOTICE:  mi_child_tt on mi_child: (pfx,memo,1,"[2002-01-01,2010-01-01)",old)
 SELECT tableoid::regclass, * FROM temporal_parent ORDER BY valid_at;
  tableoid | id |        valid_at         | name 
 ----------+----+-------------------------+------
@@ -2573,8 +2761,11 @@ SELECT * FROM ONLY temporal_parent ORDER BY valid_at;
 TRUNCATE mi_child, other_parent, temporal_parent;
 INSERT INTO mi_child (prefix, note, id, valid_at, name) VALUES
   ('pfx', 'memo', 1, daterange('2000-01-01', '2010-01-01'), 'old');
+NOTICE:  mi_child_tt on mi_child: (pfx,memo,1,"[2000-01-01,2010-01-01)",old)
 DELETE FROM temporal_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
   WHERE id = 1;
+NOTICE:  mi_child_tt on mi_child: (pfx,memo,1,"[2000-01-01,2001-01-01)",old)
+NOTICE:  mi_child_tt on mi_child: (pfx,memo,1,"[2002-01-01,2010-01-01)",old)
 SELECT tableoid::regclass, * FROM temporal_parent ORDER BY valid_at;
  tableoid | id |        valid_at         | name 
 ----------+----+-------------------------+------
@@ -2596,6 +2787,7 @@ SELECT * FROM ONLY temporal_parent ORDER BY valid_at;
 
 DROP TABLE temporal_parent CASCADE;
 NOTICE:  drop cascades to table mi_child
+DROP FUNCTION fpo_inh_transition_trigger();
 -- UPDATE FOR PORTION OF with generated columns
 -- The generated column depends on the range column, so it must be
 -- recomputed when FOR PORTION OF narrows the range.
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
index b61fe10478e..41585b519c2 100644
--- a/src/test/regress/sql/for_portion_of.sql
+++ b/src/test/regress/sql/for_portion_of.sql
@@ -1653,8 +1653,127 @@ SELECT * FROM fpo_inh_child ORDER BY valid_at;
 -- No rows should have leaked into the parent.
 SELECT * FROM ONLY fpo_inh_parent ORDER BY valid_at;
 
+-- Statement-level INSERT triggers for the leftovers must fire on the table
+-- the leftovers are actually inserted into, i.e. the child, just as they
+-- would for a plain INSERT into that child.
+CREATE FUNCTION fpo_inh_stmt_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '% % % on %', TG_NAME, TG_WHEN, TG_OP, TG_TABLE_NAME;
+  RETURN NULL;
+END;
+$$;
+
+CREATE TRIGGER fpo_inh_parent_bs BEFORE INSERT ON fpo_inh_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_inh_parent_as AFTER INSERT ON fpo_inh_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_inh_child_bs BEFORE INSERT ON fpo_inh_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_inh_child_as AFTER INSERT ON fpo_inh_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+-- control: a plain INSERT into the child fires only the child's triggers
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+
+-- One firing per leftover, all of them on the child.
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+DELETE FROM fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01';
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+
+-- The transition table for those inserts belongs to the child too, and holds
+-- the child's rows in the child's own format, including its extra columns.
+CREATE FUNCTION fpo_inh_transition_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '% on %: %', TG_NAME, TG_TABLE_NAME,
+    (SELECT string_agg(new_table::text, ', ' ORDER BY new_table::text)
+       FROM new_table);
+  RETURN NULL;
+END;
+$$;
+
+CREATE TRIGGER fpo_inh_parent_tt AFTER INSERT ON fpo_inh_parent
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_transition_trigger();
+CREATE TRIGGER fpo_inh_child_tt AFTER INSERT ON fpo_inh_child
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_transition_trigger();
+
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+-- control: a plain INSERT into the child reports description = 'initial'
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+-- The leftovers must report it too: they are child rows and they keep it.
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+SELECT tableoid::regclass, * FROM fpo_inh_child ORDER BY valid_at;
+
+-- DELETE inserts its leftovers the same way.
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+DELETE FROM fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01';
+SELECT tableoid::regclass, * FROM fpo_inh_child ORDER BY valid_at;
+
 DROP TABLE fpo_inh_parent CASCADE;
 
+-- By contrast, leftovers in a partitioned table are inserted through the
+-- root to get tuple routing, so the root is where their statement-level
+-- triggers belong, exactly as for a plain INSERT into the root.
+CREATE TABLE fpo_part_parent (
+  id int4range,
+  valid_at daterange,
+  name text
+) PARTITION BY RANGE (id);
+CREATE TABLE fpo_part_child PARTITION OF fpo_part_parent
+  FOR VALUES FROM ('[1,1]') TO ('[9,9]');
+
+CREATE TRIGGER fpo_part_parent_bs BEFORE INSERT ON fpo_part_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_part_parent_as AFTER INSERT ON fpo_part_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_part_child_bs BEFORE INSERT ON fpo_part_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_part_child_as AFTER INSERT ON fpo_part_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+
+-- and so does their transition table, which still receives the rows converted
+-- to the root's format.
+CREATE FUNCTION fpo_part_transition_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '% on %: %', TG_NAME, TG_TABLE_NAME,
+    (SELECT string_agg(new_table::text, ', ' ORDER BY new_table::text)
+       FROM new_table);
+  RETURN NULL;
+END;
+$$;
+CREATE TRIGGER fpo_part_parent_tt AFTER INSERT ON fpo_part_parent
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_part_transition_trigger();
+
+INSERT INTO fpo_part_parent VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one');
+UPDATE fpo_part_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+SELECT tableoid::regclass, * FROM fpo_part_parent ORDER BY valid_at;
+DROP FUNCTION fpo_part_transition_trigger();
+
+DROP TABLE fpo_part_parent;
+DROP FUNCTION fpo_inh_stmt_trigger();
+
 -- UPDATE FOR PORTION OF with multiple inheritance
 -- Leftover rows must stay in the child table, even if the range column's
 -- attnum differs between the target parent and child.
@@ -1669,6 +1788,12 @@ CREATE TABLE other_parent (
 );
 CREATE TABLE mi_child () INHERITS (other_parent, temporal_parent);
 
+-- The leftovers' transition table must use the child's format even when the
+-- child's columns are in a different order from the parent's.
+CREATE TRIGGER mi_child_tt AFTER INSERT ON mi_child
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_transition_trigger();
+
 -- attnum of the range column is different in temporal_parent and mi_child
 SELECT attnum, attname
   FROM pg_attribute
@@ -1704,6 +1829,7 @@ SELECT * FROM mi_child ORDER BY valid_at;
 SELECT * FROM ONLY temporal_parent ORDER BY valid_at;
 
 DROP TABLE temporal_parent CASCADE;
+DROP FUNCTION fpo_inh_transition_trigger();
 
 -- UPDATE FOR PORTION OF with generated columns
 -- The generated column depends on the range column, so it must be
-- 
2.47.3

