This is more sophisticated than one might expect. PL/pgSQL EXECUTE
strips comments and parses the query string, identifying tokens. Only actual $
-parameters are then replaced with values from the USING
clause, before planning the query. Not $n
contained in comments, strings or identifiers (like -- $1 is required
, 'My text says $2'
or "costs$3"
). We would have to duplicate much of the parser's functionality to be as accurate.
But I have been wishing for the same functionality in the past. So here goes:
Plain replace()
will be fooled by any occurrence of $n
that is not an actual parameter. Here is a poor-man's implementation that should do a better job. And it includes type data types. (But it's still far from perfect!)
First a little helper function: Postgres provides a couple of functions to format strings as identifier, literal etc:
quote_ident()
quote_literal()
quote_nullable()
We need this one:
quote_nullable
( anyelement
) → text
Converts the given value to text and then quotes it as a literal; or,
if the argument is null, returns NULL
. Embedded single-quotes and
backslashes are properly doubled.
But with an appended cast to the original data type. So I call it quote_nullable_typed()
:
CREATE OR REPLACE FUNCTION quote_nullable_typed(anyelement)
RETURNS text
LANGUAGE sql PARALLEL SAFE IMMUTABLE AS
$func$
SELECT quote_nullable($1) || '::' || pg_typeof($1);
$func$;
format()
has format specifiers inlining the functionality of the mentioned quote_*()
functions: %I
, %L
. Not for our custom function, obviously. So apply it explicitly and pass the result with %s
. Nested in another helper function:
CREATE OR REPLACE FUNCTION f_sql_string_with_params(_sql text, VARIADIC x text[])
RETURNS text
LANGUAGE sql PARALLEL SAFE IMMUTABLE AS
$func$
SELECT format(regexp_replace(replace(_sql, '%', '%%')
, '(?<=[^[:alnum:]_''"])\$(\d+)(?=[^[:alnum:]_''"])'
, '%\1$s'
, 'g'
)
, x[1],x[2],x[3],x[4],x[5],x[6],x[7],x[8],x[9],x[10]) -- allow more?
$func$;
The VARIADIC
parameter accepts a variable number of parameters. The function iterates through the first 10 (arbitrarily). If you need more, expand the list.
Core functionality is:
regexp_replace(replace(_sql, '%', '%%'), '(?<=[^[:alnum:]_''"])\$(\d+)(?=[^[:alnum:]_''"])', '%\1$s', 'g')
Double up any %
to prepare for format()
. See:
The regexp pattern (?<=[^[:alnum:]_''"])\$(\d+)(?=[^[:alnum:]_''"])
explained:
(?<=)
... positive lookbehind
[^[:alnum:]_''"]
... character class excluding word characters and _'"
\$
... literal $
(\d+)
... one or more digits in capturing parentheses (noted for replacement)
(?=)
... positive lookahead
So it only replaces $n
when not between word characters or next to quotes. Far from perfect. But should cover typical cases.
Ideally, we would strip comments first. Who ever feels in the mood to improve it further ...
Applied to your original example:
CREATE OR REPLACE FUNCTION fn()
RETURNS void
LANGUAGE plpgsql AS
$func$
DECLARE
_sql text := $q$SELECT $1, $2, $3, 'false $1', format('demo: %I', 'xX')$q$; -- sql string, extended with potential hazards
_v1 int := 1;
_v2 date := '2020-01-01';
_v3 date; -- defaults to NULL
BEGIN
RAISE NOTICE E'Query string with $-parameters:\n%', _sql;
RAISE NOTICE E'Query string with typed values:\n%'
, f_sql_string_with_params(_sql
, quote_nullable_typed(_v1)
, quote_nullable_typed(_v2)
, quote_nullable_typed(_v3)
);
EXECUTE _sql
USING _v1, _v2, _v3;
END
$func$;
The second NOTICE
will report:
SELECT '1'::integer, '2020-01-01'::date, NULL::date;
db<>fiddle here - with extended test case