From 2e8c9b8cac486e76103e87803bfd9310a10e290a Mon Sep 17 00:00:00 2001 From: Tender Wang Date: Mon, 9 Nov 2026 21:14:46 -0500 Subject: [PATCH v2 REL_15-16] Fix transition tables for cross-partition MERGE updates A MERGE UPDATE that moves a row executes a physical DELETE and INSERT. Capture both halves in the UPDATE transition tables and exclude them from the DELETE and INSERT transition tables, even when the UPDATE transition tables are not requested. Reported-by: Michael Ries Author: Tender Wang Reviewed-by: Shihao Zhong Discussion: https://postgr.es/m/CAKWDHLgETF86qEW40HcEzMgn5DdmS6yR-nSYwemXF7zFGrU+sQ@mail.gmail.com Discussion: https://postgr.es/m/CAHewXNk8MgaFMD87s=xHeguzmErEjsqeroxrMyQbKyXwG5eocw@mail.gmail.com Backpatch-through: 15 --- src/backend/executor/nodeModifyTable.c | 50 ++++++++++++-------------- src/test/regress/expected/triggers.out | 31 ++++++++++++++++ src/test/regress/sql/triggers.sql | 30 ++++++++++++++++ 3 files changed, 84 insertions(+), 27 deletions(-) diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c index e2c0cbc5097..f45ee7b9c38 100644 --- a/src/backend/executor/nodeModifyTable.c +++ b/src/backend/executor/nodeModifyTable.c @@ -1186,22 +1186,21 @@ ExecInsert(ModifyTableContext *context, if (mtstate->mt_transition_capture) mtstate->mt_transition_capture->tcs_original_insert_tuple = tcs_original_insert_tuple; ar_insert_trig_tcs = mtstate->mt_transition_capture; - if (mtstate->operation == CMD_UPDATE && mtstate->mt_transition_capture - && mtstate->mt_transition_capture->tcs_update_new_table) + if (mtstate->operation == CMD_UPDATE || + (mtstate->operation == CMD_MERGE && + context->relaction->mas_action->commandType == CMD_UPDATE)) { - ExecARUpdateTriggers(estate, resultRelInfo, - NULL, NULL, - NULL, - NULL, - slot, - NULL, - mtstate->mt_transition_capture, - false); - - /* - * We've already captured the NEW TABLE row, so make sure any AR - * INSERT trigger fired below doesn't capture it again. - */ + if (ar_insert_trig_tcs && ar_insert_trig_tcs->tcs_update_new_table) + ExecARUpdateTriggers(estate, resultRelInfo, + NULL, NULL, + NULL, + NULL, + slot, + NULL, + mtstate->mt_transition_capture, + false); + + /* A moved row does not belong in the INSERT transition table. */ ar_insert_trig_tcs = NULL; } @@ -1418,19 +1417,16 @@ ExecDeleteEpilogue(ModifyTableContext *context, ResultRelInfo *resultRelInfo, * because they happen on different tables. */ ar_delete_trig_tcs = mtstate->mt_transition_capture; - if (mtstate->operation == CMD_UPDATE && mtstate->mt_transition_capture && - mtstate->mt_transition_capture->tcs_update_old_table) + if (changingPart) { - ExecARUpdateTriggers(estate, resultRelInfo, - NULL, NULL, - tupleid, oldtuple, - NULL, NULL, mtstate->mt_transition_capture, - false); - - /* - * We've already captured the OLD TABLE row, so make sure any AR - * DELETE trigger fired below doesn't capture it again. - */ + if (ar_delete_trig_tcs && ar_delete_trig_tcs->tcs_update_old_table) + ExecARUpdateTriggers(estate, resultRelInfo, + NULL, NULL, + tupleid, oldtuple, + NULL, NULL, mtstate->mt_transition_capture, + false); + + /* A moved row does not belong in the DELETE transition table. */ ar_delete_trig_tcs = NULL; } diff --git a/src/test/regress/expected/triggers.out b/src/test/regress/expected/triggers.out index 1863fdd6a94..5fd62a1ba93 100644 --- a/src/test/regress/expected/triggers.out +++ b/src/test/regress/expected/triggers.out @@ -3625,6 +3625,37 @@ select * from merge_conv_table order by a; (2 rows) drop table merge_conv_table; +-- A MERGE UPDATE that moves a row must capture it as an update. +create table merge_move (a int, b text) partition by list (a); +create table merge_move_p1 partition of merge_move for values in (1, 2); +create table merge_move_p2 partition of merge_move for values in (3, 4); +insert into merge_move values (1, 'move'), (2, 'delete'); +create trigger move_ins after insert on merge_move + referencing new table as new_table + for each statement execute function dump_insert(); +create trigger move_upd after update on merge_move + referencing old table as old_table new table as new_table + for each statement execute function dump_update(); +create trigger move_del after delete on merge_move + referencing old table as old_table + for each statement execute function dump_delete(); +merge into merge_move t using (values (1), (2), (4)) s(a) on t.a = s.a +when matched and s.a = 1 then update set a = 3 +when matched then delete +when not matched then insert values (s.a, 'insert'); +NOTICE: trigger = move_del, old table = (2,delete) +NOTICE: trigger = move_upd, old table = (1,move), new table = (3,move) +NOTICE: trigger = move_ins, new table = (4,insert) +-- The move must stay out of INSERT and DELETE tables even without an +-- UPDATE transition table. +drop trigger move_upd on merge_move; +merge into merge_move t using (values (3), (4), (2)) s(a) on t.a = s.a +when matched and s.a = 3 then update set a = 1 +when matched then delete +when not matched then insert values (s.a, 'insert'); +NOTICE: trigger = move_del, old table = (4,insert) +NOTICE: trigger = move_ins, new table = (2,insert) +drop table merge_move; -- cleanup drop function dump_insert(); drop function dump_update(); diff --git a/src/test/regress/sql/triggers.sql b/src/test/regress/sql/triggers.sql index 2db26994e95..308dc4c50c8 100644 --- a/src/test/regress/sql/triggers.sql +++ b/src/test/regress/sql/triggers.sql @@ -2723,6 +2723,36 @@ select * from merge_conv_table order by a; drop table merge_conv_table; +-- A MERGE UPDATE that moves a row must capture it as an update. +create table merge_move (a int, b text) partition by list (a); +create table merge_move_p1 partition of merge_move for values in (1, 2); +create table merge_move_p2 partition of merge_move for values in (3, 4); +insert into merge_move values (1, 'move'), (2, 'delete'); +create trigger move_ins after insert on merge_move + referencing new table as new_table + for each statement execute function dump_insert(); +create trigger move_upd after update on merge_move + referencing old table as old_table new table as new_table + for each statement execute function dump_update(); +create trigger move_del after delete on merge_move + referencing old table as old_table + for each statement execute function dump_delete(); + +merge into merge_move t using (values (1), (2), (4)) s(a) on t.a = s.a +when matched and s.a = 1 then update set a = 3 +when matched then delete +when not matched then insert values (s.a, 'insert'); + +-- The move must stay out of INSERT and DELETE tables even without an +-- UPDATE transition table. +drop trigger move_upd on merge_move; +merge into merge_move t using (values (3), (4), (2)) s(a) on t.a = s.a +when matched and s.a = 3 then update set a = 1 +when matched then delete +when not matched then insert values (s.a, 'insert'); + +drop table merge_move; + -- cleanup drop function dump_insert(); drop function dump_update(); -- 2.37.1 (Apple Git-137.1)