From 88d09bfe1588e95f0127bc2315c67684958ae973 Mon Sep 17 00:00:00 2001
From: Srinath Reddy Sadipiralla <srinath2133@gmail.com>
Date: Thu, 11 Jun 2026 09:30:20 +0530
Subject: [PATCH 3/3] SQL/JSON: support per-action behavior clauses in
 JSON_TRANSFORM

Let JSON_TRANSFORM actions carry the behavior clauses defined by the SQL
standard.  INSERT accepts ON EXISTING and ON NULL, REPLACE accepts ON
MISSING and ON NULL, and REMOVE and RENAME accept ON MISSING.
When a clause is omitted, the standard's implicit default
is applied: ERROR ON EXISTING and NULL ON NULL for INSERT; IGNORE ON
MISSING and NULL ON NULL for REPLACE; IGNORE ON MISSING for REMOVE and
RENAME.  Previously these behaviors were hard-wired in the executor;
they are now parsed, validated against the standard, and resolved during
parse analysis, then honored by the JSON_TRANSFORM evaluator.

Two new unreserved keywords, EXISTING and MISSING, are added.  The
grammar collects a sequence of "<value> ON <target>" clauses after each
action: the standard permits a single action to carry several such
clauses (e.g. INSERT ... IGNORE ON EXISTING NULL ON NULL), so they are
gathered into a list and resolved afterwards rather than fixed
positionally.

Each clause is represented by a new JsonTransformBehaviorClause parse
node holding its target, behavior, and source location.  An earlier
draft packed (target, behavior) pairs into integers in an IntList; a
real node mirrors how json_behavior is handled, is far more readable,
and lets parse-analysis errors point at the exact offending clause.

The keyword IGNORE is shared between window-function null treatment
(IGNORE NULLS) and the new IGNORE ON ... clause, which creates a
shift/reduce conflict after a function-application path.  Resolve it by
giving IGNORE_P a precedence above the empty null_treatment rule, so the
parser shifts (binding IGNORE to NULLS) exactly as before; this follows
the existing CUBE / UNBOUNDED precedence conventions in gram.y.

Behaviors are validated in parse analysis: a target must be legal for
the action (e.g. ON EXISTING only for INSERT), a behavior must be legal
for its target, and REMOVE ON NULL is rejected for INSERT per standard.
ON EMPTY and ON ERROR are parsed but rejected as not yet implemented:
they are meaningful only for PATH-valued sources (ON ERROR additionally
needs soft-error handling), which JSON_TRANSFORM does not yet support.
---
 src/backend/executor/execExprInterp.c | 149 +++++++++++++++++---------
 src/backend/parser/gram.y             |  83 ++++++++++++--
 src/backend/parser/parse_expr.c       |  99 +++++++++++++++++
 src/include/nodes/primnodes.h         |  44 ++++++++
 src/include/parser/kwlist.h           |   2 +
 5 files changed, 319 insertions(+), 58 deletions(-)

diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index ee257267b96..3eccd9fb789 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -177,6 +177,31 @@ static Datum ExecJustHashOuterVarVirt(ExprState *state, ExprContext *econtext, b
 static Datum ExecJustHashInnerVarVirt(ExprState *state, ExprContext *econtext, bool *isnull);
 static Datum ExecJustHashOuterVarStrict(ExprState *state, ExprContext *econtext, bool *isnull);
 
+/*
+ * One step of a JSON_TRANSFORM target path.
+ *
+ * The standard restricts the path to a chain of member accessors (.key)
+ * and wildcard member accessors (.*).  We model each accessor as one of
+ * these structs; the JSON_TRANSFORM walker below consumes the array.
+ */
+typedef struct JsonTransformStep
+{
+	bool		wildcard;		/* true for '.*' (matches every member) */
+	char	   *key;			/* member name (valid only if !wildcard) */
+	int			keylen;			/* length of key (not NUL-terminated) */
+} JsonTransformStep;
+
+static void jtSetPath(JsonbIterator **it, JsonbInState *st,
+					  JsonTransformStep *steps, int nsteps, int level,
+					  JsonTransformOp op, JsonbValue *newval,
+					  JsonTransformBehavior on_existing, bool *matched);
+static void jtSetPathObject(JsonbIterator **it, JsonbInState *st,
+							JsonTransformStep *steps, int nsteps, int level,
+							JsonTransformOp op, JsonbValue *newval,
+							JsonTransformBehavior on_existing, bool *matched,
+							uint32 npairs);
+static void jtCopyValue(JsonbIterator **it, JsonbInState *st);
+
 /* execution helper functions */
 static pg_attribute_always_inline void ExecEvalArrayCompareInternal(FunctionCallInfo fcinfo,
 																	ArrayType *arr,
@@ -5110,29 +5135,6 @@ ExecEvalJsonExprPath(ExprState *state, ExprEvalStep *op,
 	return jump_eval_coercion >= 0 ? jump_eval_coercion : jsestate->jump_end;
 }
 
-/*
- * One step of a JSON_TRANSFORM target path.
- *
- * The standard restricts the path to a chain of member accessors (.key)
- * and wildcard member accessors (.*).  We model each accessor as one
- * of these structs; the walker below consumes the array.
- */
-typedef struct JsonTransformStep
-{
-	bool		wildcard;		/* true for '.*' (matches every member) */
-	char	   *key;			/* member name (valid only if !wildcard) */
-	int			keylen;			/* length of key (not NUL-terminated) */
-} JsonTransformStep;
-
-static void jtSetPath(JsonbIterator **it, JsonbInState *st,
-					  JsonTransformStep *steps, int nsteps, int level,
-					  JsonTransformOp op, JsonbValue *newval);
-static void jtSetPathObject(JsonbIterator **it, JsonbInState *st,
-							JsonTransformStep *steps, int nsteps, int level,
-							JsonTransformOp op, JsonbValue *newval,
-							uint32 npairs);
-static void jtCopyValue(JsonbIterator **it, JsonbInState *st);
-
 /*
  * Convert a JSON_TRANSFORM jsonpath into an array of JsonTransformStep.
  *
@@ -5258,7 +5260,8 @@ jtCopyValue(JsonbIterator **it, JsonbInState *st)
 static void
 jtSetPathObject(JsonbIterator **it, JsonbInState *st,
 				JsonTransformStep *steps, int nsteps, int level,
-				JsonTransformOp op, JsonbValue *newval, uint32 npairs)
+				JsonTransformOp op, JsonbValue *newval,
+				JsonTransformBehavior on_existing, bool *matched, uint32 npairs)
 {
 	JsonTransformStep *step = &steps[level];
 	bool		is_last = (level == nsteps - 1);
@@ -5270,25 +5273,26 @@ jtSetPathObject(JsonbIterator **it, JsonbInState *st,
 		JsonbValue	k,
 					v;
 		JsonbIteratorToken r;
-		bool		matched;
+		bool		is_match;
 
 		r = JsonbIteratorNext(it, &k, true);
 		Assert(r == WJB_KEY);
 
-		matched = step->wildcard ||
+		is_match = step->wildcard ||
 			(k.val.string.len == step->keylen &&
 			 memcmp(k.val.string.val, step->key, step->keylen) == 0);
 
-		if (matched && !step->wildcard)
+		if (is_match && !step->wildcard)
 			found = true;
 
-		if (matched && is_last)
+		if (is_match && is_last)
 		{
 			switch (op)
 			{
 				case TRANSFORM_REMOVE:
 					/* swallow the value; push nothing -> member removed */
 					(void) JsonbIteratorNext(it, &v, true);
+					*matched = true;
 					break;
 
 				case TRANSFORM_REPLACE:
@@ -5296,13 +5300,22 @@ jtSetPathObject(JsonbIterator **it, JsonbInState *st,
 					(void) JsonbIteratorNext(it, &v, true);
 					pushJsonbValue(st, WJB_KEY, &k);
 					pushJsonbValue(st, WJB_VALUE, newval);
+					*matched = true;
 					break;
 
 				case TRANSFORM_INSERT:
-					/* target key already present: standard default ERROR ON EXISTING */
-					ereport(ERROR,
-							errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-							errmsg("target in JSON_TRANSFORM already exists"));
+
+					/*
+					 * Target key already present.  ERROR (default) raises;
+					 * IGNORE keeps the existing member unchanged.
+					 */
+					if (on_existing == JSON_TRANSFORM_BEHAVIOR_ERROR)
+						ereport(ERROR,
+								errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+								errmsg("target in JSON_TRANSFORM already exists"));
+					(void) JsonbIteratorNext(it, &v, true);
+					pushJsonbValue(st, WJB_KEY, &k);
+					pushJsonbValue(st, WJB_VALUE, &v);
 					break;
 
 				case TRANSFORM_RENAME:
@@ -5313,17 +5326,19 @@ jtSetPathObject(JsonbIterator **it, JsonbInState *st,
 					(void) JsonbIteratorNext(it, &v, true);
 					pushJsonbValue(st, WJB_KEY, newval);
 					pushJsonbValue(st, WJB_VALUE, &v);
+					*matched = true;
 					break;
 
 				default:
 					elog(ERROR, "unexpected JsonTransformOp %d", (int) op);
 			}
 		}
-		else if (matched && !is_last)
+		else if (is_match && !is_last)
 		{
 			/* descend into this member's value to continue matching */
 			pushJsonbValue(st, WJB_KEY, &k);
-			jtSetPath(it, st, steps, nsteps, level + 1, op, newval);
+			jtSetPath(it, st, steps, nsteps, level + 1, op, newval,
+					  on_existing, matched);
 		}
 		else
 		{
@@ -5362,7 +5377,8 @@ jtSetPathObject(JsonbIterator **it, JsonbInState *st,
 static void
 jtSetPath(JsonbIterator **it, JsonbInState *st,
 		  JsonTransformStep *steps, int nsteps, int level,
-		  JsonTransformOp op, JsonbValue *newval)
+		  JsonTransformOp op, JsonbValue *newval,
+		  JsonTransformBehavior on_existing, bool *matched)
 {
 	JsonbValue	v;
 	JsonbIteratorToken r;
@@ -5375,7 +5391,7 @@ jtSetPath(JsonbIterator **it, JsonbInState *st,
 	{
 		pushJsonbValue(st, WJB_BEGIN_OBJECT, NULL);
 		jtSetPathObject(it, st, steps, nsteps, level, op, newval,
-						v.val.object.nPairs);
+						on_existing, matched, v.val.object.nPairs);
 		r = JsonbIteratorNext(it, &v, true);
 		Assert(r == WJB_END_OBJECT);
 		pushJsonbValue(st, WJB_END_OBJECT, NULL);
@@ -5418,12 +5434,10 @@ jtSetPath(JsonbIterator **it, JsonbInState *st,
  * (rather than delegating to jsonb_set/insert/delete_path on a text[] path)
  * is what lets us honor '.*', which can match many members at once.
  *
- * Behavior clauses (ON EXISTING / ON MISSING / ON NULL / ...) are not yet
- * parseable; we hardcode the standard's implicit defaults:
- *   - REMOVE  : IGNORE ON MISSING
- *   - REPLACE : IGNORE ON MISSING, NULL ON NULL
- *   - INSERT  : ERROR ON EXISTING, NULL ON NULL
- *   - RENAME  : IGNORE ON MISSING
+ * The per-action behaviors (action->on_existing / on_missing / on_null) are
+ * resolved during parse analysis from the user's clauses plus the standard's
+ * implicit defaults; here we just honor them.  ON EMPTY / ON ERROR are not
+ * yet supported (rejected at parse time).
  */
 void
 ExecEvalJsonTransform(ExprState *state, ExprEvalStep *op,
@@ -5440,6 +5454,8 @@ ExecEvalJsonTransform(ExprState *state, ExprEvalStep *op,
 	JsonbValue *newval = NULL;
 	JsonbIterator *it;
 	JsonbInState st = {0};
+	JsonTransformOp effop = action->op;
+	bool		matched = false;
 
 	/*
 	 * The JUMP_IF_NULL guards in the step array already skip us if
@@ -5454,16 +5470,40 @@ ExecEvalJsonTransform(ExprState *state, ExprEvalStep *op,
 	/* Validate path and break it into accessor steps. */
 	steps = JsonPathToTransformSteps(jp, action->op, &nsteps);
 
-	/* Build the value the action needs. */
+	/* Build the value the action needs, honoring ON NULL for INSERT/REPLACE. */
 	if (action->op == TRANSFORM_INSERT || action->op == TRANSFORM_REPLACE)
 	{
-		/* replacement/insertion value (NULL ON NULL -> JSON null) */
 		if (jtstate->action_value.isnull)
-			newvalbuf.type = jbvNull;
+		{
+			switch (action->on_null)
+			{
+				case JSON_TRANSFORM_BEHAVIOR_ERROR:
+					ereport(ERROR,
+							errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
+							errmsg("null in replacement value of JSON_TRANSFORM not allowed"));
+					break;
+				case JSON_TRANSFORM_BEHAVIOR_IGNORE:
+					/* do not perform the action; return input unchanged */
+					*op->resvalue = JsonbPGetDatum(in);
+					*op->resnull = false;
+					return;
+				case JSON_TRANSFORM_BEHAVIOR_REMOVE:
+					/* delete the target instead of setting it (REPLACE only) */
+					effop = TRANSFORM_REMOVE;
+					break;
+				default:
+					/* NULL ON NULL: store a JSON null */
+					newvalbuf.type = jbvNull;
+					newval = &newvalbuf;
+					break;
+			}
+		}
 		else
+		{
 			JsonbToJsonbValue(DatumGetJsonbP(jtstate->action_value.value),
 							  &newvalbuf);
-		newval = &newvalbuf;
+			newval = &newvalbuf;
+		}
 	}
 	else if (action->op == TRANSFORM_RENAME)
 	{
@@ -5477,18 +5517,29 @@ ExecEvalJsonTransform(ExprState *state, ExprEvalStep *op,
 	}
 
 	/*
-	 * A top-level scalar has no members for a '.key'/'.* ' path to target, so
-	 * there is nothing to do; return the input unchanged.
+	 * A top-level scalar has no members for a '.key'/'.*' path to target, so
+	 * nothing matches: honor ON MISSING, else return the input unchanged.
 	 */
 	if (JB_ROOT_IS_SCALAR(in))
 	{
+		if (action->on_missing == JSON_TRANSFORM_BEHAVIOR_ERROR)
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					errmsg("target in JSON_TRANSFORM does not exist"));
 		*op->resvalue = JsonbPGetDatum(in);
 		*op->resnull = false;
 		return;
 	}
 
 	it = JsonbIteratorInit(&in->root);
-	jtSetPath(&it, &st, steps, nsteps, 0, action->op, newval);
+	jtSetPath(&it, &st, steps, nsteps, 0, effop, newval,
+			  action->on_existing, &matched);
+
+	/* ON MISSING ERROR: the path matched no existing target. */
+	if (!matched && action->on_missing == JSON_TRANSFORM_BEHAVIOR_ERROR)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("target in JSON_TRANSFORM does not exist"));
 
 	*op->resvalue = JsonbPGetDatum(JsonbValueToJsonb(st.result));
 	*op->resnull = false;
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 22281dc69c7..e7fb3bad586 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -680,9 +680,14 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 				json_behavior_clause_opt
 				json_passing_clause_opt
 				json_table_column_definition_list
+				json_transform_behavior_list_opt
+				json_transform_behavior_list
 %type <str>		json_table_path_name_opt
+%type <node>	json_transform_behavior
 %type <ival>	json_behavior_type
 				json_predicate_type_constraint
+				json_transform_behavior_value
+				json_transform_behavior_target
 				json_quotes_clause_opt
 				json_wrapper_behavior
 %type <boolean>	json_key_uniqueness_constraint_opt
@@ -766,7 +771,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	DOUBLE_P DROP
 
 	EACH EDGE ELSE EMPTY_P ENABLE_P ENCODING ENCRYPTED END_P ENFORCED ENUM_P
-	ERROR_P ESCAPE EVENT EXCEPT EXCLUDE EXCLUDING EXCLUSIVE EXECUTE EXISTS
+	ERROR_P ESCAPE EVENT EXCEPT EXCLUDE EXCLUDING EXCLUSIVE EXECUTE EXISTING EXISTS
 	EXPLAIN EXPRESSION EXTENSION EXTERNAL EXTRACT
 
 	FALSE_P FAMILY FETCH FILTER FINALIZE FIRST_P FLOAT_P FOLLOWING FOR
@@ -791,7 +796,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	LOCALTIME LOCALTIMESTAMP LOCATION LOCK_P LOCKED LOGGED LSN_P
 
 	MAPPING MATCH MATCHED MATERIALIZED MAXVALUE MERGE MERGE_ACTION METHOD
-	MINUTE_P MINVALUE MODE MONTH_P MOVE
+	MINUTE_P MINVALUE MISSING MODE MONTH_P MOVE
 
 	NAME_P NAMES NATIONAL NATURAL NCHAR NESTED NEW NEXT NFC NFD NFKC NFKD NO NODE
 	NONE NORMALIZE NORMALIZED
@@ -931,6 +936,14 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 %nonassoc	UNBOUNDED NESTED /* ideally would have same precedence as IDENT */
 %nonassoc	IDENT PARTITION RANGE ROWS GROUPS PRECEDING FOLLOWING CUBE ROLLUP
 			SET KEYS OBJECT_P SCALAR TO USING VALUE_P WITH WITHOUT PATH
+/*
+ * IGNORE is given a precedence so the shift/reduce conflict between window
+ * null-treatment (IGNORE NULLS) and a JSON_TRANSFORM behavior clause
+ * (IGNORE ON ...) resolves in favor of shifting (binding IGNORE to NULLS).
+ * The empty null_treatment rule below is marked %prec UNBOUNDED (lower) so
+ * the shift wins by precedence rather than as an unresolved conflict.
+ */
+%nonassoc	IGNORE_P
 %left		Op OPERATOR RIGHT_ARROW '|'	/* multi-character ops and user-defined operators */
 %left		'+' '-'
 %left		'*' '/' '%'
@@ -17179,7 +17192,7 @@ func_expr_common_subexpr:
 					JsonFuncExpr *n = makeNode(JsonFuncExpr);
 					n->op = JSON_TRANSFORM_OP;
 					n->context_item = (JsonValueExpr *) $3;
-					n->action = $5;
+					n->action = (JsonTransformAction *) $5;
 					n->passing = $6;
 					n->location = @1;
 					$$ = (Node *) n;
@@ -17311,7 +17324,7 @@ filter_clause:
 null_treatment:
 			IGNORE_P NULLS_P						{ $$ = PARSER_IGNORE_NULLS; }
 			| RESPECT_P NULLS_P						{ $$ = PARSER_RESPECT_NULLS; }
-			| /*EMPTY*/								{ $$ = NO_NULLTREATMENT; }
+			| /*EMPTY*/			%prec UNBOUNDED		{ $$ = NO_NULLTREATMENT; }
 		;
 
 window_clause:
@@ -18066,50 +18079,98 @@ json_returning_clause_opt:
 
 json_transform_action:
 			/* INSERT path_expr = value_expr */
-			INSERT a_expr '=' json_value_expr
+			INSERT a_expr '=' json_value_expr json_transform_behavior_list_opt
 			{
 				JsonTransformAction *n = makeNode(JsonTransformAction);
 				n->op = TRANSFORM_INSERT;
 				n->pathspec = $2;
 				n->value_expr = $4;
+				n->behaviors = $5;
 				n->location = @1;
 
 				$$ = (Node *) n;
-			}	
+			}
 			|
-			RENAME a_expr '=' Sconst
+			RENAME a_expr '=' Sconst json_transform_behavior_list_opt
 			{
 				JsonTransformAction *n = makeNode(JsonTransformAction);
 				n->op = TRANSFORM_RENAME;
 				n->pathspec = $2;
 				n->value_expr = makeStringConst($4, @4);
+				n->behaviors = $5;
 				n->location = @1;
 
 				$$ = (Node *) n;
 			}
 			|
-			REPLACE a_expr '=' json_value_expr
+			REPLACE a_expr '=' json_value_expr json_transform_behavior_list_opt
 			{
 				JsonTransformAction *n = makeNode(JsonTransformAction);
 				n->op = TRANSFORM_REPLACE;
 				n->pathspec = $2;
 				n->value_expr = $4;
+				n->behaviors = $5;
 				n->location = @1;
 
 				$$ = (Node *) n;
 			}
 			|
-			REMOVE a_expr
+			REMOVE a_expr json_transform_behavior_list_opt
 			{
 				JsonTransformAction *n = makeNode(JsonTransformAction);
 				n->op = TRANSFORM_REMOVE;
 				n->pathspec = $2;
 				n->value_expr = NULL;
+				n->behaviors = $3;
 				n->location = @1;
 
 				$$ = (Node *) n;
 			};
 
+/*
+ * Optional per-action behavior clauses: a sequence of "<value> ON <target>".
+ * Each clause is collected as an encoded integer (see the
+ * JSON_TRANSFORM_CLAUSE_* macros) and resolved/validated in parse analysis.
+ */
+json_transform_behavior_list_opt:
+			json_transform_behavior_list			{ $$ = $1; }
+			| /* EMPTY */							{ $$ = NIL; }
+		;
+
+json_transform_behavior_list:
+			json_transform_behavior
+				{ $$ = list_make1($1); }
+			| json_transform_behavior_list json_transform_behavior
+				{ $$ = lappend($1, $2); }
+		;
+
+json_transform_behavior:
+			json_transform_behavior_value ON json_transform_behavior_target
+				{
+					JsonTransformBehaviorClause *c = makeNode(JsonTransformBehaviorClause);
+
+					c->behavior = $1;
+					c->target = $3;
+					c->location = @1;
+					$$ = (Node *) c;
+				}
+		;
+
+json_transform_behavior_value:
+			ERROR_P		{ $$ = JSON_TRANSFORM_BEHAVIOR_ERROR; }
+			| IGNORE_P	{ $$ = JSON_TRANSFORM_BEHAVIOR_IGNORE; }
+			| NULL_P	{ $$ = JSON_TRANSFORM_BEHAVIOR_NULL; }
+			| REMOVE	{ $$ = JSON_TRANSFORM_BEHAVIOR_REMOVE; }
+		;
+
+json_transform_behavior_target:
+			EXISTING	{ $$ = JSON_TRANSFORM_TARGET_EXISTING; }
+			| MISSING	{ $$ = JSON_TRANSFORM_TARGET_MISSING; }
+			| NULL_P	{ $$ = JSON_TRANSFORM_TARGET_NULL; }
+			| EMPTY_P	{ $$ = JSON_TRANSFORM_TARGET_EMPTY; }
+			| ERROR_P	{ $$ = JSON_TRANSFORM_TARGET_ERROR; }
+		;
+
 /*
  * We must assign the only-JSON production a precedence less than IDENT in
  * order to favor shifting over reduction when JSON is followed by VALUE_P,
@@ -18975,6 +19036,7 @@ unreserved_keyword:
 			| EXCLUDING
 			| EXCLUSIVE
 			| EXECUTE
+			| EXISTING
 			| EXPLAIN
 			| EXPRESSION
 			| EXTENSION
@@ -19046,6 +19108,7 @@ unreserved_keyword:
 			| METHOD
 			| MINUTE_P
 			| MINVALUE
+			| MISSING
 			| MODE
 			| MONTH_P
 			| MOVE
@@ -19574,6 +19637,7 @@ bare_label_keyword:
 			| EXCLUDING
 			| EXCLUSIVE
 			| EXECUTE
+			| EXISTING
 			| EXISTS
 			| EXPLAIN
 			| EXPRESSION
@@ -19681,6 +19745,7 @@ bare_label_keyword:
 			| MERGE_ACTION
 			| METHOD
 			| MINVALUE
+			| MISSING
 			| MODE
 			| MOVE
 			| NAME_P
diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index 89d0f348303..05127e42fe7 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -4397,6 +4397,101 @@ transformJsonSerializeExpr(ParseState *pstate, JsonSerializeExpr *expr)
 								   NULL, returning, false, false, expr->location);
 }
 
+/*
+ * Resolve the raw ON-clause list collected by the grammar into the
+ * on_existing / on_missing / on_null fields of an analyzed
+ * JSON_TRANSFORM action, applying the standard's implicit defaults for
+ * clauses the user omitted and validating the ones they specified.
+ */
+static void
+resolveJsonTransformBehaviors(ParseState *pstate,
+							  JsonTransformAction *raw,
+							  JsonTransformAction *action)
+{
+	ListCell   *lc;
+
+	/* Standard implicit defaults. */
+	switch (action->op)
+	{
+		case TRANSFORM_INSERT:
+			action->on_existing = JSON_TRANSFORM_BEHAVIOR_ERROR;
+			action->on_null = JSON_TRANSFORM_BEHAVIOR_NULL;
+			break;
+		case TRANSFORM_REPLACE:
+			action->on_missing = JSON_TRANSFORM_BEHAVIOR_IGNORE;
+			action->on_null = JSON_TRANSFORM_BEHAVIOR_NULL;
+			break;
+		case TRANSFORM_REMOVE:
+		case TRANSFORM_RENAME:
+			action->on_missing = JSON_TRANSFORM_BEHAVIOR_IGNORE;
+			break;
+	}
+
+	foreach(lc, raw->behaviors)
+	{
+		JsonTransformBehaviorClause *clause = lfirst_node(JsonTransformBehaviorClause, lc);
+		JsonTransformBehavior behavior = clause->behavior;
+
+		switch (clause->target)
+		{
+			case JSON_TRANSFORM_TARGET_EXISTING:
+				if (action->op != TRANSFORM_INSERT)
+					ereport(ERROR,
+							errcode(ERRCODE_SYNTAX_ERROR),
+							errmsg("ON EXISTING is only valid for JSON_TRANSFORM INSERT"),
+							parser_errposition(pstate, clause->location));
+				if (behavior != JSON_TRANSFORM_BEHAVIOR_ERROR &&
+					behavior != JSON_TRANSFORM_BEHAVIOR_IGNORE)
+					ereport(ERROR,
+							errcode(ERRCODE_SYNTAX_ERROR),
+							errmsg("ON EXISTING behavior must be ERROR or IGNORE"),
+							parser_errposition(pstate, clause->location));
+				action->on_existing = behavior;
+				break;
+
+			case JSON_TRANSFORM_TARGET_MISSING:
+				if (action->op == TRANSFORM_INSERT)
+					ereport(ERROR,
+							errcode(ERRCODE_SYNTAX_ERROR),
+							errmsg("ON MISSING is not valid for JSON_TRANSFORM INSERT"),
+							parser_errposition(pstate, clause->location));
+				if (behavior != JSON_TRANSFORM_BEHAVIOR_ERROR &&
+					behavior != JSON_TRANSFORM_BEHAVIOR_IGNORE)
+					ereport(ERROR,
+							errcode(ERRCODE_SYNTAX_ERROR),
+							errmsg("ON MISSING behavior must be ERROR or IGNORE"),
+							parser_errposition(pstate, clause->location));
+				action->on_missing = behavior;
+				break;
+
+			case JSON_TRANSFORM_TARGET_NULL:
+				if (action->op != TRANSFORM_INSERT &&
+					action->op != TRANSFORM_REPLACE)
+					ereport(ERROR,
+							errcode(ERRCODE_SYNTAX_ERROR),
+							errmsg("ON NULL is only valid for JSON_TRANSFORM INSERT and REPLACE"),
+							parser_errposition(pstate, clause->location));
+				/* REMOVE-on-null is disallowed for INSERT */
+				if (behavior == JSON_TRANSFORM_BEHAVIOR_REMOVE &&
+					action->op == TRANSFORM_INSERT)
+					ereport(ERROR,
+							errcode(ERRCODE_SYNTAX_ERROR),
+							errmsg("REMOVE ON NULL is not allowed for JSON_TRANSFORM INSERT"),
+							parser_errposition(pstate, clause->location));
+				action->on_null = behavior;
+				break;
+
+			case JSON_TRANSFORM_TARGET_EMPTY:
+			case JSON_TRANSFORM_TARGET_ERROR:
+				ereport(ERROR,
+						errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+						errmsg("ON EMPTY and ON ERROR clauses are not yet supported in JSON_TRANSFORM"),
+						parser_errposition(pstate, clause->location));
+				break;
+		}
+	}
+}
+
 /*
  * Transform JSON_VALUE, JSON_QUERY, JSON_EXISTS, JSON_TABLE functions into
  * a JsonExpr node.
@@ -4682,6 +4777,10 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 							"jsonpath", format_type_be(pathspec_type)),
 					 parser_errposition(pstate, pathspec_loc)));
 		analyzed_jst_action->pathspec = coerced_path_spec;
+
+		/* Resolve ON EXISTING / ON MISSING / ON NULL clauses + defaults. */
+		resolveJsonTransformBehaviors(pstate, jst_action, analyzed_jst_action);
+
 		jsexpr->action = analyzed_jst_action;
 	}
 	else
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index 0967fd0e96b..9984431f72f 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -1720,12 +1720,56 @@ typedef enum JsonTransformOp
 	TRANSFORM_REPLACE,
 }			JsonTransformOp;
 
+/*
+ * Per-action behavior, used for the ON EXISTING / ON MISSING / ON NULL
+ * clauses of JSON_TRANSFORM.  JSON_TRANSFORM_BEHAVIOR_UNSPECIFIED means the
+ * user omitted the clause; parse analysis replaces it with the spec default.
+ */
+typedef enum JsonTransformBehavior
+{
+	JSON_TRANSFORM_BEHAVIOR_UNSPECIFIED = 0,
+	JSON_TRANSFORM_BEHAVIOR_ERROR,
+	JSON_TRANSFORM_BEHAVIOR_IGNORE,
+	JSON_TRANSFORM_BEHAVIOR_NULL,
+	JSON_TRANSFORM_BEHAVIOR_REMOVE,
+}			JsonTransformBehavior;
+
+/* Which ON-clause a raw grammar behavior applies to. */
+typedef enum JsonTransformBehaviorTarget
+{
+	JSON_TRANSFORM_TARGET_EXISTING = 1,
+	JSON_TRANSFORM_TARGET_MISSING,
+	JSON_TRANSFORM_TARGET_NULL,
+	JSON_TRANSFORM_TARGET_EMPTY,
+	JSON_TRANSFORM_TARGET_ERROR,
+}			JsonTransformBehaviorTarget;
+
+/*
+ * One "<value> ON <target>" clause collected by the grammar (e.g.
+ * IGNORE ON EXISTING).  These are gathered into JsonTransformAction.behaviors
+ * and resolved into the on_existing/on_missing/on_null fields during parse
+ * analysis; the node is transient and not used past analysis.
+ */
+typedef struct JsonTransformBehaviorClause
+{
+	NodeTag		type;
+	JsonTransformBehaviorTarget target;
+	JsonTransformBehavior behavior;
+	ParseLoc	location;
+}			JsonTransformBehaviorClause;
+
 typedef struct JsonTransformAction
 {
 	NodeTag		type;
 	JsonTransformOp op;
 	Node	   *pathspec;		/* The JSON Path: '$.a' */
 	Node	   *value_expr;
+	/* raw ON-clauses from the grammar (transient; resolved in analysis) */
+	List	   *behaviors;
+	/* resolved behaviors (filled by parse analysis from defaults + clauses) */
+	JsonTransformBehavior on_existing;
+	JsonTransformBehavior on_missing;
+	JsonTransformBehavior on_null;
 	ParseLoc	location;		/* token location, or -1 if unknown */
 }			JsonTransformAction;
 
diff --git a/src/include/parser/kwlist.h b/src/include/parser/kwlist.h
index 38b09abd34c..8ce81c5f38c 100644
--- a/src/include/parser/kwlist.h
+++ b/src/include/parser/kwlist.h
@@ -165,6 +165,7 @@ PG_KEYWORD("exclude", EXCLUDE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("excluding", EXCLUDING, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("exclusive", EXCLUSIVE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("execute", EXECUTE, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("existing", EXISTING, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("exists", EXISTS, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("explain", EXPLAIN, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("expression", EXPRESSION, UNRESERVED_KEYWORD, BARE_LABEL)
@@ -286,6 +287,7 @@ PG_KEYWORD("merge_action", MERGE_ACTION, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("method", METHOD, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("minute", MINUTE_P, UNRESERVED_KEYWORD, AS_LABEL)
 PG_KEYWORD("minvalue", MINVALUE, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("missing", MISSING, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("mode", MODE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("month", MONTH_P, UNRESERVED_KEYWORD, AS_LABEL)
 PG_KEYWORD("move", MOVE, UNRESERVED_KEYWORD, BARE_LABEL)
-- 
2.43.0

