From 9e762146e04fe35872044a45abfa248494bbbf02 Mon Sep 17 00:00:00 2001
From: "Chao Li (Evan)" <lic@highgo.com>
Date: Mon, 1 Jun 2026 13:21:52 +0800
Subject: [PATCH v2] pg_stat_statements: Fix normalization of + signs for FETCH
 and MOVE

FETCH and MOVE accept a possibly-signed integer count.  Query
normalization already consumed both tokens of a negative count, but did
not do the same for an explicit plus sign.  As a result, statements such
as "FETCH FORWARD +2 c" could be displayed incorrectly in
pg_stat_statements as "FETCH FORWARD $12 c".

Treat a leading plus sign like a leading minus sign when determining the
length of a constant.  Add regression coverage for FETCH and MOVE
variants, including whitespace between the sign and the integer.

Author: Chao Li <lic@highgo.com>
---
 .../pg_stat_statements/expected/cursors.out   | 31 +++++++++++++++++++
 contrib/pg_stat_statements/sql/cursors.sql    | 10 ++++++
 src/backend/nodes/queryjumblefuncs.c          | 26 +++++++++-------
 3 files changed, 55 insertions(+), 12 deletions(-)

diff --git a/contrib/pg_stat_statements/expected/cursors.out b/contrib/pg_stat_statements/expected/cursors.out
index 6afb48ace92..56c2e7ae705 100644
--- a/contrib/pg_stat_statements/expected/cursors.out
+++ b/contrib/pg_stat_statements/expected/cursors.out
@@ -68,6 +68,37 @@ SELECT pg_stat_statements_reset() IS NOT NULL AS t;
  t
 (1 row)
 
+-- Normalization of FETCH and MOVE statements with an explicit plus sign
+BEGIN;
+DECLARE pgss_plus_cursor SCROLL CURSOR FOR SELECT FROM generate_series(1, 10);
+FETCH +2 pgss_plus_cursor;
+--
+(2 rows)
+
+FETCH FORWARD +2 pgss_plus_cursor;
+--
+(2 rows)
+
+MOVE RELATIVE +2 pgss_plus_cursor;
+COMMIT;
+SELECT calls, query FROM pg_stat_statements ORDER BY query COLLATE "C";
+ calls |                                     query                                      
+-------+--------------------------------------------------------------------------------
+     1 | BEGIN
+     1 | COMMIT
+     1 | DECLARE pgss_plus_cursor SCROLL CURSOR FOR SELECT FROM generate_series($1, $2)
+     1 | FETCH $1 pgss_plus_cursor
+     1 | FETCH FORWARD $1 pgss_plus_cursor
+     1 | MOVE RELATIVE $1 pgss_plus_cursor
+     1 | SELECT pg_stat_statements_reset() IS NOT NULL AS t
+(7 rows)
+
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+ t 
+---
+ t
+(1 row)
+
 -- Normalization of FETCH statements
 BEGIN;
 DECLARE pgss_cursor CURSOR FOR SELECT FROM generate_series(1, 10);
diff --git a/contrib/pg_stat_statements/sql/cursors.sql b/contrib/pg_stat_statements/sql/cursors.sql
index 78bb4228433..2d07a743f6b 100644
--- a/contrib/pg_stat_statements/sql/cursors.sql
+++ b/contrib/pg_stat_statements/sql/cursors.sql
@@ -29,6 +29,16 @@ COMMIT;
 SELECT calls, rows, query FROM pg_stat_statements ORDER BY query COLLATE "C";
 SELECT pg_stat_statements_reset() IS NOT NULL AS t;
 
+-- Normalization of FETCH and MOVE statements with an explicit plus sign
+BEGIN;
+DECLARE pgss_plus_cursor SCROLL CURSOR FOR SELECT FROM generate_series(1, 10);
+FETCH +2 pgss_plus_cursor;
+FETCH FORWARD +2 pgss_plus_cursor;
+MOVE RELATIVE +2 pgss_plus_cursor;
+COMMIT;
+SELECT calls, query FROM pg_stat_statements ORDER BY query COLLATE "C";
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+
 -- Normalization of FETCH statements
 BEGIN;
 DECLARE pgss_cursor CURSOR FOR SELECT FROM generate_series(1, 10);
diff --git a/src/backend/nodes/queryjumblefuncs.c b/src/backend/nodes/queryjumblefuncs.c
index 7c63766a51c..43e27df3397 100644
--- a/src/backend/nodes/queryjumblefuncs.c
+++ b/src/backend/nodes/queryjumblefuncs.c
@@ -812,9 +812,9 @@ CompLocation(const void *a, const void *b)
  * avoid re-scanning statements before the one of interest, so it's worth
  * doing.)
  *
- * N.B. There is an assumption that a '-' character at a Const location begins
- * a negative numeric constant.  This precludes there ever being another
- * reason for a constant to start with a '-'.
+ * N.B. There is an assumption that a '+' or '-' character at a Const location
+ * begins a signed numeric constant.  This precludes there ever being another
+ * reason for a constant to start with either sign.
  *
  * It is the caller's responsibility to free the result, if necessary.
  */
@@ -891,19 +891,21 @@ ComputeConstantLengths(const JumbleState *jstate, const char *query,
 			 */
 			if (yylloc >= loc)
 			{
-				if (query[loc] == '-')
+				if (query[loc] == '-' || query[loc] == '+')
 				{
 					/*
-					 * It's a negative value - this is the one and only case
+					 * It's a signed value - this is the one and only case
 					 * where we replace more than a single token.
 					 *
-					 * Do not compensate for the special-case adjustment of
-					 * location to that of the leading '-' operator in the
-					 * event of a negative constant (see doNegate() in
-					 * gram.y).  It is also useful for our purposes to start
-					 * from the minus symbol.  In this way, queries like
-					 * "select * from foo where bar = 1" and "select * from
-					 * foo where bar = -2" can be treated similarly.
+					 * Do not compensate for a location at the leading sign.
+					 * For negative constants this can happen because of
+					 * doNegate() in gram.y.  Starting from the sign allows
+					 * queries like "select * from foo where bar = 1" and
+					 * "select * from foo where bar = -2" to be treated
+					 * similarly.  Grammar rules using SignedIconst can also
+					 * provide a location at a leading '+', for example in
+					 * "FETCH +2 c".  Consuming both tokens normalizes such a
+					 * value as a single constant too.
 					 */
 					tok = core_yylex(&yylval, &yylloc, yyscanner);
 					if (tok == 0)
-- 
2.50.1 (Apple Git-155)

