From 00273b61bdfbdd8d10e02bdc58ccd3c2b61968b0 Mon Sep 17 00:00:00 2001
From: Sami Imseih <simseih@amazon.com>
Date: Tue, 3 Mar 2026 16:11:27 -0600
Subject: [PATCH v4 1/1] Clean up PREPARE query strings in multi-statement
 contexts

When PREPARE is executed as part of a multi-statement query string,
the stored query text previously included the entire multi-statement
string. This caused the extra queries to be stored in the prepared
statement cache, consuming unnecessary memory, and made them visible
when querying pg_prepared_statements. It also caused issues with
query normalization in extensions like pg_stat_statements.

This commit modifies PrepareQuery() to extract only the relevant
PREPARE statement using CleanQuerytext() before storing it in the
CachedPlanSource. The stmt_location in the Query tree is reset to 0
since the query text stored in the plan cache now contains only the
PREPARE statement, which starts at position 0. This ensures that
downstream consumers like pg_stat_statements can correctly normalize
the query text.

Author: Julien Rouhaud <rjuju123@gmail.com>
Discussion: https://www.postgresql.org/message-id/flat/aUwKEWGge5jWtaRX%40jrouhaud
---
 contrib/auto_explain/t/001_auto_explain.pl    |  8 +--
 contrib/pg_stat_statements/Makefile           |  1 +
 .../pg_stat_statements/expected/prepare.out   | 53 +++++++++++++++++++
 contrib/pg_stat_statements/meson.build        |  1 +
 contrib/pg_stat_statements/sql/prepare.sql    | 15 ++++++
 src/backend/commands/prepare.c                | 34 +++++++++++-
 src/test/regress/expected/prepare.out         | 44 +++++++++------
 src/test/regress/sql/prepare.sql              |  2 +-
 8 files changed, 135 insertions(+), 23 deletions(-)
 create mode 100644 contrib/pg_stat_statements/expected/prepare.out
 create mode 100644 contrib/pg_stat_statements/sql/prepare.sql

diff --git a/contrib/auto_explain/t/001_auto_explain.pl b/contrib/auto_explain/t/001_auto_explain.pl
index 5f673bd14c1..f2d8625f8bb 100644
--- a/contrib/auto_explain/t/001_auto_explain.pl
+++ b/contrib/auto_explain/t/001_auto_explain.pl
@@ -60,7 +60,7 @@ $log_contents = query_log($node,
 
 like(
 	$log_contents,
-	qr/Query Text: PREPARE get_proc\(name\) AS SELECT \* FROM pg_proc WHERE proname = \$1;/,
+	qr/Query Text: PREPARE get_proc\(name\) AS SELECT \* FROM pg_proc WHERE proname = \$1/,
 	"prepared query text logged, text mode");
 
 like(
@@ -82,7 +82,7 @@ $log_contents = query_log(
 
 like(
 	$log_contents,
-	qr/Query Text: PREPARE get_type\(name\) AS SELECT \* FROM pg_type WHERE typname = \$1;/,
+	qr/Query Text: PREPARE get_type\(name\) AS SELECT \* FROM pg_type WHERE typname = \$1/,
 	"prepared query text logged, text mode");
 
 like(
@@ -98,7 +98,7 @@ $log_contents = query_log(
 
 like(
 	$log_contents,
-	qr/Query Text: PREPARE get_type\(name\) AS SELECT \* FROM pg_type WHERE typname = \$1;/,
+	qr/Query Text: PREPARE get_type\(name\) AS SELECT \* FROM pg_type WHERE typname = \$1/,
 	"prepared query text logged, text mode");
 
 unlike(
@@ -164,7 +164,7 @@ $log_contents = query_log(
 
 like(
 	$log_contents,
-	qr/"Query Text": "PREPARE get_class\(name\) AS SELECT \* FROM pg_class WHERE relname = \$1;"/,
+	qr/"Query Text": "PREPARE get_class\(name\) AS SELECT \* FROM pg_class WHERE relname = \$1"/,
 	"prepared query text logged, json mode");
 
 like(
diff --git a/contrib/pg_stat_statements/Makefile b/contrib/pg_stat_statements/Makefile
index c27e9529bb6..ac967687796 100644
--- a/contrib/pg_stat_statements/Makefile
+++ b/contrib/pg_stat_statements/Makefile
@@ -36,6 +36,7 @@ REGRESS = \
 	parallel \
 	plancache \
 	squashing \
+	prepare \
 	cleanup \
 	oldextversions
 
diff --git a/contrib/pg_stat_statements/expected/prepare.out b/contrib/pg_stat_statements/expected/prepare.out
new file mode 100644
index 00000000000..010e289c1b0
--- /dev/null
+++ b/contrib/pg_stat_statements/expected/prepare.out
@@ -0,0 +1,53 @@
+-- Tests for PREPARE
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+ t 
+---
+ t
+(1 row)
+
+-- Test that prepared statements in a multi-query string behaves as expected
+SELECT 1\;PREPARE p1 AS SELECT 1\; PREPARE p2(int) AS SELECT 2 * $1\; SELECT 1, 1;
+ ?column? 
+----------
+        1
+(1 row)
+
+ ?column? | ?column? 
+----------+----------
+        1 |        1
+(1 row)
+
+SELECT calls, rows, query FROM pg_stat_statements ORDER BY query COLLATE "C";
+ calls | rows |                       query                        
+-------+------+----------------------------------------------------
+     1 |    1 | SELECT $1
+     1 |    1 | SELECT $1, $2
+     1 |    1 | SELECT pg_stat_statements_reset() IS NOT NULL AS t
+(3 rows)
+
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+ t 
+---
+ t
+(1 row)
+
+EXECUTE p1;
+ ?column? 
+----------
+        1
+(1 row)
+
+EXECUTE p2(0);
+ ?column? 
+----------
+        0
+(1 row)
+
+SELECT calls, rows, query FROM pg_stat_statements ORDER BY query COLLATE "C";
+ calls | rows |                       query                        
+-------+------+----------------------------------------------------
+     1 |    1 | PREPARE p1 AS SELECT 1
+     1 |    1 | PREPARE p2(int) AS SELECT 2 * $1
+     1 |    1 | SELECT pg_stat_statements_reset() IS NOT NULL AS t
+(3 rows)
+
diff --git a/contrib/pg_stat_statements/meson.build b/contrib/pg_stat_statements/meson.build
index 9d78cb88b7d..c9e1d446d39 100644
--- a/contrib/pg_stat_statements/meson.build
+++ b/contrib/pg_stat_statements/meson.build
@@ -58,6 +58,7 @@ tests += {
       'parallel',
       'plancache',
       'squashing',
+      'prepare',
       'cleanup',
       'oldextversions',
     ],
diff --git a/contrib/pg_stat_statements/sql/prepare.sql b/contrib/pg_stat_statements/sql/prepare.sql
new file mode 100644
index 00000000000..a6bc5de4430
--- /dev/null
+++ b/contrib/pg_stat_statements/sql/prepare.sql
@@ -0,0 +1,15 @@
+-- Tests for PREPARE
+
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+
+-- Test that prepared statements in a multi-query string behaves as expected
+SELECT 1\;PREPARE p1 AS SELECT 1\; PREPARE p2(int) AS SELECT 2 * $1\; SELECT 1, 1;
+
+SELECT calls, rows, query FROM pg_stat_statements ORDER BY query COLLATE "C";
+
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+
+EXECUTE p1;
+EXECUTE p2(0);
+
+SELECT calls, rows, query FROM pg_stat_statements ORDER BY query COLLATE "C";
diff --git a/src/backend/commands/prepare.c b/src/backend/commands/prepare.c
index 5b86a727587..8a392f6a2f8 100644
--- a/src/backend/commands/prepare.c
+++ b/src/backend/commands/prepare.c
@@ -27,6 +27,7 @@
 #include "commands/prepare.h"
 #include "funcapi.h"
 #include "nodes/nodeFuncs.h"
+#include "nodes/queryjumble.h"
 #include "parser/parse_coerce.h"
 #include "parser/parse_collate.h"
 #include "parser/parse_expr.h"
@@ -64,6 +65,8 @@ PrepareQuery(ParseState *pstate, PrepareStmt *stmt,
 	Oid		   *argtypes = NULL;
 	int			nargs;
 	List	   *query_list;
+	const char *new_query;
+	ListCell   *lc;
 
 	/*
 	 * Disallow empty-string statement name (conflicts with protocol-level
@@ -82,12 +85,29 @@ PrepareQuery(ParseState *pstate, PrepareStmt *stmt,
 	rawstmt->stmt = stmt->query;
 	rawstmt->stmt_location = stmt_location;
 	rawstmt->stmt_len = stmt_len;
+	new_query = pstate->p_sourcetext;
+
+	/*
+	 * If we have a multi-statement string, extract only the PREPARE statement
+	 * for storage in the plan cache.
+	 */
+	if (stmt_location >= 0)
+	{
+		const char *cleaned;
+		char	   *tmp;
+
+		cleaned = CleanQuerytext(pstate->p_sourcetext, &rawstmt->stmt_location, &rawstmt->stmt_len);
+
+		tmp = palloc(rawstmt->stmt_len + 1);
+		strlcpy(tmp, cleaned, rawstmt->stmt_len + 1);
+		new_query = tmp;
+	}
 
 	/*
 	 * Create the CachedPlanSource before we do parse analysis, since it needs
 	 * to see the unmodified raw parse tree.
 	 */
-	plansource = CreateCachedPlan(rawstmt, pstate->p_sourcetext,
+	plansource = CreateCachedPlan(rawstmt, new_query,
 								  CreateCommandTag(stmt->query));
 
 	/* Transform list of TypeNames to array of type OIDs */
@@ -119,6 +139,18 @@ PrepareQuery(ParseState *pstate, PrepareStmt *stmt,
 	query_list = pg_analyze_and_rewrite_varparams(rawstmt, pstate->p_sourcetext,
 												  &argtypes, &nargs, NULL);
 
+	/*
+	 * Reset stmt_location to 0 since the query text stored in the plan cache
+	 * now contains only the PREPARE statement.
+	 */
+	foreach(lc, query_list)
+	{
+		Query	   *query = lfirst_node(Query, lc);
+
+		if (query->stmt_location > 0)
+			query->stmt_location = 0;
+	}
+
 	/* Finish filling in the CachedPlanSource */
 	CompleteCachedPlan(plansource,
 					   query_list,
diff --git a/src/test/regress/expected/prepare.out b/src/test/regress/expected/prepare.out
index 5815e17b39c..c645a4e5d0e 100644
--- a/src/test/regress/expected/prepare.out
+++ b/src/test/regress/expected/prepare.out
@@ -6,7 +6,17 @@ SELECT name, statement, parameter_types, result_types FROM pg_prepared_statement
 ------+-----------+-----------------+--------------
 (0 rows)
 
-PREPARE q1 AS SELECT 1 AS a;
+SELECT 'bingo'\;  PREPARE q1 AS SELECT 1 AS a \; SELECT 42;
+ ?column? 
+----------
+ bingo
+(1 row)
+
+ ?column? 
+----------
+       42
+(1 row)
+
 EXECUTE q1;
  a 
 ---
@@ -14,9 +24,9 @@ EXECUTE q1;
 (1 row)
 
 SELECT name, statement, parameter_types, result_types FROM pg_prepared_statements;
- name |          statement           | parameter_types | result_types 
-------+------------------------------+-----------------+--------------
- q1   | PREPARE q1 AS SELECT 1 AS a; | {}              | {integer}
+ name |          statement          | parameter_types | result_types 
+------+-----------------------------+-----------------+--------------
+ q1   | PREPARE q1 AS SELECT 1 AS a | {}              | {integer}
 (1 row)
 
 -- should fail
@@ -33,18 +43,18 @@ EXECUTE q1;
 
 PREPARE q2 AS SELECT 2 AS b;
 SELECT name, statement, parameter_types, result_types FROM pg_prepared_statements;
- name |          statement           | parameter_types | result_types 
-------+------------------------------+-----------------+--------------
- q1   | PREPARE q1 AS SELECT 2;      | {}              | {integer}
- q2   | PREPARE q2 AS SELECT 2 AS b; | {}              | {integer}
+ name |          statement          | parameter_types | result_types 
+------+-----------------------------+-----------------+--------------
+ q1   | PREPARE q1 AS SELECT 2      | {}              | {integer}
+ q2   | PREPARE q2 AS SELECT 2 AS b | {}              | {integer}
 (2 rows)
 
 -- sql92 syntax
 DEALLOCATE PREPARE q1;
 SELECT name, statement, parameter_types, result_types FROM pg_prepared_statements;
- name |          statement           | parameter_types | result_types 
-------+------------------------------+-----------------+--------------
- q2   | PREPARE q2 AS SELECT 2 AS b; | {}              | {integer}
+ name |          statement          | parameter_types | result_types 
+------+-----------------------------+-----------------+--------------
+ q2   | PREPARE q2 AS SELECT 2 AS b | {}              | {integer}
 (1 row)
 
 DEALLOCATE PREPARE q2;
@@ -168,20 +178,20 @@ SELECT name, statement, parameter_types, result_types FROM pg_prepared_statement
 ------+------------------------------------------------------------------+----------------------------------------------------+--------------------------------------------------------------------------------------------------------------------------
  q2   | PREPARE q2(text) AS                                             +| {text}                                             | {name,boolean,boolean}
       |         SELECT datname, datistemplate, datallowconn             +|                                                    | 
-      |         FROM pg_database WHERE datname = $1;                     |                                                    | 
+      |         FROM pg_database WHERE datname = $1                      |                                                    | 
  q3   | PREPARE q3(text, int, float, boolean, smallint) AS              +| {text,integer,"double precision",boolean,smallint} | {integer,integer,integer,integer,integer,integer,integer,integer,integer,integer,integer,integer,integer,name,name,name}
       |         SELECT * FROM tenk1 WHERE string4 = $1 AND (four = $2 OR+|                                                    | 
       |         ten = $3::bigint OR true = $4 OR odd = $5::int)         +|                                                    | 
-      |         ORDER BY unique1;                                        |                                                    | 
+      |         ORDER BY unique1                                         |                                                    | 
  q5   | PREPARE q5(int, text) AS                                        +| {integer,text}                                     | {integer,integer,integer,integer,integer,integer,integer,integer,integer,integer,integer,integer,integer,name,name,name}
       |         SELECT * FROM tenk1 WHERE unique1 = $1 OR stringu1 = $2 +|                                                    | 
-      |         ORDER BY unique1;                                        |                                                    | 
+      |         ORDER BY unique1                                         |                                                    | 
  q6   | PREPARE q6 AS                                                   +| {integer,name}                                     | {integer,integer,integer,integer,integer,integer,integer,integer,integer,integer,integer,integer,integer,name,name,name}
-      |     SELECT * FROM tenk1 WHERE unique1 = $1 AND stringu1 = $2;    |                                                    | 
+      |     SELECT * FROM tenk1 WHERE unique1 = $1 AND stringu1 = $2     |                                                    | 
  q7   | PREPARE q7(unknown) AS                                          +| {path}                                             | {text,path}
-      |     SELECT * FROM road WHERE thepath = $1;                       |                                                    | 
+      |     SELECT * FROM road WHERE thepath = $1                        |                                                    | 
  q8   | PREPARE q8 AS                                                   +| {integer,name}                                     | 
-      |     UPDATE tenk1 SET stringu1 = $2 WHERE unique1 = $1;           |                                                    | 
+      |     UPDATE tenk1 SET stringu1 = $2 WHERE unique1 = $1            |                                                    | 
 (6 rows)
 
 -- test DEALLOCATE ALL;
diff --git a/src/test/regress/sql/prepare.sql b/src/test/regress/sql/prepare.sql
index c6098dc95ce..0e7fe44725e 100644
--- a/src/test/regress/sql/prepare.sql
+++ b/src/test/regress/sql/prepare.sql
@@ -4,7 +4,7 @@
 
 SELECT name, statement, parameter_types, result_types FROM pg_prepared_statements;
 
-PREPARE q1 AS SELECT 1 AS a;
+SELECT 'bingo'\;  PREPARE q1 AS SELECT 1 AS a \; SELECT 42;
 EXECUTE q1;
 
 SELECT name, statement, parameter_types, result_types FROM pg_prepared_statements;
-- 
2.50.1 (Apple Git-155)

