sql/json query function JsonBehavior default expression's collation may differ from returning type's collation

Started by jian he6 months ago8 messages
#1jian he
jian.universality@gmail.com

hi.

create domain d1 as text collate case_insensitive;
select json_value('{"a": "A"}', '$.a' returning d1 default 'C' on
empty) = 'a'; --return true
select json_value('{"a": "A"}', '$.c' returning d1 default 'A' on
empty) = 'a'; --return true

select json_value('{"a": "A"}', '$.c' returning d1 default 'A' collate
"C" on empty) = 'a';
currently the above query will produce an error:
ERROR: unrecognized node type: 31
this error message needs to be fixed.

however, if no error is raised, should the query return true or false?
if not error out, It also means that the collation of the json_value expression
is determined at runtime, which makes it unsuitable for use in index creation or
check constraints.

overall, raising an error if the collation of the
JsonBehavior DEFAULT clause differs from that of the RETURNING clause
is the best option.

what do you think?

#2jian he
jian.universality@gmail.com
In reply to: jian he (#1)
1 attachment(s)
Re: sql/json query function JsonBehavior default expression's collation may differ from returning type's collation

On Mon, Jul 14, 2025 at 7:39 PM jian he <jian.universality@gmail.com> wrote:

overall, raising an error if the collation of the
JsonBehavior DEFAULT clause differs from that of the RETURNING clause
is the best option.

what do you think?

in exprSetCollation, the node can be T_CollateExpr.
In that case, CollateExpr->collOid should be the same as the collation
of the caller.

--- a/src/backend/nodes/nodeFuncs.c
+++ b/src/backend/nodes/nodeFuncs.c
@@ -1131,6 +1131,10 @@ exprSetCollation(Node *expr, Oid collation)
         case T_Const:
             ((Const *) expr)->constcollid = collation;
             break;
+        case T_CollateExpr:
+            if (((CollateExpr *) expr)->collOid != collation)
+                elog(ERROR, "COLLATE clause collation should be %u",
collation);
+            break;
         case T_Param:
             ((Param *) expr)->paramcollid = collation;
             break;
diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index d66276801c6..9cbffff52c3 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -4825,6 +4825,15 @@ transformJsonBehavior(ParseState *pstate,
JsonBehavior *behavior,
                             parser_errposition(pstate, exprLocation(expr)));
             }
+            if (typcategory == TYPCATEGORY_STRING &&
+                exprCollation(coerced_expr) !=
get_typcollation(returning->typid))
+            {
+                ereport(ERROR,
+                        errcode(ERRCODE_DATATYPE_MISMATCH),
+                        errmsg("DEFAULT expression collation does not
match with RETURNING type's collation"),
+                        parser_errposition(pstate,
exprLocation(coerced_expr)));
+            }
+

create table t(a jsonb);
select json_value(a, '$.c' returning text default 'A' collate "C" on
empty) from t;
ERROR: DEFAULT expression collation does not match with RETURNING
type's collation

as you can see, this query returns a set of rows. If the collation of the
DEFAULT node differs from the default text collation, the resulting set may have
inconscient collations.
a set of rows all the collation should be the same.
overall I think it should error out.

Attachments:

v1-0001-fix-SQL-JSON-default-expression-with-CollateExpr-node.patchtext/x-patch; charset=US-ASCII; name=v1-0001-fix-SQL-JSON-default-expression-with-CollateExpr-node.patchDownload
From 1a9935377abe897c7afbf2419b71f5209034f209 Mon Sep 17 00:00:00 2001
From: jian he <jian.universality@gmail.com>
Date: Mon, 21 Jul 2025 16:49:46 +0800
Subject: [PATCH v1 1/1] fix SQL/JSON default expression with CollateExpr node

in exprSetCollation
the node can be CollateExpr, in that case,
((CollateExpr *) expr)->collOid should equal to collation.

create table t(a jsonb);
select json_value(a, '$.c' returning text default 'A' collate "C" on empty) from t;

As you can see, this query returns a set of rows. If the collation of the
DEFAULT node differs from the default text collation, the resulting set may have
inconsistent collations.

As a result, the query's collation becomes unreliable. For instance, is it valid
to create the following index in this case?
create index xx on t (( json_value(a, '$.c' returning text default 'A' on empty) ));

#TODO: regress test add later
---
 src/backend/nodes/nodeFuncs.c   | 4 ++++
 src/backend/parser/parse_expr.c | 9 +++++++++
 2 files changed, 13 insertions(+)

diff --git a/src/backend/nodes/nodeFuncs.c b/src/backend/nodes/nodeFuncs.c
index 7bc823507f1..d904668987e 100644
--- a/src/backend/nodes/nodeFuncs.c
+++ b/src/backend/nodes/nodeFuncs.c
@@ -1131,6 +1131,10 @@ exprSetCollation(Node *expr, Oid collation)
 		case T_Const:
 			((Const *) expr)->constcollid = collation;
 			break;
+		case T_CollateExpr:
+			if (((CollateExpr *) expr)->collOid != collation)
+				elog(ERROR, "COLLATE clause collation should be %u", collation);
+			break;
 		case T_Param:
 			((Param *) expr)->paramcollid = collation;
 			break;
diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index d66276801c6..9cbffff52c3 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -4825,6 +4825,15 @@ transformJsonBehavior(ParseState *pstate, JsonBehavior *behavior,
 							parser_errposition(pstate, exprLocation(expr)));
 			}
 
+			if (typcategory == TYPCATEGORY_STRING &&
+				exprCollation(coerced_expr) != get_typcollation(returning->typid))
+			{
+				ereport(ERROR,
+						errcode(ERRCODE_DATATYPE_MISMATCH),
+						errmsg("DEFAULT expression collation does not match with RETURNING type's collation"),
+						parser_errposition(pstate, exprLocation(coerced_expr)));
+			}
+
 			expr = coerced_expr;
 		}
 	}
-- 
2.34.1

#3Amit Langote
amitlangote09@gmail.com
In reply to: jian he (#2)
Re: sql/json query function JsonBehavior default expression's collation may differ from returning type's collation

Hi Jian,

Thanks for the patch and also for the offlist heads-up.

I agree with rejecting cases where the DEFAULT clause’s collation does not
match the RETURNING collation. The result collation for json_value should
come from the RETURNING clause if it has an explicit COLLATE, otherwise
from the RETURNING type’s collation, and both the extracted value source
(the value obtained from the JSON path when it matches) and the DEFAULT
source should match it.

I would not add a T_CollateExpr arm to exprSetCollation(). CollateExpr is
just a transient wrapper that the planner rewrites to RelabelType, so it
does not make sense to stamp it with a result collation there. Instead,
transformJsonBehavior() should check if a source expression is a
CollateExpr, verify that its collOid matches the target collation, and
either keep it as is (no stamping needed if the collOid already matches the
target) or strip the wrapper and stamp the inner node with
exprSetCollation() if you need the inner node itself to carry the target
collation.

That is my current understanding of how exprSetCollation works, but I will
confirm when I can check the code on my computer next week. Someone like
Tom can correct me if I have missed something in the meantime.

Thanks, Amit Langote

#4jian he
jian.universality@gmail.com
In reply to: Amit Langote (#3)
1 attachment(s)
Re: sql/json query function JsonBehavior default expression's collation may differ from returning type's collation

On Tue, Aug 12, 2025 at 7:09 PM Amit Langote <amitlangote09@gmail.com> wrote:

Hi Jian,

Thanks for the patch and also for the offlist heads-up.

I agree with rejecting cases where the DEFAULT clause’s collation does not match the RETURNING collation. The result collation for json_value should come from the RETURNING clause if it has an explicit COLLATE, otherwise from the RETURNING type’s collation, and both the extracted value source (the value obtained from the JSON path when it matches) and the DEFAULT source should match it.

hi.

based on my understand of
https://www.postgresql.org/docs/current/collation.html#COLLATION-CONCEPTS
<<<<<<<
1. If any input expression has an explicit collation derivation, then all
explicitly derived collations among the input expressions must be the same,
otherwise an error is raised. If any explicitly derived collation is present,
that is the result of the collation combination.

2. Otherwise, all input expressions must have the same implicit collation
derivation or the default collation. If any non-default collation is present,
that is the result of the collation combination. Otherwise, the result is the
default collation.
<<<<<<<

CREATE COLLATION case_insensitive (provider = icu, locale =
'und-u-ks-level2', deterministic = false);
create domain d1 as text collate case_insensitive;
create domain d2 as text collate "C";

the below two queries should error out:
select json_value('{"a": "A"}', '$.a' returning d1 default 'C'::d2 on
empty) = 'a'; --error
select json_value('{"a": "A"}', '$.a' returning d1 default 'C' collate
"C" on empty) = 'a'; --error

please check attached patch.

Attachments:

v2-0001-fix-SQL-JSON-default-expression-collation-issue.patchtext/x-patch; charset=US-ASCII; name=v2-0001-fix-SQL-JSON-default-expression-collation-issue.patchDownload
From c8489365872309b166f40101835586247c137ba0 Mon Sep 17 00:00:00 2001
From: jian he <jian.universality@gmail.com>
Date: Fri, 3 Oct 2025 20:53:48 +0800
Subject: [PATCH v2 1/1] fix SQL/JSON default expression collation issue

create table t(a jsonb);
select json_value(a, '$.c' returning text default 'A' collate "C" on empty) from t;

As you can see, this query returns a set of rows. If the collation of the
DEFAULT node differs from the default text collation, the resulting set may have
inconsistent collations.

As a result, the query's collation becomes unreliable. For instance, is it valid
to create the following index in this case?
create index xx on t (( json_value(a, '$.c' returning text default 'A' on empty) ));

discussion: https://postgr.es/m/CACJufxHVwYYSyiVQ6o+PsRX6zQ7rAFinh_fv1kCfTsT1xG4Zeg@mail.gmail.com
---
 src/backend/parser/parse_expr.c               | 59 ++++++++++++++++---
 .../regress/expected/sqljson_queryfuncs.out   | 48 +++++++++++++++
 src/test/regress/sql/sqljson_queryfuncs.sql   | 15 +++++
 3 files changed, 114 insertions(+), 8 deletions(-)

diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index e1979a80c19..0296e92e108 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -93,7 +93,8 @@ static Node *transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func);
 static void transformJsonPassingArgs(ParseState *pstate, const char *constructName,
 									 JsonFormatType format, List *args,
 									 List **passing_values, List **passing_names);
-static JsonBehavior *transformJsonBehavior(ParseState *pstate, JsonBehavior *behavior,
+static JsonBehavior *transformJsonBehavior(ParseState *pstate, JsonExpr *jsexpr,
+										   JsonBehavior *behavior,
 										   JsonBehaviorType default_behavior,
 										   JsonReturning *returning);
 static Node *GetJsonBehaviorConst(JsonBehaviorType btype, int location);
@@ -4528,13 +4529,16 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 			{
 				jsexpr->returning->typid = BOOLOID;
 				jsexpr->returning->typmod = -1;
+				jsexpr->collation = InvalidOid;
 			}
 
 			/* JSON_TABLE() COLUMNS can specify a non-boolean type. */
 			if (jsexpr->returning->typid != BOOLOID)
 				jsexpr->use_json_coercion = true;
 
-			jsexpr->on_error = transformJsonBehavior(pstate, func->on_error,
+			jsexpr->on_error = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_error,
 													 JSON_BEHAVIOR_FALSE,
 													 jsexpr->returning);
 			break;
@@ -4549,6 +4553,8 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 				ret->typmod = -1;
 			}
 
+			jsexpr->collation = get_typcollation(jsexpr->returning->typid);
+
 			/*
 			 * Keep quotes on scalar strings by default, omitting them only if
 			 * OMIT QUOTES is specified.
@@ -4565,11 +4571,15 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 				jsexpr->use_json_coercion = true;
 
 			/* Assume NULL ON EMPTY when ON EMPTY is not specified. */
-			jsexpr->on_empty = transformJsonBehavior(pstate, func->on_empty,
+			jsexpr->on_empty = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_empty,
 													 JSON_BEHAVIOR_NULL,
 													 jsexpr->returning);
 			/* Assume NULL ON ERROR when ON ERROR is not specified. */
-			jsexpr->on_error = transformJsonBehavior(pstate, func->on_error,
+			jsexpr->on_error = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_error,
 													 JSON_BEHAVIOR_NULL,
 													 jsexpr->returning);
 			break;
@@ -4581,6 +4591,7 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 				jsexpr->returning->typid = TEXTOID;
 				jsexpr->returning->typmod = -1;
 			}
+			jsexpr->collation = get_typcollation(jsexpr->returning->typid);
 
 			/*
 			 * Override whatever transformJsonOutput() set these to, which
@@ -4606,11 +4617,15 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 			}
 
 			/* Assume NULL ON EMPTY when ON EMPTY is not specified. */
-			jsexpr->on_empty = transformJsonBehavior(pstate, func->on_empty,
+			jsexpr->on_empty = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_empty,
 													 JSON_BEHAVIOR_NULL,
 													 jsexpr->returning);
 			/* Assume NULL ON ERROR when ON ERROR is not specified. */
-			jsexpr->on_error = transformJsonBehavior(pstate, func->on_error,
+			jsexpr->on_error = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_error,
 													 JSON_BEHAVIOR_NULL,
 													 jsexpr->returning);
 			break;
@@ -4621,6 +4636,7 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 				jsexpr->returning->typid = exprType(jsexpr->formatted_expr);
 				jsexpr->returning->typmod = -1;
 			}
+			jsexpr->collation = get_typcollation(jsexpr->returning->typid);
 
 			/*
 			 * Assume EMPTY ARRAY ON ERROR when ON ERROR is not specified.
@@ -4628,7 +4644,9 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 			 * ON EMPTY cannot be specified at the top level but it can be for
 			 * the individual columns.
 			 */
-			jsexpr->on_error = transformJsonBehavior(pstate, func->on_error,
+			jsexpr->on_error = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_error,
 													 JSON_BEHAVIOR_EMPTY_ARRAY,
 													 jsexpr->returning);
 			break;
@@ -4704,7 +4722,8 @@ ValidJsonBehaviorDefaultExpr(Node *expr, void *context)
  * Transform a JSON BEHAVIOR clause.
  */
 static JsonBehavior *
-transformJsonBehavior(ParseState *pstate, JsonBehavior *behavior,
+transformJsonBehavior(ParseState *pstate, JsonExpr *jsexpr,
+					  JsonBehavior *behavior,
 					  JsonBehaviorType default_behavior,
 					  JsonReturning *returning)
 {
@@ -4712,6 +4731,7 @@ transformJsonBehavior(ParseState *pstate, JsonBehavior *behavior,
 	Node	   *expr = NULL;
 	bool		coerce_at_runtime = false;
 	int			location = -1;
+	Oid			exprcoll;
 
 	if (behavior)
 	{
@@ -4720,6 +4740,29 @@ transformJsonBehavior(ParseState *pstate, JsonBehavior *behavior,
 		if (btype == JSON_BEHAVIOR_DEFAULT)
 		{
 			expr = transformExprRecurse(pstate, behavior->expr);
+
+			exprcoll = exprCollation(expr);
+			if (!OidIsValid(exprcoll))
+				exprcoll = get_typcollation(exprType(expr));
+
+			if (jsexpr->collation != exprcoll && OidIsValid(exprcoll) && OidIsValid(jsexpr->collation))
+				ereport(ERROR,
+						errcode(ERRCODE_DATATYPE_MISMATCH),
+						errmsg("the collation of DEFAULT expression conflicts with RETURNING caluse"),
+						errdetail("\"%s\" versus \"%s\"",
+								  get_collation_name(exprcoll),
+								  get_collation_name(jsexpr->collation)),
+						parser_errposition(pstate, exprLocation(expr)));
+
+			/*
+			 * Strip any top-level COLLATE clause because exprSetCollation can
+			 * not cope with explit COLLATE clause.  This is safe because we
+			 * have already verified that the DEFAULT expression's explicit
+			 * collation matches the RETURNING type's collation.
+			 */
+			while (IsA(expr, CollateExpr))
+				expr = (Node *) ((CollateExpr *) expr)->arg;
+
 			if (!ValidJsonBehaviorDefaultExpr(expr, NULL))
 				ereport(ERROR,
 						(errcode(ERRCODE_DATATYPE_MISMATCH),
diff --git a/src/test/regress/expected/sqljson_queryfuncs.out b/src/test/regress/expected/sqljson_queryfuncs.out
index 5a35aeb7bba..871d25e3287 100644
--- a/src/test/regress/expected/sqljson_queryfuncs.out
+++ b/src/test/regress/expected/sqljson_queryfuncs.out
@@ -1464,3 +1464,51 @@ SELECT JSON_VALUE(jsonb '1234', '$' RETURNING bit(3)  DEFAULT 1::bit(3) ON ERROR
 SELECT JSON_VALUE(jsonb '"111"', '$.a'  RETURNING bit(3) DEFAULT '1111' ON EMPTY);
 ERROR:  bit string length 4 does not match type bit(3)
 DROP DOMAIN queryfuncs_d_varbit3;
+CREATE COLLATION case_insensitive (provider = icu, locale = 'und-u-ks-level2', deterministic = false);
+create domain d1 as text collate case_insensitive;
+create domain d2 as text collate "C";
+select json_value('{"a": "A"}', '$.a' returning d1 default ('C' collate "C") collate case_insensitive on empty) = 'a'; --true
+ ?column? 
+----------
+ t
+(1 row)
+
+select json_value('{"a": "A"}', '$.a' returning d1 default 'C' on empty) = 'a'; --true
+ ?column? 
+----------
+ t
+(1 row)
+
+select json_value('{"a": "A"}', '$.a' returning d1 default 'C'::d2 on empty) = 'a'; --error
+ERROR:  the collation of DEFAULT expression conflicts with RETURNING caluse
+LINE 1: ...on_value('{"a": "A"}', '$.a' returning d1 default 'C'::d2 on...
+                                                             ^
+DETAIL:  "C" versus "case_insensitive"
+select json_value('{"a": "A"}', '$.a' returning d1 default 'C' collate "C" on empty) = 'a'; --error
+ERROR:  the collation of DEFAULT expression conflicts with RETURNING caluse
+LINE 1: ...on_value('{"a": "A"}', '$.a' returning d1 default 'C' collat...
+                                                             ^
+DETAIL:  "C" versus "case_insensitive"
+select json_value('{"a": "A"}', '$.c' returning d1 default 'A' on empty) = 'a'; --true
+ ?column? 
+----------
+ t
+(1 row)
+
+select json_value('{"a": "A"}', '$.c' returning d1 default 'A' collate case_insensitive on empty) = 'a'; --true
+ ?column? 
+----------
+ t
+(1 row)
+
+select json_value('{"a": "A"}', '$.c' returning d1 default 'A'::d2 on empty) = 'a'; --error
+ERROR:  the collation of DEFAULT expression conflicts with RETURNING caluse
+LINE 1: ...on_value('{"a": "A"}', '$.c' returning d1 default 'A'::d2 on...
+                                                             ^
+DETAIL:  "C" versus "case_insensitive"
+select json_value('{"a": "A"}', '$.c' returning d1 default 'A' collate "C" on empty) = 'a'; --error
+ERROR:  the collation of DEFAULT expression conflicts with RETURNING caluse
+LINE 1: ...on_value('{"a": "A"}', '$.c' returning d1 default 'A' collat...
+                                                             ^
+DETAIL:  "C" versus "case_insensitive"
+drop domain d1, d2;
diff --git a/src/test/regress/sql/sqljson_queryfuncs.sql b/src/test/regress/sql/sqljson_queryfuncs.sql
index 8d7b225b612..4c600f884b3 100644
--- a/src/test/regress/sql/sqljson_queryfuncs.sql
+++ b/src/test/regress/sql/sqljson_queryfuncs.sql
@@ -500,3 +500,18 @@ SELECT JSON_VALUE(jsonb '1234', '$' RETURNING bit(3)  DEFAULT 1 ON ERROR);
 SELECT JSON_VALUE(jsonb '1234', '$' RETURNING bit(3)  DEFAULT 1::bit(3) ON ERROR);
 SELECT JSON_VALUE(jsonb '"111"', '$.a'  RETURNING bit(3) DEFAULT '1111' ON EMPTY);
 DROP DOMAIN queryfuncs_d_varbit3;
+
+CREATE COLLATION case_insensitive (provider = icu, locale = 'und-u-ks-level2', deterministic = false);
+create domain d1 as text collate case_insensitive;
+create domain d2 as text collate "C";
+
+select json_value('{"a": "A"}', '$.a' returning d1 default ('C' collate "C") collate case_insensitive on empty) = 'a'; --true
+select json_value('{"a": "A"}', '$.a' returning d1 default 'C' on empty) = 'a'; --true
+select json_value('{"a": "A"}', '$.a' returning d1 default 'C'::d2 on empty) = 'a'; --error
+select json_value('{"a": "A"}', '$.a' returning d1 default 'C' collate "C" on empty) = 'a'; --error
+
+select json_value('{"a": "A"}', '$.c' returning d1 default 'A' on empty) = 'a'; --true
+select json_value('{"a": "A"}', '$.c' returning d1 default 'A' collate case_insensitive on empty) = 'a'; --true
+select json_value('{"a": "A"}', '$.c' returning d1 default 'A'::d2 on empty) = 'a'; --error
+select json_value('{"a": "A"}', '$.c' returning d1 default 'A' collate "C" on empty) = 'a'; --error
+drop domain d1, d2;
\ No newline at end of file
-- 
2.34.1

#5Amit Langote
amitlangote09@gmail.com
In reply to: jian he (#4)
1 attachment(s)
Re: sql/json query function JsonBehavior default expression's collation may differ from returning type's collation

Hi Jian,

(Sorry for the long delay -- I meant to get back to this a while ago.)

On Fri, Oct 3, 2025 at 9:57 PM jian he <jian.universality@gmail.com> wrote:

hi.

based on my understand of
https://www.postgresql.org/docs/current/collation.html#COLLATION-CONCEPTS
<<<<<<<
1. If any input expression has an explicit collation derivation, then all
explicitly derived collations among the input expressions must be the same,
otherwise an error is raised. If any explicitly derived collation is present,
that is the result of the collation combination.

2. Otherwise, all input expressions must have the same implicit collation
derivation or the default collation. If any non-default collation is present,
that is the result of the collation combination. Otherwise, the result is the
default collation.
<<<<<<<

CREATE COLLATION case_insensitive (provider = icu, locale =
'und-u-ks-level2', deterministic = false);
create domain d1 as text collate case_insensitive;
create domain d2 as text collate "C";

the below two queries should error out:
select json_value('{"a": "A"}', '$.a' returning d1 default 'C'::d2 on
empty) = 'a'; --error
select json_value('{"a": "A"}', '$.a' returning d1 default 'C' collate
"C" on empty) = 'a'; --error

please check attached patch.

Thanks for posting v2 of the patch. I’ve made a few follow-up changes
(v3 attached):

* Moved the regression tests from sqljson_queryfuncs.sql to
collation.icu.utf8.sql to avoid failures on buildfarm machines without
ICU support.

* Adjusted the collation-mismatch check in transformJsonBehavior() so
that it runs last within the DEFAULT-handling block. That keeps the
control flow cleaner and avoids affecting existing tests that already
fail earlier checks, preventing unnecessary regression output churn.

* Did a few cosmetic edits and fixed the error code and message text.

Otherwise, behavior and coverage remain the same.

--
Thanks, Amit Langote

Attachments:

v3-0001-Fix-internal-error-from-CollateExpr-in-SQL-JSON-D.patchapplication/octet-stream; name=v3-0001-Fix-internal-error-from-CollateExpr-in-SQL-JSON-D.patchDownload
From 09e927b864a6d53447082fb5bf83164a8ccf3fac Mon Sep 17 00:00:00 2001
From: Amit Langote <amitlan@postgresql.org>
Date: Mon, 6 Oct 2025 09:17:51 -0400
Subject: [PATCH v3] Fix internal error from CollateExpr in SQL/JSON DEFAULT
 expressions

SQL/JSON functions such as JSON_VALUE could fail with "unrecognized
node type" errors when a DEFAULT clause contained an explicit COLLATE
expression. That happened because assign_collations_walker() could
invoke exprSetCollation() on a JsonBehavior expression whose DEFAULT
still contained a CollateExpr, which exprSetCollation() does not
handle.

For example:

  SELECT JSON_VALUE('{"a":1}', '$.c' RETURNING text
                    DEFAULT 'A' COLLATE "C" ON EMPTY);

Fix by validating the DEFAULT expression's collation against the
RETURNING collation and by stripping any top-level CollateExpr in
transformJsonBehavior(), ensuring no such node is left for later
collation assignment. If the DEFAULT collation differs from the
RETURNING collation, raise a clear error at parse time. This also
guarantees a single, deterministic result collation rather than one
that could vary at runtime.

Tests are placed in collation.icu.utf8 to avoid failures on
buildfarm machines without ICU.

Reported-by: Jian He <jian.universality@gmail.com>
Author: Jian He <jian.universality@gmail.com>
Reviewed-by: Amit Langote <amitlangote09@gmail.com>
Discussion: https://postgr.es/m/CACJufxHVwYYSyiVQ6o+PsRX6zQ7rAFinh_fv1kCfTsT1xG4Zeg@mail.gmail.com
---
 src/backend/parser/parse_expr.c               | 63 ++++++++++++++++---
 .../regress/expected/collate.icu.utf8.out     | 49 +++++++++++++++
 src/test/regress/sql/collate.icu.utf8.sql     | 13 ++++
 3 files changed, 117 insertions(+), 8 deletions(-)

diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index 9d95c7140ee..db3847fbc4d 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -94,7 +94,8 @@ static Node *transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func);
 static void transformJsonPassingArgs(ParseState *pstate, const char *constructName,
 									 JsonFormatType format, List *args,
 									 List **passing_values, List **passing_names);
-static JsonBehavior *transformJsonBehavior(ParseState *pstate, JsonBehavior *behavior,
+static JsonBehavior *transformJsonBehavior(ParseState *pstate, JsonExpr *jsexpr,
+										   JsonBehavior *behavior,
 										   JsonBehaviorType default_behavior,
 										   JsonReturning *returning);
 static Node *GetJsonBehaviorConst(JsonBehaviorType btype, int location);
@@ -4529,13 +4530,16 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 			{
 				jsexpr->returning->typid = BOOLOID;
 				jsexpr->returning->typmod = -1;
+				jsexpr->collation = InvalidOid;
 			}
 
 			/* JSON_TABLE() COLUMNS can specify a non-boolean type. */
 			if (jsexpr->returning->typid != BOOLOID)
 				jsexpr->use_json_coercion = true;
 
-			jsexpr->on_error = transformJsonBehavior(pstate, func->on_error,
+			jsexpr->on_error = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_error,
 													 JSON_BEHAVIOR_FALSE,
 													 jsexpr->returning);
 			break;
@@ -4550,6 +4554,8 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 				ret->typmod = -1;
 			}
 
+			jsexpr->collation = get_typcollation(jsexpr->returning->typid);
+
 			/*
 			 * Keep quotes on scalar strings by default, omitting them only if
 			 * OMIT QUOTES is specified.
@@ -4566,11 +4572,15 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 				jsexpr->use_json_coercion = true;
 
 			/* Assume NULL ON EMPTY when ON EMPTY is not specified. */
-			jsexpr->on_empty = transformJsonBehavior(pstate, func->on_empty,
+			jsexpr->on_empty = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_empty,
 													 JSON_BEHAVIOR_NULL,
 													 jsexpr->returning);
 			/* Assume NULL ON ERROR when ON ERROR is not specified. */
-			jsexpr->on_error = transformJsonBehavior(pstate, func->on_error,
+			jsexpr->on_error = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_error,
 													 JSON_BEHAVIOR_NULL,
 													 jsexpr->returning);
 			break;
@@ -4582,6 +4592,7 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 				jsexpr->returning->typid = TEXTOID;
 				jsexpr->returning->typmod = -1;
 			}
+			jsexpr->collation = get_typcollation(jsexpr->returning->typid);
 
 			/*
 			 * Override whatever transformJsonOutput() set these to, which
@@ -4607,11 +4618,15 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 			}
 
 			/* Assume NULL ON EMPTY when ON EMPTY is not specified. */
-			jsexpr->on_empty = transformJsonBehavior(pstate, func->on_empty,
+			jsexpr->on_empty = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_empty,
 													 JSON_BEHAVIOR_NULL,
 													 jsexpr->returning);
 			/* Assume NULL ON ERROR when ON ERROR is not specified. */
-			jsexpr->on_error = transformJsonBehavior(pstate, func->on_error,
+			jsexpr->on_error = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_error,
 													 JSON_BEHAVIOR_NULL,
 													 jsexpr->returning);
 			break;
@@ -4622,6 +4637,7 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 				jsexpr->returning->typid = exprType(jsexpr->formatted_expr);
 				jsexpr->returning->typmod = -1;
 			}
+			jsexpr->collation = get_typcollation(jsexpr->returning->typid);
 
 			/*
 			 * Assume EMPTY ARRAY ON ERROR when ON ERROR is not specified.
@@ -4629,7 +4645,9 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 			 * ON EMPTY cannot be specified at the top level but it can be for
 			 * the individual columns.
 			 */
-			jsexpr->on_error = transformJsonBehavior(pstate, func->on_error,
+			jsexpr->on_error = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_error,
 													 JSON_BEHAVIOR_EMPTY_ARRAY,
 													 jsexpr->returning);
 			break;
@@ -4705,7 +4723,8 @@ ValidJsonBehaviorDefaultExpr(Node *expr, void *context)
  * Transform a JSON BEHAVIOR clause.
  */
 static JsonBehavior *
-transformJsonBehavior(ParseState *pstate, JsonBehavior *behavior,
+transformJsonBehavior(ParseState *pstate, JsonExpr *jsexpr,
+					  JsonBehavior *behavior,
 					  JsonBehaviorType default_behavior,
 					  JsonReturning *returning)
 {
@@ -4720,7 +4739,11 @@ transformJsonBehavior(ParseState *pstate, JsonBehavior *behavior,
 		location = behavior->location;
 		if (btype == JSON_BEHAVIOR_DEFAULT)
 		{
+			Oid			targetcoll = jsexpr->collation;
+			Oid			exprcoll;
+
 			expr = transformExprRecurse(pstate, behavior->expr);
+
 			if (!ValidJsonBehaviorDefaultExpr(expr, NULL))
 				ereport(ERROR,
 						(errcode(ERRCODE_DATATYPE_MISMATCH),
@@ -4736,6 +4759,30 @@ transformJsonBehavior(ParseState *pstate, JsonBehavior *behavior,
 						(errcode(ERRCODE_DATATYPE_MISMATCH),
 						 errmsg("DEFAULT expression must not return a set"),
 						 parser_errposition(pstate, exprLocation(expr))));
+
+			exprcoll = exprCollation(expr);
+			if (!OidIsValid(exprcoll))
+				exprcoll = get_typcollation(exprType(expr));
+			if (OidIsValid(targetcoll) && OidIsValid(exprcoll) &&
+				targetcoll != exprcoll)
+				ereport(ERROR,
+						errcode(ERRCODE_COLLATION_MISMATCH),
+						errmsg("the collation of DEFAULT expression conflicts with RETURNING clause"),
+						errdetail("\"%s\" versus \"%s\"",
+								  get_collation_name(exprcoll),
+								  get_collation_name(targetcoll)),
+						parser_errposition(pstate, exprLocation(expr)));
+
+			/*
+			 * Strip any top-level COLLATE from the DEFAULT expression. The
+			 * result collation of the enclosing JSON expression is
+			 * jsexpr->collation (chosen from RETURNING), and we have already
+			 * verified that any explicit COLLATE on DEFAULT equals that
+			 * target. Removing the wrapper avoids passing CollateExpr to
+			 * exprSetCollation() and does not change semantics.
+			 */
+			while (IsA(expr, CollateExpr))
+				expr = (Node *) ((CollateExpr *) expr)->arg;
 		}
 	}
 
diff --git a/src/test/regress/expected/collate.icu.utf8.out b/src/test/regress/expected/collate.icu.utf8.out
index 69805d4b9ec..d385668afaf 100644
--- a/src/test/regress/expected/collate.icu.utf8.out
+++ b/src/test/regress/expected/collate.icu.utf8.out
@@ -2690,6 +2690,55 @@ SELECT * FROM t5 ORDER BY c ASC, a ASC;
  3 | d1 | d1
 (3 rows)
 
+-- Check that DEFAULT expressions in SQL/JSON functions use the same collation
+-- as the RETURNING type.  Mismatched collations should raise an error.
+CREATE DOMAIN d1 AS text COLLATE case_insensitive;
+CREATE DOMAIN d2 AS text COLLATE "C";
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT ('C' COLLATE "C") COLLATE case_insensitive ON EMPTY) = 'a'; -- true
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C' ON EMPTY) = 'a'; -- true
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C'::d2 ON EMPTY) = 'a'; -- error
+ERROR:  the collation of DEFAULT expression conflicts with RETURNING clause
+LINE 1: ...ON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C'::d2 ON...
+                                                             ^
+DETAIL:  "C" versus "case_insensitive"
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C' COLLATE "C" ON EMPTY) = 'a'; -- error
+ERROR:  the collation of DEFAULT expression conflicts with RETURNING clause
+LINE 1: ...ON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C' COLLAT...
+                                                             ^
+DETAIL:  "C" versus "case_insensitive"
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' ON EMPTY) = 'a'; -- true
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' COLLATE case_insensitive ON EMPTY) = 'a'; -- true
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A'::d2 ON EMPTY) = 'a'; -- error
+ERROR:  the collation of DEFAULT expression conflicts with RETURNING clause
+LINE 1: ...ON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A'::d2 ON...
+                                                             ^
+DETAIL:  "C" versus "case_insensitive"
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' COLLATE "C" ON EMPTY) = 'a'; -- error
+ERROR:  the collation of DEFAULT expression conflicts with RETURNING clause
+LINE 1: ...ON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' COLLAT...
+                                                             ^
+DETAIL:  "C" versus "case_insensitive"
+DROP DOMAIN d1, d2;
 -- cleanup
 RESET search_path;
 SET client_min_messages TO warning;
diff --git a/src/test/regress/sql/collate.icu.utf8.sql b/src/test/regress/sql/collate.icu.utf8.sql
index dbc190227d0..6f5abac0dc0 100644
--- a/src/test/regress/sql/collate.icu.utf8.sql
+++ b/src/test/regress/sql/collate.icu.utf8.sql
@@ -997,6 +997,19 @@ INSERT INTO t5 (a, b) values (1, 'D1'), (2, 'D2'), (3, 'd1');
 -- rewriting.)
 SELECT * FROM t5 ORDER BY c ASC, a ASC;
 
+-- Check that DEFAULT expressions in SQL/JSON functions use the same collation
+-- as the RETURNING type.  Mismatched collations should raise an error.
+CREATE DOMAIN d1 AS text COLLATE case_insensitive;
+CREATE DOMAIN d2 AS text COLLATE "C";
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT ('C' COLLATE "C") COLLATE case_insensitive ON EMPTY) = 'a'; -- true
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C' ON EMPTY) = 'a'; -- true
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C'::d2 ON EMPTY) = 'a'; -- error
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C' COLLATE "C" ON EMPTY) = 'a'; -- error
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' ON EMPTY) = 'a'; -- true
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' COLLATE case_insensitive ON EMPTY) = 'a'; -- true
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A'::d2 ON EMPTY) = 'a'; -- error
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' COLLATE "C" ON EMPTY) = 'a'; -- error
+DROP DOMAIN d1, d2;
 
 -- cleanup
 RESET search_path;
-- 
2.47.3

#6Amit Langote
amitlangote09@gmail.com
In reply to: Amit Langote (#5)
1 attachment(s)
Re: sql/json query function JsonBehavior default expression's collation may differ from returning type's collation

On Mon, Oct 6, 2025 at 10:58 PM Amit Langote <amitlangote09@gmail.com> wrote:

(Sorry for the long delay -- I meant to get back to this a while ago.)

Thanks for posting v2 of the patch. I’ve made a few follow-up changes
(v3 attached):

* Moved the regression tests from sqljson_queryfuncs.sql to
collation.icu.utf8.sql to avoid failures on buildfarm machines without
ICU support.

* Adjusted the collation-mismatch check in transformJsonBehavior() so
that it runs last within the DEFAULT-handling block. That keeps the
control flow cleaner and avoids affecting existing tests that already
fail earlier checks, preventing unnecessary regression output churn.

* Did a few cosmetic edits and fixed the error code and message text.

Otherwise, behavior and coverage remain the same.

After sleeping on this, I realized the right fix is not to strip
CollateExpr in transformJsonBehavior(), but to stop recursing on
JsonBehavior.expr in exprSetCollation(). With this patch, the parser
now ensures that the JsonBehavior.expr has the correct collation, so
the recursion isn’t needed. There is now an Assert in its place that
checks that JsonBehavior.expr already has the target collation.

Attached v4.

--
Thanks, Amit Langote

Attachments:

v4-0001-Fix-internal-error-from-CollateExpr-in-SQL-JSON-D.patchapplication/octet-stream; name=v4-0001-Fix-internal-error-from-CollateExpr-in-SQL-JSON-D.patchDownload
From b295503b1c506e5cc46538b63a9c357e219b819b Mon Sep 17 00:00:00 2001
From: Amit Langote <amitlan@postgresql.org>
Date: Tue, 7 Oct 2025 22:18:44 -0400
Subject: [PATCH v4] Fix internal error from CollateExpr in SQL/JSON DEFAULT
 expressions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

SQL/JSON functions such as JSON_VALUE could fail with "unrecognized
node type" errors when a DEFAULT clause contained an explicit COLLATE
expression. That happened because assign_collations_walker() could
invoke exprSetCollation() on a JsonBehavior expression whose DEFAULT
still contained a CollateExpr, which exprSetCollation() does not
handle.

For example:

  SELECT JSON_VALUE('{"a":1}', '$.c' RETURNING text
                    DEFAULT 'A' COLLATE "C" ON EMPTY);

Fix by validating in transformJsonBehavior() that the DEFAULT
expression's collation matches the enclosing JSON expression’s
collation. In exprSetCollation(), replace the recursive call on the
JsonBehavior expression with an assertion that its collation already
matches the target, since the parser now enforces that condition.

Reported-by: Jian He <jian.universality@gmail.com>
Author: Jian He <jian.universality@gmail.com>
Reviewed-by: Amit Langote <amitlangote09@gmail.com>
Discussion: https://postgr.es/m/CACJufxHVwYYSyiVQ6o+PsRX6zQ7rAFinh_fv1kCfTsT1xG4Zeg@mail.gmail.com
---
 src/backend/nodes/nodeFuncs.c                 |  8 +--
 src/backend/parser/parse_expr.c               | 57 ++++++++++++++++---
 .../regress/expected/collate.icu.utf8.out     | 49 ++++++++++++++++
 src/test/regress/sql/collate.icu.utf8.sql     | 13 +++++
 4 files changed, 113 insertions(+), 14 deletions(-)

diff --git a/src/backend/nodes/nodeFuncs.c b/src/backend/nodes/nodeFuncs.c
index 7bc823507f1..ede838cd40c 100644
--- a/src/backend/nodes/nodeFuncs.c
+++ b/src/backend/nodes/nodeFuncs.c
@@ -1274,12 +1274,8 @@ exprSetCollation(Node *expr, Oid collation)
 			}
 			break;
 		case T_JsonBehavior:
-			{
-				JsonBehavior *behavior = (JsonBehavior *) expr;
-
-				if (behavior->expr)
-					exprSetCollation(behavior->expr, collation);
-			}
+			Assert(((JsonBehavior *) expr)->expr == NULL ||
+				   exprCollation(((JsonBehavior *) expr)->expr) == collation);
 			break;
 		case T_NullTest:
 			/* NullTest's result is boolean ... */
diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index 9d95c7140ee..12119f147fc 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -94,7 +94,8 @@ static Node *transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func);
 static void transformJsonPassingArgs(ParseState *pstate, const char *constructName,
 									 JsonFormatType format, List *args,
 									 List **passing_values, List **passing_names);
-static JsonBehavior *transformJsonBehavior(ParseState *pstate, JsonBehavior *behavior,
+static JsonBehavior *transformJsonBehavior(ParseState *pstate, JsonExpr *jsexpr,
+										   JsonBehavior *behavior,
 										   JsonBehaviorType default_behavior,
 										   JsonReturning *returning);
 static Node *GetJsonBehaviorConst(JsonBehaviorType btype, int location);
@@ -4529,13 +4530,16 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 			{
 				jsexpr->returning->typid = BOOLOID;
 				jsexpr->returning->typmod = -1;
+				jsexpr->collation = InvalidOid;
 			}
 
 			/* JSON_TABLE() COLUMNS can specify a non-boolean type. */
 			if (jsexpr->returning->typid != BOOLOID)
 				jsexpr->use_json_coercion = true;
 
-			jsexpr->on_error = transformJsonBehavior(pstate, func->on_error,
+			jsexpr->on_error = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_error,
 													 JSON_BEHAVIOR_FALSE,
 													 jsexpr->returning);
 			break;
@@ -4550,6 +4554,8 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 				ret->typmod = -1;
 			}
 
+			jsexpr->collation = get_typcollation(jsexpr->returning->typid);
+
 			/*
 			 * Keep quotes on scalar strings by default, omitting them only if
 			 * OMIT QUOTES is specified.
@@ -4566,11 +4572,15 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 				jsexpr->use_json_coercion = true;
 
 			/* Assume NULL ON EMPTY when ON EMPTY is not specified. */
-			jsexpr->on_empty = transformJsonBehavior(pstate, func->on_empty,
+			jsexpr->on_empty = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_empty,
 													 JSON_BEHAVIOR_NULL,
 													 jsexpr->returning);
 			/* Assume NULL ON ERROR when ON ERROR is not specified. */
-			jsexpr->on_error = transformJsonBehavior(pstate, func->on_error,
+			jsexpr->on_error = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_error,
 													 JSON_BEHAVIOR_NULL,
 													 jsexpr->returning);
 			break;
@@ -4582,6 +4592,7 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 				jsexpr->returning->typid = TEXTOID;
 				jsexpr->returning->typmod = -1;
 			}
+			jsexpr->collation = get_typcollation(jsexpr->returning->typid);
 
 			/*
 			 * Override whatever transformJsonOutput() set these to, which
@@ -4607,11 +4618,15 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 			}
 
 			/* Assume NULL ON EMPTY when ON EMPTY is not specified. */
-			jsexpr->on_empty = transformJsonBehavior(pstate, func->on_empty,
+			jsexpr->on_empty = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_empty,
 													 JSON_BEHAVIOR_NULL,
 													 jsexpr->returning);
 			/* Assume NULL ON ERROR when ON ERROR is not specified. */
-			jsexpr->on_error = transformJsonBehavior(pstate, func->on_error,
+			jsexpr->on_error = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_error,
 													 JSON_BEHAVIOR_NULL,
 													 jsexpr->returning);
 			break;
@@ -4622,6 +4637,7 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 				jsexpr->returning->typid = exprType(jsexpr->formatted_expr);
 				jsexpr->returning->typmod = -1;
 			}
+			jsexpr->collation = get_typcollation(jsexpr->returning->typid);
 
 			/*
 			 * Assume EMPTY ARRAY ON ERROR when ON ERROR is not specified.
@@ -4629,7 +4645,9 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 			 * ON EMPTY cannot be specified at the top level but it can be for
 			 * the individual columns.
 			 */
-			jsexpr->on_error = transformJsonBehavior(pstate, func->on_error,
+			jsexpr->on_error = transformJsonBehavior(pstate,
+													 jsexpr,
+													 func->on_error,
 													 JSON_BEHAVIOR_EMPTY_ARRAY,
 													 jsexpr->returning);
 			break;
@@ -4705,7 +4723,8 @@ ValidJsonBehaviorDefaultExpr(Node *expr, void *context)
  * Transform a JSON BEHAVIOR clause.
  */
 static JsonBehavior *
-transformJsonBehavior(ParseState *pstate, JsonBehavior *behavior,
+transformJsonBehavior(ParseState *pstate, JsonExpr *jsexpr,
+					  JsonBehavior *behavior,
 					  JsonBehaviorType default_behavior,
 					  JsonReturning *returning)
 {
@@ -4720,7 +4739,11 @@ transformJsonBehavior(ParseState *pstate, JsonBehavior *behavior,
 		location = behavior->location;
 		if (btype == JSON_BEHAVIOR_DEFAULT)
 		{
+			Oid			targetcoll = jsexpr->collation;
+			Oid			exprcoll;
+
 			expr = transformExprRecurse(pstate, behavior->expr);
+
 			if (!ValidJsonBehaviorDefaultExpr(expr, NULL))
 				ereport(ERROR,
 						(errcode(ERRCODE_DATATYPE_MISMATCH),
@@ -4736,6 +4759,24 @@ transformJsonBehavior(ParseState *pstate, JsonBehavior *behavior,
 						(errcode(ERRCODE_DATATYPE_MISMATCH),
 						 errmsg("DEFAULT expression must not return a set"),
 						 parser_errposition(pstate, exprLocation(expr))));
+
+			/*
+			 * Reject a DEFAULT expression whose collation differs from the
+			 * enclosing JSON expression's result collation
+			 * (jsexpr->collation), as chosen by the RETURNING clause.
+			 */
+			exprcoll = exprCollation(expr);
+			if (!OidIsValid(exprcoll))
+				exprcoll = get_typcollation(exprType(expr));
+			if (OidIsValid(targetcoll) && OidIsValid(exprcoll) &&
+				targetcoll != exprcoll)
+				ereport(ERROR,
+						errcode(ERRCODE_COLLATION_MISMATCH),
+						errmsg("the collation of DEFAULT expression conflicts with RETURNING clause"),
+						errdetail("\"%s\" versus \"%s\"",
+								  get_collation_name(exprcoll),
+								  get_collation_name(targetcoll)),
+						parser_errposition(pstate, exprLocation(expr)));
 		}
 	}
 
diff --git a/src/test/regress/expected/collate.icu.utf8.out b/src/test/regress/expected/collate.icu.utf8.out
index 69805d4b9ec..d385668afaf 100644
--- a/src/test/regress/expected/collate.icu.utf8.out
+++ b/src/test/regress/expected/collate.icu.utf8.out
@@ -2690,6 +2690,55 @@ SELECT * FROM t5 ORDER BY c ASC, a ASC;
  3 | d1 | d1
 (3 rows)
 
+-- Check that DEFAULT expressions in SQL/JSON functions use the same collation
+-- as the RETURNING type.  Mismatched collations should raise an error.
+CREATE DOMAIN d1 AS text COLLATE case_insensitive;
+CREATE DOMAIN d2 AS text COLLATE "C";
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT ('C' COLLATE "C") COLLATE case_insensitive ON EMPTY) = 'a'; -- true
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C' ON EMPTY) = 'a'; -- true
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C'::d2 ON EMPTY) = 'a'; -- error
+ERROR:  the collation of DEFAULT expression conflicts with RETURNING clause
+LINE 1: ...ON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C'::d2 ON...
+                                                             ^
+DETAIL:  "C" versus "case_insensitive"
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C' COLLATE "C" ON EMPTY) = 'a'; -- error
+ERROR:  the collation of DEFAULT expression conflicts with RETURNING clause
+LINE 1: ...ON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C' COLLAT...
+                                                             ^
+DETAIL:  "C" versus "case_insensitive"
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' ON EMPTY) = 'a'; -- true
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' COLLATE case_insensitive ON EMPTY) = 'a'; -- true
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A'::d2 ON EMPTY) = 'a'; -- error
+ERROR:  the collation of DEFAULT expression conflicts with RETURNING clause
+LINE 1: ...ON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A'::d2 ON...
+                                                             ^
+DETAIL:  "C" versus "case_insensitive"
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' COLLATE "C" ON EMPTY) = 'a'; -- error
+ERROR:  the collation of DEFAULT expression conflicts with RETURNING clause
+LINE 1: ...ON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' COLLAT...
+                                                             ^
+DETAIL:  "C" versus "case_insensitive"
+DROP DOMAIN d1, d2;
 -- cleanup
 RESET search_path;
 SET client_min_messages TO warning;
diff --git a/src/test/regress/sql/collate.icu.utf8.sql b/src/test/regress/sql/collate.icu.utf8.sql
index dbc190227d0..6f5abac0dc0 100644
--- a/src/test/regress/sql/collate.icu.utf8.sql
+++ b/src/test/regress/sql/collate.icu.utf8.sql
@@ -997,6 +997,19 @@ INSERT INTO t5 (a, b) values (1, 'D1'), (2, 'D2'), (3, 'd1');
 -- rewriting.)
 SELECT * FROM t5 ORDER BY c ASC, a ASC;
 
+-- Check that DEFAULT expressions in SQL/JSON functions use the same collation
+-- as the RETURNING type.  Mismatched collations should raise an error.
+CREATE DOMAIN d1 AS text COLLATE case_insensitive;
+CREATE DOMAIN d2 AS text COLLATE "C";
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT ('C' COLLATE "C") COLLATE case_insensitive ON EMPTY) = 'a'; -- true
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C' ON EMPTY) = 'a'; -- true
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C'::d2 ON EMPTY) = 'a'; -- error
+SELECT JSON_VALUE('{"a": "A"}', '$.a' RETURNING d1 DEFAULT 'C' COLLATE "C" ON EMPTY) = 'a'; -- error
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' ON EMPTY) = 'a'; -- true
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' COLLATE case_insensitive ON EMPTY) = 'a'; -- true
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A'::d2 ON EMPTY) = 'a'; -- error
+SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' COLLATE "C" ON EMPTY) = 'a'; -- error
+DROP DOMAIN d1, d2;
 
 -- cleanup
 RESET search_path;
-- 
2.47.3

#7jian he
jian.universality@gmail.com
In reply to: Amit Langote (#6)
Re: sql/json query function JsonBehavior default expression's collation may differ from returning type's collation

On Wed, Oct 8, 2025 at 12:10 PM Amit Langote <amitlangote09@gmail.com> wrote:

After sleeping on this, I realized the right fix is not to strip
CollateExpr in transformJsonBehavior(), but to stop recursing on
JsonBehavior.expr in exprSetCollation(). With this patch, the parser
now ensures that the JsonBehavior.expr has the correct collation, so
the recursion isn’t needed. There is now an Assert in its place that
checks that JsonBehavior.expr already has the target collation.

hi, Amit.
thanks for pushing it.
https://git.postgresql.org/cgit/postgresql.git/commit/?id=ef5e60a9d352a97791af632e0d26a572bc88e921

it took me a little time to understand why
``
exprSetCollation
case T_JsonBehavior:
Assert(((JsonBehavior *) expr)->expr == NULL ||
exprCollation(((JsonBehavior *) expr)->expr) == collation);
``
works, after looking at assign_collations_walker.

it will recursively set JsonBehavior->expr collation first then call
exprSetCollation for JsonBehavior itself.

I will double-check other JSON constructs with RETURNING clauses for potential
collation issues in the future.

#8Amit Langote
amitlangote09@gmail.com
In reply to: jian he (#7)
Re: sql/json query function JsonBehavior default expression's collation may differ from returning type's collation

Hi Jian,

On Thu, Oct 9, 2025 at 12:48 PM jian he <jian.universality@gmail.com> wrote:

On Wed, Oct 8, 2025 at 12:10 PM Amit Langote <amitlangote09@gmail.com> wrote:

After sleeping on this, I realized the right fix is not to strip
CollateExpr in transformJsonBehavior(), but to stop recursing on
JsonBehavior.expr in exprSetCollation(). With this patch, the parser
now ensures that the JsonBehavior.expr has the correct collation, so
the recursion isn’t needed. There is now an Assert in its place that
checks that JsonBehavior.expr already has the target collation.

hi, Amit.
thanks for pushing it.

https://git.postgresql.org/cgit/postgresql.git/commit/?id=ef5e60a9d352a97791af632e0d26a572bc88e921

it took me a little time to understand why
``
exprSetCollation
case T_JsonBehavior:
Assert(((JsonBehavior *) expr)->expr == NULL ||
exprCollation(((JsonBehavior *) expr)->expr) == collation);
``
works, after looking at assign_collations_walker.

it will recursively set JsonBehavior->expr collation first then call
exprSetCollation for JsonBehavior itself.

I will double-check other JSON constructs with RETURNING clauses for potential
collation issues in the future.

Yes, that would be great. Thanks for your close attention to this area of code.

--
Thanks, Amit Langote