From c7de761755c2dc67ec717f59f2a7864109e7d206 Mon Sep 17 00:00:00 2001 From: Matt Blewitt Date: Thu, 30 Jul 2026 15:40:59 +0100 Subject: [PATCH v4] Fix JSON_SERIALIZE() coercion placeholder type for jsonb input When JSON_SERIALIZE() receives a jsonb-typed argument, the CaseTestExpr placeholder used to set up coercion was unconditionally assigned JSONOID (derived from the RETURNING format, which defaults to JS_FORMAT_JSON). However, the executor passes the input argument value through directly for JSON_SERIALIZE (see ExecInitExprRec in execExpr.c), so the actual datum at runtime is jsonb, not json. This type mismatch between the placeholder and the runtime value caused the wrong coercion path to be selected. Fix by deriving the placeholder type from the actual argument type via exprType(linitial(args)) when the constructor type is JSCTOR_JSON_SERIALIZE, rather than from returning->format->format_type. Add an Assert to guard the assumption that args contains exactly one item, and update the block comment to explain why JSON_SERIALIZE differs from the other constructor types (it consumes json/jsonb rather than producing it). Add regression coverage for the default text result and an explicit bytea result with jsonb input, as well as implicit typmod coercion. Back-patch to v17, where JSON_SERIALIZE() was introduced. The fix applies at parse time, so views, index expressions and stored generated columns that use JSON_SERIALIZE() on a jsonb argument and were created before this fix keep the incorrect coercion; REINDEX does not repair them. Recreate such views and indexes, and re-specify such generated columns with ALTER TABLE ... ALTER COLUMN ... SET EXPRESSION. Clusters upgraded with pg_upgrade from an affected release get corrected expressions, but affected indexes must be reindexed, since until then they can return wrong results, and stored generated column values must be recomputed. --- src/backend/parser/parse_expr.c | 18 +++++++++++++++--- src/test/regress/expected/sqljson.out | 16 ++++++++++++++++ src/test/regress/sql/sqljson.sql | 7 +++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c index 728392f436f..abb3d334903 100644 --- a/src/backend/parser/parse_expr.c +++ b/src/backend/parser/parse_expr.c @@ -3694,7 +3694,13 @@ makeJsonConstructorExpr(ParseState *pstate, JsonConstructorType type, * Coerce to the RETURNING type and format, if needed. We abuse * CaseTestExpr here as placeholder to pass the result of either * evaluating 'fexpr' or whatever is produced by ExecEvalJsonConstructor() - * that is of type JSON or JSONB to the coercion function. + * to the coercion function. + * + * For most constructor types the placeholder type is JSON or JSONB, + * determined by the RETURNING format. JSON_SERIALIZE is different: it + * doesn't produce json/jsonb but rather consumes it, and the executor + * passes the input argument through directly (see execExpr.c), so the + * placeholder must reflect the actual argument type. */ if (fexpr) { @@ -3710,8 +3716,14 @@ makeJsonConstructorExpr(ParseState *pstate, JsonConstructorType type, { CaseTestExpr *cte = makeNode(CaseTestExpr); - cte->typeId = returning->format->format_type == JS_FORMAT_JSONB ? - JSONBOID : JSONOID; + if (type == JSCTOR_JSON_SERIALIZE) + { + Assert(list_length(args) == 1); + cte->typeId = exprType((Node *) linitial(args)); + } + else + cte->typeId = returning->format->format_type == JS_FORMAT_JSONB ? + JSONBOID : JSONOID; cte->typeMod = -1; cte->collation = InvalidOid; diff --git a/src/test/regress/expected/sqljson.out b/src/test/regress/expected/sqljson.out index 30f9e10ef0a..d3c544c40bf 100644 --- a/src/test/regress/expected/sqljson.out +++ b/src/test/regress/expected/sqljson.out @@ -288,6 +288,22 @@ EXPLAIN (VERBOSE, COSTS OFF) SELECT JSON_SERIALIZE('{}' RETURNING bytea); Output: JSON_SERIALIZE('{}'::json RETURNING bytea) (2 rows) +-- JSON_SERIALIZE() with jsonb input +SELECT JSON_SERIALIZE('{"a": 1}'::jsonb); + json_serialize +---------------- + {"a": 1} +(1 row) + +SELECT JSON_SERIALIZE('{"a": 1}'::jsonb RETURNING bytea); + json_serialize +-------------------- + \x7b2261223a20317d +(1 row) + +-- Test implicit typmod coercion with jsonb input +SELECT JSON_SERIALIZE('{ "a" : 1 } '::jsonb RETURNING varchar(2)); +ERROR: value too long for type character varying(2) -- JSON_OBJECT() SELECT JSON_OBJECT(); json_object diff --git a/src/test/regress/sql/sqljson.sql b/src/test/regress/sql/sqljson.sql index 9fa203ccb53..7a87daa4168 100644 --- a/src/test/regress/sql/sqljson.sql +++ b/src/test/regress/sql/sqljson.sql @@ -62,6 +62,13 @@ SELECT JSON_SERIALIZE('{ "a" : 1 } ' RETURNING jsonb); EXPLAIN (VERBOSE, COSTS OFF) SELECT JSON_SERIALIZE('{}'); EXPLAIN (VERBOSE, COSTS OFF) SELECT JSON_SERIALIZE('{}' RETURNING bytea); +-- JSON_SERIALIZE() with jsonb input +SELECT JSON_SERIALIZE('{"a": 1}'::jsonb); +SELECT JSON_SERIALIZE('{"a": 1}'::jsonb RETURNING bytea); + +-- Test implicit typmod coercion with jsonb input +SELECT JSON_SERIALIZE('{ "a" : 1 } '::jsonb RETURNING varchar(2)); + -- JSON_OBJECT() SELECT JSON_OBJECT(); SELECT JSON_OBJECT(RETURNING json); -- 2.54.0 (Apple Git-157)