cloud-fan commented on code in PR #58034:
URL: https://github.com/apache/spark/pull/58034#discussion_r4130523396
##########
sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4:
##########
@@ -1486,6 +1486,11 @@ primaryExpression
(nullBehavior=jsonConstructorNullBehavior ON NULL)?
(RETURNING returning=dataType)?
RIGHT_PAREN #jsonArray
+ | JSON_OBJECT LEFT_PAREN (
+ jsonObjectMember (COMMA jsonObjectMember)*
Review Comment:
**Non-blocking (P2):** Because both alternatives accept full expressions, a
valid comma-form pair such as `v:k::string, v:x` can match this first
alternative as two colon members (`v : k::string` and `v : x`). That changes
the intended one-member object into two members and can also change overload
resolution or fail on unrelated `k`/`x` bindings.
**Recommended change:** Disambiguate the two JSON_OBJECT list forms at the
grammar owner, preserve full expressions in both key and value positions, and
add focused SQL coverage for colon-bearing comma arguments and the neighboring
supported forms.
**Why this works:** Make the selected parse context encode the list form
unambiguously before visitJsonObject constructs members; retain the existing
direct-versus-routed AST handoff after the correct pairing is known.
**Scope:** Parser grammar, JSON_OBJECT AST handoff if required by the
grammar shape, and end-to-end parser/regression coverage.
**Compatibility:** The paired comma form remains an ordinary resolvable
function shape, while clause-bearing and explicit raw VALUE/colon forms retain
direct construction.
**Risks:** A disambiguation that simply reverses alternatives can break
ordinary colon members or move ambiguity to a different comma arity. Changing
the context shape can accidentally bypass routine resolution for the
clause-free comma form.
**Constraints:** Preserve VALUE, optional KEY, colon, and paired comma
spellings. Preserve full expressions, including predicates, casts, and
semi-structured extraction, in documented key and value positions. Preserve the
current shadowing and carrier behavior after the comma-form pair is identified.
**Success:** A comma-form call whose key and value both contain colon
extraction produces exactly one key/value member. Equivalent standard
colon-member syntax still produces two members where two separators were
written. Simple VALUE, colon, comma, mixed-invalid, and shadowing cases retain
their current behavior.
**Verification:**
- **Regression:** Parse and execute a comma-form JSON_OBJECT whose key and
value both contain semi-structured extraction and assert the one-member result
and argument ownership.
- **Compatibility:** Confirm neighboring VALUE/KEY, colon-member, simple
comma, invalid mixed-list, and same-named routine-resolution behaviors remain
unchanged.
- **Compile:** Regenerate and compile the SQL parser from the revised
grammar without ambiguity or generated-source failures.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala:
##########
@@ -1745,6 +1746,59 @@ object JsonArrayExpressionBuilder extends
ExpressionBuilder {
}
}
+/**
+ * Marks a routed comma-form `JSON_OBJECT(...)` value as a lexically nested
JSON producer (implicit
+ * `FORMAT JSON`). Transparent (keeps its child's type, value, foldability),
so it does not perturb
+ * a shadowing routine's overload selection; the unshadowed built-in unwraps
it and splices raw.
+ * Attached only by direct `JSON_OBJECT(...)` syntax, so qualified/generic
calls keep quoting
+ * pending SPARK-59243.
+ */
+case class JsonImplicitFormatCarrier(child: Expression) extends
TaggingExpression {
+ // Render as the bare child so generated SQL for a carrier that survives
into a routine's plan
+ // stays reparseable (the built-in unwraps it before rendering its own SQL).
+ override def sql: String = child.sql
+ override protected def withNewChildInternal(newChild: Expression):
JsonImplicitFormatCarrier =
+ copy(child = newChild)
+}
+
+@ExpressionDescription(
+ usage =
+ "_FUNC_([key, value [, key, value ...]]) - Returns a JSON object string
built from the " +
+ "given key/value pairs.",
+ arguments = """
+ Arguments:
+ * key - a non-null string naming an object member.
+ * value - the member's value.
+ """,
+ examples = """
+ Examples:
+ > SELECT _FUNC_('id', 7, 'name', 'Ada');
+ {"id":7,"name":"Ada"}
+ > SELECT _FUNC_();
+ {}
+ """,
+ since = "4.4.0",
+ group = "json_funcs")
+object JsonObjectExpressionBuilder extends ExpressionBuilder {
+ override def build(funcName: String, expressions: Seq[Expression]):
Expression = {
+ // The routed form is a flat `key, value, ...` list (the MySQL-style
spelling), so it must have
+ // an even length; an odd count leaves a dangling key with no value.
+ if (expressions.length % 2 != 0) {
+ throw QueryCompilationErrors.wrongNumArgsError(
+ funcName, Seq("2n (n >= 0)"), expressions.length)
+ }
+ // A `JsonImplicitFormatCarrier` marks a lexically nested JSON producer,
spliced raw (unwrapped
+ // here) and trusted; every other value is quoted. Routed calls carry no
explicit FORMAT JSON,
Review Comment:
**Nit (P3):** Untagged values are JSON-serialized here, not uniformly
quoted: strings are quoted, while numbers, booleans, arrays, structs, and maps
keep their JSON representation. The current wording gives maintainers the wrong
contract for every ordinary non-string value.
**Verification:**
- **Inspection:** Check the revised wording against the builder's raw flag
construction and the evaluator's scalar and complex JSON outputs.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala:
##########
@@ -1283,17 +1284,17 @@ object JsonConstructorNullBehavior {
/**
* Marker for expressions whose result is JSON text and therefore carry an
implicit SQL/JSON
* `FORMAT JSON`: when such an expression appears as an argument of a JSON
constructor (e.g.
- * `JSON_ARRAY`), its value is spliced in verbatim rather than quoted as a
JSON string:
+ * `JSON_ARRAY` or `JSON_OBJECT`), its value is spliced in verbatim rather
than quoted as a JSON
Review Comment:
**Nit (P3):** This promises that JSON_OBJECT honors the marker whenever such
an expression is an argument, but qualified and generic routed calls rebuild
through the expression builder without inspecting it. Only direct lexical
construction and the carrier-backed unqualified comma route preserve implicit
rawness, so the contract should state that boundary.
**Verification:**
- **Inspection:** Trace each stated marker guarantee to the corresponding
parser or builder branch and confirm the documented exclusions match current
tests.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala:
##########
@@ -2272,3 +2326,409 @@ case class JsonTypeof(child: Expression)
override protected def withNewChildInternal(newChild: Expression):
JsonTypeof =
copy(child = newChild)
}
+
+/**
+ * The SQL:2016 `JSON_OBJECT` constructor function (feature T811): constructs
a JSON object from
+ * key-value pairs, written `key VALUE value`, `KEY key VALUE value`, `key :
value`, or the
+ * MySQL-style `key, value`.
+ *
+ * Keys must be non-null strings; a null key is an error. The `ON NULL` clause
controls whether
+ * null-valued pairs are included (NULL ON NULL, the standard default) or
omitted (ABSENT ON NULL).
+ *
+ * A value is spliced in raw (unquoted) when it carries `FORMAT JSON` (tracked
per member by
+ * `rawJson`): the explicit clause (e.g. `JSON_OBJECT('a' VALUE '{"b":1}'
FORMAT JSON)`), or the
+ * implicit form carried by a nested JSON constructor / `JSON_QUERY` under
KEEP QUOTES (see
+ * `rawJsonValue`). Explicit `FORMAT JSON` on a non-constructor is arbitrary
user text, so
+ * `needsValidation` marks it for JSON validation at eval; a nested
constructor is trusted.
+ *
+ * Examples:
+ * JSON_OBJECT('id' VALUE 7, 'name' VALUE 'Ada') ->
'{"id":7,"name":"Ada"}'
+ * JSON_OBJECT('id': 7, 'v': NULL) -> '{"id":7,"v":null}'
+ * JSON_OBJECT('id': 7, 'v': NULL ABSENT ON NULL) -> '{"id":7}'
+ * JSON_OBJECT() -> '{}'
+ *
+ * A clause-free call that is not itself lexically nested in an enclosing
constructor's *value*
+ * position routes through function resolution and is rebuilt by
`JsonObjectExpressionBuilder`, so a
+ * same-named routine can shadow the built-in `json_object`: always for the
comma form (a nested
+ * producer value routes too, carrying its raw-splice eligibility via
`JsonImplicitFormatCarrier`),
+ * but for the `VALUE` / `:` forms only when no member carries a raw value
(`rawJson.contains(true)`
+ * -- an explicit `FORMAT JSON` or a nested producer in *value* position --
keeps it direct).
+ * Otherwise it is built directly from the grammar (clause-bearing, or
value-nested); a nested
+ * *key*-position call stays routable (see `AstBuilder.visitJsonObject`).
+ * The user-facing reference lives in
`docs/sql-ref-syntax-qry-select-json-object.md`.
+ */
+case class JsonObjectExpr(
+ members: Seq[(Expression, Expression)],
+ rawJson: Seq[Boolean],
+ needsValidation: Seq[Boolean],
+ nullBehavior: JsonConstructorNullBehavior =
JsonConstructorNullBehavior.Null,
+ returning: DataType = StringType,
+ timeZoneId: Option[String] = None)
+ extends Expression
+ with TimeZoneAwareExpression
+ with CodegenFallback
+ with QueryErrorsBase
+ // Default RETURNING is a plain STRING, so
`DefaultStringProducingExpression` lets
+ // `ApplyDefaultCollation` cast the result to a non-default collation; the
`dataType` override
+ // below stays authoritative when RETURNING is given explicitly.
+ with DefaultStringProducingExpression
+ with ImplicitlyFormattedAsJson {
+
+ // `rawJson(i)` splices member `i`'s value verbatim (already-JSON text)
rather than quoting it;
+ // `needsValidation(i)` marks that raw text as arbitrary user input to
JSON-validate at eval (an
+ // explicit `FORMAT JSON` on a non-constructor). Both are frozen at parse
time (see
+ // `AstBuilder.visitJsonObject`). A validated value is always a spliced one.
+ require(members.length == rawJson.length && members.length ==
needsValidation.length,
+ "JsonObjectExpr requires one rawJson and one needsValidation flag per
member")
+ require(needsValidation.lazyZip(rawJson).forall((nv, raw) => !nv || raw),
+ "JsonObjectExpr needsValidation implies rawJson")
+
+ @transient private lazy val memberArray: Array[(Expression, Expression)] =
+ members.toArray
+
+ @transient private lazy val rawJsonArray: Array[Boolean] =
+ rawJson.toArray
+
+ @transient private lazy val needsValidationArray: Array[Boolean] =
+ needsValidation.toArray
+
+ // Always throwable: a null key raises JSON_OBJECT_NULL_KEY at eval even
with non-throwable
+ // children, which keeps the optimizer from pushing it below a filtering
join. Left non-foldable
+ // (the default) so folding a constant call does not eagerly raise that
null-key error at
+ // optimization for rows a filter would later drop.
+ override lazy val throwable: Boolean = true
+
+ // The value is never null, but report nullable as `throwable` (like
JsonArray): `NullPropagation`
+ // keys off `nullable`, so a non-nullable constructor would let it fold `IS
[NOT] NULL` to a
+ // constant or `count(...)` to `count(1)`, skipping the eval that must raise
JSON_OBJECT_NULL_KEY.
+ override def nullable: Boolean = throwable
+
+ override def dataType: DataType = returning
+
+ // Flattened `[k0, v0, k1, v1, ...]` view, cached since `children` is walked
repeatedly.
+ @transient private lazy val childrenSeq: Seq[Expression] =
+ memberArray.flatMap { case (k, v) => Seq(k, v) }.toImmutableArraySeq
+
+ override def children: Seq[Expression] = childrenSeq
+
+ override def withTimeZone(timeZoneId: String): TimeZoneAwareExpression =
+ copy(timeZoneId = Option(timeZoneId))
+
+ override def checkInputDataTypes(): TypeCheckResult = {
+ val inputCheck = super.checkInputDataTypes()
+ if (inputCheck.isFailure) {
+ inputCheck
+ } else if (!JsonObjectExpr.isValidReturningType(returning)) {
+ DataTypeMismatch(
+ errorSubClass = "INVALID_JSON_RETURNING_TYPE",
+ messageParameters = Map(
+ "functionName" -> toSQLId(prettyName),
+ "returningType" -> toSQLType(returning)))
+ } else {
+ // Keys must be character strings (they become JSON object member
names). Reject any other
+ // concrete type up front so a non-string key cannot reach the
StringType Jackson writer and
+ // fail with an internal ClassCastException. NullType is allowed
through: a null key value is
+ // reported at runtime as JSON_OBJECT_NULL_KEY rather than a type error.
+ val keyCheck = memberArray.iterator.map(_._1).zipWithIndex.collectFirst {
+ case (k, keyIndex) if !(k.dataType.isInstanceOf[StringType] ||
k.dataType == NullType) =>
+ DataTypeMismatch(
+ errorSubClass = "UNEXPECTED_INPUT_TYPE",
+ messageParameters = Map(
+ "paramIndex" -> ordinalNumber(keyIndex * 2),
+ "requiredType" -> toSQLType(StringType),
+ "inputSql" -> toSQLExpr(k),
+ "inputType" -> toSQLType(k.dataType)))
+ }.getOrElse(TypeCheckResult.TypeCheckSuccess)
+ if (keyCheck.isFailure) {
+ keyCheck
+ } else {
+ // Every value must be serializable to JSON. This mirrors `to_json`'s
analysis-time
+ // `JacksonUtils.verifyType` check, plus a guard for the spatial
atomics that check accepts
+ // but `JacksonGenerator` cannot write (rejecting a runtime failure at
analysis instead).
+ memberArray.iterator.map(_._2).zipWithIndex
+ .foldLeft(TypeCheckResult.TypeCheckSuccess: TypeCheckResult) {
+ case (acc, _) if acc.isFailure => acc
+ case (_, (v, valueIndex)) if rawJsonArray(valueIndex) &&
+ !(v.dataType.isInstanceOf[StringType] || v.dataType ==
NullType) =>
+ // A raw-spliced value must carry JSON text (string); NullType
is allowed (eval drops
+ // or emits it before splicing). Reject any other type up front,
matching JSON_ARRAY's
+ // FORMAT JSON check, rather than failing with a
ClassCastException at eval.
+ DataTypeMismatch(
+ errorSubClass = "INVALID_JSON_FORMAT_JSON_INPUT",
+ messageParameters = Map(
+ "functionName" -> toSQLId(prettyName),
+ // Flattened 1-based arg position, not the member ordinal
(value of member i is
+ // arg 2i+2), matching the key check's
`ordinalNumber(keyIndex*2)`.
+ "position" -> (valueIndex * 2 + 2).toString,
+ "inputType" -> toSQLType(v.dataType)))
+ case (_, (v, _)) =>
+ val elemCheck = JacksonUtils.verifyType(prettyName, v.dataType)
+ if (elemCheck.isFailure) {
+ elemCheck
+ } else if
(JsonObjectExpr.containsUnsupportedJsonType(v.dataType)) {
+ DataTypeMismatch(
+ errorSubClass = "CANNOT_CONVERT_TO_JSON",
+ messageParameters = Map(
+ "name" -> toSQLId(prettyName),
+ "type" -> toSQLType(v.dataType)))
+ } else {
+ TypeCheckResult.TypeCheckSuccess
+ }
+ }
+ }
+ }
+ }
+
+ override def stateful: Boolean = true
+
+ @transient private lazy val resolvedZoneId: String =
+ timeZoneId.getOrElse(SQLConf.get.sessionLocalTimeZone)
+
+ // JacksonGenerator (shared with `to_json`, so rendering matches) can only
serialize a container,
+ // not a bare scalar, so each key/value is wrapped in a one-element array
whose brackets are
+ // stripped in renderKey/appendRenderedValue below.
+ @transient private lazy val keyEvaluator: StructsToJsonEvaluator =
+ StructsToJsonEvaluator(Map.empty, ArrayType(StringType),
Some(resolvedZoneId))
+
+ @transient private lazy val valueEvaluators: Array[StructsToJsonEvaluator] =
+ memberArray.map { case (_, v) =>
+ StructsToJsonEvaluator(Map.empty, ArrayType(v.dataType),
+ Some(resolvedZoneId))
+ }
+
+ @transient private lazy val singleElem: Array[Any] = new Array[Any](1)
+
+ @transient private lazy val singleElemData: GenericArrayData =
+ new GenericArrayData(singleElem)
+
+ @transient private lazy val castInput: GenericInternalRow =
+ new GenericInternalRow(1)
+
+ @transient private lazy val returningCast: Expression =
+ Cast(BoundReference(0, StringType, nullable = true), returning, timeZoneId,
+ EvalMode.ANSI)
+
+ // `evaluateString` returns the Java String directly, skipping `evaluate`'s
UTF8String round trip.
+ // The key is cached or appended whole, so it still materializes the
interior substring.
+ private def renderKey(key: Any): String = {
+ singleElem(0) = key
+ val arrJson = keyEvaluator.evaluateString(singleElemData)
+ arrJson.substring(1, arrJson.length - 1)
+ }
+
+ // Append the interior of the "[<frag>]" wrapper straight into the builder
(no per-value
+ // substring), mirroring `JsonArray.appendRenderedElement`.
+ private def appendRenderedValue(sb: java.lang.StringBuilder, idx: Int,
value: Any): Unit = {
+ singleElem(0) = value
+ val arrJson = valueEvaluators(idx).evaluateString(singleElemData)
+ sb.append(arrJson, 1, arrJson.length - 1)
+ }
+
+ @transient private lazy val formatJsonFactory: JsonFactory = new
JsonFactory()
+
+ @transient private lazy val cachedValidatedTexts: Array[String] =
+ new Array[String](memberArray.length)
+
+ // A `FORMAT JSON` value is spliced verbatim, so it must be exactly one
well-formed JSON value
+ // (a string like `1,2` or `{bad` would corrupt the object). Parse one
value; reject anything
+ // trailing. Mirrors `JsonArray.validateJsonText`.
+ private def validateJsonText(idx: Int, text: String): Unit = {
+ val valid =
+ try {
+ Utils.tryWithResource(formatJsonFactory.createParser(text)) { parser =>
+ if (parser.nextToken() == null) {
+ false
+ } else {
+ parser.skipChildren()
+ parser.nextToken() == null
+ }
+ }
+ } catch {
+ case _: JsonProcessingException => false
+ }
+ if (!valid) {
+ // Flattened 1-based value-arg position (member `idx` is arg `2*idx+2`),
matching the
+ // analysis-time INVALID_JSON_FORMAT_JSON_INPUT check.
+ throw QueryExecutionErrors.invalidJsonFormatJsonValueError(prettyName,
idx * 2 + 2, text)
+ }
+ }
+
+ // Raw text to splice for member `idx`: a trusted nested constructor splices
as-is; an explicit
+ // `FORMAT JSON` string is validated once (cached) when foldable, else per
row.
+ private def rawJsonText(idx: Int, value: Any): String = {
+ if (!needsValidationArray(idx)) {
+ value.asInstanceOf[UTF8String].toString
+ } else if (memberArray(idx)._2.foldable) {
+ val cached = cachedValidatedTexts(idx)
+ if (cached != null) {
+ cached
+ } else {
+ val text = value.asInstanceOf[UTF8String].toString
+ validateJsonText(idx, text)
+ cachedValidatedTexts(idx) = text
+ text
+ }
+ } else {
+ val text = value.asInstanceOf[UTF8String].toString
+ validateJsonText(idx, text)
+ text
+ }
+ }
+
+ // A foldable, non-null key (the common literal-key case, e.g.
`JSON_OBJECT('id' VALUE col)`)
+ // renders to the same JSON member name on every row, so render it once here
instead of re-running
+ // the Jackson writer per row. `renderedKeys(i)` holds that cached name, or
`null` when the key
+ // must be evaluated and rendered per row -- a non-foldable key, or a
foldable key that evaluates
+ // to `null` (still reported as JSON_OBJECT_NULL_KEY at eval).
+ @transient private lazy val renderedKeys: Array[String] = memberArray.map {
case (k, _) =>
+ if (k.foldable) {
+ val key = k.eval(EmptyRow)
+ if (key == null) null else renderKey(key)
+ } else {
+ null
+ }
+ }
+
+ override def eval(input: InternalRow): Any = {
+ val sb = new java.lang.StringBuilder("{")
+ var first = true
+ var i = 0
+ val localMembers = memberArray
+ val localRawJson = rawJsonArray
+ while (i < localMembers.length) {
+ val (keyExpr, valueExpr) = localMembers(i)
+ // Resolve the member's key. A foldable non-null key is already rendered
and cached. Otherwise
+ // evaluate the key before the value so a null key deterministically
raises
+ // JSON_OBJECT_NULL_KEY, independent of whether the value expression
happens to throw.
+ // Delay rendering dynamic keys until we know the member will be
emitted, avoiding work for
+ // null values under ABSENT ON NULL.
+ val cachedKey = renderedKeys(i)
+ val key = if (cachedKey == null) {
+ val key = keyExpr.eval(input)
+ if (key == null) {
+ throw QueryExecutionErrors.jsonObjectNullKeyError()
+ }
+ key
+ } else {
+ null
+ }
+ val value = valueExpr.eval(input)
+ if (value != null || nullBehavior == JsonConstructorNullBehavior.Null) {
+ if (!first) sb.append(",")
+ first = false
+ val keyName = if (cachedKey != null) cachedKey else renderKey(key)
+ sb.append(keyName).append(":")
+ if (value == null) {
+ sb.append("null")
+ } else if (localRawJson(i)) {
+ sb.append(rawJsonText(i, value))
+ } else {
+ appendRenderedValue(sb, i, value)
+ }
+ }
+ i += 1
+ }
+ sb.append("}")
+ val jsonStr = UTF8String.fromString(sb.toString)
+ if (returning == StringType) {
+ jsonStr
+ } else {
+ castInput.update(0, jsonStr)
+ returningCast.eval(castInput)
+ }
+ }
+
+ override def prettyName: String = "json_object"
+
+ override def sql: String = {
Review Comment:
**Non-blocking (P2):** The explicit ON NULL clause is needed for canonical
round trips, but this same rendering is also used by `toPrettySQL` for
generated aliases. As a result, unaliased expressions expose synthetic
ownership text such as `JSON_OBJECT(NULL ON NULL)` even when the user omitted
it; canonical and display rendering need to remain distinct.
**Verification:**
- **Behavior:** Assert unaliased JSON_OBJECT schema names for empty,
ordinary, and explicit-clause calls do not expose synthetic ownership text.
- **Compatibility:** Render and reparse canonical JSON_OBJECT SQL under a
shadowing routine and verify it remains bound to the built-in with
raw-versus-quoted semantics preserved.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala:
##########
@@ -1745,6 +1746,59 @@ object JsonArrayExpressionBuilder extends
ExpressionBuilder {
}
}
+/**
+ * Marks a routed comma-form `JSON_OBJECT(...)` value as a lexically nested
JSON producer (implicit
+ * `FORMAT JSON`). Transparent (keeps its child's type, value, foldability),
so it does not perturb
+ * a shadowing routine's overload selection; the unshadowed built-in unwraps
it and splices raw.
+ * Attached only by direct `JSON_OBJECT(...)` syntax, so qualified/generic
calls keep quoting
+ * pending SPARK-59243.
+ */
+case class JsonImplicitFormatCarrier(child: Expression) extends
TaggingExpression {
+ // Render as the bare child so generated SQL for a carrier that survives
into a routine's plan
+ // stays reparseable (the built-in unwraps it before rendering its own SQL).
+ override def sql: String = child.sql
+ override protected def withNewChildInternal(newChild: Expression):
JsonImplicitFormatCarrier =
+ copy(child = newChild)
+}
+
+@ExpressionDescription(
+ usage =
+ "_FUNC_([key, value [, key, value ...]]) - Returns a JSON object string
built from the " +
+ "given key/value pairs.",
+ arguments = """
+ Arguments:
+ * key - a non-null string naming an object member.
+ * value - the member's value.
+ """,
+ examples = """
+ Examples:
+ > SELECT _FUNC_('id', 7, 'name', 'Ada');
+ {"id":7,"name":"Ada"}
+ > SELECT _FUNC_();
+ {}
+ """,
+ since = "4.4.0",
+ group = "json_funcs")
+object JsonObjectExpressionBuilder extends ExpressionBuilder {
+ override def build(funcName: String, expressions: Seq[Expression]):
Expression = {
+ // The routed form is a flat `key, value, ...` list (the MySQL-style
spelling), so it must have
+ // an even length; an odd count leaves a dangling key with no value.
+ if (expressions.length % 2 != 0) {
+ throw QueryCompilationErrors.wrongNumArgsError(
+ funcName, Seq("2n (n >= 0)"), expressions.length)
+ }
+ // A `JsonImplicitFormatCarrier` marks a lexically nested JSON producer,
spliced raw (unwrapped
+ // here) and trusted; every other value is quoted. Routed calls carry no
explicit FORMAT JSON,
+ // so nothing needs validation.
+ val taggedMembers = expressions.grouped(2).map {
Review Comment:
**Non-blocking (P2):** The routed builder's per-member flag association is
not exercised with mixed values: current routed tests use all-ordinary members
or one raw member, while the existing mixed test takes the direct VALUE path. A
grouping/indexing error here could splice or quote the neighboring member
without failing the suite.
**Verification:**
- **Regression:** Assert exact output for one routed object combining quoted
ordinary text and raw nested JSON producers in distinct member positions.
##########
docs/sql-ref-syntax-qry-select-json-object.md:
##########
@@ -0,0 +1,168 @@
+---
+layout: global
+title: JSON_OBJECT
+displayTitle: JSON_OBJECT
+license: |
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+---
+
+### Description
+
+The `JSON_OBJECT` constructor function builds a JSON object from key/value
pairs and returns it as
+JSON text. This is the SQL-standard way (SQL:2016) to assemble a JSON object
inline, with common
+compatibility syntax from other systems. `JSON_OBJECT` is an expression that
can appear anywhere a
+value is allowed.
+
+Each value is serialized with the same JSON writer as the built-in `to_json`
function, so numbers,
+decimals, booleans, dates, timestamps, and nested structs/arrays/maps render
the same way.
+Null-field handling inside a struct value therefore follows
+`spark.sql.jsonGenerator.ignoreNullFields`, exactly as `to_json` does; the `ON
NULL` clause below
+controls only the top-level object members.
+
+This is an initial subset of the SQL:2016 `JSON_OBJECT` constructor. It
supports the key/value
+members, the value-level `FORMAT JSON` marker, the `{ NULL | ABSENT } ON NULL`
clause, and a
+string-type `RETURNING`. The following SQL/JSON clauses are not yet supported:
+
+* The `{ WITH | WITHOUT } UNIQUE KEYS` clause. Duplicate keys are kept in
source order (the
+ `WITHOUT UNIQUE KEYS` behavior); there is no option to reject them.
+* `RETURNING` to a non-string type, or with a binary/`FORMAT JSON` output
clause. The result is
+ always JSON text of a string type.
+
+### Syntax
+
+```sql
+JSON_OBJECT ( [ { key VALUE value | KEY key VALUE value | key : value } [
FORMAT JSON ] [, ...] ]
+ [ { NULL | ABSENT } ON NULL ]
+ [ RETURNING data_type ] )
+
+JSON_OBJECT ( [ key, value [, key, value] ... ]
+ [ { NULL | ABSENT } ON NULL ]
+ [ RETURNING data_type ] )
+```
+
+### Parameters
+
+* **key**
+
+ An expression producing a member name. It must be a non-null string; a
`NULL` key raises an
+ error at runtime. The key is validated before the `ON NULL` handling, so a
`NULL` key raises the
+ error even when the member's value is `NULL` and `ABSENT ON NULL` would
otherwise omit it.
+
+* **value**
+
+ An expression producing the member value. Values may have different types
and may be nested
+ JSON constructors. `JSON_OBJECT()` with no members produces the empty
object `{}`. A value is
+ spliced in as raw JSON, rather than quoted as a string, only when it
carries `FORMAT JSON` (see
+ below) -- either the explicit clause or the implicit form carried by a
recognized JSON producer
+ written directly in the value position. This covers a nested `JSON_OBJECT`
or `JSON_ARRAY` (e.g.
+ `JSON_OBJECT('a' VALUE JSON_OBJECT('b' VALUE 1))` produces
`{"a":{"b":1}}`), and a nested
+ `JSON_QUERY` under the default `KEEP QUOTES`, which returns JSON text (e.g.
+ `JSON_OBJECT('a' VALUE JSON_QUERY('{"o":{"x":1}}', '$.o'))` produces
`{"a":{"x":1}}`). A
+ `JSON_QUERY` with `OMIT QUOTES` returns an ordinary string, so it is
quoted like any other value
+ (e.g. `JSON_OBJECT('n' VALUE JSON_QUERY('{"n":"Ada"}', '$.n' OMIT
QUOTES))` produces
+ `{"n":"Ada"}`). Without `FORMAT JSON`, an ordinary string value is always
quoted, even when its
+ contents are themselves valid JSON text (e.g. `JSON_OBJECT('a' VALUE
'{"b":1}')` produces
+ `{"a":"{\"b\":1}"}`). Implicit raw splicing applies only to the direct
syntax above: a qualified
+ or otherwise routed call (e.g. `builtin.json_object('a', json_object('b',
1))`) currently quotes
Review Comment:
**Nit (P3):** The unqualified comma form is an otherwise routed
counterexample to this statement: `json_object('a', json_object('b', 1))`
carries the nested producer through `JsonImplicitFormatCarrier` and yields a
raw nested object. Please distinguish that carrier-backed route from qualified
or generic routed calls, which still quote.
**Verification:**
- **Inspection:** Compare every documented routing claim and example with
the direct, carrier-backed unqualified comma, and qualified/generic
implementation paths.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala:
##########
@@ -2272,3 +2326,409 @@ case class JsonTypeof(child: Expression)
override protected def withNewChildInternal(newChild: Expression):
JsonTypeof =
copy(child = newChild)
}
+
+/**
+ * The SQL:2016 `JSON_OBJECT` constructor function (feature T811): constructs
a JSON object from
+ * key-value pairs, written `key VALUE value`, `KEY key VALUE value`, `key :
value`, or the
+ * MySQL-style `key, value`.
+ *
+ * Keys must be non-null strings; a null key is an error. The `ON NULL` clause
controls whether
+ * null-valued pairs are included (NULL ON NULL, the standard default) or
omitted (ABSENT ON NULL).
+ *
+ * A value is spliced in raw (unquoted) when it carries `FORMAT JSON` (tracked
per member by
+ * `rawJson`): the explicit clause (e.g. `JSON_OBJECT('a' VALUE '{"b":1}'
FORMAT JSON)`), or the
+ * implicit form carried by a nested JSON constructor / `JSON_QUERY` under
KEEP QUOTES (see
+ * `rawJsonValue`). Explicit `FORMAT JSON` on a non-constructor is arbitrary
user text, so
+ * `needsValidation` marks it for JSON validation at eval; a nested
constructor is trusted.
+ *
+ * Examples:
+ * JSON_OBJECT('id' VALUE 7, 'name' VALUE 'Ada') ->
'{"id":7,"name":"Ada"}'
+ * JSON_OBJECT('id': 7, 'v': NULL) -> '{"id":7,"v":null}'
+ * JSON_OBJECT('id': 7, 'v': NULL ABSENT ON NULL) -> '{"id":7}'
+ * JSON_OBJECT() -> '{}'
+ *
+ * A clause-free call that is not itself lexically nested in an enclosing
constructor's *value*
+ * position routes through function resolution and is rebuilt by
`JsonObjectExpressionBuilder`, so a
+ * same-named routine can shadow the built-in `json_object`: always for the
comma form (a nested
+ * producer value routes too, carrying its raw-splice eligibility via
`JsonImplicitFormatCarrier`),
+ * but for the `VALUE` / `:` forms only when no member carries a raw value
(`rawJson.contains(true)`
+ * -- an explicit `FORMAT JSON` or a nested producer in *value* position --
keeps it direct).
+ * Otherwise it is built directly from the grammar (clause-bearing, or
value-nested); a nested
+ * *key*-position call stays routable (see `AstBuilder.visitJsonObject`).
+ * The user-facing reference lives in
`docs/sql-ref-syntax-qry-select-json-object.md`.
+ */
+case class JsonObjectExpr(
+ members: Seq[(Expression, Expression)],
+ rawJson: Seq[Boolean],
+ needsValidation: Seq[Boolean],
+ nullBehavior: JsonConstructorNullBehavior =
JsonConstructorNullBehavior.Null,
+ returning: DataType = StringType,
+ timeZoneId: Option[String] = None)
+ extends Expression
+ with TimeZoneAwareExpression
+ with CodegenFallback
+ with QueryErrorsBase
+ // Default RETURNING is a plain STRING, so
`DefaultStringProducingExpression` lets
+ // `ApplyDefaultCollation` cast the result to a non-default collation; the
`dataType` override
+ // below stays authoritative when RETURNING is given explicitly.
+ with DefaultStringProducingExpression
+ with ImplicitlyFormattedAsJson {
+
+ // `rawJson(i)` splices member `i`'s value verbatim (already-JSON text)
rather than quoting it;
+ // `needsValidation(i)` marks that raw text as arbitrary user input to
JSON-validate at eval (an
+ // explicit `FORMAT JSON` on a non-constructor). Both are frozen at parse
time (see
+ // `AstBuilder.visitJsonObject`). A validated value is always a spliced one.
+ require(members.length == rawJson.length && members.length ==
needsValidation.length,
+ "JsonObjectExpr requires one rawJson and one needsValidation flag per
member")
+ require(needsValidation.lazyZip(rawJson).forall((nv, raw) => !nv || raw),
+ "JsonObjectExpr needsValidation implies rawJson")
+
+ @transient private lazy val memberArray: Array[(Expression, Expression)] =
+ members.toArray
+
+ @transient private lazy val rawJsonArray: Array[Boolean] =
+ rawJson.toArray
+
+ @transient private lazy val needsValidationArray: Array[Boolean] =
+ needsValidation.toArray
+
+ // Always throwable: a null key raises JSON_OBJECT_NULL_KEY at eval even
with non-throwable
+ // children, which keeps the optimizer from pushing it below a filtering
join. Left non-foldable
+ // (the default) so folding a constant call does not eagerly raise that
null-key error at
+ // optimization for rows a filter would later drop.
+ override lazy val throwable: Boolean = true
+
+ // The value is never null, but report nullable as `throwable` (like
JsonArray): `NullPropagation`
+ // keys off `nullable`, so a non-nullable constructor would let it fold `IS
[NOT] NULL` to a
+ // constant or `count(...)` to `count(1)`, skipping the eval that must raise
JSON_OBJECT_NULL_KEY.
+ override def nullable: Boolean = throwable
+
+ override def dataType: DataType = returning
+
+ // Flattened `[k0, v0, k1, v1, ...]` view, cached since `children` is walked
repeatedly.
+ @transient private lazy val childrenSeq: Seq[Expression] =
+ memberArray.flatMap { case (k, v) => Seq(k, v) }.toImmutableArraySeq
+
+ override def children: Seq[Expression] = childrenSeq
+
+ override def withTimeZone(timeZoneId: String): TimeZoneAwareExpression =
+ copy(timeZoneId = Option(timeZoneId))
+
+ override def checkInputDataTypes(): TypeCheckResult = {
+ val inputCheck = super.checkInputDataTypes()
+ if (inputCheck.isFailure) {
+ inputCheck
+ } else if (!JsonObjectExpr.isValidReturningType(returning)) {
+ DataTypeMismatch(
+ errorSubClass = "INVALID_JSON_RETURNING_TYPE",
+ messageParameters = Map(
+ "functionName" -> toSQLId(prettyName),
+ "returningType" -> toSQLType(returning)))
+ } else {
+ // Keys must be character strings (they become JSON object member
names). Reject any other
+ // concrete type up front so a non-string key cannot reach the
StringType Jackson writer and
+ // fail with an internal ClassCastException. NullType is allowed
through: a null key value is
+ // reported at runtime as JSON_OBJECT_NULL_KEY rather than a type error.
+ val keyCheck = memberArray.iterator.map(_._1).zipWithIndex.collectFirst {
+ case (k, keyIndex) if !(k.dataType.isInstanceOf[StringType] ||
k.dataType == NullType) =>
+ DataTypeMismatch(
+ errorSubClass = "UNEXPECTED_INPUT_TYPE",
+ messageParameters = Map(
+ "paramIndex" -> ordinalNumber(keyIndex * 2),
+ "requiredType" -> toSQLType(StringType),
+ "inputSql" -> toSQLExpr(k),
+ "inputType" -> toSQLType(k.dataType)))
+ }.getOrElse(TypeCheckResult.TypeCheckSuccess)
+ if (keyCheck.isFailure) {
+ keyCheck
+ } else {
+ // Every value must be serializable to JSON. This mirrors `to_json`'s
analysis-time
+ // `JacksonUtils.verifyType` check, plus a guard for the spatial
atomics that check accepts
+ // but `JacksonGenerator` cannot write (rejecting a runtime failure at
analysis instead).
+ memberArray.iterator.map(_._2).zipWithIndex
+ .foldLeft(TypeCheckResult.TypeCheckSuccess: TypeCheckResult) {
+ case (acc, _) if acc.isFailure => acc
+ case (_, (v, valueIndex)) if rawJsonArray(valueIndex) &&
+ !(v.dataType.isInstanceOf[StringType] || v.dataType ==
NullType) =>
+ // A raw-spliced value must carry JSON text (string); NullType
is allowed (eval drops
+ // or emits it before splicing). Reject any other type up front,
matching JSON_ARRAY's
+ // FORMAT JSON check, rather than failing with a
ClassCastException at eval.
+ DataTypeMismatch(
+ errorSubClass = "INVALID_JSON_FORMAT_JSON_INPUT",
+ messageParameters = Map(
+ "functionName" -> toSQLId(prettyName),
+ // Flattened 1-based arg position, not the member ordinal
(value of member i is
+ // arg 2i+2), matching the key check's
`ordinalNumber(keyIndex*2)`.
+ "position" -> (valueIndex * 2 + 2).toString,
+ "inputType" -> toSQLType(v.dataType)))
+ case (_, (v, _)) =>
+ val elemCheck = JacksonUtils.verifyType(prettyName, v.dataType)
+ if (elemCheck.isFailure) {
+ elemCheck
+ } else if
(JsonObjectExpr.containsUnsupportedJsonType(v.dataType)) {
+ DataTypeMismatch(
+ errorSubClass = "CANNOT_CONVERT_TO_JSON",
+ messageParameters = Map(
+ "name" -> toSQLId(prettyName),
+ "type" -> toSQLType(v.dataType)))
+ } else {
+ TypeCheckResult.TypeCheckSuccess
+ }
+ }
+ }
+ }
+ }
+
+ override def stateful: Boolean = true
+
+ @transient private lazy val resolvedZoneId: String =
+ timeZoneId.getOrElse(SQLConf.get.sessionLocalTimeZone)
+
+ // JacksonGenerator (shared with `to_json`, so rendering matches) can only
serialize a container,
+ // not a bare scalar, so each key/value is wrapped in a one-element array
whose brackets are
+ // stripped in renderKey/appendRenderedValue below.
+ @transient private lazy val keyEvaluator: StructsToJsonEvaluator =
+ StructsToJsonEvaluator(Map.empty, ArrayType(StringType),
Some(resolvedZoneId))
+
+ @transient private lazy val valueEvaluators: Array[StructsToJsonEvaluator] =
+ memberArray.map { case (_, v) =>
+ StructsToJsonEvaluator(Map.empty, ArrayType(v.dataType),
+ Some(resolvedZoneId))
+ }
+
+ @transient private lazy val singleElem: Array[Any] = new Array[Any](1)
+
+ @transient private lazy val singleElemData: GenericArrayData =
+ new GenericArrayData(singleElem)
+
+ @transient private lazy val castInput: GenericInternalRow =
+ new GenericInternalRow(1)
+
+ @transient private lazy val returningCast: Expression =
+ Cast(BoundReference(0, StringType, nullable = true), returning, timeZoneId,
+ EvalMode.ANSI)
+
+ // `evaluateString` returns the Java String directly, skipping `evaluate`'s
UTF8String round trip.
+ // The key is cached or appended whole, so it still materializes the
interior substring.
+ private def renderKey(key: Any): String = {
+ singleElem(0) = key
+ val arrJson = keyEvaluator.evaluateString(singleElemData)
+ arrJson.substring(1, arrJson.length - 1)
+ }
+
+ // Append the interior of the "[<frag>]" wrapper straight into the builder
(no per-value
+ // substring), mirroring `JsonArray.appendRenderedElement`.
+ private def appendRenderedValue(sb: java.lang.StringBuilder, idx: Int,
value: Any): Unit = {
+ singleElem(0) = value
+ val arrJson = valueEvaluators(idx).evaluateString(singleElemData)
+ sb.append(arrJson, 1, arrJson.length - 1)
+ }
+
+ @transient private lazy val formatJsonFactory: JsonFactory = new
JsonFactory()
+
+ @transient private lazy val cachedValidatedTexts: Array[String] =
+ new Array[String](memberArray.length)
+
+ // A `FORMAT JSON` value is spliced verbatim, so it must be exactly one
well-formed JSON value
+ // (a string like `1,2` or `{bad` would corrupt the object). Parse one
value; reject anything
+ // trailing. Mirrors `JsonArray.validateJsonText`.
+ private def validateJsonText(idx: Int, text: String): Unit = {
+ val valid =
+ try {
+ Utils.tryWithResource(formatJsonFactory.createParser(text)) { parser =>
+ if (parser.nextToken() == null) {
+ false
+ } else {
+ parser.skipChildren()
+ parser.nextToken() == null
+ }
+ }
+ } catch {
+ case _: JsonProcessingException => false
+ }
+ if (!valid) {
+ // Flattened 1-based value-arg position (member `idx` is arg `2*idx+2`),
matching the
+ // analysis-time INVALID_JSON_FORMAT_JSON_INPUT check.
+ throw QueryExecutionErrors.invalidJsonFormatJsonValueError(prettyName,
idx * 2 + 2, text)
+ }
+ }
+
+ // Raw text to splice for member `idx`: a trusted nested constructor splices
as-is; an explicit
+ // `FORMAT JSON` string is validated once (cached) when foldable, else per
row.
+ private def rawJsonText(idx: Int, value: Any): String = {
+ if (!needsValidationArray(idx)) {
+ value.asInstanceOf[UTF8String].toString
+ } else if (memberArray(idx)._2.foldable) {
+ val cached = cachedValidatedTexts(idx)
+ if (cached != null) {
+ cached
+ } else {
+ val text = value.asInstanceOf[UTF8String].toString
+ validateJsonText(idx, text)
+ cachedValidatedTexts(idx) = text
+ text
+ }
+ } else {
+ val text = value.asInstanceOf[UTF8String].toString
+ validateJsonText(idx, text)
+ text
+ }
+ }
+
+ // A foldable, non-null key (the common literal-key case, e.g.
`JSON_OBJECT('id' VALUE col)`)
+ // renders to the same JSON member name on every row, so render it once here
instead of re-running
+ // the Jackson writer per row. `renderedKeys(i)` holds that cached name, or
`null` when the key
+ // must be evaluated and rendered per row -- a non-foldable key, or a
foldable key that evaluates
+ // to `null` (still reported as JSON_OBJECT_NULL_KEY at eval).
+ @transient private lazy val renderedKeys: Array[String] = memberArray.map {
case (k, _) =>
+ if (k.foldable) {
+ val key = k.eval(EmptyRow)
+ if (key == null) null else renderKey(key)
+ } else {
+ null
+ }
+ }
+
+ override def eval(input: InternalRow): Any = {
+ val sb = new java.lang.StringBuilder("{")
+ var first = true
+ var i = 0
+ val localMembers = memberArray
+ val localRawJson = rawJsonArray
+ while (i < localMembers.length) {
+ val (keyExpr, valueExpr) = localMembers(i)
+ // Resolve the member's key. A foldable non-null key is already rendered
and cached. Otherwise
+ // evaluate the key before the value so a null key deterministically
raises
+ // JSON_OBJECT_NULL_KEY, independent of whether the value expression
happens to throw.
+ // Delay rendering dynamic keys until we know the member will be
emitted, avoiding work for
+ // null values under ABSENT ON NULL.
+ val cachedKey = renderedKeys(i)
+ val key = if (cachedKey == null) {
+ val key = keyExpr.eval(input)
+ if (key == null) {
+ throw QueryExecutionErrors.jsonObjectNullKeyError()
+ }
+ key
+ } else {
+ null
+ }
+ val value = valueExpr.eval(input)
+ if (value != null || nullBehavior == JsonConstructorNullBehavior.Null) {
+ if (!first) sb.append(",")
+ first = false
+ val keyName = if (cachedKey != null) cachedKey else renderKey(key)
+ sb.append(keyName).append(":")
+ if (value == null) {
+ sb.append("null")
+ } else if (localRawJson(i)) {
+ sb.append(rawJsonText(i, value))
+ } else {
+ appendRenderedValue(sb, i, value)
+ }
+ }
+ i += 1
+ }
+ sb.append("}")
+ val jsonStr = UTF8String.fromString(sb.toString)
+ if (returning == StringType) {
+ jsonStr
+ } else {
+ castInput.update(0, jsonStr)
+ returningCast.eval(castInput)
+ }
+ }
+
+ override def prettyName: String = "json_object"
+
+ override def sql: String = {
+ val membersSQL = members.zip(rawJson).map { case ((k, v), raw) =>
+ // Render SQL that reparses to the frozen `raw` flag, regardless of
optimizer rewrites of the
+ // child:
+ // - raw + child still a bare implicit producer (bare, or under a
pass-through COLLATE):
+ // render that bare constructor so reparse re-derives implicit raw
splicing.
+ // - raw + child no longer recoverable as a constructor
(constant-folded to a literal, or a
+ // Cast wrapper): render an explicit `FORMAT JSON` so reparse splices
it raw.
+ // - quoted + child would reparse as implicit JSON (an
optimizer-inlined constructor, bare or
+ // under COLLATE): neutralize with `CAST(... AS STRING)` so reparse
keeps it quoted.
+ // - otherwise the child's shape already reproduces the flag.
+ val valueSQL = (raw, JsonObjectExpr.rawJsonValue(v)) match {
+ case (true, Some(c)) => c.sql
+ case (true, None) => s"${v.sql} FORMAT JSON"
+ case (false, Some(_)) => s"CAST(${v.sql} AS STRING)"
+ case (false, None) => v.sql
+ }
+ s"${k.sql} VALUE $valueSQL"
+ }.mkString(", ")
+ // Always render an explicit ON NULL clause. It forces the direct grammar
path on reparse (a
+ // clause-free call routes through function resolution and could bind a
shadowing routine), so a
+ // resolved built-in round-trips to the same built-in. This diverges from
`JsonArray`, which
+ // omits its default clause and has the same latent gap.
+ // TODO(SPARK-59728): align `JsonArray.sql` to render an explicit
direct-path marker too.
+ val nullSQL = nullBehavior match {
+ case JsonConstructorNullBehavior.Null => " NULL ON NULL"
+ case JsonConstructorNullBehavior.Absent => " ABSENT ON NULL"
Review Comment:
**Non-blocking (P2):** This new renderer branch has no render-and-reparse
coverage. The direct ABSENT execution tests bypass `.sql`, so they would stay
green if this arm emitted the default clause or omitted ABSENT and a canonical
round trip began retaining null-valued members.
**Verification:**
- **Regression:** Render, reparse, and evaluate an ABSENT ON NULL expression
with a null member and assert that the member remains omitted.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]