From 87aed61ef6aec338b1fcbf5329d356702d3be8dc Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <pj@illuminatedcomputing.com>
Date: Tue, 28 Jul 2026 21:49:03 -0700
Subject: [PATCH v1 4/5] Expose FOR PORTION OF to plpython triggers

It is helpful for triggers to see what the FOR PORTION OF clause
specified: both the column name and the targeted bounds. We already pass
this information to plpgsql trigger functions; this commit does the same
for plpython, using the new TD["period_name"] and TD["period_bounds"]
elements.

These are also set when inserting temporal leftovers, so that triggers can
distinguish regular inserts from those automatic ones. Since an INSERT can't
use FOR PORTION OF itself, there is no ambiguity.

Author: Paul A. Jungwirth <pj@illuminatedcomputing.com>
---
 doc/src/sgml/plpython.sgml                    | 31 +++++++++++
 src/pl/plpython/expected/plpython_trigger.out | 54 +++++++++++++++++++
 src/pl/plpython/plpy_exec.c                   | 27 ++++++++++
 src/pl/plpython/sql/plpython_trigger.sql      | 38 +++++++++++++
 4 files changed, 150 insertions(+)

diff --git a/doc/src/sgml/plpython.sgml b/doc/src/sgml/plpython.sgml
index c860a47a2e1..19077556a1c 100644
--- a/doc/src/sgml/plpython.sgml
+++ b/doc/src/sgml/plpython.sgml
@@ -751,6 +751,37 @@ $$ LANGUAGE plpython3u;
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><literal>TD["period_name"]</literal></term>
+     <listitem>
+      <para>
+       contains the column name used in a <literal>FOR PORTION OF</literal>
+       clause. This key is not present if <literal>FOR PORTION OF</literal>
+       was not used. Also set for the implicit
+       <literal>INSERT</literal> statements to add the
+       <glossterm linkend="glossary-temporal-leftovers">temporal
+       leftovers</glossterm>.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><literal>TD["period_bounds"]</literal></term>
+     <listitem>
+      <para>
+       contains the range/multirange given as the bounds of a
+       <literal>FOR PORTION OF</literal> clause, either directly (with parens
+       syntax) or computed from the <literal>FROM</literal> and
+       <literal>TO</literal> bounds. This key is not present if
+       <literal>FOR PORTION OF</literal> was not used. Also set for the
+       implicit <literal>INSERT</literal> statements to add the
+       <glossterm linkend="glossary-temporal-leftovers">temporal
+       leftovers</glossterm>. This is a text value based on the type's output
+       function, since the type can't be known at function creation time.
+      </para>
+     </listitem>
+    </varlistentry>
+
     <varlistentry>
      <term><literal>TD["args"]</literal></term>
      <listitem>
diff --git a/src/pl/plpython/expected/plpython_trigger.out b/src/pl/plpython/expected/plpython_trigger.out
index bd35b220c5e..907b6b0933d 100644
--- a/src/pl/plpython/expected/plpython_trigger.out
+++ b/src/pl/plpython/expected/plpython_trigger.out
@@ -673,3 +673,57 @@ NOTICE:  TD[event] => ddl_command_start ; TD[tag] => DROP TABLE
 NOTICE:  TD[event] => ddl_command_end ; TD[tag] => DROP TABLE
 DROP EVENT TRIGGER python_a_snitch;
 DROP EVENT TRIGGER python_b_snitch;
+-- FOR PORTION OF
+CREATE TABLE temporal_trigger_test (
+    id int,
+    valid_at daterange,
+    v text
+);
+INSERT INTO temporal_trigger_test VALUES (1, '[2018-01-01,2020-01-01)', 'one');
+CREATE FUNCTION for_portion_of_trigger_func() RETURNS trigger
+LANGUAGE plpython3u
+AS $$
+plpy.notice("TD[when] => " + TD["when"] + " ; TD[event] => " + TD["event"] +
+            " ; TD[period_name] => " + repr(TD.get("period_name")) +
+            " ; TD[period_bounds] => " + repr(TD.get("period_bounds")))
+return None
+$$;
+CREATE TRIGGER for_portion_of_trigger_trig
+  BEFORE INSERT OR UPDATE OR DELETE ON temporal_trigger_test
+  FOR EACH ROW EXECUTE PROCEDURE for_portion_of_trigger_func();
+UPDATE temporal_trigger_test
+  FOR PORTION OF valid_at FROM '2019-01-01' TO '2019-06-01'
+  SET v = 'updated';
+NOTICE:  TD[when] => BEFORE ; TD[event] => UPDATE ; TD[period_name] => 'valid_at' ; TD[period_bounds] => '[2019-01-01,2019-06-01)'
+NOTICE:  TD[when] => BEFORE ; TD[event] => INSERT ; TD[period_name] => 'valid_at' ; TD[period_bounds] => '[2019-01-01,2019-06-01)'
+NOTICE:  TD[when] => BEFORE ; TD[event] => INSERT ; TD[period_name] => 'valid_at' ; TD[period_bounds] => '[2019-01-01,2019-06-01)'
+SELECT * FROM temporal_trigger_test ORDER BY valid_at;
+ id |        valid_at         |    v    
+----+-------------------------+---------
+  1 | [2018-01-01,2019-01-01) | one
+  1 | [2019-01-01,2019-06-01) | updated
+  1 | [2019-06-01,2020-01-01) | one
+(3 rows)
+
+DELETE FROM temporal_trigger_test
+  FOR PORTION OF valid_at FROM '2018-06-01' TO '2018-09-01';
+NOTICE:  TD[when] => BEFORE ; TD[event] => DELETE ; TD[period_name] => 'valid_at' ; TD[period_bounds] => '[2018-06-01,2018-09-01)'
+NOTICE:  TD[when] => BEFORE ; TD[event] => INSERT ; TD[period_name] => 'valid_at' ; TD[period_bounds] => '[2018-06-01,2018-09-01)'
+NOTICE:  TD[when] => BEFORE ; TD[event] => INSERT ; TD[period_name] => 'valid_at' ; TD[period_bounds] => '[2018-06-01,2018-09-01)'
+SELECT * FROM temporal_trigger_test ORDER BY valid_at;
+ id |        valid_at         |    v    
+----+-------------------------+---------
+  1 | [2018-01-01,2018-06-01) | one
+  1 | [2018-09-01,2019-01-01) | one
+  1 | [2019-01-01,2019-06-01) | updated
+  1 | [2019-06-01,2020-01-01) | one
+(4 rows)
+
+-- no FOR PORTION OF, so the keys are absent
+UPDATE temporal_trigger_test SET v = 'all of it';
+NOTICE:  TD[when] => BEFORE ; TD[event] => UPDATE ; TD[period_name] => None ; TD[period_bounds] => None
+NOTICE:  TD[when] => BEFORE ; TD[event] => UPDATE ; TD[period_name] => None ; TD[period_bounds] => None
+NOTICE:  TD[when] => BEFORE ; TD[event] => UPDATE ; TD[period_name] => None ; TD[period_bounds] => None
+NOTICE:  TD[when] => BEFORE ; TD[event] => UPDATE ; TD[period_name] => None ; TD[period_bounds] => None
+DROP TABLE temporal_trigger_test;
+DROP FUNCTION for_portion_of_trigger_func();
diff --git a/src/pl/plpython/plpy_exec.c b/src/pl/plpython/plpy_exec.c
index 14e63625070..68c531bd578 100644
--- a/src/pl/plpython/plpy_exec.c
+++ b/src/pl/plpython/plpy_exec.c
@@ -16,6 +16,7 @@
 #include "plpy_subxactobject.h"
 #include "plpy_util.h"
 #include "utils/fmgrprotos.h"
+#include "utils/lsyscache.h"
 
 static void ShutdownPLyFunction(Datum arg);
 static PyObject *PLy_function_build_args(FunctionCallInfo fcinfo, PLyProcedure *proc);
@@ -803,6 +804,8 @@ PLy_trigger_build_args(FunctionCallInfo fcinfo, PLyProcedure *proc, HeapTuple *r
 			   *pltrelid,
 			   *plttablename,
 			   *plttableschema,
+			   *pltperiodname,
+			   *pltperiodbounds,
 			   *pltargs,
 			   *pytnew,
 			   *pytold,
@@ -858,6 +861,30 @@ PLy_trigger_build_args(FunctionCallInfo fcinfo, PLyProcedure *proc, HeapTuple *r
 		Py_DECREF(plttableschema);
 		pfree(stroid);
 
+		/*
+		 * If the query used FOR PORTION OF, report the column name and the
+		 * targeted bounds; otherwise leave these keys out of the dictionary.
+		 * The bounds could be any range or multirange type, so the best we
+		 * can offer is their text representation.
+		 */
+		if (tdata->tg_temporal)
+		{
+			ForPortionOfState *fpo = tdata->tg_temporal;
+			Oid			funcid;
+			bool		varlena;
+
+			pltperiodname = PLyUnicode_FromString(fpo->fp_rangeName);
+			PyDict_SetItemString(pltdata, "period_name", pltperiodname);
+			Py_DECREF(pltperiodname);
+
+			getTypeOutputInfo(fpo->fp_rangeType, &funcid, &varlena);
+			stroid = OidOutputFunctionCall(funcid, fpo->fp_targetRange);
+			pltperiodbounds = PLyUnicode_FromString(stroid);
+			PyDict_SetItemString(pltdata, "period_bounds", pltperiodbounds);
+			Py_DECREF(pltperiodbounds);
+			pfree(stroid);
+		}
+
 		if (TRIGGER_FIRED_BEFORE(tdata->tg_event))
 			pltwhen = PLyUnicode_FromString("BEFORE");
 		else if (TRIGGER_FIRED_AFTER(tdata->tg_event))
diff --git a/src/pl/plpython/sql/plpython_trigger.sql b/src/pl/plpython/sql/plpython_trigger.sql
index e1a552e079f..273e605257d 100644
--- a/src/pl/plpython/sql/plpython_trigger.sql
+++ b/src/pl/plpython/sql/plpython_trigger.sql
@@ -516,3 +516,41 @@ DROP TABLE foo;
 
 DROP EVENT TRIGGER python_a_snitch;
 DROP EVENT TRIGGER python_b_snitch;
+
+
+-- FOR PORTION OF
+
+CREATE TABLE temporal_trigger_test (
+    id int,
+    valid_at daterange,
+    v text
+);
+INSERT INTO temporal_trigger_test VALUES (1, '[2018-01-01,2020-01-01)', 'one');
+
+CREATE FUNCTION for_portion_of_trigger_func() RETURNS trigger
+LANGUAGE plpython3u
+AS $$
+plpy.notice("TD[when] => " + TD["when"] + " ; TD[event] => " + TD["event"] +
+            " ; TD[period_name] => " + repr(TD.get("period_name")) +
+            " ; TD[period_bounds] => " + repr(TD.get("period_bounds")))
+return None
+$$;
+
+CREATE TRIGGER for_portion_of_trigger_trig
+  BEFORE INSERT OR UPDATE OR DELETE ON temporal_trigger_test
+  FOR EACH ROW EXECUTE PROCEDURE for_portion_of_trigger_func();
+
+UPDATE temporal_trigger_test
+  FOR PORTION OF valid_at FROM '2019-01-01' TO '2019-06-01'
+  SET v = 'updated';
+SELECT * FROM temporal_trigger_test ORDER BY valid_at;
+
+DELETE FROM temporal_trigger_test
+  FOR PORTION OF valid_at FROM '2018-06-01' TO '2018-09-01';
+SELECT * FROM temporal_trigger_test ORDER BY valid_at;
+
+-- no FOR PORTION OF, so the keys are absent
+UPDATE temporal_trigger_test SET v = 'all of it';
+
+DROP TABLE temporal_trigger_test;
+DROP FUNCTION for_portion_of_trigger_func();
-- 
2.47.3

