From df2e5540f5becec4e511c0e40968c52f0b94241f Mon Sep 17 00:00:00 2001 From: Amit Langote Date: Thu, 12 Nov 2026 09:52:58 +0900 Subject: [PATCH v2] SQL/JSON: Fix boolean DEFAULT expressions in ON ERROR / ON EMPTY When a boolean-valued behavior expression must be coerced to the RETURNING type at run time using json_populate_type(), transformJsonBehavior() replaces it with a jsonb Const, because json_populate_type() only accepts jsonb input. The value of that Const was chosen by looking at the behavior type, 'true' for TRUE ON ERROR and 'false' otherwise. That is fine for the TRUE and FALSE behaviors, but a user-supplied DEFAULT expression has behavior type DEFAULT, so it was unconditionally replaced by 'false' without ever looking at the expression. For example: SELECT JSON_VALUE(jsonb '{"a": 1}', 'strict $.b' DEFAULT true ON ERROR); json_value ------------ false The same happened for DEFAULT NULL::bool and for non-constant expressions such as DEFAULT (1 = 1), with either ON ERROR or ON EMPTY, in JSON_VALUE(), JSON_QUERY() and JSON_TABLE() columns. Only cases where the RETURNING type is boolean, integer, or a domain over integer were unaffected, as they don't take this path. Fix by wrapping a boolean DEFAULT expression in a call to to_jsonb(), there being no cast from boolean to jsonb, so that the value passed to json_populate_type() is whatever the expression evaluates to. The call is visible when the expression is deparsed, for example, a view shows DEFAULT to_jsonb(true) ON ERROR, which reads back in to give the same result. The TRUE and FALSE behaviors continue to use a jsonb Const as before. Note that this does not fix views, rules, or other stored expressions created before this fix, since those already contain the incorrect 'false'::jsonb constant. They will need to be recreated to get the correct result. We'll need to mention this in the release notes. Bug: #19625 Bug: #19822 Reported-by: Man Zeng Reported-by: Chunling Qin <303677365@qq.com> Author: Man Zeng Co-authored-by: Amit Langote Discussion: https://postgr.es/m/19625-683b498c92087bc8@postgresql.org Discussion: https://postgr.es/m/19822-8bdbbe6b7cdc9048@postgresql.org Backpatch-through: 17 --- src/backend/parser/parse_expr.c | 38 ++++++++--- .../regress/expected/sqljson_queryfuncs.out | 68 +++++++++++++++++++ src/test/regress/sql/sqljson_queryfuncs.sql | 29 ++++++++ 3 files changed, 125 insertions(+), 10 deletions(-) diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c index 728392f436f..575ce3a9a42 100644 --- a/src/backend/parser/parse_expr.c +++ b/src/backend/parser/parse_expr.c @@ -4931,19 +4931,37 @@ transformJsonBehavior(ParseState *pstate, JsonExpr *jsexpr, coerce_at_runtime = true; /* - * json_populate_type() expects to be passed a jsonb value, so gin - * up a Const containing the appropriate boolean value represented - * as jsonb, discarding the original Const containing a plain - * boolean. + * json_populate_type() expects to be passed a jsonb value, so a + * boolean expression must be converted to jsonb first. + * + * For a user-supplied DEFAULT expression, do that by wrapping it + * in a call to to_jsonb(), there being no cast from boolean to + * jsonb, so that the result reflects whatever the expression + * evaluates to, including NULL. + * + * Otherwise, the expression is the boolean Const made by + * GetJsonBehaviorConst() for TRUE or FALSE, which is simply + * replaced by a jsonb Const containing the same value. */ if (exprType(expr) == BOOLOID) { - char *val = btype == JSON_BEHAVIOR_TRUE ? "true" : "false"; - - expr = (Node *) makeConst(JSONBOID, -1, InvalidOid, -1, - DirectFunctionCall1(jsonb_in, - CStringGetDatum(val)), - false, false); + if (btype == JSON_BEHAVIOR_DEFAULT) + expr = (Node *) makeFuncExpr(F_TO_JSONB, JSONBOID, + list_make1(expr), + InvalidOid, InvalidOid, + COERCE_EXPLICIT_CALL); + else + { + char *val; + + Assert(btype == JSON_BEHAVIOR_TRUE || + btype == JSON_BEHAVIOR_FALSE); + val = (btype == JSON_BEHAVIOR_TRUE) ? "true" : "false"; + expr = (Node *) makeConst(JSONBOID, -1, InvalidOid, -1, + DirectFunctionCall1(jsonb_in, + CStringGetDatum(val)), + false, false); + } } } else diff --git a/src/test/regress/expected/sqljson_queryfuncs.out b/src/test/regress/expected/sqljson_queryfuncs.out index c0086de4c7e..f5c696c0da8 100644 --- a/src/test/regress/expected/sqljson_queryfuncs.out +++ b/src/test/regress/expected/sqljson_queryfuncs.out @@ -1776,3 +1776,71 @@ SELECT JSON_VALUE(x, '$' RETURNING jsonb) FROM (VALUES ('1'::jsonb), (NULL), ('2 2 (3 rows) +-- Check that a boolean DEFAULT expression that must be coerced to the +-- RETURNING type at run time yields the value it evaluates to, rather than +-- always false (bug #19625). +SELECT JSON_VALUE(jsonb '{"a": 1}', 'strict $.b' DEFAULT true ON ERROR) AS t, + JSON_VALUE(jsonb '{"a": 1}', 'strict $.b' DEFAULT false ON ERROR) AS f, + JSON_VALUE(jsonb '{"a": 1}', 'strict $.b' DEFAULT NULL::bool ON ERROR) IS NULL AS n; + t | f | n +------+-------+--- + true | false | t +(1 row) + +SELECT JSON_VALUE(jsonb '{"a": 1}', '$.b' DEFAULT (1 < 2) ON EMPTY) AS t, + JSON_VALUE(jsonb '{"a": 1}', '$.b' DEFAULT (random() < 0) ON EMPTY) AS f; + t | f +------+------- + true | false +(1 row) + +SELECT JSON_QUERY(jsonb '{"a": 1}', 'strict $.b' DEFAULT true ON ERROR) AS t, + JSON_QUERY(jsonb '{"a": 1}', '$.b' DEFAULT (1 < 2) ON EMPTY) AS t, + JSON_QUERY(jsonb '{"a": 1}', '$.b' RETURNING text DEFAULT false ON EMPTY) AS f; + t | t | f +------+------+------- + true | true | false +(1 row) + +SELECT JSON_VALUE(jsonb '{"a": 1}', '$.b' RETURNING char(5) DEFAULT true ON EMPTY) AS t; + t +------- + true +(1 row) + +CREATE DOMAIN queryfuncs_d_bool AS bool CHECK (VALUE); +SELECT JSON_VALUE(jsonb '{"a": 1}', '$.b' RETURNING queryfuncs_d_bool DEFAULT true ON EMPTY); + json_value +------------ + t +(1 row) + +SELECT JSON_VALUE(jsonb '{"a": 1}', '$.b' RETURNING queryfuncs_d_bool DEFAULT false ON EMPTY); +ERROR: could not coerce ON EMPTY expression (DEFAULT) to the RETURNING type +DETAIL: value for domain queryfuncs_d_bool violates check constraint "queryfuncs_d_bool_check" +DROP DOMAIN queryfuncs_d_bool; +SELECT * FROM JSON_TABLE(jsonb '{"a": 1}', '$' + COLUMNS (t text PATH 'strict $.b' DEFAULT true ON ERROR, + f text PATH '$.b' DEFAULT (1 > 2) ON EMPTY, + et text EXISTS PATH 'strict $.b' TRUE ON ERROR, + ef text EXISTS PATH 'strict $.b' FALSE ON ERROR)); + t | f | et | ef +------+-------+------+------- + true | false | true | false +(1 row) + +-- the to_jsonb() call added for the coercion is visible when deparsed +CREATE VIEW json_bool_default_view AS + SELECT JSON_VALUE(jsonb '{"a": 1}', 'strict $.b' DEFAULT true ON ERROR) AS t, + JSON_QUERY(jsonb '{"a": 1}', '$.b' DEFAULT (1 > 2) ON EMPTY) AS f; +\sv json_bool_default_view +CREATE OR REPLACE VIEW public.json_bool_default_view AS + SELECT JSON_VALUE('{"a": 1}'::jsonb, 'strict $."b"' RETURNING text DEFAULT to_jsonb(true) ON ERROR) AS t, + JSON_QUERY('{"a": 1}'::jsonb, '$."b"' RETURNING jsonb WITHOUT WRAPPER KEEP QUOTES DEFAULT to_jsonb(1 > 2) ON EMPTY) AS f +SELECT * FROM json_bool_default_view; + t | f +------+------- + true | false +(1 row) + +DROP VIEW json_bool_default_view; diff --git a/src/test/regress/sql/sqljson_queryfuncs.sql b/src/test/regress/sql/sqljson_queryfuncs.sql index d5ea5b3d8d2..406adf7a332 100644 --- a/src/test/regress/sql/sqljson_queryfuncs.sql +++ b/src/test/regress/sql/sqljson_queryfuncs.sql @@ -600,3 +600,32 @@ SELECT JSON_VALUE(x, '$' RETURNING json) FROM (VALUES ('1'::json), ('null'), ('2 SELECT JSON_VALUE(x, '$.a' RETURNING jsonb) FROM (VALUES ('{"a": 1}'::jsonb), ('{}'), ('{"a": 2}')) v(x); SELECT JSON_VALUE(x, 'strict $.a' RETURNING jsonb) FROM (VALUES ('{"a": 1}'::jsonb), ('[]'), ('{"a": 2}')) v(x); SELECT JSON_VALUE(x, '$' RETURNING jsonb) FROM (VALUES ('1'::jsonb), (NULL), ('2')) v(x); + +-- Check that a boolean DEFAULT expression that must be coerced to the +-- RETURNING type at run time yields the value it evaluates to, rather than +-- always false (bug #19625). +SELECT JSON_VALUE(jsonb '{"a": 1}', 'strict $.b' DEFAULT true ON ERROR) AS t, + JSON_VALUE(jsonb '{"a": 1}', 'strict $.b' DEFAULT false ON ERROR) AS f, + JSON_VALUE(jsonb '{"a": 1}', 'strict $.b' DEFAULT NULL::bool ON ERROR) IS NULL AS n; +SELECT JSON_VALUE(jsonb '{"a": 1}', '$.b' DEFAULT (1 < 2) ON EMPTY) AS t, + JSON_VALUE(jsonb '{"a": 1}', '$.b' DEFAULT (random() < 0) ON EMPTY) AS f; +SELECT JSON_QUERY(jsonb '{"a": 1}', 'strict $.b' DEFAULT true ON ERROR) AS t, + JSON_QUERY(jsonb '{"a": 1}', '$.b' DEFAULT (1 < 2) ON EMPTY) AS t, + JSON_QUERY(jsonb '{"a": 1}', '$.b' RETURNING text DEFAULT false ON EMPTY) AS f; +SELECT JSON_VALUE(jsonb '{"a": 1}', '$.b' RETURNING char(5) DEFAULT true ON EMPTY) AS t; +CREATE DOMAIN queryfuncs_d_bool AS bool CHECK (VALUE); +SELECT JSON_VALUE(jsonb '{"a": 1}', '$.b' RETURNING queryfuncs_d_bool DEFAULT true ON EMPTY); +SELECT JSON_VALUE(jsonb '{"a": 1}', '$.b' RETURNING queryfuncs_d_bool DEFAULT false ON EMPTY); +DROP DOMAIN queryfuncs_d_bool; +SELECT * FROM JSON_TABLE(jsonb '{"a": 1}', '$' + COLUMNS (t text PATH 'strict $.b' DEFAULT true ON ERROR, + f text PATH '$.b' DEFAULT (1 > 2) ON EMPTY, + et text EXISTS PATH 'strict $.b' TRUE ON ERROR, + ef text EXISTS PATH 'strict $.b' FALSE ON ERROR)); +-- the to_jsonb() call added for the coercion is visible when deparsed +CREATE VIEW json_bool_default_view AS + SELECT JSON_VALUE(jsonb '{"a": 1}', 'strict $.b' DEFAULT true ON ERROR) AS t, + JSON_QUERY(jsonb '{"a": 1}', '$.b' DEFAULT (1 > 2) ON EMPTY) AS f; +\sv json_bool_default_view +SELECT * FROM json_bool_default_view; +DROP VIEW json_bool_default_view; -- 2.47.3