From f4e9ecd198239f7958e94a7f8aceff3373476ed5 Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <pj@illuminatedcomputing.com>
Date: Tue, 27 Oct 2026 21:45:27 -0700
Subject: [PATCH v1 6/6] Allow FOR PORTION OF on views with INSTEAD OF triggers

An INSTEAD OF trigger replaces the whole command, including its
temporal leftovers, so skipped them instead of refusing the statement.
Also added the overlaps qual and range assignment to the view's query,
so the trigger fires only for overlapping rows and NEW holds the
targeted portion.

Reported-by: Satyanarayana Narlapuram <satyanarlapuram@gmail.com>
Author: jian he <jian.universality@gmail.com>
Author: Paul A. Jungwirth <pj@illuminatedcomputing.com>
Discussion: https://postgr.es/m/CAHg%2BQDd74fnd4obCRMqVS0AVWf%3DcSFH%3DCv7trTJWgm%2B_bhTK6w%40mail.gmail.com
Discussion: https://postgr.es/m/CAJ7c6TME%2Bix6VRf-2TPnVTsj8qn_hy6sYAOmMhZEivwsu2wS6g%40mail.gmail.com
---
 doc/src/sgml/dml.sgml                         |  14 ++
 src/backend/executor/nodeModifyTable.c        |  24 ++-
 src/backend/rewrite/rewriteHandler.c          |  47 +++--
 src/test/regress/expected/updatable_views.out | 176 +++++++++++++++---
 src/test/regress/sql/updatable_views.sql      | 126 ++++++++++---
 5 files changed, 306 insertions(+), 81 deletions(-)

diff --git a/doc/src/sgml/dml.sgml b/doc/src/sgml/dml.sgml
index d7c1fc08374..32083242200 100644
--- a/doc/src/sgml/dml.sgml
+++ b/doc/src/sgml/dml.sgml
@@ -409,6 +409,20 @@ DELETE FROM products
    whether the rule is on a table or a view.
   </para>
 
+  <para>
+   Similarly, a view's row-level <literal>INSTEAD OF UPDATE</literal> and
+   <literal>INSTEAD OF DELETE</literal> triggers (see <xref
+   linkend="trigger-definition"/>) fire only for rows that overlap the
+   targeted portion.  In those triggers, the application-time value in
+   <literal>NEW</literal> is narrowed based on the <literal>FOR PORTION
+   OF</literal> target, while <literal>OLD</literal> has the original value.
+   The trigger replaces the command, so no temporal leftovers are inserted.
+   The trigger can use <literal>FOR PORTION OF</literal> itself to preserve
+   the untargeted history.  But note that an
+   <literal>INSTEAD OF DELETE</literal> trigger receives only
+   <literal>OLD</literal>, so it cannot tell which portion was targeted.
+  </para>
+
   <para>
    In <literal>READ COMMITTED</literal> mode, a temporal update or delete can
    fail with a serialization failure when it touches a row that another
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 89740494260..1a6aef86e97 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -1632,6 +1632,12 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 		rootRelInfo &&
 		rootRelInfo->ri_RelationDesc->rd_rel->relkind == RELKIND_PARTITIONED_TABLE;
 
+	/*
+	 * We only insert leftovers into a table: CheckValidResultRel rejects
+	 * foreign tables, and our callers skip views with INSTEAD OF triggers.
+	 */
+	Assert(resultRelInfo->ri_RelationDesc->rd_rel->relkind == RELKIND_RELATION);
+
 	fpoState = resultRelInfo->ri_forPortionOf;
 	oldtupleSlot = fpoState->fp_existing;
 	leftoverSlot = fpoState->fp_leftover;
@@ -2012,8 +2018,13 @@ ExecDeleteEpilogue(ModifyTableContext *context, ResultRelInfo *resultRelInfo,
 		ar_delete_trig_tcs = NULL;
 	}
 
-	/* Compute temporal leftovers in FOR PORTION OF */
-	if (((ModifyTable *) context->mtstate->ps.plan)->forPortionOf)
+	/*
+	 * Compute temporal leftovers from FOR PORTION OF, unless the command was
+	 * replaced by an INSTEAD OF trigger.
+	 */
+	if (((ModifyTable *) context->mtstate->ps.plan)->forPortionOf &&
+		!(resultRelInfo->ri_TrigDesc &&
+		  resultRelInfo->ri_TrigDesc->trig_delete_instead_row))
 		ExecForPortionOfLeftovers(context, estate, resultRelInfo, tupleid);
 
 	/* AFTER ROW DELETE Triggers */
@@ -2856,8 +2867,13 @@ ExecUpdateEpilogue(ModifyTableContext *context, UpdateContext *updateCxt,
 		ExecWithCheckOptions(WCO_VIEW_CHECK, resultRelInfo,
 							 slot, context->estate);
 
-	/* Compute temporal leftovers in FOR PORTION OF */
-	if (((ModifyTable *) context->mtstate->ps.plan)->forPortionOf)
+	/*
+	 * Compute temporal leftovers from FOR PORTION OF, unless the command was
+	 * replaced by an INSTEAD OF trigger.
+	 */
+	if (((ModifyTable *) context->mtstate->ps.plan)->forPortionOf &&
+		!(resultRelInfo->ri_TrigDesc &&
+		  resultRelInfo->ri_TrigDesc->trig_update_instead_row))
 		ExecForPortionOfLeftovers(context, context->estate, resultRelInfo, tupleid);
 
 	/* AFTER ROW UPDATE Triggers */
diff --git a/src/backend/rewrite/rewriteHandler.c b/src/backend/rewrite/rewriteHandler.c
index 5bf696960f0..9fbd8062d11 100644
--- a/src/backend/rewrite/rewriteHandler.c
+++ b/src/backend/rewrite/rewriteHandler.c
@@ -4210,6 +4210,7 @@ RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length,
 		int			product_orig_rt_length;
 		List	   *product_queries;
 		Query	   *rule_parsetree;
+		bool		defer_fpo;
 		bool		hasUpdate = false;
 		int			values_rte_index = 0;
 		bool		defaults_remaining = false;
@@ -4225,13 +4226,21 @@ RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length,
 		 */
 		rt_entry_relation = relation_open(rt_entry->relid, NoLock);
 
-		/* We don't support FOR PORTION OF on views with INSTEAD OF triggers. */
-		if (parsetree->forPortionOf &&
+		/*
+		 * Usually we add the FOR PORTION OF details (the overlaps qual and
+		 * the range assignment) to the query right away.  But if the target
+		 * is a view that we may auto-update, we wait until we recurse on the
+		 * auto-updated query, so that we don't add them twice.  A view with
+		 * an INSTEAD OF trigger is never auto-updated, so it gets them now:
+		 * the trigger should fire only for overlapping rows, and NEW should
+		 * hold the targeted portion of the range.
+		 *
+		 * Views don't need to do anything special here to remap Vars; that is
+		 * handled by the tree walker.
+		 */
+		defer_fpo = parsetree->forPortionOf &&
 			rt_entry_relation->rd_rel->relkind == RELKIND_VIEW &&
-			view_has_instead_trigger(rt_entry_relation, event, NIL))
-			ereport(ERROR,
-					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-					 errmsg("views with INSTEAD OF triggers do not support FOR PORTION OF")));
+			!view_has_instead_trigger(rt_entry_relation, event, NIL);
 
 		/*
 		 * Rewrite the targetlist as needed for the command type.
@@ -4312,16 +4321,7 @@ RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length,
 		{
 			Assert(parsetree->override == OVERRIDING_NOT_SET);
 
-			/*
-			 * Don't add FOR PORTION OF details to a view's query, so that we
-			 * don't add the same qual and TLE again when we recurse on the
-			 * auto-updated query.  Rule actions get them below.
-			 *
-			 * Views don't need to do anything special here to remap Vars;
-			 * that is handled by the tree walker.
-			 */
-			if (parsetree->forPortionOf &&
-				rt_entry_relation->rd_rel->relkind != RELKIND_VIEW)
+			if (parsetree->forPortionOf && !defer_fpo)
 				addForPortionOfDetails(parsetree, rt_entry_relation);
 
 			parsetree->targetList =
@@ -4369,9 +4369,7 @@ RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length,
 		}
 		else if (event == CMD_DELETE)
 		{
-			/* As for UPDATE, a view's query doesn't get these yet. */
-			if (parsetree->forPortionOf &&
-				rt_entry_relation->rd_rel->relkind != RELKIND_VIEW)
+			if (parsetree->forPortionOf && !defer_fpo)
 				addForPortionOfDetails(parsetree, rt_entry_relation);
 		}
 		else
@@ -4387,14 +4385,13 @@ RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length,
 		 * Rule actions on a view must see the FOR PORTION OF restriction,
 		 * just as they do on a table: they should only fire for rows that
 		 * overlap the target range, and an UPDATE rule's NEW should hold the
-		 * targeted portion of the range.  We didn't add those details to the
-		 * view's query above, so build the rule actions from a copy that has
-		 * them.  If the view gets auto-updated below, the original query gets
-		 * them when we recurse on the base relation.
+		 * targeted portion of the range.  If we deferred adding those details
+		 * above, build the rule actions from a copy that has them.  If the
+		 * view gets auto-updated below, the original query gets them when we
+		 * recurse on the base relation.
 		 */
 		rule_parsetree = parsetree;
-		if (parsetree->forPortionOf &&
-			rt_entry_relation->rd_rel->relkind == RELKIND_VIEW)
+		if (defer_fpo)
 		{
 			rule_parsetree = copyObject(parsetree);
 			addForPortionOfDetails(rule_parsetree, rt_entry_relation);
diff --git a/src/test/regress/expected/updatable_views.out b/src/test/regress/expected/updatable_views.out
index 80531078fd1..af0a9ef9321 100644
--- a/src/test/regress/expected/updatable_views.out
+++ b/src/test/regress/expected/updatable_views.out
@@ -4384,10 +4384,23 @@ select * from uv_fpo_rinst_log order by tag, old_valid_at;
 
 drop view uv_fpo_rinst_view;
 drop table uv_fpo_rinst_tab, uv_fpo_rinst_log;
--- FOR PORTION OF is not supported on views with INSTEAD OF triggers
-create view uv_fpo_instead_view as select id, valid_at, b from uv_fpo_tab;
+-- FOR PORTION OF on a view with INSTEAD OF triggers.  The triggers fire only
+-- for rows that overlap the target range, NEW holds the targeted portion of
+-- the range, and OLD holds the whole row.  The trigger replaces the command,
+-- so there are no temporal leftovers.
+create table uv_fpo_instead_tab (id int4range, valid_at daterange, b int);
+insert into uv_fpo_instead_tab values
+  ('[1,1]', '[2000-01-01,2010-01-01)', 0),
+  ('[2,2]', '[2020-01-01,2030-01-01)', 0);
+create view uv_fpo_instead_view as
+  select id, valid_at, b from uv_fpo_instead_tab;
 create function uv_fpo_instead_trig() returns trigger language plpgsql as
-$$ begin return null; end $$;
+$$
+begin
+  raise notice '% OLD: %, NEW: %', tg_op, old, new;
+  return coalesce(new, old);
+end;
+$$;
 create trigger uv_fpo_instead_upd_trig
   instead of update on uv_fpo_instead_view
   for each row execute function uv_fpo_instead_trig();
@@ -4395,24 +4408,133 @@ create trigger uv_fpo_instead_del_trig
   instead of delete on uv_fpo_instead_view
   for each row execute function uv_fpo_instead_trig();
 update uv_fpo_instead_view
-  for portion of valid_at from '2015-01-01' to '2020-01-01'
-  set b = 99 where id = '[1,1]'; -- error
-ERROR:  views with INSTEAD OF triggers do not support FOR PORTION OF
+  for portion of valid_at from '2002-01-01' to '2004-01-01' set b = 1
+  returning *;
+NOTICE:  UPDATE OLD: ("[1,2)","[01-01-2000,01-01-2010)",0), NEW: ("[1,2)","[01-01-2002,01-01-2004)",1)
+  id   |        valid_at         | b 
+-------+-------------------------+---
+ [1,2) | [01-01-2002,01-01-2004) | 1
+(1 row)
+
 delete from uv_fpo_instead_view
-  for portion of valid_at from '2017-01-01' to '2022-01-01'
-  where id = '[1,1]'; -- error
-ERROR:  views with INSTEAD OF triggers do not support FOR PORTION OF
--- The check does not depend on which rows match, so it errors even when
--- no rows do.
+  for portion of valid_at from '2006-01-01' to '2008-01-01'
+  returning *;
+NOTICE:  DELETE OLD: ("[1,2)","[01-01-2000,01-01-2010)",0), NEW: <NULL>
+  id   |        valid_at         | b 
+-------+-------------------------+---
+ [1,2) | [01-01-2000,01-01-2010) | 0
+(1 row)
+
 update uv_fpo_instead_view
-  for portion of valid_at from '2015-01-01' to '2020-01-01'
-  set b = 99 where id = '[9,9]'; -- error, even with no matching rows
-ERROR:  views with INSTEAD OF triggers do not support FOR PORTION OF
+  for portion of valid_at from '2012-01-01' to '2014-01-01' set b = 2;
+select * from uv_fpo_instead_tab order by id, valid_at;
+  id   |        valid_at         | b 
+-------+-------------------------+---
+ [1,2) | [01-01-2000,01-01-2010) | 0
+ [2,3) | [01-01-2020,01-01-2030) | 0
+(2 rows)
+
+-- A check option on a view on top still applies to the trigger's NEW.
+create view uv_fpo_instead_wco as
+  select * from uv_fpo_instead_view where b < 10 with check option;
+update uv_fpo_instead_wco
+  for portion of valid_at from '2002-01-01' to '2004-01-01' set b = 20; -- error
+NOTICE:  UPDATE OLD: ("[1,2)","[01-01-2000,01-01-2010)",0), NEW: ("[1,2)","[01-01-2002,01-01-2004)",20)
+ERROR:  new row violates check option for view "uv_fpo_instead_wco"
+DETAIL:  Failing row contains ([1,2), [01-01-2002,01-01-2004), 20).
+drop view uv_fpo_instead_wco;
+-- With only one of the triggers, the other command auto-updates the view and
+-- inserts leftovers.
+drop trigger uv_fpo_instead_del_trig on uv_fpo_instead_view;
 delete from uv_fpo_instead_view
-  for portion of valid_at from '2017-01-01' to '2022-01-01'
-  where id = '[9,9]'; -- error, even with no matching rows
-ERROR:  views with INSTEAD OF triggers do not support FOR PORTION OF
+  for portion of valid_at from '2006-01-01' to '2008-01-01';
+drop trigger uv_fpo_instead_upd_trig on uv_fpo_instead_view;
+create trigger uv_fpo_instead_del_trig
+  instead of delete on uv_fpo_instead_view
+  for each row execute function uv_fpo_instead_trig();
+update uv_fpo_instead_view
+  for portion of valid_at from '2024-01-01' to '2026-01-01' set b = 3;
+select * from uv_fpo_instead_tab order by id, valid_at;
+  id   |        valid_at         | b 
+-------+-------------------------+---
+ [1,2) | [01-01-2000,01-01-2006) | 0
+ [1,2) | [01-01-2008,01-01-2010) | 0
+ [2,3) | [01-01-2020,01-01-2024) | 0
+ [2,3) | [01-01-2024,01-01-2026) | 3
+ [2,3) | [01-01-2026,01-01-2030) | 0
+(5 rows)
+
 drop view uv_fpo_instead_view;
+drop table uv_fpo_instead_tab;
+drop function uv_fpo_instead_trig();
+-- A trigger can preserve the untargeted history by using FOR PORTION OF
+-- itself, here on a partitioned table, where a leftover moves to the other
+-- partition.  The trigger also fires through an auto-updatable view on top,
+-- and a DO ALSO rule's action sees the same narrowed NEW.  A DELETE trigger
+-- sees only OLD, though, so this one can't tell which portion was targeted,
+-- and deletes the whole row.
+create table uv_fpo_instead_part (id int4range, valid_at daterange, b int)
+  partition by range (lower(valid_at));
+create table uv_fpo_instead_part1 partition of uv_fpo_instead_part
+  for values from ('2000-01-01') to ('2005-01-01');
+create table uv_fpo_instead_part2 partition of uv_fpo_instead_part
+  for values from ('2005-01-01') to (maxvalue);
+insert into uv_fpo_instead_part values
+  ('[1,1]', '[2000-01-01,2008-01-01)', 0),
+  ('[2,2]', '[2000-01-01,2008-01-01)', 0);
+create view uv_fpo_instead_view as
+  select id, valid_at, b from uv_fpo_instead_part;
+create view uv_fpo_instead_outer as
+  select b, valid_at, id from uv_fpo_instead_view;
+create function uv_fpo_instead_trig() returns trigger language plpgsql as
+$$
+begin
+  if tg_op = 'DELETE' then
+    delete from uv_fpo_instead_part
+      for portion of valid_at (old.valid_at) where id = old.id;
+    return old;
+  end if;
+  update uv_fpo_instead_part
+    for portion of valid_at (new.valid_at) set b = new.b where id = old.id;
+  return new;
+end;
+$$;
+create trigger uv_fpo_instead_upd_trig
+  instead of update on uv_fpo_instead_view
+  for each row execute function uv_fpo_instead_trig();
+create trigger uv_fpo_instead_del_trig
+  instead of delete on uv_fpo_instead_view
+  for each row execute function uv_fpo_instead_trig();
+create table uv_fpo_instead_log (old_valid_at daterange,
+  new_valid_at daterange, b int);
+create rule uv_fpo_instead_also as on update to uv_fpo_instead_view
+  do also insert into uv_fpo_instead_log
+    values (old.valid_at, new.valid_at, new.b);
+update uv_fpo_instead_outer
+  for portion of valid_at from '2002-01-01' to '2006-01-01' set b = 1
+  where id = '[1,1]';
+delete from uv_fpo_instead_view
+  for portion of valid_at from '2004-01-01' to '2005-01-01'
+  where id = '[2,2]';
+select * from uv_fpo_instead_log;
+      old_valid_at       |      new_valid_at       | b 
+-------------------------+-------------------------+---
+ [01-01-2000,01-01-2008) | [01-01-2002,01-01-2006) | 1
+(1 row)
+
+select tableoid::regclass, * from uv_fpo_instead_part order by id, valid_at;
+       tableoid       |  id   |        valid_at         | b 
+----------------------+-------+-------------------------+---
+ uv_fpo_instead_part1 | [1,2) | [01-01-2000,01-01-2002) | 0
+ uv_fpo_instead_part1 | [1,2) | [01-01-2002,01-01-2006) | 1
+ uv_fpo_instead_part2 | [1,2) | [01-01-2006,01-01-2008) | 0
+(3 rows)
+
+drop table uv_fpo_instead_part cascade;
+NOTICE:  drop cascades to 2 other objects
+DETAIL:  drop cascades to view uv_fpo_instead_view
+drop cascades to view uv_fpo_instead_outer
+drop table uv_fpo_instead_log;
 drop function uv_fpo_instead_trig();
 -- FOR PORTION OF on a view with DO ALSO rules.  As on a table, the rule
 -- actions fire only for rows that overlap the target range, NEW holds the
@@ -4496,11 +4618,11 @@ select * from uv_fpo_also_tab_log order by valid_at;
 (1 row)
 
 drop table uv_fpo_also_tab, uv_fpo_also_tab_log;
--- Forbid INSTEAD OF triggers with FOR PORTION OF even if the FOR PORTION OF
--- statement is parsed before the trigger exists.
--- This can happen in at least a couple ways: a rewrite rule or a BEGIN ATOMIC function.
+-- INSTEAD OF triggers also apply to FOR PORTION OF statements parsed before
+-- the trigger existed.  This can happen in at least a couple ways: a rewrite
+-- rule or a BEGIN ATOMIC function.
 create function uv_fpo_instead_trig() returns trigger language plpgsql as
-$$ begin return new; end $$;
+$$ begin raise notice 'NEW: %', new; return new; end $$;
 -- via a rewrite rule
 create table uv_fpo_rule_tab (id int4range, valid_at tsrange, b float);
 insert into uv_fpo_rule_tab values ('[1,1]', '[2000-01-01, 2030-01-01)', 0);
@@ -4512,9 +4634,8 @@ create rule uv_fpo_rule as on insert to uv_fpo_rule_tab do also
 create trigger uv_fpo_rule_instead
   instead of update on uv_fpo_rule_view
   for each row execute function uv_fpo_instead_trig();
--- Would crash if we checked at parse-time:
 insert into uv_fpo_rule_tab values ('[2,2]', '[2000-01-01,2030-01-01)', 0);
-ERROR:  views with INSTEAD OF triggers do not support FOR PORTION OF
+NOTICE:  NEW: ("[1,2)","[""Fri Jan 01 00:00:00 2010"",""Wed Jan 01 00:00:00 2020"")",99)
 drop table uv_fpo_rule_tab cascade;
 NOTICE:  drop cascades to view uv_fpo_rule_view
 -- via a BEGIN ATOMIC function body
@@ -4529,10 +4650,13 @@ end;
 create trigger uv_fpo_atomic_instead
   instead of update on uv_fpo_atomic_view
   for each row execute function uv_fpo_instead_trig();
--- Would crash if we checked at parse-time:
 select uv_fpo_atomic_fn();
-ERROR:  views with INSTEAD OF triggers do not support FOR PORTION OF
-CONTEXT:  SQL function "uv_fpo_atomic_fn" statement 1
+NOTICE:  NEW: ("[1,2)","[""Fri Jan 01 00:00:00 2010"",""Wed Jan 01 00:00:00 2020"")",99)
+ uv_fpo_atomic_fn 
+------------------
+ 
+(1 row)
+
 drop function uv_fpo_atomic_fn();
 drop table uv_fpo_atomic_tab cascade;
 NOTICE:  drop cascades to view uv_fpo_atomic_view
diff --git a/src/test/regress/sql/updatable_views.sql b/src/test/regress/sql/updatable_views.sql
index 3a60d47994b..0343ee0d94e 100644
--- a/src/test/regress/sql/updatable_views.sql
+++ b/src/test/regress/sql/updatable_views.sql
@@ -2289,38 +2289,114 @@ select * from uv_fpo_rinst_log order by tag, old_valid_at;
 drop view uv_fpo_rinst_view;
 drop table uv_fpo_rinst_tab, uv_fpo_rinst_log;
 
--- FOR PORTION OF is not supported on views with INSTEAD OF triggers
-create view uv_fpo_instead_view as select id, valid_at, b from uv_fpo_tab;
-
+-- FOR PORTION OF on a view with INSTEAD OF triggers.  The triggers fire only
+-- for rows that overlap the target range, NEW holds the targeted portion of
+-- the range, and OLD holds the whole row.  The trigger replaces the command,
+-- so there are no temporal leftovers.
+create table uv_fpo_instead_tab (id int4range, valid_at daterange, b int);
+insert into uv_fpo_instead_tab values
+  ('[1,1]', '[2000-01-01,2010-01-01)', 0),
+  ('[2,2]', '[2020-01-01,2030-01-01)', 0);
+create view uv_fpo_instead_view as
+  select id, valid_at, b from uv_fpo_instead_tab;
 create function uv_fpo_instead_trig() returns trigger language plpgsql as
-$$ begin return null; end $$;
-
+$$
+begin
+  raise notice '% OLD: %, NEW: %', tg_op, old, new;
+  return coalesce(new, old);
+end;
+$$;
 create trigger uv_fpo_instead_upd_trig
   instead of update on uv_fpo_instead_view
   for each row execute function uv_fpo_instead_trig();
 create trigger uv_fpo_instead_del_trig
   instead of delete on uv_fpo_instead_view
   for each row execute function uv_fpo_instead_trig();
-
 update uv_fpo_instead_view
-  for portion of valid_at from '2015-01-01' to '2020-01-01'
-  set b = 99 where id = '[1,1]'; -- error
-
+  for portion of valid_at from '2002-01-01' to '2004-01-01' set b = 1
+  returning *;
 delete from uv_fpo_instead_view
-  for portion of valid_at from '2017-01-01' to '2022-01-01'
-  where id = '[1,1]'; -- error
-
--- The check does not depend on which rows match, so it errors even when
--- no rows do.
+  for portion of valid_at from '2006-01-01' to '2008-01-01'
+  returning *;
 update uv_fpo_instead_view
-  for portion of valid_at from '2015-01-01' to '2020-01-01'
-  set b = 99 where id = '[9,9]'; -- error, even with no matching rows
-
+  for portion of valid_at from '2012-01-01' to '2014-01-01' set b = 2;
+select * from uv_fpo_instead_tab order by id, valid_at;
+-- A check option on a view on top still applies to the trigger's NEW.
+create view uv_fpo_instead_wco as
+  select * from uv_fpo_instead_view where b < 10 with check option;
+update uv_fpo_instead_wco
+  for portion of valid_at from '2002-01-01' to '2004-01-01' set b = 20; -- error
+drop view uv_fpo_instead_wco;
+
+-- With only one of the triggers, the other command auto-updates the view and
+-- inserts leftovers.
+drop trigger uv_fpo_instead_del_trig on uv_fpo_instead_view;
 delete from uv_fpo_instead_view
-  for portion of valid_at from '2017-01-01' to '2022-01-01'
-  where id = '[9,9]'; -- error, even with no matching rows
-
+  for portion of valid_at from '2006-01-01' to '2008-01-01';
+drop trigger uv_fpo_instead_upd_trig on uv_fpo_instead_view;
+create trigger uv_fpo_instead_del_trig
+  instead of delete on uv_fpo_instead_view
+  for each row execute function uv_fpo_instead_trig();
+update uv_fpo_instead_view
+  for portion of valid_at from '2024-01-01' to '2026-01-01' set b = 3;
+select * from uv_fpo_instead_tab order by id, valid_at;
 drop view uv_fpo_instead_view;
+drop table uv_fpo_instead_tab;
+drop function uv_fpo_instead_trig();
+
+-- A trigger can preserve the untargeted history by using FOR PORTION OF
+-- itself, here on a partitioned table, where a leftover moves to the other
+-- partition.  The trigger also fires through an auto-updatable view on top,
+-- and a DO ALSO rule's action sees the same narrowed NEW.  A DELETE trigger
+-- sees only OLD, though, so this one can't tell which portion was targeted,
+-- and deletes the whole row.
+create table uv_fpo_instead_part (id int4range, valid_at daterange, b int)
+  partition by range (lower(valid_at));
+create table uv_fpo_instead_part1 partition of uv_fpo_instead_part
+  for values from ('2000-01-01') to ('2005-01-01');
+create table uv_fpo_instead_part2 partition of uv_fpo_instead_part
+  for values from ('2005-01-01') to (maxvalue);
+insert into uv_fpo_instead_part values
+  ('[1,1]', '[2000-01-01,2008-01-01)', 0),
+  ('[2,2]', '[2000-01-01,2008-01-01)', 0);
+create view uv_fpo_instead_view as
+  select id, valid_at, b from uv_fpo_instead_part;
+create view uv_fpo_instead_outer as
+  select b, valid_at, id from uv_fpo_instead_view;
+create function uv_fpo_instead_trig() returns trigger language plpgsql as
+$$
+begin
+  if tg_op = 'DELETE' then
+    delete from uv_fpo_instead_part
+      for portion of valid_at (old.valid_at) where id = old.id;
+    return old;
+  end if;
+  update uv_fpo_instead_part
+    for portion of valid_at (new.valid_at) set b = new.b where id = old.id;
+  return new;
+end;
+$$;
+create trigger uv_fpo_instead_upd_trig
+  instead of update on uv_fpo_instead_view
+  for each row execute function uv_fpo_instead_trig();
+create trigger uv_fpo_instead_del_trig
+  instead of delete on uv_fpo_instead_view
+  for each row execute function uv_fpo_instead_trig();
+create table uv_fpo_instead_log (old_valid_at daterange,
+  new_valid_at daterange, b int);
+create rule uv_fpo_instead_also as on update to uv_fpo_instead_view
+  do also insert into uv_fpo_instead_log
+    values (old.valid_at, new.valid_at, new.b);
+update uv_fpo_instead_outer
+  for portion of valid_at from '2002-01-01' to '2006-01-01' set b = 1
+  where id = '[1,1]';
+delete from uv_fpo_instead_view
+  for portion of valid_at from '2004-01-01' to '2005-01-01'
+  where id = '[2,2]';
+select * from uv_fpo_instead_log;
+select tableoid::regclass, * from uv_fpo_instead_part order by id, valid_at;
+drop table uv_fpo_instead_part cascade;
+drop table uv_fpo_instead_log;
 drop function uv_fpo_instead_trig();
 
 -- FOR PORTION OF on a view with DO ALSO rules.  As on a table, the rule
@@ -2377,11 +2453,11 @@ select * from uv_fpo_also_tab order by valid_at;
 select * from uv_fpo_also_tab_log order by valid_at;
 drop table uv_fpo_also_tab, uv_fpo_also_tab_log;
 
--- Forbid INSTEAD OF triggers with FOR PORTION OF even if the FOR PORTION OF
--- statement is parsed before the trigger exists.
--- This can happen in at least a couple ways: a rewrite rule or a BEGIN ATOMIC function.
+-- INSTEAD OF triggers also apply to FOR PORTION OF statements parsed before
+-- the trigger existed.  This can happen in at least a couple ways: a rewrite
+-- rule or a BEGIN ATOMIC function.
 create function uv_fpo_instead_trig() returns trigger language plpgsql as
-$$ begin return new; end $$;
+$$ begin raise notice 'NEW: %', new; return new; end $$;
 
 -- via a rewrite rule
 create table uv_fpo_rule_tab (id int4range, valid_at tsrange, b float);
@@ -2394,7 +2470,6 @@ create rule uv_fpo_rule as on insert to uv_fpo_rule_tab do also
 create trigger uv_fpo_rule_instead
   instead of update on uv_fpo_rule_view
   for each row execute function uv_fpo_instead_trig();
--- Would crash if we checked at parse-time:
 insert into uv_fpo_rule_tab values ('[2,2]', '[2000-01-01,2030-01-01)', 0);
 drop table uv_fpo_rule_tab cascade;
 
@@ -2410,7 +2485,6 @@ end;
 create trigger uv_fpo_atomic_instead
   instead of update on uv_fpo_atomic_view
   for each row execute function uv_fpo_instead_trig();
--- Would crash if we checked at parse-time:
 select uv_fpo_atomic_fn();
 drop function uv_fpo_atomic_fn();
 drop table uv_fpo_atomic_tab cascade;
-- 
2.47.3

