From b8ec4063aba3a790ad6ebbc0d2df950c2f160ad6 Mon Sep 17 00:00:00 2001
From: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Date: Fri, 28 Aug 2026 12:33:47 +0500
Subject: [PATCH v2] Fold immutable XmlExpr forms in eval_const_expressions

IS DOCUMENT was left as XmlExpr even with a Const argument, so CASE
still simplified unused WHEN results and could execute xpath() during
planning.  That started failing for SQL-language functions once they
began using custom plans.

Not every XmlExpr is safe to fold.  XMLELEMENT and XMLFOREST go through
map_sql_value_to_xml_value(), which may depend on session settings or
type output functions.  Fold those only when every argument type maps
immutably.  Other XmlExprOps only manipulate xml/text.

Bug: #19487
Author: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Reported-by: Ilya Portnov <i.portnov@compassplus.com>
Discussion: https://www.postgresql.org/message-id/19487-367258bc497b923a%40postgresql.org
Backpatch-through: 18
---
 src/backend/optimizer/util/clauses.c | 53 ++++++++++++++++++++++
 src/backend/utils/adt/xml.c          | 38 ++++++++++++++++
 src/include/utils/xml.h              |  1 +
 src/test/regress/expected/xml.out    | 67 ++++++++++++++++++++++++++++
 src/test/regress/sql/xml.sql         | 33 ++++++++++++++
 5 files changed, 192 insertions(+)

diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c
index 8da4ed617b5..56f651b81f7 100644
--- a/src/backend/optimizer/util/clauses.c
+++ b/src/backend/optimizer/util/clauses.c
@@ -64,5 +64,6 @@
 #include "utils/syscache.h"
 #include "utils/typcache.h"
+#include "utils/xml.h"
 
 typedef struct
 {
@@ -115,6 +116,7 @@ static bool contain_agg_clause_walker(Node *node, void *context);
 static bool find_window_functions_walker(Node *node, WindowFuncLists *lists);
 static bool contain_subplans_walker(Node *node, void *context);
 static bool contain_mutable_functions_walker(Node *node, void *context);
+static bool xmlexpr_is_immutable(XmlExpr *xexpr);
 static bool contain_volatile_functions_walker(Node *node, void *context);
 static bool contain_volatile_functions_not_nextval_walker(Node *node, void *context);
 static bool max_parallel_hazard_walker(Node *node,
@@ -496,6 +498,49 @@ contain_mutable_functions_walker(Node *node, void *context)
 								  context);
 }
 
+/*
+ * xmlexpr_is_immutable
+ *	  True if this XmlExpr is immutable.
+ *
+ * XMLELEMENT and XMLFOREST go through map_sql_value_to_xml_value(), which
+ * may depend on session settings (e.g. TimeZone, xmlbinary) or on type
+ * output functions.  Other XmlExprOps only manipulate xml/text.
+ */
+static bool
+xmlexpr_is_immutable(XmlExpr *xexpr)
+{
+	ListCell   *lc;
+	List	   *arglists[2];
+	int			i;
+
+	switch (xexpr->op)
+	{
+		case IS_XMLCONCAT:
+		case IS_XMLPARSE:
+		case IS_XMLPI:
+		case IS_XMLROOT:
+		case IS_XMLSERIALIZE:
+		case IS_DOCUMENT:
+			return true;
+
+		case IS_XMLELEMENT:
+		case IS_XMLFOREST:
+			arglists[0] = xexpr->named_args;
+			arglists[1] = xexpr->args;
+			for (i = 0; i < lengthof(arglists); i++)
+			{
+				foreach(lc, arglists[i])
+				{
+					if (!map_sql_value_to_xml_is_immutable(exprType(lfirst(lc))))
+						return false;
+				}
+			}
+			return true;
+	}
+
+	return false;
+}
+
 /*
  * contain_mutable_functions_after_planning
  *	  Test whether given expression contains mutable functions.
@@ -3702,6 +3747,14 @@ eval_const_expressions_mutator(Node *node,
 					return ece_evaluate_expr(node);
 				return node;
 			}
+		case T_XmlExpr:
+			{
+				node = ece_generic_processing(node);
+				if (ece_all_arguments_const(node) &&
+					xmlexpr_is_immutable((XmlExpr *) node))
+					return ece_evaluate_expr(node);
+				return node;
+			}
 		case T_CoalesceExpr:
 			{
 				CoalesceExpr *coalesceexpr = (CoalesceExpr *) node;
diff --git a/src/backend/utils/adt/xml.c b/src/backend/utils/adt/xml.c
index 1f75ffcfd9d..2034f7b39a4 100644
--- a/src/backend/utils/adt/xml.c
+++ b/src/backend/utils/adt/xml.c
@@ -85,3 +85,4 @@
 #include "catalog/namespace.h"
 #include "catalog/pg_class.h"
+#include "catalog/pg_proc.h"
 #include "catalog/pg_type.h"
@@ -2727,6 +2728,43 @@ map_sql_value_to_xml_value(Datum value, Oid type, bool xml_escape_strings)
 	}
 }
 
+/*
+ * map_sql_value_to_xml_is_immutable
+ *	  True if mapping this type to XML text is immutable.
+ *
+ * Mirrors the special cases in map_sql_value_to_xml_value(): bool/date/timestamp
+ * use fixed formatting rather than DateStyle-sensitive output, and bytea uses
+ * xmlbinary rather than byteaout (which is marked immutable).
+ */
+bool
+map_sql_value_to_xml_is_immutable(Oid type)
+{
+	Oid			typeOut;
+	bool		isvarlena;
+
+	if (type_is_array_domain(type))
+		return map_sql_value_to_xml_is_immutable(get_base_element_type(type));
+
+	type = getBaseType(type);
+
+	switch (type)
+	{
+		case BOOLOID:
+		case DATEOID:
+		case TIMESTAMPOID:
+			/* Fixed formatting, not type output / DateStyle */
+			return true;
+
+		case BYTEAOID:
+			/* Uses xmlbinary, not byteaout (marked immutable) */
+			return false;
+
+		default:
+			getTypeOutputInfo(type, &typeOut, &isvarlena);
+			return (func_volatile(typeOut) == PROVOLATILE_IMMUTABLE);
+	}
+}
+
 
 /*
  * Escape characters in text that have special meanings in XML.
diff --git a/src/include/utils/xml.h b/src/include/utils/xml.h
index ca266f448d6..daa5d96ae1d 100644
--- a/src/include/utils/xml.h
+++ b/src/include/utils/xml.h
@@ -84,6 +84,7 @@ extern char *escape_xml(const char *str);
 extern char *map_sql_identifier_to_xml_name(const char *ident, bool fully_escaped, bool escape_period);
 extern char *map_xml_name_to_sql_identifier(const char *name);
 extern char *map_sql_value_to_xml_value(Datum value, Oid type, bool xml_escape_strings);
+extern bool map_sql_value_to_xml_is_immutable(Oid type);
 
 extern PGDLLIMPORT int xmlbinary;	/* XmlBinaryType, but int for guc enum */
 
diff --git a/src/test/regress/expected/xml.out b/src/test/regress/expected/xml.out
index fb3e0ec41b2..9dd6d5a9226 100644
--- a/src/test/regress/expected/xml.out
+++ b/src/test/regress/expected/xml.out
@@ -713,6 +713,73 @@ LINE 1: SELECT '<>' IS NOT DOCUMENT;
 DETAIL:  line 1: StartTag: invalid element name
 <>
  ^
+--
+-- Constant-folding of XmlExpr
+--
+-- Fold IS DOCUMENT so CASE does not simplify unused xpath() at plan time.
+SELECT CASE WHEN ('2019-12-16T00:00:00.000'::xml) IS DOCUMENT
+	THEN (xpath('/*/text()', '2019-12-16T00:00:00.000'::xml))[1]::text
+	ELSE ('2019-12-16T00:00:00.000'::xml)::text
+END;
+          case           
+-------------------------
+ 2019-12-16T00:00:00.000
+(1 row)
+
+-- XMLFOREST must not fold when map_sql_value_to_xml_value() is mutable.
+-- timestamptz follows TimeZone.
+PREPARE xmlforest_tz AS SELECT xmlforest('2026-08-28 12:00 UTC'::timestamptz AS foo);
+SET timezone = 'America/New_York';
+EXECUTE xmlforest_tz;
+              xmlforest               
+--------------------------------------
+ <foo>2026-08-28T08:00:00-04:00</foo>
+(1 row)
+
+SET timezone = 'America/Chicago';
+EXECUTE xmlforest_tz;
+              xmlforest               
+--------------------------------------
+ <foo>2026-08-28T07:00:00-05:00</foo>
+(1 row)
+
+DEALLOCATE xmlforest_tz;
+SET timezone = 'GMT';
+-- bytea follows xmlbinary.
+PREPARE xmlforest_bin AS SELECT xmlforest(bytea 'bar' AS foo);
+SET xmlbinary TO base64;
+EXECUTE xmlforest_bin;
+    xmlforest    
+-----------------
+ <foo>YmFy</foo>
+(1 row)
+
+SET xmlbinary TO hex;
+EXECUTE xmlforest_bin;
+     xmlforest     
+-------------------
+ <foo>626172</foo>
+(1 row)
+
+DEALLOCATE xmlforest_bin;
+-- interval follows IntervalStyle (via type output).
+PREPARE xmlforest_iv AS SELECT xmlforest(interval '1 year 2 mons 3 days 04:05:06' AS foo);
+SET intervalstyle TO postgres;
+EXECUTE xmlforest_iv;
+                xmlforest                 
+------------------------------------------
+ <foo>1 year 2 mons 3 days 04:05:06</foo>
+(1 row)
+
+SET intervalstyle TO iso_8601;
+EXECUTE xmlforest_iv;
+         xmlforest         
+---------------------------
+ <foo>P1Y2M3DT4H5M6S</foo>
+(1 row)
+
+DEALLOCATE xmlforest_iv;
+RESET intervalstyle;
 SELECT xmlagg(data) FROM xmltest;
                 xmlagg                
 --------------------------------------
diff --git a/src/test/regress/sql/xml.sql b/src/test/regress/sql/xml.sql
index aafd39433a6..31fc18dd85d 100644
--- a/src/test/regress/sql/xml.sql
+++ b/src/test/regress/sql/xml.sql
@@ -185,6 +185,39 @@ SELECT xml '<abc/>' IS NOT DOCUMENT;
 SELECT xml 'abc' IS NOT DOCUMENT;
 SELECT '<>' IS NOT DOCUMENT;
 
+--
+-- Constant-folding of XmlExpr
+--
+-- Fold IS DOCUMENT so CASE does not simplify unused xpath() at plan time.
+SELECT CASE WHEN ('2019-12-16T00:00:00.000'::xml) IS DOCUMENT
+	THEN (xpath('/*/text()', '2019-12-16T00:00:00.000'::xml))[1]::text
+	ELSE ('2019-12-16T00:00:00.000'::xml)::text
+END;
+
+-- XMLFOREST must not fold when map_sql_value_to_xml_value() is mutable.
+-- timestamptz follows TimeZone.
+PREPARE xmlforest_tz AS SELECT xmlforest('2026-08-28 12:00 UTC'::timestamptz AS foo);
+SET timezone = 'America/New_York';
+EXECUTE xmlforest_tz;
+SET timezone = 'America/Chicago';
+EXECUTE xmlforest_tz;
+DEALLOCATE xmlforest_tz;
+SET timezone = 'GMT';
+-- bytea follows xmlbinary.
+PREPARE xmlforest_bin AS SELECT xmlforest(bytea 'bar' AS foo);
+SET xmlbinary TO base64;
+EXECUTE xmlforest_bin;
+SET xmlbinary TO hex;
+EXECUTE xmlforest_bin;
+DEALLOCATE xmlforest_bin;
+-- interval follows IntervalStyle (via type output).
+PREPARE xmlforest_iv AS SELECT xmlforest(interval '1 year 2 mons 3 days 04:05:06' AS foo);
+SET intervalstyle TO postgres;
+EXECUTE xmlforest_iv;
+SET intervalstyle TO iso_8601;
+EXECUTE xmlforest_iv;
+DEALLOCATE xmlforest_iv;
+RESET intervalstyle;
 
 SELECT xmlagg(data) FROM xmltest;
 SELECT xmlagg(data) FROM xmltest WHERE id > 10;
-- 
2.53.0

