From 6a2e6ebf080365f48e99f389dda9e3d40b121112 Mon Sep 17 00:00:00 2001
From: Srinath Reddy Sadipiralla <srinath2133@gmail.com>
Date: Fri, 24 Apr 2026 23:06:35 +0530
Subject: [PATCH 1/1] SQL/JSON: Add initial JSON_TRANSFORM implementation

This patch introduces JSON_TRANSFORM(), a SQL/JSON function that yields a
new JSON value by applying a modification to an input JSON value.

---
 src/backend/executor/execExpr.c       | 159 ++++++++++++++++++++++
 src/backend/executor/execExprInterp.c | 187 ++++++++++++++++++++++++++
 src/backend/parser/gram.y             |  68 +++++++++-
 src/backend/parser/parse_expr.c       | 108 ++++++++++++---
 src/backend/parser/parse_target.c     |   3 +
 src/include/executor/execExpr.h       |   9 ++
 src/include/nodes/execnodes.h         |  14 ++
 src/include/nodes/parsenodes.h        |   1 +
 src/include/nodes/primnodes.h         |  20 +++
 src/include/parser/kwlist.h           |   2 +
 10 files changed, 552 insertions(+), 19 deletions(-)

diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c
index 77229141b38..0d14fc8c474 100644
--- a/src/backend/executor/execExpr.c
+++ b/src/backend/executor/execExpr.c
@@ -99,6 +99,9 @@ static void ExecBuildAggTransCall(ExprState *state, AggState *aggstate,
 static void ExecInitJsonExpr(JsonExpr *jsexpr, ExprState *state,
 							 Datum *resv, bool *resnull,
 							 ExprEvalStep *scratch);
+static void ExecInitJsonTransformExpr(JsonExpr *jsexpr, ExprState *state,
+									  Datum *resv, bool *resnull,
+									  ExprEvalStep *scratch);
 static void ExecInitJsonCoercion(ExprState *state, JsonReturning *returning,
 								 ErrorSaveContext *escontext, bool omit_quotes,
 								 bool exists_coerce,
@@ -2525,6 +2528,8 @@ ExecInitExprRec(Expr *node, ExprState *state,
 				if (jsexpr->op == JSON_TABLE_OP)
 					ExecInitExprRec((Expr *) jsexpr->formatted_expr, state,
 									resv, resnull);
+				else if (jsexpr->op == JSON_TRANSFORM_OP)
+					ExecInitJsonTransformExpr(jsexpr, state, resv, resnull, &scratch);
 				else
 					ExecInitJsonExpr(jsexpr, state, resv, resnull, &scratch);
 				break;
@@ -5071,6 +5076,160 @@ ExecInitJsonExpr(JsonExpr *jsexpr, ExprState *state,
 	jsestate->jump_end = state->steps_len;
 }
 
+/*
+ * Compile a JSON_TRANSFORM expression into a sequence of expression-eval
+ * steps.
+ *
+ * JSON_TRANSFORM is fundamentally different from JSON_QUERY/VALUE/EXISTS:
+ * those ops query a document, we mutate one.  So we don't reuse
+ * ExecInitJsonExpr; we emit our own step sequence tailored to the mutation
+ * pipeline.
+ *
+ * For now, a single action is attached to the JsonExpr.  The step layout is:
+ *
+ *   steps[..]  evaluate formatted_expr (input doc)  -> jtstate->formatted_expr
+ *   steps[K]   EEOP_JUMP_IF_NULL                     (if doc is NULL, jump to M)
+ *   steps[..]  evaluate action->pathspec             -> jtstate->pathspec
+ *   steps[L]   EEOP_JUMP_IF_NULL                     (if path is NULL, jump to M)
+ *   steps[..]  evaluate action->value_expr (if any)  -> jtstate->action_value
+ *   steps[..]  evaluate PASSING args (currently unused by mutation, but
+ *              we evaluate them for parity with JSON_QUERY and for future use)
+ *   steps[P]   EEOP_JSON_TRANSFORM                   (handler: do the mutation)
+ *   steps[M]   EEOP_CONST(NULL)                      (return-null landing pad)
+ *
+ */
+static void
+ExecInitJsonTransformExpr(JsonExpr *jsexpr, ExprState *state,
+						  Datum *resv, bool *resnull,
+						  ExprEvalStep *scratch)
+{
+	JsonTransformExprState *jtstate = palloc0(sizeof(JsonTransformExprState));
+	JsonTransformAction *action = jsexpr->action;
+	List	   *jumps_return_null = NIL;
+	ListCell   *argexprlc;
+	ListCell   *argnamelc;
+	ListCell   *lc;
+
+	Assert(action != NULL);
+
+	jtstate->jsexpr = jsexpr;
+
+	/*
+	 * Evaluate formatted_expr storing the result into
+	 * jtstate->formatted_expr.
+	 */
+	ExecInitExprRec((Expr *) jsexpr->formatted_expr, state,
+					&jtstate->formatted_expr.value,
+					&jtstate->formatted_expr.isnull);
+
+	/* JUMP to return-NULL landing pad if formatted_expr is NULL */
+	jumps_return_null = lappend_int(jumps_return_null, state->steps_len);
+	scratch->opcode = EEOP_JUMP_IF_NULL;
+	scratch->resnull = &jtstate->formatted_expr.isnull;
+	scratch->d.jump.jumpdone = -1;	/* patched below */
+	ExprEvalPushStep(state, scratch);
+
+	/*
+	 * Evaluate the action's pathspec (a compiled jsonpath Const) into
+	 * jtstate->pathspec.
+	 */
+	ExecInitExprRec((Expr *) action->pathspec, state,
+					&jtstate->pathspec.value,
+					&jtstate->pathspec.isnull);
+
+	/* JUMP to return-NULL landing pad if pathspec is NULL */
+	jumps_return_null = lappend_int(jumps_return_null, state->steps_len);
+	scratch->opcode = EEOP_JUMP_IF_NULL;
+	scratch->resnull = &jtstate->pathspec.isnull;
+	scratch->d.jump.jumpdone = -1;	/* patched below */
+	ExprEvalPushStep(state, scratch);
+
+	/*
+	 * Evaluate the action's value_expr, if any.  REMOVE has no value.
+	 */
+	if (action->value_expr != NULL)
+	{
+		ExecInitExprRec((Expr *) action->value_expr, state,
+						&jtstate->action_value.value,
+						&jtstate->action_value.isnull);
+	}	
+
+	/*
+	 * Steps to compute PASSING args.  These don't feed the mutation functions
+	 * directly, but the jsonpath engine may reference them if in future we
+	 * switch to native jsonpath-aware mutation.  For now we evaluate but don't
+	 * use them; kept for forward compatibility and for parity with how
+	 * JSON_QUERY handles PASSING.
+	 */
+	jtstate->args = NIL;
+	forboth(argexprlc, jsexpr->passing_values,
+			argnamelc, jsexpr->passing_names)
+	{
+		Expr	   *argexpr = (Expr *) lfirst(argexprlc);
+		String	   *argname = lfirst_node(String, argnamelc);
+		JsonPathVariable *var = palloc(sizeof(*var));
+
+		var->name = argname->sval;
+		var->namelen = strlen(var->name);
+		var->typid = exprType((Node *) argexpr);
+		var->typmod = exprTypmod((Node *) argexpr);
+
+		ExecInitExprRec((Expr *) argexpr, state, &var->value, &var->isnull);
+
+		jtstate->args = lappend(jtstate->args, var);
+	}
+
+	/*
+	 * The main step: EEOP_JSON_TRANSFORM.  Its handler branches on
+	 * action->op, converts the jsonpath to a text[] path, and calls the
+	 * appropriate jsonb mutation function, writing the result to resv.
+	 */
+	scratch->opcode = EEOP_JSON_TRANSFORM;
+	scratch->resvalue = resv;
+	scratch->resnull = resnull;
+	scratch->d.json_transform.jtstate = jtstate;
+	ExprEvalPushStep(state, scratch);
+
+	/*
+	 * Unconditional JUMP over the NULL landing pad below.  Without this, a
+	 * successful main step would fall through into EEOP_CONST(NULL) and have
+	 * its result overwritten with NULL.
+	 */
+	{
+		int			jump_past_null = state->steps_len;
+
+		scratch->opcode = EEOP_JUMP;
+		scratch->d.jump.jumpdone = -1;	/* patched below */
+		ExprEvalPushStep(state, scratch);
+
+		/*
+		 * Patch the JUMP_IF_NULL placeholders to land on the NULL pad we are
+		 * about to emit.
+		 */
+		foreach(lc, jumps_return_null)
+		{
+			ExprEvalStep *as = &state->steps[lfirst_int(lc)];
+
+			as->d.jump.jumpdone = state->steps_len;
+		}
+
+		/* Return-NULL landing pad */
+		scratch->opcode = EEOP_CONST;
+		scratch->resvalue = resv;
+		scratch->resnull = resnull;
+		scratch->d.constval.value = (Datum) 0;
+		scratch->d.constval.isnull = true;
+		ExprEvalPushStep(state, scratch);
+
+		/*
+		 * Patch the unconditional JUMP to land just past the NULL pad.
+		 * Success path (via JUMP) and null path (via fallthrough) converge
+		 * here.
+		 */
+		state->steps[jump_past_null].d.jump.jumpdone = state->steps_len;
+	}
+}
+
 /*
  * Initialize a EEOP_JSONEXPR_COERCION step to coerce the value given in resv
  * to the given RETURNING type.
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 0634af964a9..a9a43caf2ae 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -579,6 +579,7 @@ ExecInterpExpr(ExprState *state, ExprContext *econtext, bool *isnull)
 		&&CASE_EEOP_JSON_CONSTRUCTOR,
 		&&CASE_EEOP_IS_JSON,
 		&&CASE_EEOP_JSONEXPR_PATH,
+		&&CASE_EEOP_JSON_TRANSFORM,
 		&&CASE_EEOP_JSONEXPR_COERCION,
 		&&CASE_EEOP_JSONEXPR_COERCION_FINISH,
 		&&CASE_EEOP_AGGREF,
@@ -1942,6 +1943,13 @@ ExecInterpExpr(ExprState *state, ExprContext *econtext, bool *isnull)
 			EEO_JUMP(ExecEvalJsonExprPath(state, op, econtext));
 		}
 
+		EEO_CASE(EEOP_JSON_TRANSFORM)
+		{
+			/* too complex for an inline implementation */
+			ExecEvalJsonTransform(state, op, econtext);
+			EEO_NEXT();
+		}
+
 		EEO_CASE(EEOP_JSONEXPR_COERCION)
 		{
 			/* too complex for an inline implementation */
@@ -5102,6 +5110,185 @@ ExecEvalJsonExprPath(ExprState *state, ExprEvalStep *op,
 	return jump_eval_coercion >= 0 ? jump_eval_coercion : jsestate->jump_end;
 }
 
+/* Forward declarations for SQL-callable jsonb functions we invoke directly. */
+extern Datum jsonb_set(PG_FUNCTION_ARGS);
+extern Datum jsonb_insert(PG_FUNCTION_ARGS);
+extern Datum jsonb_delete_path(PG_FUNCTION_ARGS);
+
+/*
+ * Convert a simple jsonpath (chain of jpiRoot + jpiKey items only) into a
+ * text[] Datum suitable for passing to jsonb_set / jsonb_insert /
+ * jsonb_delete_path.
+ *
+ * If the jsonpath contains any item type other than jpiRoot/jpiKey (e.g.,
+ * jpiAnyKey, jpiIndexArray, jpiFilter), raise an ereport.  The JSON_TRANSFORM
+ * spec restricts target paths to member accessors, but because the jsonpath
+ * type is general-purpose we must enforce the restriction here.  For now we additionally disallow jpiAnyKey (wildcard '.*')
+ * since the text[] API can't express it.
+ */
+static Datum
+JsonPathToTextArray(JsonPath *jp)
+{
+	JsonPathItem v;
+	ArrayBuildState *astate;
+	MemoryContext curctx = CurrentMemoryContext;
+
+	astate = initArrayResult(TEXTOID, curctx, false);
+
+	jspInit(&v, jp);
+
+	/* Per spec, the path must begin with '$'. */
+	if (v.type != jpiRoot)
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("JSON_TRANSFORM target path must start with the context variable $, not a named variable. The transformation applies to the input document."));
+
+	/*
+	 * Walk the chain.  Each subsequent item must be a jpiKey; anything else
+	 * is rejected.
+	 */
+	while (jspHasNext(&v))
+	{
+		JsonPathItem next;
+		char	   *name;
+		int32		namelen;
+		text	   *t;
+
+		jspGetNext(&v, &next);
+		v = next;
+
+		if (v.type != jpiKey)
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("JSON_TRANSFORM target path may only contain named member accessors"),
+					errdetail("Only '.key' accessors are supported now; wildcards, array subscripts, filters, and methods are not allowed."));
+
+		name = jspGetString(&v, &namelen);
+		t = cstring_to_text_with_len(name, namelen);
+
+		astate = accumArrayResult(astate,
+								  PointerGetDatum(t),
+								  false,
+								  TEXTOID,
+								  curctx);
+	}
+
+	/*
+	 * At least one key must follow the root (otherwise the path is just '$'
+	 * which has nothing to target).
+	 */
+	if (astate->nelems == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("JSON_TRANSFORM target path must name at least one member"));
+
+	return makeArrayResult(astate, curctx);
+}
+
+/*
+ * Runtime handler for EEOP_JSON_TRANSFORM.
+ *
+ * By the time we get here, prior steps have populated:
+ *   jtstate->formatted_expr  — the input jsonb (guaranteed non-null; the
+ *								EEOP_JUMP_IF_NULL before us handles null)
+ *   jtstate->pathspec        — the compiled jsonpath Datum (non-null)
+ *   jtstate->action_value    — the value for INSERT/REPLACE/RENAME
+ *								(isnull=true for REMOVE, or if user passed NULL)
+ *
+ * We branch on action->op, convert the jsonpath to a text[], and delegate
+ * to the existing jsonb_set / jsonb_insert / jsonb_delete_path C functions.
+ *
+ * Note on behavior clauses: per spec 6.44, each action supports per-action
+ * ON EXISTING / ON MISSING / ON NULL / ON EMPTY / ON ERROR.  This v1
+ * implementation uses HARDCODED behavior — whatever jsonb_set/insert/delete
+ * do naturally:
+ *   - REMOVE  : no-op if path missing   (matches spec default IGNORE ON MISSING)
+ *   - INSERT  : error if key exists     (matches spec default ERROR ON EXISTING)
+ *   - REPLACE : no-op if path missing   (matches spec default IGNORE ON MISSING)
+ *   - RENAME  : not yet implemented; raises an error
+ *
+ * Per-action behavior clauses (e.g., IGNORE ON EXISTING, NULL ON NULL) are
+ * not yet supported. 
+ */
+void
+ExecEvalJsonTransform(ExprState *state, ExprEvalStep *op,
+					  ExprContext *econtext)
+{
+	JsonTransformExprState *jtstate = op->d.json_transform.jtstate;
+	JsonExpr   *jsexpr = jtstate->jsexpr;
+	JsonTransformAction *action = jsexpr->action;
+	Jsonb	   *in;
+	JsonPath   *jp;
+	Datum		path_array;
+	Datum		result;
+
+	/*
+	 * The JUMP_IF_NULL guards in the step array already skip us if
+	 * formatted_expr or pathspec is NULL.
+	 */
+	Assert(!jtstate->formatted_expr.isnull);
+	Assert(!jtstate->pathspec.isnull);
+
+	in = DatumGetJsonbP(jtstate->formatted_expr.value);
+	jp = DatumGetJsonPathP(jtstate->pathspec.value);
+
+	/* Validate + convert jsonpath to text[] */
+	path_array = JsonPathToTextArray(jp);
+
+	switch (action->op)
+	{
+		case TRANSFORM_REMOVE:
+			result = DirectFunctionCall2(jsonb_delete_path,
+										 JsonbPGetDatum(in),
+										 path_array);
+			break;
+
+		case TRANSFORM_INSERT:
+			if (jtstate->action_value.isnull)
+				ereport(ERROR,
+						errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
+						errmsg("NULL value for INSERT not allowed in JSON_TRANSFORM"));
+			/* insert_after=false means before (standard insert semantic) */
+			result = DirectFunctionCall4(jsonb_insert,
+										 JsonbPGetDatum(in),
+										 path_array,
+										 jtstate->action_value.value,
+										 BoolGetDatum(false));
+			break;
+
+		case TRANSFORM_REPLACE:
+			if (jtstate->action_value.isnull)
+				ereport(ERROR,
+						errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
+						errmsg("NULL value for REPLACE not allowed in JSON_TRANSFORM"));
+			/* create_missing=false: REPLACE is no-op if path missing */
+			result = DirectFunctionCall4(jsonb_set,
+										 JsonbPGetDatum(in),
+										 path_array,
+										 jtstate->action_value.value,
+										 BoolGetDatum(false));
+			break;
+
+		case TRANSFORM_RENAME:
+
+			/*
+			 * RENAME operates on KEYS, not values.  remove/insert/replace
+			 * work on values at a given path, so RENAME isn't trivially
+			 * expressible in terms of them.
+			 */
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("JSON_TRANSFORM RENAME is not yet implemented"));
+			break;
+
+		default:
+			elog(ERROR, "unrecognized JsonTransformOp: %d", (int) action->op);
+	}
+
+	*op->resvalue = result;
+	*op->resnull = false;
+}
+
 /*
  * Convert the given JsonbValue to its C string representation
  *
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..22281dc69c7 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -672,6 +672,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 				json_table
 				json_table_column_definition
 				json_table_column_path_clause_opt
+				json_transform_action
 %type <list>	json_name_and_value_list
 				json_value_expr_list
 				json_array_aggregate_order_by_clause_opt
@@ -781,7 +782,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	INTERSECT INTERVAL INTO INVOKER IS ISNULL ISOLATION
 
 	JOIN JSON JSON_ARRAY JSON_ARRAYAGG JSON_EXISTS JSON_OBJECT JSON_OBJECTAGG
-	JSON_QUERY JSON_SCALAR JSON_SERIALIZE JSON_TABLE JSON_VALUE
+	JSON_QUERY JSON_SCALAR JSON_SERIALIZE JSON_TABLE JSON_TRANSFORM JSON_VALUE
 
 	KEEP KEY KEYS
 
@@ -809,7 +810,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	QUOTE QUOTES
 
 	RANGE READ REAL REASSIGN RECURSIVE REF_P REFERENCES REFERENCING
-	REFRESH REINDEX RELATIONSHIP RELATIVE_P RELEASE RENAME REPACK REPEATABLE REPLACE REPLICA
+	REFRESH REINDEX RELATIONSHIP RELATIVE_P RELEASE REMOVE RENAME REPACK REPEATABLE REPLACE REPLICA
 	RESET RESPECT_P RESTART RESTRICT RETURN RETURNING RETURNS REVOKE RIGHT ROLE ROLLBACK ROLLUP
 	ROUTINE ROUTINES ROW ROWS RULE
 
@@ -17170,6 +17171,19 @@ func_expr_common_subexpr:
 					n->location = @1;
 					$$ = (Node *) n;
 				}
+			| JSON_TRANSFORM '('
+				json_value_expr ',' json_transform_action
+				json_passing_clause_opt
+			')'
+				{
+					JsonFuncExpr *n = makeNode(JsonFuncExpr);
+					n->op = JSON_TRANSFORM_OP;
+					n->context_item = (JsonValueExpr *) $3;
+					n->action = $5;
+					n->passing = $6;
+					n->location = @1;
+					$$ = (Node *) n;
+				}	
 			;
 
 
@@ -18050,6 +18064,52 @@ json_returning_clause_opt:
 			| /* EMPTY */							{ $$ = NULL; }
 		;
 
+json_transform_action:
+			/* INSERT path_expr = value_expr */
+			INSERT a_expr '=' json_value_expr
+			{
+				JsonTransformAction *n = makeNode(JsonTransformAction);
+				n->op = TRANSFORM_INSERT;
+				n->pathspec = $2;
+				n->value_expr = $4;
+				n->location = @1;
+
+				$$ = (Node *) n;
+			}	
+			|
+			RENAME a_expr '=' Sconst
+			{
+				JsonTransformAction *n = makeNode(JsonTransformAction);
+				n->op = TRANSFORM_RENAME;
+				n->pathspec = $2;
+				n->value_expr = makeStringConst($4, @4);
+				n->location = @1;
+
+				$$ = (Node *) n;
+			}
+			|
+			REPLACE a_expr '=' json_value_expr
+			{
+				JsonTransformAction *n = makeNode(JsonTransformAction);
+				n->op = TRANSFORM_REPLACE;
+				n->pathspec = $2;
+				n->value_expr = $4;
+				n->location = @1;
+
+				$$ = (Node *) n;
+			}
+			|
+			REMOVE a_expr
+			{
+				JsonTransformAction *n = makeNode(JsonTransformAction);
+				n->op = TRANSFORM_REMOVE;
+				n->pathspec = $2;
+				n->value_expr = NULL;
+				n->location = @1;
+
+				$$ = (Node *) n;
+			};
+
 /*
  * 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,
@@ -19061,6 +19121,7 @@ unreserved_keyword:
 			| RELATIONSHIP
 			| RELATIVE_P
 			| RELEASE
+			| REMOVE
 			| RENAME
 			| REPACK
 			| REPEATABLE
@@ -19209,6 +19270,7 @@ col_name_keyword:
 			| JSON_SCALAR
 			| JSON_SERIALIZE
 			| JSON_TABLE
+			| JSON_TRANSFORM
 			| JSON_VALUE
 			| LEAST
 			| MERGE_ACTION
@@ -19584,6 +19646,7 @@ bare_label_keyword:
 			| JSON_SCALAR
 			| JSON_SERIALIZE
 			| JSON_TABLE
+			| JSON_TRANSFORM
 			| JSON_VALUE
 			| KEEP
 			| KEY
@@ -19710,6 +19773,7 @@ bare_label_keyword:
 			| RELATIONSHIP
 			| RELATIVE_P
 			| RELEASE
+			| REMOVE
 			| RENAME
 			| REPACK
 			| REPEATABLE
diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index f535f3b9351..47f8b54e995 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -4326,6 +4326,7 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 	Node	   *coerced_path_spec;
 	const char *func_name = NULL;
 	JsonFormatType default_format;
+	JsonTransformAction *jst_action = func->action;
 
 	switch (func->op)
 	{
@@ -4345,6 +4346,10 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 			func_name = "JSON_TABLE";
 			default_format = JS_FORMAT_JSONB;
 			break;
+		case JSON_TRANSFORM_OP:
+			func_name = "JSON_TRANSFORM";
+			default_format = JS_FORMAT_JSONB;
+			break;
 		default:
 			elog(ERROR, "invalid JsonFuncExpr op %d", (int) func->op);
 			default_format = JS_FORMAT_DEFAULT; /* keep compiler quiet */
@@ -4526,7 +4531,7 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 	jsexpr->location = func->location;
 	jsexpr->op = func->op;
 	jsexpr->column_name = func->column_name;
-
+	
 	/*
 	 * jsonpath machinery can only handle jsonb documents, so coerce the input
 	 * if not already of jsonb type.
@@ -4538,22 +4543,81 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 													false);
 	jsexpr->format = func->context_item->format;
 
-	path_spec = transformExprRecurse(pstate, func->pathspec);
-	pathspec_type = exprType(path_spec);
-	pathspec_loc = exprLocation(path_spec);
-	coerced_path_spec = coerce_to_target_type(pstate, path_spec,
-											  pathspec_type,
-											  JSONPATHOID, -1,
-											  COERCION_EXPLICIT,
-											  COERCE_IMPLICIT_CAST,
-											  pathspec_loc);
-	if (coerced_path_spec == NULL)
-		ereport(ERROR,
-				(errcode(ERRCODE_DATATYPE_MISMATCH),
-				 errmsg("JSON path expression must be of type %s, not of type %s",
-						"jsonpath", format_type_be(pathspec_type)),
-				 parser_errposition(pstate, pathspec_loc)));
-	jsexpr->path_spec = coerced_path_spec;
+	if (jst_action)
+	{
+		JsonTransformAction *analyzed_jst_action = makeNode(JsonTransformAction);
+
+		analyzed_jst_action->op = jst_action->op;
+		analyzed_jst_action->location = jst_action->location;
+
+		switch (jst_action->op)
+		{
+			case TRANSFORM_INSERT:
+			case TRANSFORM_REPLACE:
+				analyzed_jst_action->value_expr = transformJsonValueExpr(pstate, func_name,
+																		 (JsonValueExpr *) jst_action->value_expr,
+																		 default_format,
+																		 JSONBOID,
+																		 false);
+				break;
+			case TRANSFORM_RENAME:
+				Node	   *v = transformExprRecurse(pstate, jst_action->value_expr);
+
+				v = coerce_to_target_type(pstate, v, exprType(v),
+										  TEXTOID, -1,
+										  COERCION_EXPLICIT,
+										  COERCE_IMPLICIT_CAST,
+										  exprLocation(v));
+				if (v == NULL)
+					ereport(ERROR,
+							errcode(ERRCODE_DATATYPE_MISMATCH),
+							errmsg("RENAME target must be convertible to text"),
+							parser_errposition(pstate, exprLocation(jst_action->value_expr)));
+				analyzed_jst_action->value_expr = v;
+				break;
+			case TRANSFORM_REMOVE:
+				/* REMOVE has no value_expr */
+				analyzed_jst_action->value_expr = NULL;
+				break;
+		}
+
+		path_spec = transformExprRecurse(pstate, jst_action->pathspec);
+		pathspec_type = exprType(path_spec);
+		pathspec_loc = exprLocation(path_spec);
+		coerced_path_spec = coerce_to_target_type(pstate, path_spec,
+												  pathspec_type,
+												  JSONPATHOID, -1,
+												  COERCION_EXPLICIT,
+												  COERCE_IMPLICIT_CAST,
+												  pathspec_loc);
+		if (coerced_path_spec == NULL)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATATYPE_MISMATCH),
+					 errmsg("JSON path expression must be of type %s, not of type %s",
+							"jsonpath", format_type_be(pathspec_type)),
+					 parser_errposition(pstate, pathspec_loc)));
+		analyzed_jst_action->pathspec = coerced_path_spec;
+		jsexpr->action = analyzed_jst_action;
+	}
+	else
+	{
+		path_spec = transformExprRecurse(pstate, func->pathspec);
+		pathspec_type = exprType(path_spec);
+		pathspec_loc = exprLocation(path_spec);
+		coerced_path_spec = coerce_to_target_type(pstate, path_spec,
+												  pathspec_type,
+												  JSONPATHOID, -1,
+												  COERCION_EXPLICIT,
+												  COERCE_IMPLICIT_CAST,
+												  pathspec_loc);
+		if (coerced_path_spec == NULL)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATATYPE_MISMATCH),
+					 errmsg("JSON path expression must be of type %s, not of type %s",
+							"jsonpath", format_type_be(pathspec_type)),
+					 parser_errposition(pstate, pathspec_loc)));
+		jsexpr->path_spec = coerced_path_spec;
+	}
 
 	/* Transform and coerce the PASSING arguments to jsonb. */
 	transformJsonPassingArgs(pstate, func_name,
@@ -4695,6 +4759,16 @@ transformJsonFuncExpr(ParseState *pstate, JsonFuncExpr *func)
 													 jsexpr->returning);
 			break;
 
+		case JSON_TRANSFORM_OP:
+			/* Return type is always jsonb */
+			if (!OidIsValid(jsexpr->returning->typid))
+			{
+				jsexpr->returning->typid = JSONBOID;
+				jsexpr->returning->typmod = -1;
+			}
+			jsexpr->collation = get_typcollation(jsexpr->returning->typid);
+			/* No top-level ON EMPTY / ON ERROR for JSON_TRANSFORM */
+			break;
 		default:
 			elog(ERROR, "invalid JsonFuncExpr op %d", (int) func->op);
 			break;
diff --git a/src/backend/parser/parse_target.c b/src/backend/parser/parse_target.c
index 541fef5f183..529616c1152 100644
--- a/src/backend/parser/parse_target.c
+++ b/src/backend/parser/parse_target.c
@@ -2034,6 +2034,9 @@ FigureColnameInternal(Node *node, char **name)
 				case JSON_VALUE_OP:
 					*name = "json_value";
 					return 2;
+				case JSON_TRANSFORM_OP:
+					*name = "json_transform";
+					return 2;
 					/* JSON_TABLE_OP can't happen here. */
 				default:
 					elog(ERROR, "unrecognized JsonExpr op: %d",
diff --git a/src/include/executor/execExpr.h b/src/include/executor/execExpr.h
index c61b3d624d5..bb385db0bd1 100644
--- a/src/include/executor/execExpr.h
+++ b/src/include/executor/execExpr.h
@@ -266,6 +266,7 @@ typedef enum ExprEvalOp
 	EEOP_JSON_CONSTRUCTOR,
 	EEOP_IS_JSON,
 	EEOP_JSONEXPR_PATH,
+	EEOP_JSON_TRANSFORM,
 	EEOP_JSONEXPR_COERCION,
 	EEOP_JSONEXPR_COERCION_FINISH,
 	EEOP_AGGREF,
@@ -760,6 +761,12 @@ typedef struct ExprEvalStep
 			struct JsonExprState *jsestate;
 		}			jsonexpr;
 
+		/* for EEOP_JSON_TRANSFORM */
+		struct
+		{
+			struct JsonTransformExprState *jtstate;
+		}			json_transform;
+
 		/* for EEOP_JSONEXPR_COERCION */
 		struct
 		{
@@ -894,6 +901,8 @@ extern void ExecEvalJsonConstructor(ExprState *state, ExprEvalStep *op,
 extern void ExecEvalJsonIsPredicate(ExprState *state, ExprEvalStep *op);
 extern int	ExecEvalJsonExprPath(ExprState *state, ExprEvalStep *op,
 								 ExprContext *econtext);
+extern void ExecEvalJsonTransform(ExprState *state, ExprEvalStep *op,
+								  ExprContext *econtext);
 extern void ExecEvalJsonCoercion(ExprState *state, ExprEvalStep *op,
 								 ExprContext *econtext);
 extern void ExecEvalJsonCoercionFinish(ExprState *state, ExprEvalStep *op);
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 13359180d25..98f90ce76d8 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -1095,6 +1095,20 @@ typedef struct DomainConstraintState
 	ExprState  *check_exprstate;	/* check_expr's eval state, or NULL */
 } DomainConstraintState;
 
+typedef struct JsonTransformExprState
+{
+	JsonExpr   *jsexpr;			/* back-pointer to analyzed node */
+
+	/* Runtime slots — filled by prior steps */
+	NullableDatum formatted_expr;	/* input jsonb document */
+	NullableDatum pathspec;		/* compiled jsonpath Datum */
+	NullableDatum action_value; /* value to for transform ops (NULL for
+								 * REMOVE) */
+
+	/* PASSING args (only used if jsonpath needs them) */
+	List	   *args;			/* List of JsonPathVariable */
+}			JsonTransformExprState;
+
 /*
  * State for JsonExpr evaluation, too big to inline.
  *
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 91377a6cde3..1081589d0be 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -1960,6 +1960,7 @@ typedef struct JsonFuncExpr
 								 * not for a JSON_TABLE() */
 	JsonValueExpr *context_item;	/* context item expression */
 	Node	   *pathspec;		/* JSON path specification expression */
+	JsonTransformAction *action;	/* Actions: INSERT/REMOVE/RENAME/REPLACE */
 	List	   *passing;		/* list of PASSING clause arguments, if any */
 	JsonOutput *output;			/* output clause, if specified */
 	JsonBehavior *on_empty;		/* ON EMPTY behavior */
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index 6dfc946c20b..f8ae1f9de93 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -1712,6 +1712,23 @@ typedef struct JsonValueExpr
 	JsonFormat *format;			/* FORMAT clause, if specified */
 } JsonValueExpr;
 
+typedef enum JsonTransformOp
+{
+	TRANSFORM_INSERT,
+	TRANSFORM_REMOVE,
+	TRANSFORM_RENAME,
+	TRANSFORM_REPLACE,
+}			JsonTransformOp;
+
+typedef struct JsonTransformAction
+{
+	NodeTag		type;
+	JsonTransformOp op;
+	Node	   *pathspec;		/* The JSON Path: '$.a' */
+	Node	   *value_expr;
+	ParseLoc	location;		/* token location, or -1 if unknown */
+}			JsonTransformAction;
+
 typedef enum JsonConstructorType
 {
 	JSCTOR_JSON_OBJECT = 1,
@@ -1831,6 +1848,7 @@ typedef enum JsonExprOp
 	JSON_QUERY_OP,				/* JSON_QUERY() */
 	JSON_VALUE_OP,				/* JSON_VALUE() */
 	JSON_TABLE_OP,				/* JSON_TABLE() */
+	JSON_TRANSFORM_OP,			/* JSON_TRANSFORM() */
 } JsonExprOp;
 
 /*
@@ -1856,6 +1874,8 @@ typedef struct JsonExpr
 	/* jsonpath-valued expression containing the query pattern */
 	Node	   *path_spec;
 
+	JsonTransformAction *action;
+
 	/* Expected type/format of the output. */
 	JsonReturning *returning;
 
diff --git a/src/include/parser/kwlist.h b/src/include/parser/kwlist.h
index 51ead54f015..38b09abd34c 100644
--- a/src/include/parser/kwlist.h
+++ b/src/include/parser/kwlist.h
@@ -249,6 +249,7 @@ PG_KEYWORD("json_query", JSON_QUERY, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("json_scalar", JSON_SCALAR, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("json_serialize", JSON_SERIALIZE, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("json_table", JSON_TABLE, COL_NAME_KEYWORD, BARE_LABEL)
+PG_KEYWORD("json_transform", JSON_TRANSFORM, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("json_value", JSON_VALUE, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("keep", KEEP, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("key", KEY, UNRESERVED_KEYWORD, BARE_LABEL)
@@ -385,6 +386,7 @@ PG_KEYWORD("reindex", REINDEX, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("relationship", RELATIONSHIP, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("relative", RELATIVE_P, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("release", RELEASE, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("remove", REMOVE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("rename", RENAME, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("repack", REPACK, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("repeatable", REPEATABLE, UNRESERVED_KEYWORD, BARE_LABEL)
-- 
2.43.0

