From 59c1a6059b2fca4a77e7820d6a6fe4fda97061af Mon Sep 17 00:00:00 2001
From: Shinya Kato <shinya11.kato@gmail.com>
Date: Tue, 18 Aug 2026 22:37:47 +0900
Subject: [PATCH v2 1/2] Make pgbench \gset and \aset store SQL NULL as the
 null value

Previously, readCommandResponse() stored every column with PQgetvalue(),
which returns an empty string for SQL NULL.  An empty string is not a
valid value in the pgbench expression language, so the first expression
use of such a variable aborted the client, and neither --max-tries nor
--continue-on-error recovered from it.  An empty string returned by the
server was also indistinguishable from a NULL.

Check PQgetisnull() on the row being stored and assign the null value
for NULL columns, exactly as \set varname NULL does, so the variable can
be tested with IS NULL.  Empty strings are still stored as empty
strings.  Scripts that captured a NULL and interpolated it textually now
get NULL instead of an empty string.  A NULL-valued variable is still
bound as the string "NULL" in the extended and prepared query modes,
just as one created by \set is.

Author: Shinya Kato <shinya11.kato@gmail.com>
Reviewed-by: Kyotaro Horiguchi <horikyota.ntt@gmail.com>
Discussion: https://postgr.es/m/CAOzEurT9J9QMmpJt_8pWZCziNYCUHOD=c-hoccOVYJLc_VxVRg@mail.gmail.com
---
 src/bin/pgbench/pgbench.c                    | 19 +++++-
 src/bin/pgbench/t/001_pgbench_with_server.pl | 67 ++++++++++++++++++++
 2 files changed, 83 insertions(+), 3 deletions(-)

diff --git a/src/bin/pgbench/pgbench.c b/src/bin/pgbench/pgbench.c
index 5862758427f..bfd33383f7d 100644
--- a/src/bin/pgbench/pgbench.c
+++ b/src/bin/pgbench/pgbench.c
@@ -3319,6 +3319,7 @@ readCommandResponse(CState *st, MetaCommand meta, char *varprefix)
 				if ((is_last && meta == META_GSET) || meta == META_ASET)
 				{
 					int			ntuples = PQntuples(res);
+					const char *context = meta == META_ASET ? "aset" : "gset";
 
 					if (meta == META_GSET && ntuples != 1)
 					{
@@ -3338,14 +3339,26 @@ readCommandResponse(CState *st, MetaCommand meta, char *varprefix)
 					for (int fld = 0; fld < PQnfields(res); fld++)
 					{
 						char	   *varname = PQfname(res, fld);
+						bool		ok;
 
 						/* allocate varname only if necessary, freed below */
 						if (*varprefix != '\0')
 							varname = psprintf("%s%s", varprefix, varname);
 
-						/* store last row result as a string */
-						if (!putVariable(&st->variables, meta == META_ASET ? "aset" : "gset", varname,
-										 PQgetvalue(res, ntuples - 1, fld)))
+						/* store last row result, SQL NULL as null value */
+						if (PQgetisnull(res, ntuples - 1, fld))
+						{
+							PgBenchValue nullval;
+
+							setNullValue(&nullval);
+							ok = putVariableValue(&st->variables, context,
+												  varname, &nullval);
+						}
+						else
+							ok = putVariable(&st->variables, context, varname,
+											 PQgetvalue(res, ntuples - 1, fld));
+
+						if (!ok)
 						{
 							/* internal error */
 							pg_log_error("client %d script %d command %d query %d: error storing into variable %s",
diff --git a/src/bin/pgbench/t/001_pgbench_with_server.pl b/src/bin/pgbench/t/001_pgbench_with_server.pl
index bb12ade59f5..8913dc62c42 100644
--- a/src/bin/pgbench/t/001_pgbench_with_server.pl
+++ b/src/bin/pgbench/t/001_pgbench_with_server.pl
@@ -850,6 +850,73 @@ SELECT 5432 AS fail UNION SELECT 5433 ORDER BY 1 \gset
 }
 	});
 
+# \gset stores a SQL NULL as the null value, which expressions can use.
+$node->pgbench(
+	'-t 1', 0,
+	[ qr{type: .*/001_pgbench_gset_null}, qr{processed: 1/1} ],
+	[ qr{command=2.: null\b}, qr{command=3.: boolean true\b} ],
+	'pgbench gset command with NULL',
+	{
+		'001_pgbench_gset_null' => q{-- NULL is stored as the null value
+SELECT NULL AS nv \gset
+\set i debug(:nv)
+\set i debug(:nv IS NULL)
+}
+	});
+
+# NULL and empty string captured by \gset stay distinct when interpolated
+$node->safe_psql('postgres',
+	'CREATE UNLOGGED TABLE gset_null_tab(id INTEGER, t TEXT);');
+
+$node->pgbench(
+	'-t 1', 0,
+	[ qr{type: .*/001_pgbench_gset_null_interp}, qr{processed: 1/1} ],
+	[],
+	'pgbench gset NULL interpolation',
+	{
+		'001_pgbench_gset_null_interp' => q{-- interpolate NULL and ''
+SELECT NULL AS nv, ''::text AS es \gset
+INSERT INTO gset_null_tab VALUES (1, :nv);
+INSERT INTO gset_null_tab VALUES (2, ':es');
+}
+	});
+
+is( $node->safe_psql(
+		'postgres', 'SELECT t IS NULL FROM gset_null_tab WHERE id = 1;'),
+	't',
+	'gset NULL interpolates as SQL NULL');
+is( $node->safe_psql(
+		'postgres', "SELECT t = '' FROM gset_null_tab WHERE id = 2;"),
+	't',
+	'gset empty string stays empty');
+
+# In the extended and prepared query modes a NULL-valued variable is bound as
+# the string "NULL", not as an SQL null parameter.
+for my $mode ('extended', 'prepared')
+{
+	$node->pgbench(
+		"-t 1 -M $mode",
+		0,
+		[ qr{type: .*/001_pgbench_gset_null_param_$mode}, qr{processed: 1/1} ],
+		[],
+		"pgbench gset NULL as query parameter ($mode)",
+		{
+			"001_pgbench_gset_null_param_$mode" =>
+			  q{-- NULL bound as a query parameter
+SELECT NULL AS nv \gset
+INSERT INTO gset_null_tab VALUES (3, :nv);
+}
+		});
+}
+
+is( $node->safe_psql(
+		'postgres',
+		"SELECT count(*) FROM gset_null_tab WHERE id = 3 AND t = 'NULL';"),
+	'2',
+	'gset NULL is bound as the string NULL in extended and prepared modes');
+
+$node->safe_psql('postgres', 'DROP TABLE gset_null_tab;');
+
 # working \aset
 # Valid cases.
 $node->pgbench(
-- 
2.47.3

