From 5f19a3689a317d29eab274775aa09ceabbf6688a Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <pj@illuminatedcomputing.com>
Date: Thu, 3 Sep 2026 12:18:49 -0700
Subject: [PATCH v1] Resolve untyped parameters in FOR PORTION OF FROM/TO
 bounds

FOR PORTION OF valid_at FROM $1 TO $2 fails with "could not determine data
type of parameter $1", so using PREPARE requires users to say $1::date
etc. But transformForPortionOfClause() already coerces the bounds, since
it builds a constructor for the targeted range. We just need to store the
coerced expressions in targetFrom and targetTo.

This also improves pg_get_ruledef(), which now renders an untyped NULL
bound as NULL::date instead of NULL::unknown.

Reported-by: Noah Misch <noah@leadboat.com>
Author: Paul A. Jungwirth <pj@illuminatedcomputing.com>
Backpatch-through: 19
---
 src/backend/parser/analyze.c                 |  12 ++-
 src/test/regress/expected/for_portion_of.out | 106 ++++++++++++++++++-
 src/test/regress/sql/for_portion_of.sql      |  71 +++++++++++++
 3 files changed, 185 insertions(+), 4 deletions(-)

diff --git a/src/backend/parser/analyze.c b/src/backend/parser/analyze.c
index 581457c69c9..7a873c8ee7a 100644
--- a/src/backend/parser/analyze.c
+++ b/src/backend/parser/analyze.c
@@ -1462,8 +1462,7 @@ transformForPortionOfClause(ParseState *pstate,
 										 EXPR_KIND_FOR_PORTION);
 		actual_arg_types[0] = exprType(result->targetFrom);
 		actual_arg_types[1] = exprType(result->targetTo);
-		args = list_make2(copyObject(result->targetFrom),
-						  copyObject(result->targetTo));
+		args = list_make2(result->targetFrom, result->targetTo);
 
 		/*
 		 * Check the bound types separately, for better error message and
@@ -1487,6 +1486,15 @@ transformForPortionOfClause(ParseState *pstate,
 					 parser_errposition(pstate, exprLocation(forPortionOf->target_end))));
 
 		make_fn_arguments(pstate, args, actual_arg_types, declared_arg_types);
+
+		/*
+		 * Keep the *coerced* bounds.  This lets prepared statements use
+		 * parameters without explicit casts, and it improves deparsing when
+		 * FOR PORTION OF appears in a function or RULE.
+		 */
+		result->targetFrom = copyObject((Node *) linitial(args));
+		result->targetTo = copyObject((Node *) lsecond(args));
+
 		result->targetRange = (Node *) makeFuncExpr(get_range_constructor2(attbasetype),
 													attbasetype,
 													args,
diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out
index 64789d1777b..e4090a145c2 100644
--- a/src/test/regress/expected/for_portion_of.out
+++ b/src/test/regress/expected/for_portion_of.out
@@ -652,7 +652,7 @@ END;
          RETURNS text
          LANGUAGE sql
 1       BEGIN ATOMIC
-2        UPDATE for_portion_of_test FOR PORTION OF valid_at FROM '2018-01-15' TO '2019-01-01' SET name = 'one^1'::text
+2        UPDATE for_portion_of_test FOR PORTION OF valid_at FROM '2018-01-15'::date TO '2019-01-01'::date SET name = 'one^1'::text
 3          RETURNING for_portion_of_test.name;
 4       END
 CREATE OR REPLACE function fpo_update()
@@ -1029,7 +1029,7 @@ END;
          RETURNS text
          LANGUAGE sql
 1       BEGIN ATOMIC
-2        DELETE FROM for_portion_of_test FOR PORTION OF valid_at FROM '2018-01-15' TO '2019-01-01'
+2        DELETE FROM for_portion_of_test FOR PORTION OF valid_at FROM '2018-01-15'::date TO '2019-01-01'::date
 3          RETURNING for_portion_of_test.name;
 4       END
 CREATE OR REPLACE function fpo_delete()
@@ -2793,4 +2793,106 @@ SELECT * FROM fpo_rls ORDER BY valid_at;
 
 DROP TABLE fpo_rls;
 DROP ROLE regress_fpo_rls;
+--
+-- Parameters in the FOR PORTION OF bounds
+--
+CREATE TABLE fpo_param (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO fpo_param (id, valid_at, name) VALUES
+  ('[1,2)', daterange('2000-01-01', '2010-01-01'), 'one');
+-- A parameter of unspecified type in an ordinary expression gets its type
+-- resolved from context.  This is the control case for the FROM/TO bounds
+-- below: it builds exactly the same daterange the FROM/TO form does.
+PREPARE fpo_param_control AS
+  UPDATE fpo_param SET name = 'ctl' WHERE valid_at && daterange($1, $2);
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_control';
+ parameter_types 
+-----------------
+ {date,date}
+(1 row)
+
+-- The (portion) form resolves the parameter type from the range column.
+PREPARE fpo_param_portion AS
+  UPDATE fpo_param FOR PORTION OF valid_at ($1) SET name = 'portion';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_portion';
+ parameter_types 
+-----------------
+ {daterange}
+(1 row)
+
+-- The FROM/TO form should likewise resolve its bounds to the range's
+-- subtype, so that clients need not spell out the parameter types.
+PREPARE fpo_param_update AS
+  UPDATE fpo_param FOR PORTION OF valid_at FROM $1 TO $2 SET name = 'upd';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_update';
+ parameter_types 
+-----------------
+ {date,date}
+(1 row)
+
+PREPARE fpo_param_delete AS
+  DELETE FROM fpo_param FOR PORTION OF valid_at FROM $1 TO $2;
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_delete';
+ parameter_types 
+-----------------
+ {date,date}
+(1 row)
+
+-- Only one bound parameterized.
+PREPARE fpo_param_one AS
+  UPDATE fpo_param FOR PORTION OF valid_at FROM $1 TO '2003-01-01'
+    SET name = 'one-bound';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_one';
+ parameter_types 
+-----------------
+ {date}
+(1 row)
+
+-- A parameter used as both bounds must still resolve to one type.
+PREPARE fpo_param_same AS
+  UPDATE fpo_param FOR PORTION OF valid_at FROM $1 TO $1 SET name = 'same';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_same';
+ parameter_types 
+-----------------
+ {date}
+(1 row)
+
+EXECUTE fpo_param_update('2002-01-01', '2003-01-01');
+SELECT * FROM fpo_param ORDER BY valid_at;
+  id   |        valid_at         | name 
+-------+-------------------------+------
+ [1,2) | [2000-01-01,2002-01-01) | one
+ [1,2) | [2002-01-01,2003-01-01) | upd
+ [1,2) | [2003-01-01,2010-01-01) | one
+(3 rows)
+
+DEALLOCATE fpo_param_control;
+DEALLOCATE fpo_param_portion;
+DEALLOCATE fpo_param_update;
+DEALLOCATE fpo_param_delete;
+DEALLOCATE fpo_param_one;
+DEALLOCATE fpo_param_same;
+-- The bounds we keep for deparsing are the coerced ones, so an untyped NULL
+-- bound renders with the range's subtype instead of "unknown".
+CREATE TABLE fpo_param_src (id int);
+CREATE RULE fpo_param_r AS ON DELETE TO fpo_param_src DO INSTEAD
+  DELETE FROM fpo_param FOR PORTION OF valid_at FROM NULL TO '2001-01-01';
+SELECT definition FROM pg_rules WHERE rulename = 'fpo_param_r';
+                                                               definition                                                               
+----------------------------------------------------------------------------------------------------------------------------------------
+ CREATE RULE fpo_param_r AS                                                                                                            +
+     ON DELETE TO public.fpo_param_src DO INSTEAD  DELETE FROM fpo_param FOR PORTION OF valid_at FROM NULL::date TO '2001-01-01'::date;
+(1 row)
+
+DROP TABLE fpo_param_src;
+DROP TABLE fpo_param;
 RESET datestyle;
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
index b61fe10478e..ede1f28d0ed 100644
--- a/src/test/regress/sql/for_portion_of.sql
+++ b/src/test/regress/sql/for_portion_of.sql
@@ -1849,4 +1849,75 @@ SELECT * FROM fpo_rls ORDER BY valid_at;
 DROP TABLE fpo_rls;
 DROP ROLE regress_fpo_rls;
 
+--
+-- Parameters in the FOR PORTION OF bounds
+--
+
+CREATE TABLE fpo_param (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO fpo_param (id, valid_at, name) VALUES
+  ('[1,2)', daterange('2000-01-01', '2010-01-01'), 'one');
+
+-- A parameter of unspecified type in an ordinary expression gets its type
+-- resolved from context.  This is the control case for the FROM/TO bounds
+-- below: it builds exactly the same daterange the FROM/TO form does.
+PREPARE fpo_param_control AS
+  UPDATE fpo_param SET name = 'ctl' WHERE valid_at && daterange($1, $2);
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_control';
+
+-- The (portion) form resolves the parameter type from the range column.
+PREPARE fpo_param_portion AS
+  UPDATE fpo_param FOR PORTION OF valid_at ($1) SET name = 'portion';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_portion';
+
+-- The FROM/TO form should likewise resolve its bounds to the range's
+-- subtype, so that clients need not spell out the parameter types.
+PREPARE fpo_param_update AS
+  UPDATE fpo_param FOR PORTION OF valid_at FROM $1 TO $2 SET name = 'upd';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_update';
+
+PREPARE fpo_param_delete AS
+  DELETE FROM fpo_param FOR PORTION OF valid_at FROM $1 TO $2;
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_delete';
+
+-- Only one bound parameterized.
+PREPARE fpo_param_one AS
+  UPDATE fpo_param FOR PORTION OF valid_at FROM $1 TO '2003-01-01'
+    SET name = 'one-bound';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_one';
+
+-- A parameter used as both bounds must still resolve to one type.
+PREPARE fpo_param_same AS
+  UPDATE fpo_param FOR PORTION OF valid_at FROM $1 TO $1 SET name = 'same';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_same';
+
+EXECUTE fpo_param_update('2002-01-01', '2003-01-01');
+SELECT * FROM fpo_param ORDER BY valid_at;
+
+DEALLOCATE fpo_param_control;
+DEALLOCATE fpo_param_portion;
+DEALLOCATE fpo_param_update;
+DEALLOCATE fpo_param_delete;
+DEALLOCATE fpo_param_one;
+DEALLOCATE fpo_param_same;
+
+-- The bounds we keep for deparsing are the coerced ones, so an untyped NULL
+-- bound renders with the range's subtype instead of "unknown".
+CREATE TABLE fpo_param_src (id int);
+CREATE RULE fpo_param_r AS ON DELETE TO fpo_param_src DO INSTEAD
+  DELETE FROM fpo_param FOR PORTION OF valid_at FROM NULL TO '2001-01-01';
+SELECT definition FROM pg_rules WHERE rulename = 'fpo_param_r';
+
+DROP TABLE fpo_param_src;
+DROP TABLE fpo_param;
+
 RESET datestyle;
-- 
2.47.3

