From cf269d1a02ac35d7dc674c2ef0a7271b4f10dd74 Mon Sep 17 00:00:00 2001
From: Yeongjae Min <dudwo990413@gmail.com>
Date: Mon, 9 Nov 2026 16:05:09 +0900
Subject: [PATCH v3] Validate JSON object keys before omitting null values

JSON_OBJECT with ABSENT ON NULL can skip key validation when it omits
an entry with a null value. The json path accepts null keys, and both
json and jsonb paths can accept non-scalar keys. The strict object
aggregate workers have the same non-scalar-key validation gap.

Move the json constructor's null-key check before omission, and validate
key categories in the skipped-entry paths of constructors and aggregates.
Factor the existing category checks into json_validate_key_category(),
shared with datum_to_json_internal() and datum_to_jsonb_internal(). This
preserves the existing error messages and SQLSTATEs and avoids invoking
output or cast functions for omitted entries without uniqueness checks.

Clarify that key restrictions apply even when ABSENT ON NULL omits the
pair, and document the restrictions for strict object aggregates.
Add regression coverage for constructors and aggregates, including
array, composite, json/jsonb and user-defined JSON-cast keys.

Reported in BUG #19736:
https://www.postgresql.org/message-id/19736-3a44617ea5534c77@postgresql.org
---
v3: Rebase onto master at 553262aff40d after b58a9a97fe removed
json_check_mutability(). Update the shared-helper insertion context
without restoring the reverted function. No behavior changes from v2.

Validation: 239 core regression tests and 660 supplemental behavior
assertions pass on macOS/arm64 with assertions enabled.

 doc/src/sgml/func/func-aggregate.sgml | 12 ++++--
 doc/src/sgml/func/func-json.sgml      |  2 +
 src/backend/utils/adt/json.c          | 33 +++++++++-------
 src/backend/utils/adt/jsonb.c         | 25 +++++++------
 src/backend/utils/adt/jsonfuncs.c     | 18 +++++++++
 src/include/utils/jsonfuncs.h         |  1 +
 src/test/regress/expected/sqljson.out | 54 +++++++++++++++++++++++++++
 src/test/regress/sql/sqljson.sql      | 30 +++++++++++++++
 8 files changed, 146 insertions(+), 29 deletions(-)

diff --git a/doc/src/sgml/func/func-aggregate.sgml b/doc/src/sgml/func/func-aggregate.sgml
index c3669898f0..462f720e2c 100644
--- a/doc/src/sgml/func/func-aggregate.sgml
+++ b/doc/src/sgml/func/func-aggregate.sgml
@@ -434,8 +434,10 @@
         Collects all the key/value pairs into a JSON object.  Key arguments
         are coerced to text; value arguments are converted as per
         <function>to_json</function> or <function>to_jsonb</function>.
-        The <parameter>key</parameter> cannot be null. If the
-        <parameter>value</parameter> is null then the pair is skipped.
+        The <parameter>key</parameter> must be a non-null scalar and cannot
+        belong to a type that has a cast to <type>json</type>, even if the
+        pair is skipped. If the <parameter>value</parameter> is null then
+        the pair is skipped.
         Duplicate keys are handled as for <function>json_object_agg</function>
         and <function>jsonb_object_agg</function>, after removing any pairs
         with null values.
@@ -495,8 +497,10 @@
         Collects all the key/value pairs into a JSON object.  Key arguments
         are coerced to text; value arguments are converted as per
         <function>to_json</function> or <function>to_jsonb</function>.
-        The <parameter>key</parameter> cannot be null. If the
-        <parameter>value</parameter> is null then the pair is skipped.
+        The <parameter>key</parameter> must be a non-null scalar and cannot
+        belong to a type that has a cast to <type>json</type>, even if the
+        pair is skipped. If the <parameter>value</parameter> is null then
+        the pair is skipped.
         If there is a duplicate key (including pairs with null values)
         an error is thrown.
        </para></entry>
diff --git a/doc/src/sgml/func/func-json.sgml b/doc/src/sgml/func/func-json.sgml
index ede39e7d02..28623adfad 100644
--- a/doc/src/sgml/func/func-json.sgml
+++ b/doc/src/sgml/func/func-json.sgml
@@ -724,6 +724,8 @@
          converted to the <type>text</type> type.
          It cannot be <literal>NULL</literal> nor can it
          belong to a type that has a cast to the <type>json</type> type.
+         These restrictions apply even if the pair is omitted from the output
+         due to <literal>ABSENT ON NULL</literal>.
          If <literal>WITH UNIQUE KEYS</literal> is specified, there must not
          be any duplicate <replaceable>key_expression</replaceable>.
          Any pair for which the <replaceable>value_expression</replaceable>
diff --git a/src/backend/utils/adt/json.c b/src/backend/utils/adt/json.c
index 125fb0c120..3de7c5da2a 100644
--- a/src/backend/utils/adt/json.c
+++ b/src/backend/utils/adt/json.c
@@ -194,14 +194,8 @@ datum_to_json_internal(Datum val, bool is_null, StringInfo result,
 		return;
 	}
 
-	if (key_scalar &&
-		(tcategory == JSONTYPE_ARRAY ||
-		 tcategory == JSONTYPE_COMPOSITE ||
-		 tcategory == JSONTYPE_JSON ||
-		 tcategory == JSONTYPE_CAST))
-		ereport(ERROR,
-				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-				 errmsg("key value must be scalar, not array, composite, or json")));
+	if (key_scalar)
+		json_validate_key_category(tcategory);
 
 	switch (tcategory)
 	{
@@ -1088,7 +1082,10 @@ json_object_agg_transfn_worker(FunctionCallInfo fcinfo,
 		 * buffer to store the key name so that we can check it.
 		 */
 		if (!unique_keys)
+		{
+			json_validate_key_category(state->key_category);
 			PG_RETURN_POINTER(state);
+		}
 
 		out = json_unique_builder_get_throwawaybuf(&state->unique_check);
 	}
@@ -1252,6 +1249,12 @@ json_build_object_worker(int nargs, const Datum *args, const bool *nulls, const
 		bool		skip;
 		int			key_offset;
 
+		/* Keys must not be null, even if their values will be omitted. */
+		if (nulls[i])
+			ereport(ERROR,
+					(errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
+					 errmsg("null value not allowed for object key")));
+
 		/* Skip null values if absent_on_null */
 		skip = absent_on_null && nulls[i + 1];
 
@@ -1259,7 +1262,15 @@ json_build_object_worker(int nargs, const Datum *args, const bool *nulls, const
 		{
 			/* If key uniqueness check is needed we must save skipped keys */
 			if (!unique_keys)
+			{
+				JsonTypeCategory tcategory;
+				Oid			outfuncoid;
+
+				/* Validate the key type without converting the omitted key. */
+				json_categorize_type(types[i], false, &tcategory, &outfuncoid);
+				json_validate_key_category(tcategory);
 				continue;
+			}
 
 			out = json_unique_builder_get_throwawaybuf(&unique_check);
 		}
@@ -1270,12 +1281,6 @@ json_build_object_worker(int nargs, const Datum *args, const bool *nulls, const
 			out = result;
 		}
 
-		/* process key */
-		if (nulls[i])
-			ereport(ERROR,
-					(errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
-					 errmsg("null value not allowed for object key")));
-
 		/* save key offset before appending it */
 		key_offset = out->len;
 
diff --git a/src/backend/utils/adt/jsonb.c b/src/backend/utils/adt/jsonb.c
index c3cbbe2791..29550f38e8 100644
--- a/src/backend/utils/adt/jsonb.c
+++ b/src/backend/utils/adt/jsonb.c
@@ -646,19 +646,11 @@ datum_to_jsonb_internal(Datum val, bool is_null, JsonbInState *result,
 		Assert(!key_scalar);
 		jb.type = jbvNull;
 	}
-	else if (key_scalar &&
-			 (tcategory == JSONTYPE_ARRAY ||
-			  tcategory == JSONTYPE_COMPOSITE ||
-			  tcategory == JSONTYPE_JSON ||
-			  tcategory == JSONTYPE_JSONB ||
-			  tcategory == JSONTYPE_CAST))
-	{
-		ereport(ERROR,
-				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-				 errmsg("key value must be scalar, not array, composite, or json")));
-	}
 	else
 	{
+		if (key_scalar)
+			json_validate_key_category(tcategory);
+
 		switch (tcategory)
 		{
 			case JSONTYPE_ARRAY:
@@ -1194,7 +1186,15 @@ jsonb_build_object_worker(int nargs, const Datum *args, const bool *nulls, const
 
 		/* we need to save skipped keys for the key uniqueness check */
 		if (skip && !unique_keys)
+		{
+			JsonTypeCategory tcategory;
+			Oid			outfuncoid;
+
+			/* Validate the key type without converting the omitted key. */
+			json_categorize_type(types[i], true, &tcategory, &outfuncoid);
+			json_validate_key_category(tcategory);
 			continue;
+		}
 
 		add_jsonb(args[i], false, &result, types[i], true);
 
@@ -1677,7 +1677,10 @@ jsonb_object_agg_transfn_worker(FunctionCallInfo fcinfo,
 	skip = absent_on_null && PG_ARGISNULL(2);
 
 	if (skip && !unique_keys)
+	{
+		json_validate_key_category(state->key_category);
 		PG_RETURN_POINTER(state);
+	}
 
 	/*
 	 * We run this code in the normal function context, so that we don't leak
diff --git a/src/backend/utils/adt/jsonfuncs.c b/src/backend/utils/adt/jsonfuncs.c
index 4e149d21d4..d8a1d34df0 100644
--- a/src/backend/utils/adt/jsonfuncs.c
+++ b/src/backend/utils/adt/jsonfuncs.c
@@ -6083,3 +6083,21 @@ json_categorize_type(Oid typoid, bool is_jsonb,
 			break;
 	}
 }
+
+/*
+ * Check that a JSON type category is valid for an object key.
+ * This must also be checked when a NULL value causes the pair to be omitted.
+ * The category must have been obtained from json_categorize_type().
+ */
+void
+json_validate_key_category(JsonTypeCategory tcategory)
+{
+	if (tcategory == JSONTYPE_ARRAY ||
+		tcategory == JSONTYPE_COMPOSITE ||
+		tcategory == JSONTYPE_JSON ||
+		tcategory == JSONTYPE_JSONB ||
+		tcategory == JSONTYPE_CAST)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("key value must be scalar, not array, composite, or json")));
+}
diff --git a/src/include/utils/jsonfuncs.h b/src/include/utils/jsonfuncs.h
index 636f0f5584..6f502e9df6 100644
--- a/src/include/utils/jsonfuncs.h
+++ b/src/include/utils/jsonfuncs.h
@@ -83,6 +83,7 @@ typedef enum
 
 extern void json_categorize_type(Oid typoid, bool is_jsonb,
 								 JsonTypeCategory *tcategory, Oid *outfuncoid);
+extern void json_validate_key_category(JsonTypeCategory tcategory);
 extern Datum datum_to_json(Datum val, JsonTypeCategory tcategory,
 						   Oid outfuncoid);
 extern Datum datum_to_jsonb(Datum val, JsonTypeCategory tcategory,
diff --git a/src/test/regress/expected/sqljson.out b/src/test/regress/expected/sqljson.out
index 1085475541..90aed84a15 100644
--- a/src/test/regress/expected/sqljson.out
+++ b/src/test/regress/expected/sqljson.out
@@ -539,6 +539,31 @@ SELECT JSON_OBJECT('a': '1', 'b': NULL, 'c': 2 ABSENT ON NULL);
  {"a" : "1", "c" : 2}
 (1 row)
 
+-- Keys must be valid even when ABSENT ON NULL omits their values.
+SELECT JSON_OBJECT((NULL::text) VALUE NULL::text ABSENT ON NULL RETURNING json);
+ERROR:  null value not allowed for object key
+SELECT JSON_OBJECT((NULL::text) VALUE NULL::text ABSENT ON NULL WITH UNIQUE KEYS RETURNING json);
+ERROR:  null value not allowed for object key
+SELECT JSON_OBJECT((ARRAY[1,2]) VALUE NULL::text ABSENT ON NULL RETURNING json);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT JSON_OBJECT((ROW(1,2)) VALUE NULL::text ABSENT ON NULL RETURNING json);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT JSON_OBJECT((json '1') VALUE NULL::text ABSENT ON NULL RETURNING json);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT JSON_OBJECT((jsonb '1') VALUE NULL::text ABSENT ON NULL RETURNING json);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT JSON_OBJECT((NULL::text) VALUE NULL::text ABSENT ON NULL RETURNING jsonb);
+ERROR:  argument 1: key must not be null
+SELECT JSON_OBJECT((NULL::text) VALUE NULL::text ABSENT ON NULL WITH UNIQUE KEYS RETURNING jsonb);
+ERROR:  argument 1: key must not be null
+SELECT JSON_OBJECT((ARRAY[1,2]) VALUE NULL::text ABSENT ON NULL RETURNING jsonb);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT JSON_OBJECT((ROW(1,2)) VALUE NULL::text ABSENT ON NULL RETURNING jsonb);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT JSON_OBJECT((json '1') VALUE NULL::text ABSENT ON NULL RETURNING jsonb);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT JSON_OBJECT((jsonb '1') VALUE NULL::text ABSENT ON NULL RETURNING jsonb);
+ERROR:  key value must be scalar, not array, composite, or json
 SELECT JSON_OBJECT(1: 1, '2': NULL, '3': 1, repeat('x', 1000): 1, 2: repeat('a', 100) WITH UNIQUE);
 ERROR:  duplicate JSON object key value: "2"
 SELECT JSON_OBJECT(1: 1, '1': NULL WITH UNIQUE);
@@ -607,6 +632,14 @@ $$ LANGUAGE sql IMMUTABLE;
 CREATE CAST (mood AS json) WITH FUNCTION mood_to_json(mood) AS IMPLICIT;
 SELECT JSON_OBJECT('happy'::mood: '123'::jsonb);
 ERROR:  key value must be scalar, not array, composite, or json
+SELECT JSON_OBJECT('happy'::mood: NULL ABSENT ON NULL RETURNING json);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT JSON_OBJECT('happy'::mood: NULL ABSENT ON NULL RETURNING jsonb);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT json_object_agg_strict('happy'::mood, NULL);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT jsonb_object_agg_strict('happy'::mood, NULL);
+ERROR:  key value must be scalar, not array, composite, or json
 DROP CAST (mood AS json);
 DROP FUNCTION mood_to_json;
 DROP TYPE mood;
@@ -963,6 +996,27 @@ SELECT JSON_OBJECTAGG(NULL: 1);
 ERROR:  null value not allowed for object key
 SELECT JSON_OBJECTAGG(NULL: 1 RETURNING jsonb);
 ERROR:  field name must not be null
+-- Aggregate keys must also be valid when NULL values are omitted.
+SELECT json_object_agg_strict(ARRAY[1,2], NULL);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT json_object_agg_strict(ROW(1,2), NULL);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT json_object_agg_strict(json '1', NULL);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT json_object_agg_strict(jsonb '1', NULL);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT JSON_OBJECTAGG((jsonb '1'): NULL ABSENT ON NULL RETURNING json);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT jsonb_object_agg_strict(ARRAY[1,2], NULL);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT jsonb_object_agg_strict(ROW(1,2), NULL);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT jsonb_object_agg_strict(json '1', NULL);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT jsonb_object_agg_strict(jsonb '1', NULL);
+ERROR:  key value must be scalar, not array, composite, or json
+SELECT JSON_OBJECTAGG((jsonb '1'): NULL ABSENT ON NULL RETURNING jsonb);
+ERROR:  key value must be scalar, not array, composite, or json
 SELECT
 	JSON_OBJECTAGG(i: i),
 --	JSON_OBJECTAGG(i VALUE i),
diff --git a/src/test/regress/sql/sqljson.sql b/src/test/regress/sql/sqljson.sql
index 43106fdda9..f329691b7f 100644
--- a/src/test/regress/sql/sqljson.sql
+++ b/src/test/regress/sql/sqljson.sql
@@ -138,6 +138,20 @@ SELECT JSON_OBJECT('a': '1', 'b': NULL, 'c': 2);
 SELECT JSON_OBJECT('a': '1', 'b': NULL, 'c': 2 NULL ON NULL);
 SELECT JSON_OBJECT('a': '1', 'b': NULL, 'c': 2 ABSENT ON NULL);
 
+-- Keys must be valid even when ABSENT ON NULL omits their values.
+SELECT JSON_OBJECT((NULL::text) VALUE NULL::text ABSENT ON NULL RETURNING json);
+SELECT JSON_OBJECT((NULL::text) VALUE NULL::text ABSENT ON NULL WITH UNIQUE KEYS RETURNING json);
+SELECT JSON_OBJECT((ARRAY[1,2]) VALUE NULL::text ABSENT ON NULL RETURNING json);
+SELECT JSON_OBJECT((ROW(1,2)) VALUE NULL::text ABSENT ON NULL RETURNING json);
+SELECT JSON_OBJECT((json '1') VALUE NULL::text ABSENT ON NULL RETURNING json);
+SELECT JSON_OBJECT((jsonb '1') VALUE NULL::text ABSENT ON NULL RETURNING json);
+SELECT JSON_OBJECT((NULL::text) VALUE NULL::text ABSENT ON NULL RETURNING jsonb);
+SELECT JSON_OBJECT((NULL::text) VALUE NULL::text ABSENT ON NULL WITH UNIQUE KEYS RETURNING jsonb);
+SELECT JSON_OBJECT((ARRAY[1,2]) VALUE NULL::text ABSENT ON NULL RETURNING jsonb);
+SELECT JSON_OBJECT((ROW(1,2)) VALUE NULL::text ABSENT ON NULL RETURNING jsonb);
+SELECT JSON_OBJECT((json '1') VALUE NULL::text ABSENT ON NULL RETURNING jsonb);
+SELECT JSON_OBJECT((jsonb '1') VALUE NULL::text ABSENT ON NULL RETURNING jsonb);
+
 SELECT JSON_OBJECT(1: 1, '2': NULL, '3': 1, repeat('x', 1000): 1, 2: repeat('a', 100) WITH UNIQUE);
 
 SELECT JSON_OBJECT(1: 1, '1': NULL WITH UNIQUE);
@@ -171,6 +185,10 @@ CREATE FUNCTION mood_to_json(mood) RETURNS json AS $$
 $$ LANGUAGE sql IMMUTABLE;
 CREATE CAST (mood AS json) WITH FUNCTION mood_to_json(mood) AS IMPLICIT;
 SELECT JSON_OBJECT('happy'::mood: '123'::jsonb);
+SELECT JSON_OBJECT('happy'::mood: NULL ABSENT ON NULL RETURNING json);
+SELECT JSON_OBJECT('happy'::mood: NULL ABSENT ON NULL RETURNING jsonb);
+SELECT json_object_agg_strict('happy'::mood, NULL);
+SELECT jsonb_object_agg_strict('happy'::mood, NULL);
 DROP CAST (mood AS json);
 DROP FUNCTION mood_to_json;
 DROP TYPE mood;
@@ -293,6 +311,18 @@ SELECT JSON_OBJECTAGG(NULL: 1);
 
 SELECT JSON_OBJECTAGG(NULL: 1 RETURNING jsonb);
 
+-- Aggregate keys must also be valid when NULL values are omitted.
+SELECT json_object_agg_strict(ARRAY[1,2], NULL);
+SELECT json_object_agg_strict(ROW(1,2), NULL);
+SELECT json_object_agg_strict(json '1', NULL);
+SELECT json_object_agg_strict(jsonb '1', NULL);
+SELECT JSON_OBJECTAGG((jsonb '1'): NULL ABSENT ON NULL RETURNING json);
+SELECT jsonb_object_agg_strict(ARRAY[1,2], NULL);
+SELECT jsonb_object_agg_strict(ROW(1,2), NULL);
+SELECT jsonb_object_agg_strict(json '1', NULL);
+SELECT jsonb_object_agg_strict(jsonb '1', NULL);
+SELECT JSON_OBJECTAGG((jsonb '1'): NULL ABSENT ON NULL RETURNING jsonb);
+
 SELECT
 	JSON_OBJECTAGG(i: i),
 --	JSON_OBJECTAGG(i VALUE i),

base-commit: 553262aff40d34711e04e989e91d1da430589822
-- 
2.54.0 (Apple Git-157)

