From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Ayush Tiwari <ayushtiwari.slg01@gmail.com>
Date: Fri, 5 Jun 2026 16:05:00 +0000
Subject: [PATCH v2] Avoid NULL SQL/JSON expressions after failed text coercion

transformJsonParseArg() tries to coerce UNKNOWN and string-category input
types to text before building SQL/JSON parse and IS JSON expressions.  It did
not check whether coerce_to_target_type() succeeded before replacing the
expression and setting exprtype to TEXTOID.

For a user-defined string-category type without an implicit cast to text,
coerce_to_target_type() returns NULL.  IS JSON then stores that NULL as the
predicate subject, and the executor crashes while initializing expression
state.

Raise a normal coercion error when the implicit cast to text cannot be
created.  This avoids the crash and reports the failure at the point where
the parser decided text coercion was required.  The same helper is used for
JSON() WITH UNIQUE KEYS, so cover that path as well.

Reported-by: Chi Zhang <798604270@qq.com>
Reviewed-by: Srinath Reddy Sadipiralla <srinath2133@gmail.com>
Discussion: https://postgr.es/m/19491-7aafc221ec63f288@postgresql.org
---
diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index f1003e57fb2..db5ecf80b43 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -4199,10 +4199,21 @@ transformJsonParseArg(ParseState *pstate, Node *jsexpr, JsonFormat *format,
 
 		if (*exprtype == UNKNOWNOID || typcategory == TYPCATEGORY_STRING)
 		{
-			expr = coerce_to_target_type(pstate, expr, *exprtype,
+			Node	   *coerced_expr;
+
+			coerced_expr = coerce_to_target_type(pstate, expr, *exprtype,
 										 TEXTOID, -1,
 										 COERCION_IMPLICIT,
 										 COERCE_IMPLICIT_CAST, -1);
+			if (coerced_expr == NULL)
+				ereport(ERROR,
+						(errcode(ERRCODE_CANNOT_COERCE),
+						 errmsg("cannot cast type %s to %s",
+								format_type_be(*exprtype),
+								format_type_be(TEXTOID)),
+						 parser_errposition(pstate, exprLocation(expr))));
+
+			expr = coerced_expr;
 			*exprtype = TEXTOID;
 		}
 
diff --git a/src/test/regress/expected/sqljson.out b/src/test/regress/expected/sqljson.out
index 143d961c077..6dc0abc9c5c 100644
--- a/src/test/regress/expected/sqljson.out
+++ b/src/test/regress/expected/sqljson.out
@@ -1470,6 +1470,45 @@ LINE 1: SELECT NULL::jd5 IS JSON WITH UNIQUE KEYS;
 -- domain constraint violation during cast
 SELECT a::jd2 IS JSON WITH UNIQUE KEYS as col1 FROM (VALUES('{"a": 1, "a": 2}')) s(a); -- error
 ERROR:  value for domain jd2 violates check constraint "jd2_check"
+-- A user-defined string-category type with no implicit cast to text must
+-- produce a clean error rather than crash for IS JSON / JSON() input
+-- (per bug #19491).
+CREATE FUNCTION sqljson_mystr_in(cstring) RETURNS sqljson_mystr
+	AS 'textin' LANGUAGE internal IMMUTABLE STRICT;
+NOTICE:  type "sqljson_mystr" is not yet defined
+DETAIL:  Creating a shell type definition.
+CREATE FUNCTION sqljson_mystr_out(sqljson_mystr) RETURNS cstring
+	AS 'textout' LANGUAGE internal IMMUTABLE STRICT;
+NOTICE:  argument type sqljson_mystr is only a shell
+LINE 1: CREATE FUNCTION sqljson_mystr_out(sqljson_mystr) RETURNS cst...
+                                          ^
+CREATE TYPE sqljson_mystr (
+	INPUT = sqljson_mystr_in,
+	OUTPUT = sqljson_mystr_out,
+	LIKE = text,
+	CATEGORY = 'S'
+);
+SELECT '{"a":1}'::sqljson_mystr IS JSON;                -- error
+ERROR:  cannot cast type sqljson_mystr to text
+LINE 1: SELECT '{"a":1}'::sqljson_mystr IS JSON;
+               ^
+SELECT JSON('{"a":1}'::sqljson_mystr WITH UNIQUE KEYS); -- error
+ERROR:  cannot cast type sqljson_mystr to text
+LINE 1: SELECT JSON('{"a":1}'::sqljson_mystr WITH UNIQUE KEYS);
+                    ^
+-- An implicit cast to text lets the same query work normally.
+CREATE CAST (sqljson_mystr AS text) WITHOUT FUNCTION AS IMPLICIT;
+SELECT '{"a":1}'::sqljson_mystr IS JSON;
+ ?column? 
+----------
+ t
+(1 row)
+
+DROP TYPE sqljson_mystr CASCADE;
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to function sqljson_mystr_in(cstring)
+drop cascades to function sqljson_mystr_out(sqljson_mystr)
+drop cascades to cast from sqljson_mystr to text
 -- view creation and deparsing with domain IS JSON
 CREATE VIEW domain_isjson AS
 WITH cte(a) AS (VALUES('{"a": 1, "a": 2}'))
diff --git a/src/test/regress/sql/sqljson.sql b/src/test/regress/sql/sqljson.sql
index ed044d81fdd..689af6f0681 100644
--- a/src/test/regress/sql/sqljson.sql
+++ b/src/test/regress/sql/sqljson.sql
@@ -559,6 +559,26 @@ SELECT NULL::jd5 IS JSON WITH UNIQUE KEYS; -- error
 -- domain constraint violation during cast
 SELECT a::jd2 IS JSON WITH UNIQUE KEYS as col1 FROM (VALUES('{"a": 1, "a": 2}')) s(a); -- error
 
+-- A user-defined string-category type with no implicit cast to text must
+-- produce a clean error rather than crash for IS JSON / JSON() input
+-- (per bug #19491).
+CREATE FUNCTION sqljson_mystr_in(cstring) RETURNS sqljson_mystr
+	AS 'textin' LANGUAGE internal IMMUTABLE STRICT;
+CREATE FUNCTION sqljson_mystr_out(sqljson_mystr) RETURNS cstring
+	AS 'textout' LANGUAGE internal IMMUTABLE STRICT;
+CREATE TYPE sqljson_mystr (
+	INPUT = sqljson_mystr_in,
+	OUTPUT = sqljson_mystr_out,
+	LIKE = text,
+	CATEGORY = 'S'
+);
+SELECT '{"a":1}'::sqljson_mystr IS JSON;                -- error
+SELECT JSON('{"a":1}'::sqljson_mystr WITH UNIQUE KEYS); -- error
+-- An implicit cast to text lets the same query work normally.
+CREATE CAST (sqljson_mystr AS text) WITHOUT FUNCTION AS IMPLICIT;
+SELECT '{"a":1}'::sqljson_mystr IS JSON;
+DROP TYPE sqljson_mystr CASCADE;
+
 -- view creation and deparsing with domain IS JSON
 CREATE VIEW domain_isjson AS
 WITH cte(a) AS (VALUES('{"a": 1, "a": 2}'))
