From 70717fb20e8859bd322a94e923e11ddc5ce8d3d1 Mon Sep 17 00:00:00 2001
From: jian he <jian.universality@gmail.com>
Date: Thu, 29 Oct 2026 11:04:44 +0800
Subject: [PATCH v2 1/1] Fix JSON_ARRAY(query) interact with constrained domain

Discussion: https://postgr.es/m/CAMbWs4-yH6nsC4na99rWeXzA+sLXvJdtcevgkQDEU6RwJNbFKQ@mail.gmail.com
---
 src/backend/parser/parse_expr.c       | 42 +++++++++++++++++++++++----
 src/test/regress/expected/sqljson.out | 39 +++++++++++++++++++++++++
 src/test/regress/sql/sqljson.sql      | 25 ++++++++++++++++
 3 files changed, 100 insertions(+), 6 deletions(-)

diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index 728392f436f..82e31f66d1d 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -3775,7 +3775,7 @@ transformJsonObjectConstructor(ParseState *pstate, JsonObjectConstructor *ctor)
  *  - func: the executable form, which is a COALESCE expression wrapping a
  *    JSON_ARRAYAGG subquery:
  *
- *        COALESCE((SELECT JSON_ARRAYAGG(a) FROM (subquery) q(a)), '[]')
+ *       (COALESCE((SELECT JSON_ARRAYAGG(a) FROM (subquery) q(a)), '[]'))::returning_type
  *
  *    The COALESCE ensures that an empty result set produces '[]' rather than
  *    NULL, per the SQL/JSON standard.
@@ -3800,6 +3800,7 @@ transformJsonArrayQueryConstructor(ParseState *pstate,
 	JsonArrayAgg *agg;
 	ColumnRef  *colref;
 	Node	   *exec_expr;
+	Node	   *cast_expr;
 	CoalesceExpr *coalesce;
 	Const	   *empty_const;
 	Oid			result_type;
@@ -3809,6 +3810,9 @@ transformJsonArrayQueryConstructor(ParseState *pstate,
 	int16		typlen;
 	bool		typbyval;
 	JsonReturning *returning;
+	JsonOutput *output;
+	Oid			base_typid;
+	int32		base_typmod;
 	List	   *args;
 	Node	   *result;
 
@@ -3829,6 +3833,26 @@ transformJsonArrayQueryConstructor(ParseState *pstate,
 
 	free_parsestate(qpstate);
 
+	args = list_make1(linitial_node(TargetEntry, query->targetList)->expr);
+	returning = transformJsonConstructorOutput(pstate, ctor->output, args);
+
+	/*
+	 * For a domain RETURNING type, we must let the JSON_ARRAYAGG subquery
+	 * coerce to the domain's base type, and let the CoerceToDomain be applied
+	 * to the COALESCE instead.  Otherwise the domain constraint would see the
+	 * subquery's NULL result for an empty set and fail before we could fall
+	 * back to '[]'.
+	 */
+	base_typmod = returning->typmod;
+	base_typid = getBaseTypeAndTypmod(returning->typid, &base_typmod);
+
+	output = copyObject(ctor->output);
+
+	if (base_typid != returning->typid)
+	{
+		output->typeName = makeTypeNameFromOid(base_typid, base_typmod);
+	}
+
 	/*
 	 * Build the executable form by constructing query:
 	 *
@@ -3848,7 +3872,7 @@ transformJsonArrayQueryConstructor(ParseState *pstate,
 	agg->absent_on_null = ctor->absent_on_null;
 	agg->constructor = makeNode(JsonAggConstructor);
 	agg->constructor->agg_order = NIL;
-	agg->constructor->output = ctor->output;
+	agg->constructor->output = output;
 	agg->constructor->location = ctor->location;
 
 	target = makeNode(ResTarget);
@@ -3907,17 +3931,23 @@ transformJsonArrayQueryConstructor(ParseState *pstate,
 	coalesce->args = list_make2(exec_expr, empty_const);
 	coalesce->location = ctor->location;
 
+	cast_expr = coerce_to_target_type(pstate, (Node *) coalesce,
+									  exprType(exec_expr),
+									  returning->typid,
+									  returning->typmod,
+									  COERCION_ASSIGNMENT,
+									  COERCE_IMPLICIT_CAST,
+									  ctor->location);
+	Assert(cast_expr != NULL);
+
 	/*
 	 * Build the JSCTOR_JSON_ARRAY_QUERY node.  The COALESCE goes in func as
 	 * the executable form; during planning, eval_const_expressions replaces
 	 * the entire node with func.  The transformed Query is stored in
 	 * orig_query so that ruleutils.c can deparse the original syntax.
 	 */
-	args = list_make1(linitial_node(TargetEntry, query->targetList)->expr);
-	returning = transformJsonConstructorOutput(pstate, ctor->output, args);
-
 	result = makeJsonConstructorExpr(pstate, JSCTOR_JSON_ARRAY_QUERY,
-									 NIL, (Expr *) coalesce, returning,
+									 NIL, (Expr *) cast_expr, returning,
 									 false, ctor->absent_on_null,
 									 ctor->location);
 	((JsonConstructorExpr *) result)->orig_query = (Node *) query;
diff --git a/src/test/regress/expected/sqljson.out b/src/test/regress/expected/sqljson.out
index 30f9e10ef0a..0b5f8200864 100644
--- a/src/test/regress/expected/sqljson.out
+++ b/src/test/regress/expected/sqljson.out
@@ -827,6 +827,44 @@ SELECT JSON_ARRAY(SELECT 1 WHERE FALSE RETURNING varchar(2));
  []
 (1 row)
 
+-- JSON_ARRAY(subquery) RETURNING a domain: its constraints are checked on
+-- the empty array at run time, and only if it is used
+CREATE DOMAIN sqljson_nonempty_arr AS jsonb
+  CHECK (jsonb_array_length(VALUE) > 0);
+-- Should work
+SELECT JSON_ARRAY(SELECT 1 RETURNING sqljson_nonempty_arr);
+ json_array 
+------------
+ [1]
+(1 row)
+
+-- Should fail
+SELECT JSON_ARRAY(SELECT 1 WHERE FALSE RETURNING sqljson_nonempty_arr);
+ERROR:  value for domain sqljson_nonempty_arr violates check constraint "sqljson_nonempty_arr_check"
+CREATE DOMAIN sqljson_arr AS jsonb;
+CREATE VIEW sqljson_arr_view AS
+  SELECT JSON_ARRAY(SELECT 1 WHERE FALSE RETURNING sqljson_arr) AS a;
+-- Should work
+SELECT * FROM sqljson_arr_view;
+ a  
+----
+ []
+(1 row)
+
+ALTER DOMAIN sqljson_arr ADD CONSTRAINT sqljson_arr_nonempty
+  CHECK (jsonb_array_length(VALUE) > 0);
+-- Should fail
+SELECT * FROM sqljson_arr_view;
+ERROR:  value for domain sqljson_arr violates check constraint "sqljson_arr_nonempty"
+DROP VIEW sqljson_arr_view;
+DROP DOMAIN sqljson_nonempty_arr, sqljson_arr;
+CREATE DOMAIN sqljsonb_not_null AS jsonb NOT NULL;
+SELECT JSON_ARRAY(SELECT 1 WHERE false RETURNING sqljsonb_not_null); -- ok
+ json_array 
+------------
+ []
+(1 row)
+
 -- Should fail
 SELECT JSON_ARRAY(SELECT FROM (VALUES (1)) foo(i));
 ERROR:  subquery must return only one column
@@ -1890,3 +1928,4 @@ ORDER BY count(*);
                      Function Call: generate_series(1, 3)
 (11 rows)
 
+DROP DOMAIN sqljsonb_not_null;
diff --git a/src/test/regress/sql/sqljson.sql b/src/test/regress/sql/sqljson.sql
index 9fa203ccb53..9963c40a060 100644
--- a/src/test/regress/sql/sqljson.sql
+++ b/src/test/regress/sql/sqljson.sql
@@ -232,6 +232,29 @@ SELECT JSON_ARRAY(SELECT 1 WHERE FALSE RETURNING varchar(1));
 SELECT JSON_ARRAY(SELECT 1 RETURNING varchar(3));
 SELECT JSON_ARRAY(SELECT 1 WHERE FALSE RETURNING varchar(2));
 
+-- JSON_ARRAY(subquery) RETURNING a domain: its constraints are checked on
+-- the empty array at run time, and only if it is used
+CREATE DOMAIN sqljson_nonempty_arr AS jsonb
+  CHECK (jsonb_array_length(VALUE) > 0);
+-- Should work
+SELECT JSON_ARRAY(SELECT 1 RETURNING sqljson_nonempty_arr);
+-- Should fail
+SELECT JSON_ARRAY(SELECT 1 WHERE FALSE RETURNING sqljson_nonempty_arr);
+CREATE DOMAIN sqljson_arr AS jsonb;
+CREATE VIEW sqljson_arr_view AS
+  SELECT JSON_ARRAY(SELECT 1 WHERE FALSE RETURNING sqljson_arr) AS a;
+-- Should work
+SELECT * FROM sqljson_arr_view;
+ALTER DOMAIN sqljson_arr ADD CONSTRAINT sqljson_arr_nonempty
+  CHECK (jsonb_array_length(VALUE) > 0);
+-- Should fail
+SELECT * FROM sqljson_arr_view;
+DROP VIEW sqljson_arr_view;
+DROP DOMAIN sqljson_nonempty_arr, sqljson_arr;
+
+CREATE DOMAIN sqljsonb_not_null AS jsonb NOT NULL;
+SELECT JSON_ARRAY(SELECT 1 WHERE false RETURNING sqljsonb_not_null); -- ok
+
 -- Should fail
 SELECT JSON_ARRAY(SELECT FROM (VALUES (1)) foo(i));
 SELECT JSON_ARRAY(SELECT i, i FROM (VALUES (1)) foo(i));
@@ -766,3 +789,5 @@ SELECT i % 2 AS g,
 FROM generate_series(1, 3) i
 GROUP BY i % 2
 ORDER BY count(*);
+
+DROP DOMAIN sqljsonb_not_null;
-- 
2.34.1

