This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new f777036d64d0 [SPARK-58685][SQL] Support the ANSI SQL/JSON JSON_VALUE
scalar function
f777036d64d0 is described below
commit f777036d64d0c0b7413907526f869bd7cb44e49b
Author: Ganesha S <[email protected]>
AuthorDate: Tue Aug 11 20:33:23 2026 +0800
[SPARK-58685][SQL] Support the ANSI SQL/JSON JSON_VALUE scalar function
### What changes were proposed in this pull request?
Add the ANSI SQL:2016 `JSON_VALUE` scalar function (feature T821):
```
JSON_VALUE(jsonExpr, path
[ RETURNING <type> ]
[ (NULL | ERROR | DEFAULT expr) ON EMPTY ]
[ (NULL | ERROR | DEFAULT expr) ON ERROR ])
```
It extracts the scalar at `path`, cast to the RETURNING type (default
STRING). A missing path fires ON EMPTY; malformed JSON, a non-scalar match, or
a failed cast fires ON ERROR; both default to NULL. An explicit JSON null
returns SQL NULL, and a SQL NULL input propagates to NULL.
Grammar adds non-reserved keywords `JSON_VALUE`, `RETURNING`, `EMPTY` and a
dedicated production, mirroring `JSON_TABLE` (SPARK-58366). Evaluation reuses
JSON_TABLE's token-aware path evaluator via a new `JsonTableEvaluator.lookup`
entry point that preserves the missing / JSON-null / found distinction. The
extracted-scalar cast is always ANSI (throwing) so ON ERROR governs a failed
conversion regardless of `spark.sql.ansi.enabled`.
v1 supports STRING input and scalar (string/numeric/boolean/datetime)
RETURNING types per ANSI 9075-2 6.28; VARIANT RETURNING and PASSING are
deferred.
### Why are the changes needed?
Spark implements none of the ANSI scalar JSON functions. `JSON_VALUE` is
near-universal (Oracle, SQL Server, PostgreSQL, Trino, BigQuery, ...), so its
absence forces migrated queries to rewrite to `CAST(get_json_object(...))`,
which is STRING-only, cannot express RETURNING, and conflates missing vs.
error.
### Does this PR introduce _any_ user-facing change?
Yes. It adds the new `JSON_VALUE` SQL function. The new keywords are
non-reserved in both ANSI and default modes, so existing identifiers named
`json_value` /`returning` / `empty` continue to work.
### How was this patch tested?
New `JsonValueSuite` (both ANSI modes), `json-functions.sql` golden files,
`ExpressionParserSuite`, `SQLKeywordSuite`, and `SparkThrowableSuite` for the
new error conditions.
### Was this patch authored or co-authored using generative AI tooling?
Yes, Claude Opus 4.8.
Closes #57888 from ganeshashree/SPARK-58685.
Lead-authored-by: Ganesha S <[email protected]>
Co-authored-by: Ganesha S <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../src/main/resources/error/error-conditions.json | 28 +++
docs/sql-ref-ansi-compliance.md | 3 +
docs/sql-ref-syntax-qry-select-json-value.md | 136 +++++++++++
docs/sql-ref-syntax-qry-select.md | 1 +
docs/sql-ref-syntax.md | 1 +
.../spark/sql/catalyst/parser/SqlBaseLexer.g4 | 3 +
.../spark/sql/catalyst/parser/SqlBaseParser.g4 | 18 ++
.../expressions/json/JsonExpressionEvalUtils.scala | 101 ++++++++
.../sql/catalyst/expressions/jsonExpressions.scala | 226 +++++++++++++++++-
.../spark/sql/catalyst/parser/AstBuilder.scala | 31 +++
.../spark/sql/errors/QueryExecutionErrors.scala | 14 ++
.../expressions/JsonExpressionsSuite.scala | 9 +-
.../catalyst/parser/ExpressionParserSuite.scala | 24 ++
.../jdbc/SparkConnectDatabaseMetaDataSuite.scala | 2 +-
.../analyzer-results/json-functions.sql.out | 213 +++++++++++++++++
.../resources/sql-tests/inputs/json-functions.sql | 38 +++
.../sql-tests/results/json-functions.sql.out | 264 +++++++++++++++++++++
.../sql-tests/results/keywords-enforced.sql.out | 3 +
.../resources/sql-tests/results/keywords.sql.out | 3 +
.../sql-tests/results/nonansi/keywords.sql.out | 3 +
.../org/apache/spark/sql/JsonValueSuite.scala | 263 ++++++++++++++++++++
.../ThriftServerWithSparkContextSuite.scala | 2 +-
22 files changed, 1381 insertions(+), 5 deletions(-)
diff --git a/common/utils/src/main/resources/error/error-conditions.json
b/common/utils/src/main/resources/error/error-conditions.json
index 400fd41b913b..62eef58c0543 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -1958,6 +1958,16 @@
"Input schema <schema> can only contain STRING as a key type for a
MAP."
]
},
+ "INVALID_JSON_PATH" : {
+ "message" : [
+ "<functionName> has an invalid or unsupported JSON path <path>. Only
simple, wildcard-free paths are supported."
+ ]
+ },
+ "INVALID_JSON_SCALAR_RETURNING_TYPE" : {
+ "message" : [
+ "<functionName> cannot return a value of type <returningType>. The
RETURNING type must be a scalar (string, numeric, boolean, or datetime) type."
+ ]
+ },
"INVALID_JSON_SCHEMA" : {
"message" : [
"Input schema <schema> must be a struct, an array, a map or a
variant."
@@ -5574,6 +5584,24 @@
],
"sqlState" : "42K0E"
},
+ "JSON_VALUE_ON_ERROR" : {
+ "message" : [
+ "<functionName> could not extract a scalar value at path <path>."
+ ],
+ "subClass" : {
+ "EMPTY" : {
+ "message" : [
+ "The path matched no value. This error was requested by the ERROR ON
EMPTY clause."
+ ]
+ },
+ "ERROR" : {
+ "message" : [
+ "The input is not valid JSON, the matched value is not a scalar, or
it could not be cast to the RETURNING type. This error was requested by the
ERROR ON ERROR clause."
+ ]
+ }
+ },
+ "sqlState" : "2203G"
+ },
"KAFKA_DATA_SOURCE_NOT_ENABLED" : {
"message" : [
"Failed to find data source: <provider>. Please deploy the application
as per the deployment section of Structured Streaming + Kafka Integration
Guide."
diff --git a/docs/sql-ref-ansi-compliance.md b/docs/sql-ref-ansi-compliance.md
index 4eb886a16e60..77c4d96da1ea 100644
--- a/docs/sql-ref-ansi-compliance.md
+++ b/docs/sql-ref-ansi-compliance.md
@@ -537,6 +537,7 @@ Below is a list of all the keywords in Spark SQL.
|DROP|non-reserved|non-reserved|reserved|
|ELSE|reserved|non-reserved|reserved|
|ELSEIF|non-reserved|non-reserved|non-reserved|
+|EMPTY|non-reserved|non-reserved|reserved|
|END|reserved|non-reserved|reserved|
|ENFORCED|non-reserved|non-reserved|non-reserved|
|ERROR|non-reserved|non-reserved|non-reserved|
@@ -618,6 +619,7 @@ Below is a list of all the keywords in Spark SQL.
|JOIN|reserved|strict-non-reserved|reserved|
|JSON|non-reserved|non-reserved|non-reserved|
|JSON_TABLE|non-reserved|non-reserved|reserved|
+|JSON_VALUE|non-reserved|non-reserved|reserved|
|KEY|non-reserved|non-reserved|non-reserved|
|KEYS|non-reserved|non-reserved|non-reserved|
|LANGUAGE|non-reserved|non-reserved|reserved|
@@ -735,6 +737,7 @@ Below is a list of all the keywords in Spark SQL.
|RESPECT|non-reserved|non-reserved|non-reserved|
|RESTRICT|non-reserved|non-reserved|non-reserved|
|RETURN|non-reserved|non-reserved|reserved|
+|RETURNING|non-reserved|non-reserved|non-reserved|
|RETURNS|non-reserved|non-reserved|reserved|
|REVOKE|non-reserved|non-reserved|reserved|
|RIGHT|reserved|strict-non-reserved|reserved|
diff --git a/docs/sql-ref-syntax-qry-select-json-value.md
b/docs/sql-ref-syntax-qry-select-json-value.md
new file mode 100644
index 000000000000..deb0be82c2b5
--- /dev/null
+++ b/docs/sql-ref-syntax-qry-select-json-value.md
@@ -0,0 +1,136 @@
+---
+layout: global
+title: JSON_VALUE
+displayTitle: JSON_VALUE
+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_VALUE` scalar function extracts a single scalar value located by a
SQL/JSON path from a
+JSON document and returns it cast to the `RETURNING` type (`STRING` by
default). This is the
+SQL-standard way (SQL:2016) to pull an individual value out of JSON, and is
commonly used to
+migrate queries from other systems such as Oracle, DB2, and MySQL. Unlike
+[JSON_TABLE](sql-ref-syntax-qry-select-json-table.html), which produces rows
in a `FROM` clause,
+`JSON_VALUE` is an expression that can appear anywhere a scalar is allowed.
+
+The function returns a scalar only. A path that matches an object or array is
an *error* case (see
+`ON ERROR`), not a value. Use
[JSON_TABLE](sql-ref-syntax-qry-select-json-table.html) or the
+built-in `get_json_object` function to extract structured fragments.
+
+### Syntax
+
+```sql
+JSON_VALUE ( json_expr, path
+ [ RETURNING data_type ]
+ [ empty_behavior ON EMPTY ]
+ [ error_behavior ON ERROR ] )
+
+empty_behavior
+ { NULL | ERROR | DEFAULT default_expr }
+
+error_behavior
+ { NULL | ERROR | DEFAULT default_expr }
+```
+
+### Parameters
+
+* **json_expr**
+
+ An expression that evaluates to a `STRING` containing the JSON document. A
`NULL` input yields
+ `NULL` directly (it triggers neither the `ON EMPTY` nor the `ON ERROR`
behavior).
+
+* **path**
+
+ A SQL/JSON path literal that locates the value, for example `'$.a.b'` or
`'$.items[0]'`. The
+ path must be wildcard-free, since `JSON_VALUE` returns a single value; a
path containing `[*]`
+ is rejected at analysis time.
+
+* **RETURNING data_type**
+
+ The type the extracted value is cast to. It must be a scalar (atomic)
type: a string, numeric,
+ boolean, or datetime type. Non-atomic types (`STRUCT`, `ARRAY`, `MAP`) and
`VARIANT` / `BINARY`
+ are not supported. If `RETURNING` is omitted, the result type is `STRING`.
+
+* **empty_behavior ON EMPTY**
+
+ What to produce when `path` matches nothing:
+ * `NULL` (the default) returns SQL `NULL`.
+ * `ERROR` raises an error.
+ * `DEFAULT default_expr` returns `default_expr`, cast to the `RETURNING`
type.
+
+* **error_behavior ON ERROR**
+
+ What to produce when the extraction fails: the input is not well-formed
JSON, the path matches a
+ non-scalar (object or array) value, or casting the matched scalar to the
`RETURNING` type fails.
+ * `NULL` (the default) returns SQL `NULL`.
+ * `ERROR` raises an error.
+ * `DEFAULT default_expr` returns `default_expr`, cast to the `RETURNING`
type.
+
+ The cast of the matched scalar to the `RETURNING` type always follows ANSI
semantics (a failed
+ conversion routes to `ON ERROR`), independently of the session's
`spark.sql.ansi.enabled`
+ setting.
+
+A path that matches an explicit JSON `null` is a present scalar value and
returns SQL `NULL` (it is
+neither the `ON EMPTY` nor the `ON ERROR` case).
+
+### Examples
+
+```sql
+-- Extract a scalar as STRING (the default)
+SELECT json_value('{"id":7,"name":"Ada"}', '$.name');
++-------------------------------------------+
+|json_value({"id":7,"name":"Ada"}, $.name) |
++-------------------------------------------+
+|Ada |
++-------------------------------------------+
+
+-- Cast the extracted value with RETURNING
+SELECT json_value('{"id":7}', '$.id' RETURNING INT) + 1;
++---------------------------------------------+
+|(json_value({"id":7}, $.id) + 1) |
++---------------------------------------------+
+|8 |
++---------------------------------------------+
+
+-- A missing path defaults to NULL; supply a fallback with DEFAULT ... ON EMPTY
+-- (RETURNING, when present, comes before the ON EMPTY / ON ERROR clauses)
+SELECT json_value('{"id":7}', '$.missing' RETURNING INT DEFAULT -1 ON EMPTY);
++---------------------------------------------------------------+
+|json_value({"id":7}, $.missing RETURNING INT DEFAULT -1 ON EMPTY)|
++---------------------------------------------------------------+
+|-1 |
++---------------------------------------------------------------+
+
+-- A non-scalar match or malformed input is an ON ERROR case
+SELECT json_value('{"addr":{"city":"NYC"}}', '$.addr' DEFAULT 'n/a' ON ERROR);
++---------------------------------------------------------------+
+|json_value({"addr":{"city":"NYC"}}, $.addr DEFAULT n/a ON ERROR)|
++---------------------------------------------------------------+
+|n/a |
++---------------------------------------------------------------+
+
+-- ERROR ON ERROR raises instead of returning a value
+SELECT json_value('not json', '$.a' ERROR ON ERROR);
+[JSON_VALUE_ON_ERROR.ERROR] ...
+```
+
+### Related Statements
+
+* [SELECT](sql-ref-syntax-qry-select.html)
+* [JSON_TABLE](sql-ref-syntax-qry-select-json-table.html)
+* [Built-in Functions](sql-ref-functions-builtin.html)
diff --git a/docs/sql-ref-syntax-qry-select.md
b/docs/sql-ref-syntax-qry-select.md
index d07f95e6b7b6..b2afd160c5a3 100644
--- a/docs/sql-ref-syntax-qry-select.md
+++ b/docs/sql-ref-syntax-qry-select.md
@@ -213,6 +213,7 @@ SELECT [ hints , ... ] [ ALL | DISTINCT ] { [ [
named_expression | regex_column_
* [Set Operators](sql-ref-syntax-qry-select-setops.html)
* [TABLESAMPLE](sql-ref-syntax-qry-select-sampling.html)
* [Table-valued Function](sql-ref-syntax-qry-select-tvf.html)
+* [JSON_VALUE](sql-ref-syntax-qry-select-json-value.html)
* [Window Function](sql-ref-syntax-qry-select-window.html)
* [CASE Clause](sql-ref-syntax-qry-select-case.html)
* [PIVOT Clause](sql-ref-syntax-qry-select-pivot.html)
diff --git a/docs/sql-ref-syntax.md b/docs/sql-ref-syntax.md
index 36cd297aaf82..c8ddecaf6bdb 100644
--- a/docs/sql-ref-syntax.md
+++ b/docs/sql-ref-syntax.md
@@ -84,6 +84,7 @@ ability to generate logical and physical plan for a given
query using
* [TABLESAMPLE](sql-ref-syntax-qry-select-sampling.html)
* [Table-valued Function](sql-ref-syntax-qry-select-tvf.html)
* [JSON_TABLE](sql-ref-syntax-qry-select-json-table.html)
+ * [JSON_VALUE](sql-ref-syntax-qry-select-json-value.html)
* [WHERE Clause](sql-ref-syntax-qry-select-where.html)
* [Aggregate Function](sql-ref-syntax-qry-select-aggregate.html)
* [Window Function](sql-ref-syntax-qry-select-window.html)
diff --git
a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseLexer.g4
b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseLexer.g4
index 5e15b72d6a30..c042bca5e8ce 100644
---
a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseLexer.g4
+++
b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseLexer.g4
@@ -254,6 +254,7 @@ DOUBLE: 'DOUBLE';
DROP: 'DROP';
ELSE: 'ELSE';
ELSEIF: 'ELSEIF';
+EMPTY: 'EMPTY';
END: 'END';
ENFORCED: 'ENFORCED';
ERROR: 'ERROR';
@@ -335,6 +336,7 @@ ITERATE: 'ITERATE';
JOIN: 'JOIN';
JSON: 'JSON';
JSON_TABLE: 'JSON_TABLE';
+JSON_VALUE: 'JSON_VALUE';
KEY: 'KEY';
KEYS: 'KEYS';
LANGUAGE: 'LANGUAGE';
@@ -450,6 +452,7 @@ RESET: 'RESET';
RESPECT: 'RESPECT';
RESTRICT: 'RESTRICT';
RETURN: 'RETURN';
+RETURNING: 'RETURNING';
RETURNS: 'RETURNS';
REVOKE: 'REVOKE';
RIGHT: 'RIGHT';
diff --git
a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
index 98fb1cd5f29f..ee7de7eac2bf 100644
---
a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
+++
b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
@@ -1462,6 +1462,10 @@ primaryExpression
| ANY_VALUE LEFT_PAREN expression (IGNORE NULLS)? RIGHT_PAREN
#any_value
| LAST LEFT_PAREN expression (IGNORE NULLS)? RIGHT_PAREN
#last
| POSITION LEFT_PAREN substr=valueExpression IN str=valueExpression
RIGHT_PAREN #position
+ | JSON_VALUE LEFT_PAREN jsonExpr=valueExpression COMMA path=stringLit
+ (RETURNING returning=dataType)?
+ (emptyBehavior=jsonValueBehavior ON EMPTY)?
+ (errorBehavior=jsonValueBehavior ON ERROR)? RIGHT_PAREN
#jsonValue
| constant
#constantDefault
| ASTERISK exceptClause?
#star
| qualifiedName DOT ASTERISK exceptClause?
#star
@@ -1488,6 +1492,14 @@ primaryExpression
FROM position=valueExpression (FOR length=valueExpression)? RIGHT_PAREN
#overlay
;
+// The behavior selected by a JSON_VALUE `... ON EMPTY` / `... ON ERROR`
clause. NULL and ERROR are
+// keywords; DEFAULT carries an expression evaluated in place of the
missing/erroring value.
+jsonValueBehavior
+ : NULL
#jsonValueBehaviorNull
+ | ERROR
#jsonValueBehaviorError
+ | DEFAULT defaultExpr=expression
#jsonValueBehaviorDefault
+ ;
+
semiStructuredExtractionPath
: jsonPathFirstPart (jsonPathParts)*
;
@@ -2171,6 +2183,7 @@ ansiNonReserved
| DOUBLE
| DROP
| ELSEIF
+ | EMPTY
| ENFORCED
| ERROR
| ESCAPED
@@ -2232,6 +2245,7 @@ ansiNonReserved
| ITERATE
| JSON
| JSON_TABLE
+ | JSON_VALUE
| KEY
| KEYS
| LANGUAGE
@@ -2330,6 +2344,7 @@ ansiNonReserved
| RESPECT
| RESTRICT
| RETURN
+ | RETURNING
| RETURNS
| REVOKE
| RLIKE
@@ -2592,6 +2607,7 @@ nonReserved
| DROP
| ELSE
| ELSEIF
+ | EMPTY
| END
| ENFORCED
| ERROR
@@ -2668,6 +2684,7 @@ nonReserved
| ITERATE
| JSON
| JSON_TABLE
+ | JSON_VALUE
| KEY
| KEYS
| LANGUAGE
@@ -2780,6 +2797,7 @@ nonReserved
| RESPECT
| RESTRICT
| RETURN
+ | RETURNING
| RETURNS
| REVOKE
| RLIKE
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/json/JsonExpressionEvalUtils.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/json/JsonExpressionEvalUtils.scala
index fbb81bccaa80..bf0891ef7236 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/json/JsonExpressionEvalUtils.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/json/JsonExpressionEvalUtils.scala
@@ -373,6 +373,25 @@ object JsonPathResult {
case class Found(raw: UTF8String) extends JsonPathResult
}
+/**
+ * The result of a single-value [[JsonTableEvaluator.lookup]] for
`JSON_VALUE`. Unlike
+ * [[JsonPathResult]] -- whose `Found` carries verbatim JSON text for
`JSON_TABLE` to unquote later
+ * -- a `Scalar` here already holds the value's cast-ready text (a string's
unquoted/unescaped
+ * content, a number's or boolean's source text), and an object/array match is
reported as
+ * `NonScalar` without being serialized at all, since `JSON_VALUE` routes
non-scalars to ON ERROR.
+ */
+sealed trait JsonValueLookup
+object JsonValueLookup {
+ /** The path did not match (routes to ON EMPTY). */
+ case object Missing extends JsonValueLookup
+ /** The path matched a JSON `null` literal (yields SQL NULL). */
+ case object NullValue extends JsonValueLookup
+ /** The path matched an object or array, i.e. not a scalar (routes to ON
ERROR). */
+ case object NonScalar extends JsonValueLookup
+ /** The path matched a scalar; `text` is its cast-ready value (strings
already unquoted). */
+ case class Scalar(text: UTF8String) extends JsonValueLookup
+}
+
/**
* A prefix trie over the (wildcard-free) column paths of a single
`JSON_TABLE` invocation, built
* once via [[JsonTableEvaluator.buildPathTrie]] and reused for every row. It
lets
@@ -483,6 +502,88 @@ case class JsonTableEvaluator(containerPath:
Seq[PathInstruction], explodeRoot:
}
}
+ /**
+ * Resolves `containerPath` against a single JSON value for `JSON_VALUE`,
preserving the missing /
+ * JSON-null / found distinction that [[evaluate]] collapses. Returns:
+ *
+ * - `None` if the input is not a single well-formed JSON value (malformed
/ trailing garbage /
+ * empty);
+ * - `Some(Missing)` if the path matches nothing;
+ * - `Some(NullValue)` if the path matches an explicit JSON `null`;
+ * - `Some(NonScalar)` if the path matches an object or array;
+ * - `Some(Scalar(text))` if the path matches a scalar, where `text` is
its cast-ready value --
+ * a string's unquoted/unescaped content, a number's or boolean's source
text (see
+ * [[scalarAt]]).
+ *
+ * A `null` input is the caller's responsibility. `explodeRoot` is ignored:
this is a single-value
+ * lookup, so construct the evaluator with `explodeRoot = false`.
+ *
+ * A single parser both navigates the path and validates well-formedness:
after the matched value
+ * is captured (or the path is found missing), [[drainToRootEnd]] consumes
the rest of the root
+ * value and rejects any trailing content, so a valid prefix followed by
garbage (or a second
+ * root value) is rejected exactly as a fully malformed document is. This
avoids the extra
+ * O(document size) validation pass a separate `isSingleWellFormedValue`
scan would add per row.
+ */
+ final def lookup(json: UTF8String): Option[JsonValueLookup] = {
+ Utils.tryWithResource(CreateJacksonParser.utf8String(jsonFactory, json)) {
parser =>
+ try {
+ if (parser.nextToken() == null) {
+ None // empty or whitespace-only
+ } else {
+ val result = positionAt(parser, containerPath) match {
+ case PositionResult.Missing => JsonValueLookup.Missing
+ case PositionResult.NullValue => JsonValueLookup.NullValue
+ case PositionResult.AtValue => scalarAt(parser)
+ }
+ // Reject a valid prefix trailed by extra content, keeping
malformed-input semantics
+ // identical to the array row source (which validates the whole
document up front).
+ if (drainToRootEnd(parser)) Some(result) else None
+ }
+ } catch {
+ case _: JsonProcessingException => None
+ }
+ }
+ }
+
+ /**
+ * Classifies the value the parser is positioned at (the `AtValue` case of
[[positionAt]]) for a
+ * `JSON_VALUE` [[lookup]], avoiding the serialize-then-reparse round trip
that
+ * [[serializeCurrentValue]] followed by [[unquotedString]] would incur:
+ *
+ * - an object or array is consumed with `skipChildren` -- so
[[drainToRootEnd]] can still walk
+ * back out and validate the document -- and reported as `NonScalar`,
never serialized, since
+ * `JSON_VALUE` routes non-scalars to ON ERROR and so never needs the
value;
+ * - a scalar's cast-ready text is read straight from the parser via
`getText`, which returns a
+ * string's unquoted, unescaped content and a number's or boolean's
verbatim source characters
+ * (so a high-precision fraction reaches a DECIMAL/STRING cast intact,
as with
+ * `copyCurrentStructureExact`).
+ *
+ * A JSON `null` never reaches here -- [[positionAt]] reports it as
`NullValue`.
+ */
+ private def scalarAt(parser: JsonParser): JsonValueLookup =
parser.currentToken match {
+ case JsonToken.START_OBJECT | JsonToken.START_ARRAY =>
+ parser.skipChildren()
+ JsonValueLookup.NonScalar
+ case _ =>
+ JsonValueLookup.Scalar(UTF8String.fromString(parser.getText))
+ }
+
+ /**
+ * Finishes consuming the root JSON value the `parser` is partway through
and verifies nothing
+ * follows it, returning false if the input is truncated or has trailing
content. Callers navigate
+ * to (and serialize) a matched value with the same parser, which can leave
it positioned inside
+ * the enclosing containers; this walks back out to the root and confirms
the document held
+ * exactly one well-formed value.
+ */
+ private def drainToRootEnd(parser: JsonParser): Boolean = {
+ // Walk out of any still-open containers, consuming the remainder of the
root value.
+ while (!parser.getParsingContext.inRoot) {
+ if (parser.nextToken() == null) return false // truncated mid-value
+ }
+ // At the root now: exactly one value was present iff nothing remains.
+ parser.nextToken() == null
+ }
+
/**
* Navigates `path` and leaves the parser positioned at the first token of
the matched value
* (returning `AtValue`), or returns `Missing`/`NullValue`. Unlike the
column projection traversal
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala
index 577956bb15bd..c91221690091 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala
@@ -25,8 +25,8 @@ import
org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext, CodeGe
import org.apache.spark.sql.catalyst.expressions.codegen.Block.BlockHelper
import org.apache.spark.sql.catalyst.expressions.json.{GetJsonObjectEvaluator,
JsonExpressionUtils,
JsonPathParser, JsonPathResult, JsonTableEvaluator, JsonTablePathTrie,
JsonToStructsEvaluator,
- JsonTupleEvaluator, MultiGetJsonObjectEvaluator, PathInstruction,
SchemaOfJsonEvaluator,
- StructsToJsonEvaluator}
+ JsonTupleEvaluator, JsonValueLookup, MultiGetJsonObjectEvaluator,
PathInstruction,
+ SchemaOfJsonEvaluator, StructsToJsonEvaluator}
import org.apache.spark.sql.catalyst.expressions.objects.{Invoke, StaticInvoke}
import org.apache.spark.sql.catalyst.json._
import org.apache.spark.sql.catalyst.trees.TreePattern.{GET_JSON_OBJECT,
JSON_TO_STRUCT,
@@ -637,6 +637,228 @@ case class JsonTable(
copy(child = newChild)
}
+/**
+ * Behavior of `JSON_VALUE`'s `ON EMPTY` / `ON ERROR` clause: what to produce
when the path matches
+ * nothing, or when the input/extraction fails.
+ */
+sealed trait JsonValueBehavior
+object JsonValueBehavior {
+ /** Produce SQL NULL (the SQL-standard default for both ON EMPTY and ON
ERROR). */
+ case object Null extends JsonValueBehavior
+ /** Raise an error. */
+ case object Error extends JsonValueBehavior
+ /** Produce the value of a `DEFAULT` expression, cast to the RETURNING type.
*/
+ case object Default extends JsonValueBehavior
+}
+
+// scalastyle:off line.size.limit
+/**
+ * The SQL:2016 `JSON_VALUE` scalar function (feature T821): extracts a single
scalar located by a
+ * SQL/JSON `path` from a JSON input, casts it to the `RETURNING` type
(default STRING), and applies
+ * the `ON EMPTY` / `ON ERROR` behavior when the path matches nothing or the
extraction/cast fails:
+ *
+ * - missing path -> ON EMPTY behavior
+ * - explicit JSON `null` -> SQL NULL
+ * - non-scalar (object/array) match -> ON ERROR behavior
+ * - malformed / non-single-value input -> ON ERROR behavior
+ * - scalar match, cast fails -> ON ERROR behavior
+ * - scalar match, cast succeeds -> the cast value
+ *
+ * Both clauses default to NULL per the standard. A `null` JSON input yields
SQL NULL directly, not
+ * the ON EMPTY/ERROR path.
+ *
+ * `emptyDefault` / `errorDefault` hold the `DEFAULT <expr>` expressions,
present only for the
+ * corresponding `Default` behavior. The child list is variable (0-2
defaults), so this extends
+ * `Expression` directly rather than `UnaryExpression`.
+ *
+ * {{{
+ * JSON_VALUE('{"id":7}', '$.id' RETURNING INT) -- 7
+ * JSON_VALUE('{"id":7}', '$.missing' DEFAULT -1 ON EMPTY) -- -1
+ * JSON_VALUE('{"a":{}}', '$.a' ERROR ON ERROR) --
raises (non-scalar)
+ * }}}
+ */
+// scalastyle:on line.size.limit
+case class JsonValue(
+ child: Expression,
+ path: String,
+ returning: DataType,
+ onEmpty: JsonValueBehavior,
+ onError: JsonValueBehavior,
+ emptyDefault: Option[Expression],
+ errorDefault: Option[Expression],
+ timeZoneId: Option[String] = None,
+ ansiEnabled: Boolean = SQLConf.get.ansiEnabled)
+ extends Expression
+ with TimeZoneAwareExpression
+ with CodegenFallback
+ with ExpectsInputTypes
+ with QueryErrorsBase {
+
+ override def nullable: Boolean = true
+
+ // Reuses the mutable `castInput` row across rows (see `castScalar`), so it
holds evaluation
+ // state. Interpreted execution must fresh-copy the expression before use,
or a shared instance
+ // could cast another concurrent evaluation's value; matches the neighboring
JSON expressions.
+ override def stateful: Boolean = true
+
+ // Children: the JSON input first, then whichever DEFAULT expressions are
present. The two
+ // defaults are resolved/coerced through the normal child machinery; their
cast to `returning`
+ // happens at eval time via `emptyDefaultCast` / `errorDefaultCast`.
+ override def children: Seq[Expression] =
+ child +: (emptyDefault.toSeq ++ errorDefault.toSeq)
+
+ // One entry per child: the JSON input must be STRING; the DEFAULT children
accept anything (they
+ // are cast to `returning` explicitly at eval). One entry per child is
required because the
+ // coercion rule zips `children` against `inputTypes` and rebuilds via
`withNewChildren`; a
+ // shorter list would truncate the zip and pass the wrong child count.
+ override def inputTypes: Seq[AbstractDataType] =
+ StringTypeWithCollation(supportsTrimCollation = true) +:
+ children.tail.map(_ => AnyDataType)
+
+ override def dataType: DataType = returning
+
+ override def withTimeZone(timeZoneId: String): TimeZoneAwareExpression =
+ copy(timeZoneId = Option(timeZoneId))
+
+ override def checkInputDataTypes(): TypeCheckResult = {
+ val inputCheck = super.checkInputDataTypes()
+ if (inputCheck.isFailure) {
+ inputCheck
+ } else if (!JsonPathParser.hasWildcard(path).contains(false)) {
+ // The path must parse and be wildcard-free (JSON_VALUE returns a single
scalar).
+ DataTypeMismatch(
+ errorSubClass = "INVALID_JSON_PATH",
+ messageParameters = Map(
+ "functionName" -> toSQLId(prettyName), "path" -> toSQLValue(path)))
+ } else if (!JsonValue.isValidReturningType(returning)) {
+ // RETURNING is restricted to scalar (atomic) types per ANSI 9075-2 6.28.
+ DataTypeMismatch(
+ errorSubClass = "INVALID_JSON_SCALAR_RETURNING_TYPE",
+ messageParameters = Map(
+ "functionName" -> toSQLId(prettyName), "returningType" ->
toSQLType(returning)))
+ } else {
+ // Each DEFAULT expression is cast to the RETURNING type at eval time
(see
+ // `emptyDefaultCast` / `errorDefaultCast`). Those casts are not
analyzed children, so an
+ // uncastable default (e.g. `DEFAULT array(1)` with `RETURNING INT`)
would otherwise slip
+ // past analysis and fail late only when its branch is taken. Surface
the cast's own type
+ // check here so it is rejected up front with the standard CAST_*
message.
+ (emptyDefaultCast ++ errorDefaultCast)
+ .map(_.checkInputDataTypes())
+ .find(_.isFailure)
+ .getOrElse(TypeCheckResult.TypeCheckSuccess)
+ }
+ }
+
+ // Eval mode for the user-provided DEFAULT expression casts: follows the
session ANSI setting like
+ // any ordinary value cast. The extracted-scalar cast is separate (see
`valueCast`).
+ @transient private lazy val defaultEvalMode =
EvalMode.fromBoolean(ansiEnabled)
+
+ // Path parsed once (the grammar makes it a string literal).
`checkInputDataTypes` guarantees it
+ // parses and is wildcard-free, so the evaluator is only built for a valid
path.
+ @transient private lazy val evaluator: JsonTableEvaluator =
+ JsonTableEvaluator(JsonPathParser.parse(path).getOrElse(Nil), explodeRoot
= false)
+
+ // Cast from the extracted scalar's STRING form to the RETURNING type, built
once over a reused
+ // input slot to avoid per-row allocation. Always an ANSI (throwing) cast,
independent of the
+ // session's ANSI setting, so a failed conversion always routes to ON ERROR
(see `eval`) rather
+ // than being silently turned into NULL by a non-ANSI session.
+ @transient private lazy val valueCast: Expression =
+ Cast(BoundReference(0, StringType, nullable = true), returning,
timeZoneId, EvalMode.ANSI)
+ @transient private lazy val castInput: GenericInternalRow = new
GenericInternalRow(1)
+
+ // Casts for the DEFAULT expressions to the RETURNING type (only built when
present).
+ @transient private lazy val emptyDefaultCast: Option[Expression] =
+ emptyDefault.map(e => Cast(e, returning, timeZoneId, defaultEvalMode))
+ @transient private lazy val errorDefaultCast: Option[Expression] =
+ errorDefault.map(e => Cast(e, returning, timeZoneId, defaultEvalMode))
+
+ private def castScalar(text: UTF8String): Any = {
+ castInput.update(0, text)
+ valueCast.eval(castInput)
+ }
+
+ // Handle the ON EMPTY case per the configured behavior.
+ private def onEmptyResult(input: InternalRow): Any = onEmpty match {
+ case JsonValueBehavior.Null => null
+ case JsonValueBehavior.Default => emptyDefaultCast.get.eval(input)
+ case JsonValueBehavior.Error =>
+ throw QueryExecutionErrors.jsonValueOnEmptyError(prettyName, path, cause
= null)
+ }
+
+ // Handle the ON ERROR case per the configured behavior. `cause` (if any) is
attached for context.
+ private def onErrorResult(input: InternalRow, cause: Throwable): Any =
onError match {
+ case JsonValueBehavior.Null => null
+ case JsonValueBehavior.Default => errorDefaultCast.get.eval(input)
+ case JsonValueBehavior.Error =>
+ throw QueryExecutionErrors.jsonValueOnErrorError(prettyName, path, cause)
+ }
+
+ override def eval(input: InternalRow): Any = {
+ val json = child.eval(input).asInstanceOf[UTF8String]
+ // NULL input propagates to NULL (not ON EMPTY / ON ERROR), matching ANSI
and the other engines.
+ if (json == null) return null
+ evaluator.lookup(json) match {
+ // Malformed / non-single-value input.
+ case None => onErrorResult(input, cause = null)
+ // Path matched nothing.
+ case Some(JsonValueLookup.Missing) => onEmptyResult(input)
+ // Matched an explicit JSON null: a present, scalar null value -> SQL
NULL.
+ case Some(JsonValueLookup.NullValue) => null
+ // Matched an object or array: not a scalar -> ON ERROR.
+ case Some(JsonValueLookup.NonScalar) => onErrorResult(input, cause =
null)
+ case Some(JsonValueLookup.Scalar(text)) =>
+ // `valueCast` throws on a failed conversion, which routes to ON ERROR.
+ try castScalar(text) catch { case e: Exception => onErrorResult(input,
e) }
+ }
+ }
+
+ override def prettyName: String = "json_value"
+
+ override def sql: String = {
+ val returningSQL = if (returning == StringType) "" else s" RETURNING
${returning.sql}"
+ def behaviorSQL(b: JsonValueBehavior, default: Option[Expression]): String
= b match {
+ case JsonValueBehavior.Null => "NULL"
+ case JsonValueBehavior.Error => "ERROR"
+ case JsonValueBehavior.Default => s"DEFAULT ${default.get.sql}"
+ }
+ val emptySQL = if (onEmpty == JsonValueBehavior.Null) ""
+ else s" ${behaviorSQL(onEmpty, emptyDefault)} ON EMPTY"
+ val errorSQL = if (onError == JsonValueBehavior.Null) ""
+ else s" ${behaviorSQL(onError, errorDefault)} ON ERROR"
+ // Render the path as a properly escaped string literal so bracket-quoted
paths such as
+ // `$['a']` (and any path containing a quote or backslash) round-trip as
valid SQL.
+ val pathSQL = Literal(UTF8String.fromString(path), StringType).sql
+ s"JSON_VALUE(${child.sql}, $pathSQL$returningSQL$emptySQL$errorSQL)"
+ }
+
+ override protected def withNewChildrenInternal(
+ newChildren: IndexedSeq[Expression]): JsonValue = {
+ // Rebuild the child list in the same order `children` produced it: json,
then the present
+ // defaults. `copy(child = ...)` alone would drop coercion applied to the
DEFAULT children.
+ var i = 1
+ val newEmpty = emptyDefault.map { _ => val e = newChildren(i); i += 1; e }
+ val newError = errorDefault.map { _ => val e = newChildren(i); i += 1; e }
+ copy(child = newChildren(0), emptyDefault = newEmpty, errorDefault =
newError)
+ }
+}
+
+object JsonValue {
+ /**
+ * ANSI (9075-2 6.28) restricts JSON_VALUE RETURNING to predefined scalar
types: string, numeric,
+ * boolean, and datetime. We allow exactly those families. Note this
deliberately excludes VARIANT
+ * (a Spark extension, deferred per the design's open question) and BINARY,
even though both are
+ * `AtomicType`s -- so an `AtomicType` check is not sufficient.
STRUCT/ARRAY/MAP are excluded as
+ * non-atomic. CHAR/VARCHAR are normalized to STRING by the parser before
reaching here.
+ */
+ def isValidReturningType(dt: DataType): Boolean = dt match {
+ case _: StringType => true
+ case _: NumericType => true
+ case BooleanType => true
+ case _: DatetimeType => true
+ case _ => false
+ }
+}
+
/**
* Converts an json input string to a [[StructType]], [[ArrayType]] or
[[MapType]]
* with the specified schema.
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
index a5ecff610b29..74c691699609 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
@@ -4169,6 +4169,37 @@ class AstBuilder extends DataTypeAstBuilder
}
}
+ /**
+ * Resolve a `jsonValueBehavior` clause (`NULL` / `ERROR` / `DEFAULT
<expr>`) into a
+ * [[JsonValueBehavior]] and, for the `DEFAULT` case, its expression.
+ */
+ private def buildJsonValueBehavior(
+ ctx: JsonValueBehaviorContext): (JsonValueBehavior, Option[Expression])
= ctx match {
+ case _: JsonValueBehaviorNullContext => (JsonValueBehavior.Null, None)
+ case _: JsonValueBehaviorErrorContext => (JsonValueBehavior.Error, None)
+ case d: JsonValueBehaviorDefaultContext =>
+ (JsonValueBehavior.Default, Some(expression(d.defaultExpr)))
+ }
+
+ /**
+ * Create a [[JsonValue]] expression for the SQL:2016 `JSON_VALUE` scalar
function. The `ON EMPTY`
+ * / `ON ERROR` clauses default to `NULL` when absent, per the standard.
+ */
+ override def visitJsonValue(ctx: JsonValueContext): Expression =
withOrigin(ctx) {
+ val jsonExpr = expression(ctx.jsonExpr)
+ val path = string(visitStringLit(ctx.path))
+ // Default RETURNING type is STRING. Normalize CHAR/VARCHAR to STRING for
the cast, as the value
+ // is produced by a `Cast` to the declared type (a raw CHAR/VARCHAR target
has no encoder).
+ val returning = Option(ctx.returning)
+ .map(dt =>
CharVarcharUtils.replaceCharVarcharWithStringForCast(typedVisit[DataType](dt)))
+ .getOrElse(StringType)
+ val (onEmpty, emptyDefault) = Option(ctx.emptyBehavior)
+ .map(buildJsonValueBehavior).getOrElse((JsonValueBehavior.Null, None))
+ val (onError, errorDefault) = Option(ctx.errorBehavior)
+ .map(buildJsonValueBehavior).getOrElse((JsonValueBehavior.Null, None))
+ JsonValue(jsonExpr, path, returning, onEmpty, onError, emptyDefault,
errorDefault)
+ }
+
/**
* Create a (windowed) Function expression.
*/
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
index 7d00aa292a3c..8f1b16232fd4 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
@@ -1625,6 +1625,20 @@ private[sql] object QueryExecutionErrors extends
QueryErrorsBase with ExecutionE
cause = e)
}
+ def jsonValueOnEmptyError(functionName: String, path: String, cause:
Throwable): Throwable = {
+ new SparkRuntimeException(
+ errorClass = "JSON_VALUE_ON_ERROR.EMPTY",
+ messageParameters = Map("functionName" -> toSQLId(functionName), "path"
-> toSQLValue(path)),
+ cause = cause)
+ }
+
+ def jsonValueOnErrorError(functionName: String, path: String, cause:
Throwable): Throwable = {
+ new SparkRuntimeException(
+ errorClass = "JSON_VALUE_ON_ERROR.ERROR",
+ messageParameters = Map("functionName" -> toSQLId(functionName), "path"
-> toSQLValue(path)),
+ cause = cause)
+ }
+
def invalidKerberosConfigForHiveServer2Error(): Throwable = {
new SparkException(
errorClass = "_LEGACY_ERROR_TEMP_2179",
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/JsonExpressionsSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/JsonExpressionsSuite.scala
index fb8641659a74..d8d68ef75c10 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/JsonExpressionsSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/JsonExpressionsSuite.scala
@@ -1068,7 +1068,7 @@ class JsonExpressionsSuite extends SparkFunSuite with
ExpressionEvalHelper {
input)
}
- test("JsonToStructs, GetJsonObject, JsonTuple, MultiGetJsonObject are
stateful " +
+ test("JsonToStructs, GetJsonObject, JsonTuple, MultiGetJsonObject, JsonValue
are stateful " +
"and produce fresh copies") {
val schema = StructType(StructField("a", IntegerType) :: Nil)
val jsonToStructs = JsonToStructs(schema, Map.empty, Literal("{}"),
UTC_OPT)
@@ -1086,6 +1086,13 @@ class JsonExpressionsSuite extends SparkFunSuite with
ExpressionEvalHelper {
val multiGetJsonObject = MultiGetJsonObject(Literal("{}"), Seq("$.a",
"$.b"))
assert(multiGetJsonObject.stateful)
assert(multiGetJsonObject.freshCopyIfContainsStatefulExpression() ne
multiGetJsonObject)
+
+ // JsonValue reuses a mutable row to cast the extracted scalar, so it must
be stateful.
+ val jsonValue = JsonValue(
+ Literal("{}"), "$.a", StringType,
+ JsonValueBehavior.Null, JsonValueBehavior.Null, None, None)
+ assert(jsonValue.stateful)
+ assert(jsonValue.freshCopyIfContainsStatefulExpression() ne jsonValue)
}
}
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/ExpressionParserSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/ExpressionParserSuite.scala
index 3812dd287837..18a92a6496d3 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/ExpressionParserSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/ExpressionParserSuite.scala
@@ -307,6 +307,30 @@ class ExpressionParserSuite extends AnalysisTest {
assertEqual("-+~~a", -( +(~(~$"a"))))
}
+ test("JSON_VALUE expressions") {
+ import org.apache.spark.sql.catalyst.expressions.JsonValueBehavior
+ // Bare form: default STRING RETURNING, NULL ON EMPTY / NULL ON ERROR.
+ assertEqual(
+ "json_value(a, '$.b')",
+ JsonValue($"a", "$.b", StringType, JsonValueBehavior.Null,
JsonValueBehavior.Null,
+ None, None))
+ // RETURNING.
+ assertEqual(
+ "json_value(a, '$.b' RETURNING INT)",
+ JsonValue($"a", "$.b", IntegerType, JsonValueBehavior.Null,
JsonValueBehavior.Null,
+ None, None))
+ // ERROR ON EMPTY / ERROR ON ERROR.
+ assertEqual(
+ "json_value(a, '$.b' ERROR ON EMPTY ERROR ON ERROR)",
+ JsonValue($"a", "$.b", StringType, JsonValueBehavior.Error,
JsonValueBehavior.Error,
+ None, None))
+ // DEFAULT ON EMPTY / DEFAULT ON ERROR carry expressions.
+ assertEqual(
+ "json_value(a, '$.b' DEFAULT 'x' ON EMPTY DEFAULT 'y' ON ERROR)",
+ JsonValue($"a", "$.b", StringType, JsonValueBehavior.Default,
JsonValueBehavior.Default,
+ Some(Literal("x")), Some(Literal("y"))))
+ }
+
test("cast expressions") {
// Note that DataType parsing is tested elsewhere.
assertEqual("cast(a as int)", $"a".cast(IntegerType))
diff --git
a/sql/connect/client/jdbc/src/test/scala/org/apache/spark/sql/connect/client/jdbc/SparkConnectDatabaseMetaDataSuite.scala
b/sql/connect/client/jdbc/src/test/scala/org/apache/spark/sql/connect/client/jdbc/SparkConnectDatabaseMetaDataSuite.scala
index b305541893bb..b07f9da119a4 100644
---
a/sql/connect/client/jdbc/src/test/scala/org/apache/spark/sql/connect/client/jdbc/SparkConnectDatabaseMetaDataSuite.scala
+++
b/sql/connect/client/jdbc/src/test/scala/org/apache/spark/sql/connect/client/jdbc/SparkConnectDatabaseMetaDataSuite.scala
@@ -210,7 +210,7 @@ class SparkConnectDatabaseMetaDataSuite extends
ConnectFunSuite with RemoteSpark
val metadata = conn.getMetaData
// scalastyle:off line.size.limit
// CURRENT_PATH and SYSTEM are excluded: getSQLKeywords drops SQL:2003
reserved words (see companion).
- assert(metadata.getSQLKeywords ===
"ADD,AFTER,AGGREGATE,ALIGN,ALWAYS,ANALYZE,ANTI,ANY_VALUE,APPLY,APPROX,ARCHIVE,ASC,ASOF,AUTO,BERNOULLI,BIN,BINDING,BIN_DISTRIBUTE_RATIO,BIN_END,BIN_START,BUCKET,BUCKETS,BYTE,CACHE,CASCADE,CATALOG,CATALOGS,CDC,CHANGE,CHANGES,CLEAR,CLUSTER,CLUSTERED,CODEGEN,COLLATION,COLLATIONS,COLLECTION,COLUMNS,COMMENT,COMPACT,COMPACTIONS,COMPENSATION,COMPUTE,CONCATENATE,CONTAINS,CONTINUE,COST,CURRENT_DATABASE,CURRENT_SCHEMA,DATA,DATABASE,DATABASES,DATEADD,DATEDIFF
[...]
+ assert(metadata.getSQLKeywords ===
"ADD,AFTER,AGGREGATE,ALIGN,ALWAYS,ANALYZE,ANTI,ANY_VALUE,APPLY,APPROX,ARCHIVE,ASC,ASOF,AUTO,BERNOULLI,BIN,BINDING,BIN_DISTRIBUTE_RATIO,BIN_END,BIN_START,BUCKET,BUCKETS,BYTE,CACHE,CASCADE,CATALOG,CATALOGS,CDC,CHANGE,CHANGES,CLEAR,CLUSTER,CLUSTERED,CODEGEN,COLLATION,COLLATIONS,COLLECTION,COLUMNS,COMMENT,COMPACT,COMPACTIONS,COMPENSATION,COMPUTE,CONCATENATE,CONTAINS,CONTINUE,COST,CURRENT_DATABASE,CURRENT_SCHEMA,DATA,DATABASE,DATABASES,DATEADD,DATEDIFF
[...]
// scalastyle:on line.size.limit
}
}
diff --git
a/sql/core/src/test/resources/sql-tests/analyzer-results/json-functions.sql.out
b/sql/core/src/test/resources/sql-tests/analyzer-results/json-functions.sql.out
index 0582c531d570..9b33afd10db8 100644
---
a/sql/core/src/test/resources/sql-tests/analyzer-results/json-functions.sql.out
+++
b/sql/core/src/test/resources/sql-tests/analyzer-results/json-functions.sql.out
@@ -1145,3 +1145,216 @@ GlobalLimit 1
+- LocalLimit 1
+- Project [from_json(StructField(time,TimeType(6),true), {"time":
"14:30:45"}, Some(America/Los_Angeles), false) AS from_json({"time":
"14:30:45"})#x]
+- OneRowRelation
+
+
+-- !query
+select
json_value('{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null}',
'$.name')
+-- !query analysis
+Project
[json_value({"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null},
$.name, StringType, Null, Null, None, None, Some(America/Los_Angeles), true)
AS
JSON_VALUE({"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null},
'$.name')#x]
++- OneRowRelation
+
+
+-- !query
+select
json_value('{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null}',
'$.id' RETURNING INT)
+-- !query analysis
+Project
[json_value({"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null},
$.id, IntegerType, Null, Null, None, None, Some(America/Los_Angeles), true) AS
JSON_VALUE({"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null},
'$.id' RETURNING INT)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"id":7,"name":"Ada"}', '$.id' RETURNING INT) + 1
+-- !query analysis
+Project [(json_value({"id":7,"name":"Ada"}, $.id, IntegerType, Null, Null,
None, None, Some(America/Los_Angeles), true) + 1) AS
(JSON_VALUE({"id":7,"name":"Ada"}, '$.id' RETURNING INT) + 1)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"score":null}', '$.score')
+-- !query analysis
+Project [json_value({"score":null}, $.score, StringType, Null, Null, None,
None, Some(America/Los_Angeles), true) AS JSON_VALUE({"score":null},
'$.score')#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"addr":{"city":"NYC"}}', '$.addr')
+-- !query analysis
+Project [json_value({"addr":{"city":"NYC"}}, $.addr, StringType, Null, Null,
None, None, Some(America/Los_Angeles), true) AS
JSON_VALUE({"addr":{"city":"NYC"}}, '$.addr')#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"tags":["x","y"]}', '$.tags')
+-- !query analysis
+Project [json_value({"tags":["x","y"]}, $.tags, StringType, Null, Null, None,
None, Some(America/Los_Angeles), true) AS JSON_VALUE({"tags":["x","y"]},
'$.tags')#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"id":7}', '$.missing')
+-- !query analysis
+Project [json_value({"id":7}, $.missing, StringType, Null, Null, None, None,
Some(America/Los_Angeles), true) AS JSON_VALUE({"id":7}, '$.missing')#x]
++- OneRowRelation
+
+
+-- !query
+select json_value(cast(null as string), '$.a')
+-- !query analysis
+Project [json_value(cast(null as string), $.a, StringType, Null, Null, None,
None, Some(America/Los_Angeles), true) AS JSON_VALUE(CAST(NULL AS STRING),
'$.a')#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"id":7}', '$.missing' DEFAULT '?' ON EMPTY)
+-- !query analysis
+Project [json_value({"id":7}, $.missing, StringType, Default, Null, Some(?),
None, Some(America/Los_Angeles), true) AS JSON_VALUE({"id":7}, '$.missing'
DEFAULT ? ON EMPTY)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"id":7}', '$.missing' RETURNING INT DEFAULT 42 ON EMPTY)
+-- !query analysis
+Project [json_value({"id":7}, $.missing, IntegerType, Default, Null, Some(42),
None, Some(America/Los_Angeles), true) AS JSON_VALUE({"id":7}, '$.missing'
RETURNING INT DEFAULT 42 ON EMPTY)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"id":7}', '$.missing' ERROR ON EMPTY)
+-- !query analysis
+Project [json_value({"id":7}, $.missing, StringType, Error, Null, None, None,
Some(America/Los_Angeles), true) AS JSON_VALUE({"id":7}, '$.missing' ERROR ON
EMPTY)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"addr":{"city":"NYC"}}', '$.addr' DEFAULT 'n/a' ON ERROR)
+-- !query analysis
+Project [json_value({"addr":{"city":"NYC"}}, $.addr, StringType, Null,
Default, None, Some(n/a), Some(America/Los_Angeles), true) AS
JSON_VALUE({"addr":{"city":"NYC"}}, '$.addr' DEFAULT n/a ON ERROR)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('not json', '$.a' DEFAULT 'bad' ON ERROR)
+-- !query analysis
+Project [json_value(not json, $.a, StringType, Null, Default, None, Some(bad),
Some(America/Los_Angeles), true) AS JSON_VALUE(not json, '$.a' DEFAULT bad ON
ERROR)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('not json', '$.a' ERROR ON ERROR)
+-- !query analysis
+Project [json_value(not json, $.a, StringType, Null, Error, None, None,
Some(America/Los_Angeles), true) AS JSON_VALUE(not json, '$.a' ERROR ON
ERROR)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"name":"Ada"}', '$.name' RETURNING INT)
+-- !query analysis
+Project [json_value({"name":"Ada"}, $.name, IntegerType, Null, Null, None,
None, Some(America/Los_Angeles), true) AS JSON_VALUE({"name":"Ada"}, '$.name'
RETURNING INT)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"name":"Ada"}', '$.name' RETURNING INT ERROR ON ERROR)
+-- !query analysis
+Project [json_value({"name":"Ada"}, $.name, IntegerType, Null, Error, None,
None, Some(America/Los_Angeles), true) AS JSON_VALUE({"name":"Ada"}, '$.name'
RETURNING INT ERROR ON ERROR)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"name":"Ada"}', '$.name' RETURNING INT DEFAULT -1 ON ERROR)
+-- !query analysis
+Project [json_value({"name":"Ada"}, $.name, IntegerType, Null, Default, None,
Some(-1), Some(America/Los_Angeles), true) AS JSON_VALUE({"name":"Ada"},
'$.name' RETURNING INT DEFAULT -1 ON ERROR)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"a":"x"}', '$.b' DEFAULT 'e' ON EMPTY DEFAULT 'r' ON ERROR)
+-- !query analysis
+Project [json_value({"a":"x"}, $.b, StringType, Default, Default, Some(e),
Some(r), Some(America/Los_Angeles), true) AS JSON_VALUE({"a":"x"}, '$.b'
DEFAULT e ON EMPTY DEFAULT r ON ERROR)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"v":"3.14"}', '$.v' RETURNING DOUBLE)
+-- !query analysis
+Project [json_value({"v":"3.14"}, $.v, DoubleType, Null, Null, None, None,
Some(America/Los_Angeles), true) AS JSON_VALUE({"v":"3.14"}, '$.v' RETURNING
DOUBLE)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"v":"true"}', '$.v' RETURNING BOOLEAN)
+-- !query analysis
+Project [json_value({"v":"true"}, $.v, BooleanType, Null, Null, None, None,
Some(America/Los_Angeles), true) AS JSON_VALUE({"v":"true"}, '$.v' RETURNING
BOOLEAN)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"v":"2020-01-02"}', '$.v' RETURNING DATE)
+-- !query analysis
+Project [json_value({"v":"2020-01-02"}, $.v, DateType, Null, Null, None, None,
Some(America/Los_Angeles), true) AS JSON_VALUE({"v":"2020-01-02"}, '$.v'
RETURNING DATE)#x]
++- OneRowRelation
+
+
+-- !query
+select json_value('{"a":[1,2]}', '$.a[*]')
+-- !query analysis
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+ "errorClass" : "DATATYPE_MISMATCH.INVALID_JSON_PATH",
+ "sqlState" : "42K09",
+ "messageParameters" : {
+ "functionName" : "`json_value`",
+ "path" : "'$.a[*]'",
+ "sqlExpr" : "\"JSON_VALUE({\"a\":[1,2]}, '$.a[*]')\""
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 8,
+ "stopIndex" : 42,
+ "fragment" : "json_value('{\"a\":[1,2]}', '$.a[*]')"
+ } ]
+}
+
+
+-- !query
+select json_value('{"a":1}', '$.a' RETURNING STRUCT<x:INT>)
+-- !query analysis
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+ "errorClass" : "DATATYPE_MISMATCH.INVALID_JSON_SCALAR_RETURNING_TYPE",
+ "sqlState" : "42K09",
+ "messageParameters" : {
+ "functionName" : "`json_value`",
+ "returningType" : "\"STRUCT<x: INT>\"",
+ "sqlExpr" : "\"JSON_VALUE({\"a\":1}, '$.a' RETURNING STRUCT<x: INT>)\""
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 8,
+ "stopIndex" : 59,
+ "fragment" : "json_value('{\"a\":1}', '$.a' RETURNING STRUCT<x:INT>)"
+ } ]
+}
+
+
+-- !query
+select json_value('{}', '$.x' RETURNING INT DEFAULT array(1) ON EMPTY)
+-- !query analysis
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+ "errorClass" : "DATATYPE_MISMATCH.CAST_WITHOUT_SUGGESTION",
+ "sqlState" : "42K09",
+ "messageParameters" : {
+ "sqlExpr" : "\"JSON_VALUE({}, '$.x' RETURNING INT DEFAULT array(1) ON
EMPTY)\"",
+ "srcType" : "\"ARRAY<INT>\"",
+ "targetType" : "\"INT\""
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 8,
+ "stopIndex" : 70,
+ "fragment" : "json_value('{}', '$.x' RETURNING INT DEFAULT array(1) ON
EMPTY)"
+ } ]
+}
diff --git a/sql/core/src/test/resources/sql-tests/inputs/json-functions.sql
b/sql/core/src/test/resources/sql-tests/inputs/json-functions.sql
index 2a1fae4a6b45..fa1cb6628ec9 100644
--- a/sql/core/src/test/resources/sql-tests/inputs/json-functions.sql
+++ b/sql/core/src/test/resources/sql-tests/inputs/json-functions.sql
@@ -173,3 +173,41 @@ select to_json(from_json('{"time":"23:59:59.999999"}',
'time TIME(6)'));
select schema_of_json('{"time": "14:30:45"}');
select schema_of_json('{"time": "14:30:45.123456"}');
select from_json('{"time": "14:30:45"}', 'time TIME') LIMIT 1;
+
+-- JSON_VALUE: extract a scalar value (ANSI SQL:2016)
+select
json_value('{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null}',
'$.name');
+select
json_value('{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null}',
'$.id' RETURNING INT);
+select json_value('{"id":7,"name":"Ada"}', '$.id' RETURNING INT) + 1;
+-- present but JSON null -> SQL NULL
+select json_value('{"score":null}', '$.score');
+-- non-scalar (object / array) -> NULL ON ERROR (default)
+select json_value('{"addr":{"city":"NYC"}}', '$.addr');
+select json_value('{"tags":["x","y"]}', '$.tags');
+-- missing path -> NULL ON EMPTY (default)
+select json_value('{"id":7}', '$.missing');
+-- NULL input propagates to NULL
+select json_value(cast(null as string), '$.a');
+-- ON EMPTY behaviors
+select json_value('{"id":7}', '$.missing' DEFAULT '?' ON EMPTY);
+select json_value('{"id":7}', '$.missing' RETURNING INT DEFAULT 42 ON EMPTY);
+select json_value('{"id":7}', '$.missing' ERROR ON EMPTY);
+-- ON ERROR behaviors
+select json_value('{"addr":{"city":"NYC"}}', '$.addr' DEFAULT 'n/a' ON ERROR);
+select json_value('not json', '$.a' DEFAULT 'bad' ON ERROR);
+select json_value('not json', '$.a' ERROR ON ERROR);
+-- failed cast -> ON ERROR
+select json_value('{"name":"Ada"}', '$.name' RETURNING INT);
+select json_value('{"name":"Ada"}', '$.name' RETURNING INT ERROR ON ERROR);
+select json_value('{"name":"Ada"}', '$.name' RETURNING INT DEFAULT -1 ON
ERROR);
+-- combined ON EMPTY + ON ERROR
+select json_value('{"a":"x"}', '$.b' DEFAULT 'e' ON EMPTY DEFAULT 'r' ON
ERROR);
+-- RETURNING types
+select json_value('{"v":"3.14"}', '$.v' RETURNING DOUBLE);
+select json_value('{"v":"true"}', '$.v' RETURNING BOOLEAN);
+select json_value('{"v":"2020-01-02"}', '$.v' RETURNING DATE);
+-- invalid: wildcard path
+select json_value('{"a":[1,2]}', '$.a[*]');
+-- invalid: non-scalar RETURNING type
+select json_value('{"a":1}', '$.a' RETURNING STRUCT<x:INT>);
+-- invalid: a DEFAULT that cannot cast to the RETURNING type
+select json_value('{}', '$.x' RETURNING INT DEFAULT array(1) ON EMPTY);
diff --git
a/sql/core/src/test/resources/sql-tests/results/json-functions.sql.out
b/sql/core/src/test/resources/sql-tests/results/json-functions.sql.out
index adfe51d05bca..6656a7723cd1 100644
--- a/sql/core/src/test/resources/sql-tests/results/json-functions.sql.out
+++ b/sql/core/src/test/resources/sql-tests/results/json-functions.sql.out
@@ -1282,3 +1282,267 @@ select from_json('{"time": "14:30:45"}', 'time TIME')
LIMIT 1
struct<from_json({"time": "14:30:45"}):struct<time:time(6)>>
-- !query output
{"time":14:30:45}
+
+
+-- !query
+select
json_value('{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null}',
'$.name')
+-- !query schema
+struct<JSON_VALUE({"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null},
'$.name'):string>
+-- !query output
+Ada
+
+
+-- !query
+select
json_value('{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null}',
'$.id' RETURNING INT)
+-- !query schema
+struct<JSON_VALUE({"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null},
'$.id' RETURNING INT):int>
+-- !query output
+7
+
+
+-- !query
+select json_value('{"id":7,"name":"Ada"}', '$.id' RETURNING INT) + 1
+-- !query schema
+struct<(JSON_VALUE({"id":7,"name":"Ada"}, '$.id' RETURNING INT) + 1):int>
+-- !query output
+8
+
+
+-- !query
+select json_value('{"score":null}', '$.score')
+-- !query schema
+struct<JSON_VALUE({"score":null}, '$.score'):string>
+-- !query output
+NULL
+
+
+-- !query
+select json_value('{"addr":{"city":"NYC"}}', '$.addr')
+-- !query schema
+struct<JSON_VALUE({"addr":{"city":"NYC"}}, '$.addr'):string>
+-- !query output
+NULL
+
+
+-- !query
+select json_value('{"tags":["x","y"]}', '$.tags')
+-- !query schema
+struct<JSON_VALUE({"tags":["x","y"]}, '$.tags'):string>
+-- !query output
+NULL
+
+
+-- !query
+select json_value('{"id":7}', '$.missing')
+-- !query schema
+struct<JSON_VALUE({"id":7}, '$.missing'):string>
+-- !query output
+NULL
+
+
+-- !query
+select json_value(cast(null as string), '$.a')
+-- !query schema
+struct<JSON_VALUE(CAST(NULL AS STRING), '$.a'):string>
+-- !query output
+NULL
+
+
+-- !query
+select json_value('{"id":7}', '$.missing' DEFAULT '?' ON EMPTY)
+-- !query schema
+struct<JSON_VALUE({"id":7}, '$.missing' DEFAULT ? ON EMPTY):string>
+-- !query output
+?
+
+
+-- !query
+select json_value('{"id":7}', '$.missing' RETURNING INT DEFAULT 42 ON EMPTY)
+-- !query schema
+struct<JSON_VALUE({"id":7}, '$.missing' RETURNING INT DEFAULT 42 ON EMPTY):int>
+-- !query output
+42
+
+
+-- !query
+select json_value('{"id":7}', '$.missing' ERROR ON EMPTY)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.SparkRuntimeException
+{
+ "errorClass" : "JSON_VALUE_ON_ERROR.EMPTY",
+ "sqlState" : "2203G",
+ "messageParameters" : {
+ "functionName" : "`json_value`",
+ "path" : "'$.missing'"
+ }
+}
+
+
+-- !query
+select json_value('{"addr":{"city":"NYC"}}', '$.addr' DEFAULT 'n/a' ON ERROR)
+-- !query schema
+struct<JSON_VALUE({"addr":{"city":"NYC"}}, '$.addr' DEFAULT n/a ON
ERROR):string>
+-- !query output
+n/a
+
+
+-- !query
+select json_value('not json', '$.a' DEFAULT 'bad' ON ERROR)
+-- !query schema
+struct<JSON_VALUE(not json, '$.a' DEFAULT bad ON ERROR):string>
+-- !query output
+bad
+
+
+-- !query
+select json_value('not json', '$.a' ERROR ON ERROR)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.SparkRuntimeException
+{
+ "errorClass" : "JSON_VALUE_ON_ERROR.ERROR",
+ "sqlState" : "2203G",
+ "messageParameters" : {
+ "functionName" : "`json_value`",
+ "path" : "'$.a'"
+ }
+}
+
+
+-- !query
+select json_value('{"name":"Ada"}', '$.name' RETURNING INT)
+-- !query schema
+struct<JSON_VALUE({"name":"Ada"}, '$.name' RETURNING INT):int>
+-- !query output
+NULL
+
+
+-- !query
+select json_value('{"name":"Ada"}', '$.name' RETURNING INT ERROR ON ERROR)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.SparkRuntimeException
+{
+ "errorClass" : "JSON_VALUE_ON_ERROR.ERROR",
+ "sqlState" : "2203G",
+ "messageParameters" : {
+ "functionName" : "`json_value`",
+ "path" : "'$.name'"
+ }
+}
+
+
+-- !query
+select json_value('{"name":"Ada"}', '$.name' RETURNING INT DEFAULT -1 ON ERROR)
+-- !query schema
+struct<JSON_VALUE({"name":"Ada"}, '$.name' RETURNING INT DEFAULT -1 ON
ERROR):int>
+-- !query output
+-1
+
+
+-- !query
+select json_value('{"a":"x"}', '$.b' DEFAULT 'e' ON EMPTY DEFAULT 'r' ON ERROR)
+-- !query schema
+struct<JSON_VALUE({"a":"x"}, '$.b' DEFAULT e ON EMPTY DEFAULT r ON
ERROR):string>
+-- !query output
+e
+
+
+-- !query
+select json_value('{"v":"3.14"}', '$.v' RETURNING DOUBLE)
+-- !query schema
+struct<JSON_VALUE({"v":"3.14"}, '$.v' RETURNING DOUBLE):double>
+-- !query output
+3.14
+
+
+-- !query
+select json_value('{"v":"true"}', '$.v' RETURNING BOOLEAN)
+-- !query schema
+struct<JSON_VALUE({"v":"true"}, '$.v' RETURNING BOOLEAN):boolean>
+-- !query output
+true
+
+
+-- !query
+select json_value('{"v":"2020-01-02"}', '$.v' RETURNING DATE)
+-- !query schema
+struct<JSON_VALUE({"v":"2020-01-02"}, '$.v' RETURNING DATE):date>
+-- !query output
+2020-01-02
+
+
+-- !query
+select json_value('{"a":[1,2]}', '$.a[*]')
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+ "errorClass" : "DATATYPE_MISMATCH.INVALID_JSON_PATH",
+ "sqlState" : "42K09",
+ "messageParameters" : {
+ "functionName" : "`json_value`",
+ "path" : "'$.a[*]'",
+ "sqlExpr" : "\"JSON_VALUE({\"a\":[1,2]}, '$.a[*]')\""
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 8,
+ "stopIndex" : 42,
+ "fragment" : "json_value('{\"a\":[1,2]}', '$.a[*]')"
+ } ]
+}
+
+
+-- !query
+select json_value('{"a":1}', '$.a' RETURNING STRUCT<x:INT>)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+ "errorClass" : "DATATYPE_MISMATCH.INVALID_JSON_SCALAR_RETURNING_TYPE",
+ "sqlState" : "42K09",
+ "messageParameters" : {
+ "functionName" : "`json_value`",
+ "returningType" : "\"STRUCT<x: INT>\"",
+ "sqlExpr" : "\"JSON_VALUE({\"a\":1}, '$.a' RETURNING STRUCT<x: INT>)\""
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 8,
+ "stopIndex" : 59,
+ "fragment" : "json_value('{\"a\":1}', '$.a' RETURNING STRUCT<x:INT>)"
+ } ]
+}
+
+
+-- !query
+select json_value('{}', '$.x' RETURNING INT DEFAULT array(1) ON EMPTY)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+ "errorClass" : "DATATYPE_MISMATCH.CAST_WITHOUT_SUGGESTION",
+ "sqlState" : "42K09",
+ "messageParameters" : {
+ "sqlExpr" : "\"JSON_VALUE({}, '$.x' RETURNING INT DEFAULT array(1) ON
EMPTY)\"",
+ "srcType" : "\"ARRAY<INT>\"",
+ "targetType" : "\"INT\""
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 8,
+ "stopIndex" : 70,
+ "fragment" : "json_value('{}', '$.x' RETURNING INT DEFAULT array(1) ON
EMPTY)"
+ } ]
+}
diff --git
a/sql/core/src/test/resources/sql-tests/results/keywords-enforced.sql.out
b/sql/core/src/test/resources/sql-tests/results/keywords-enforced.sql.out
index fe26fef775ca..37f174acfe53 100644
--- a/sql/core/src/test/resources/sql-tests/results/keywords-enforced.sql.out
+++ b/sql/core/src/test/resources/sql-tests/results/keywords-enforced.sql.out
@@ -130,6 +130,7 @@ DOUBLE false
DROP false
ELSE true
ELSEIF false
+EMPTY false
END true
ENFORCED false
ERROR false
@@ -212,6 +213,7 @@ ITERATE false
JOIN true
JSON false
JSON_TABLE false
+JSON_VALUE false
KEY false
KEYS false
LANGUAGE false
@@ -327,6 +329,7 @@ RESET false
RESPECT false
RESTRICT false
RETURN false
+RETURNING false
RETURNS false
REVOKE false
RIGHT true
diff --git a/sql/core/src/test/resources/sql-tests/results/keywords.sql.out
b/sql/core/src/test/resources/sql-tests/results/keywords.sql.out
index d23be26a60dd..f6b910a54c3a 100644
--- a/sql/core/src/test/resources/sql-tests/results/keywords.sql.out
+++ b/sql/core/src/test/resources/sql-tests/results/keywords.sql.out
@@ -130,6 +130,7 @@ DOUBLE false
DROP false
ELSE false
ELSEIF false
+EMPTY false
END false
ENFORCED false
ERROR false
@@ -212,6 +213,7 @@ ITERATE false
JOIN false
JSON false
JSON_TABLE false
+JSON_VALUE false
KEY false
KEYS false
LANGUAGE false
@@ -327,6 +329,7 @@ RESET false
RESPECT false
RESTRICT false
RETURN false
+RETURNING false
RETURNS false
REVOKE false
RIGHT false
diff --git
a/sql/core/src/test/resources/sql-tests/results/nonansi/keywords.sql.out
b/sql/core/src/test/resources/sql-tests/results/nonansi/keywords.sql.out
index d23be26a60dd..f6b910a54c3a 100644
--- a/sql/core/src/test/resources/sql-tests/results/nonansi/keywords.sql.out
+++ b/sql/core/src/test/resources/sql-tests/results/nonansi/keywords.sql.out
@@ -130,6 +130,7 @@ DOUBLE false
DROP false
ELSE false
ELSEIF false
+EMPTY false
END false
ENFORCED false
ERROR false
@@ -212,6 +213,7 @@ ITERATE false
JOIN false
JSON false
JSON_TABLE false
+JSON_VALUE false
KEY false
KEYS false
LANGUAGE false
@@ -327,6 +329,7 @@ RESET false
RESPECT false
RESTRICT false
RETURN false
+RETURNING false
RETURNS false
REVOKE false
RIGHT false
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/JsonValueSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/JsonValueSuite.scala
new file mode 100644
index 000000000000..d3106ac33ddb
--- /dev/null
+++ b/sql/core/src/test/scala/org/apache/spark/sql/JsonValueSuite.scala
@@ -0,0 +1,263 @@
+/*
+ * 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.
+ */
+
+package org.apache.spark.sql
+
+import org.apache.spark.SparkRuntimeException
+import org.apache.spark.sql.catalyst.expressions.JsonValue
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{IntegerType, StringType}
+
+/**
+ * End-to-end tests for the SQL:2016 `JSON_VALUE` scalar function.
+ */
+class JsonValueSuite extends QueryTest with SharedSparkSession {
+ import testImplicits._
+
+ private val doc =
+
"""{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null,"f":"3.14"}"""
+
+ test("extract a scalar value as STRING by default") {
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.name')"), Row("Ada"))
+ // Numbers and booleans come back as their JSON text under the default
STRING RETURNING.
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.id')"), Row("7"))
+ }
+
+ test("RETURNING casts the scalar to the requested type") {
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.id' RETURNING INT)"),
Row(7))
+ assert(sql(s"SELECT json_value('$doc', '$$.id' RETURNING
INT)").schema.head.dataType
+ === IntegerType)
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.f' RETURNING DOUBLE)"),
Row(3.14d))
+ checkAnswer(sql("SELECT json_value('{\"v\":\"true\"}', '$.v' RETURNING
BOOLEAN)"), Row(true))
+ checkAnswer(
+ sql("SELECT json_value('{\"v\":\"2020-01-02\"}', '$.v' RETURNING DATE)"),
+ Row(java.sql.Date.valueOf("2020-01-02")))
+ }
+
+ test("default RETURNING type is STRING") {
+ assert(sql(s"SELECT json_value('$doc', '$$.name')").schema.head.dataType
=== StringType)
+ }
+
+ test("a raw JSON number keeps its exact source digits (no double rounding)")
{
+ // The matched scalar is read straight from the parser, so a fraction with
more digits than a
+ // double can represent reaches the DECIMAL cast (and the default STRING
form) intact.
+ val big = """{"v":0.123456789012345678}"""
+ checkAnswer(
+ sql(s"SELECT json_value('$big', '$$.v' RETURNING DECIMAL(38,18))"),
+ Row(new java.math.BigDecimal("0.123456789012345678")))
+ checkAnswer(sql(s"SELECT json_value('$big', '$$.v')"),
Row("0.123456789012345678"))
+ }
+
+ test("a present JSON null yields SQL NULL") {
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.score')"), Row(null))
+ }
+
+ test("a non-scalar (object/array) match is an ON ERROR case, NULL by
default") {
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.addr')"), Row(null))
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.tags')"), Row(null))
+ }
+
+ test("a missing path is an ON EMPTY case, NULL by default") {
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.missing')"), Row(null))
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.addr.zip')"), Row(null))
+ }
+
+ test("NULL JSON input propagates to NULL (not ON EMPTY / ON ERROR)") {
+ checkAnswer(sql("SELECT json_value(CAST(NULL AS STRING), '$.a' ERROR ON
EMPTY ERROR ON ERROR)"),
+ Row(null))
+ }
+
+ test("DEFAULT ON EMPTY") {
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.missing' DEFAULT '?' ON
EMPTY)"), Row("?"))
+ checkAnswer(
+ sql(s"SELECT json_value('$doc', '$$.missing' RETURNING INT DEFAULT 42 ON
EMPTY)"), Row(42))
+ }
+
+ test("ERROR ON EMPTY raises for a missing path") {
+ val e = intercept[SparkRuntimeException] {
+ sql(s"SELECT json_value('$doc', '$$.missing' ERROR ON EMPTY)").collect()
+ }
+ assert(e.getCondition == "JSON_VALUE_ON_ERROR.EMPTY")
+ }
+
+ test("DEFAULT ON ERROR for a non-scalar match and for malformed input") {
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.addr' DEFAULT 'n/a' ON
ERROR)"), Row("n/a"))
+ checkAnswer(sql("SELECT json_value('not json', '$.a' DEFAULT 'bad' ON
ERROR)"), Row("bad"))
+ }
+
+ test("ERROR ON ERROR raises for malformed input") {
+ val e = intercept[SparkRuntimeException] {
+ sql("SELECT json_value('not json', '$.a' ERROR ON ERROR)").collect()
+ }
+ assert(e.getCondition == "JSON_VALUE_ON_ERROR.ERROR")
+ }
+
+ test("a valid JSON prefix followed by trailing content is an ON ERROR case")
{
+ // The whole input must be a single well-formed JSON value: a value that
parses but is trailed
+ // by garbage (or a second root value) is malformed, even when the path
matches within the
+ // prefix. NULL ON ERROR by default; ERROR ON ERROR raises.
+ checkAnswer(sql("""SELECT json_value('{"a":1} trailing', '$.a')"""),
Row(null))
+ checkAnswer(sql("""SELECT json_value('{"a":1}{"a":2}', '$.a')"""),
Row(null))
+ val e = intercept[SparkRuntimeException] {
+ sql("""SELECT json_value('{"a":1} trailing', '$.a' ERROR ON
ERROR)""").collect()
+ }
+ assert(e.getCondition == "JSON_VALUE_ON_ERROR.ERROR")
+ }
+
+ test("ERROR ON ERROR raises for a non-scalar match") {
+ val e = intercept[SparkRuntimeException] {
+ sql(s"SELECT json_value('$doc', '$$.addr' ERROR ON ERROR)").collect()
+ }
+ assert(e.getCondition == "JSON_VALUE_ON_ERROR.ERROR")
+ }
+
+ test("a failed cast is an ON ERROR case") {
+ // NULL ON ERROR default.
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.name' RETURNING INT)"),
Row(null))
+ // DEFAULT ON ERROR.
+ checkAnswer(
+ sql(s"SELECT json_value('$doc', '$$.name' RETURNING INT DEFAULT -1 ON
ERROR)"), Row(-1))
+ // ERROR ON ERROR.
+ val e = intercept[SparkRuntimeException] {
+ sql(s"SELECT json_value('$doc', '$$.name' RETURNING INT ERROR ON
ERROR)").collect()
+ }
+ assert(e.getCondition == "JSON_VALUE_ON_ERROR.ERROR")
+ }
+
+ test("independent ON EMPTY and ON ERROR behaviors") {
+ // Missing path -> ON EMPTY branch; malformed input / non-scalar value ->
ON ERROR branch.
+ checkAnswer(
+ sql(s"SELECT json_value('$doc', '$$.missing' DEFAULT 'e' ON EMPTY
DEFAULT 'r' ON ERROR)"),
+ Row("e"))
+ checkAnswer(
+ sql(s"SELECT json_value('$doc', '$$.addr' DEFAULT 'e' ON EMPTY DEFAULT
'r' ON ERROR)"),
+ Row("r"))
+ }
+
+ test("ON ERROR governs a failed cast in BOTH ANSI and non-ANSI mode") {
+ // The extracted-scalar cast is always an ANSI (throwing) cast, so a bad
conversion routes to ON
+ // ERROR identically regardless of the session's ANSI setting -- a
non-ANSI session must not
+ // silently return NULL and bypass DEFAULT / ERROR ON ERROR. (A
NULL-ON-ERROR-only check would
+ // pass vacuously in non-ANSI mode, where a lenient cast already yields
NULL.)
+ Seq("true", "false").foreach { ansi =>
+ withSQLConf(SQLConf.ANSI_ENABLED.key -> ansi) {
+ withClue(s"ansi=$ansi ") {
+ // A valid conversion still succeeds in both modes (the ANSI cast is
not stricter here).
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.id' RETURNING
INT)"), Row(7))
+ // NULL ON ERROR (default).
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.name' RETURNING
INT)"), Row(null))
+ // DEFAULT ON ERROR.
+ checkAnswer(
+ sql(s"SELECT json_value('$doc', '$$.name' RETURNING INT DEFAULT -1
ON ERROR)"),
+ Row(-1))
+ // ERROR ON ERROR.
+ val e = intercept[SparkRuntimeException] {
+ sql(s"SELECT json_value('$doc', '$$.name' RETURNING INT ERROR ON
ERROR)").collect()
+ }
+ assert(e.getCondition == "JSON_VALUE_ON_ERROR.ERROR")
+ }
+ }
+ }
+ }
+
+ test("works over a column of JSON documents") {
+ withTempView("docs") {
+ Seq(
+ (1, """{"a":10}"""),
+ (2, """{"a":20}"""),
+ (3, """{"b":30}"""), // missing -> NULL ON EMPTY
+ (4, "not json")) // malformed -> NULL ON ERROR
+ .toDF("k", "j").createOrReplaceTempView("docs")
+ checkAnswer(
+ sql("SELECT k, json_value(j, '$.a' RETURNING INT) AS a FROM docs ORDER
BY k"),
+ Seq(Row(1, 10), Row(2, 20), Row(3, null), Row(4, null)))
+ }
+ }
+
+ test("invalid: wildcard path is rejected at analysis") {
+ val e = intercept[AnalysisException] {
+ sql(s"SELECT json_value('$doc', '$$.tags[*]')").collect()
+ }
+ assert(e.getCondition == "DATATYPE_MISMATCH.INVALID_JSON_PATH")
+ }
+
+ test("invalid: non-scalar RETURNING type is rejected at analysis") {
+ val e = intercept[AnalysisException] {
+ sql(s"SELECT json_value('$doc', '$$.addr' RETURNING
STRUCT<x:INT>)").collect()
+ }
+ assert(e.getCondition ==
"DATATYPE_MISMATCH.INVALID_JSON_SCALAR_RETURNING_TYPE")
+ }
+
+ test("invalid: a DEFAULT that cannot cast to the RETURNING type is rejected
at analysis") {
+ // The DEFAULT-to-RETURNING cast is validated up front, so an uncastable
default (here an
+ // ARRAY DEFAULT with RETURNING INT) fails at analysis rather than late in
the fallback branch.
+ Seq("ON EMPTY", "ON ERROR").foreach { clause =>
+ val e = intercept[AnalysisException] {
+ sql(s"SELECT json_value('{}', '$$.x' RETURNING INT DEFAULT array(1)
$clause)").collect()
+ }
+ assert(e.getCondition == "DATATYPE_MISMATCH.CAST_WITHOUT_SUGGESTION",
+ s"unexpected condition for $clause: ${e.getCondition}")
+ }
+ }
+
+ test("bracket path syntax and array index") {
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.tags[0]')"), Row("x"))
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$[\\'name\\']')"),
Row("Ada"))
+ }
+
+ test("sql renders a bracket-quoted path as a valid, re-parseable string
literal") {
+ // A path containing single quotes (e.g. `$['name']`) must be escaped when
rendered back to
+ // SQL, or the round-tripped statement is malformed. Extract the JsonValue
expression, render
+ // its `.sql`, and confirm the rendered form both parses and evaluates to
the same result.
+ val df = sql(s"SELECT json_value('$doc', '$$[\\'name\\']')")
+ val jsonValue = df.queryExecution.analyzed.expressions
+ .flatMap(_.collect { case jv: JsonValue => jv }).head
+ val rendered = jsonValue.sql
+ assert(rendered.contains("\\'name\\'"), s"path was not escaped in:
$rendered")
+ checkAnswer(sql(s"SELECT $rendered"), Row("Ada"))
+ }
+
+ test("nested path into an object") {
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.addr.city')"), Row("NYC"))
+ }
+
+ test("DEFAULT NULL behaves like NULL ON ERROR/EMPTY") {
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.missing' DEFAULT NULL ON
EMPTY)"), Row(null))
+ checkAnswer(sql(s"SELECT json_value('$doc', '$$.addr' DEFAULT NULL ON
ERROR)"), Row(null))
+ }
+
+ test("DEFAULT is a real child expression, not a constant") {
+ // A DEFAULT that references a column proves the DEFAULT clause is
resolved as a child, and that
+ // the value flows per row.
+ withTempView("t") {
+ Seq((1, "a"), (2, "b")).toDF("k",
"fallback").createOrReplaceTempView("t")
+ checkAnswer(
+ sql("SELECT json_value('{}', '$.x' DEFAULT fallback ON EMPTY) FROM t
ORDER BY k"),
+ Seq(Row("a"), Row("b")))
+ }
+ }
+
+ test("a non-string JSON input is rejected at analysis") {
+ // JSON_VALUE takes a STRING JSON input; an INT input is a type mismatch
(matching the existing
+ // JSON functions' ExpectsInputTypes behavior), not a silent coercion.
+ val e = intercept[AnalysisException] {
+ sql("SELECT json_value(123, '$.a')").collect()
+ }
+ assert(e.getCondition == "DATATYPE_MISMATCH.UNEXPECTED_INPUT_TYPE")
+ }
+}
diff --git
a/sql/hive-thriftserver/src/test/scala/org/apache/spark/sql/hive/thriftserver/ThriftServerWithSparkContextSuite.scala
b/sql/hive-thriftserver/src/test/scala/org/apache/spark/sql/hive/thriftserver/ThriftServerWithSparkContextSuite.scala
index c285023f82b0..e6aa18256c90 100644
---
a/sql/hive-thriftserver/src/test/scala/org/apache/spark/sql/hive/thriftserver/ThriftServerWithSparkContextSuite.scala
+++
b/sql/hive-thriftserver/src/test/scala/org/apache/spark/sql/hive/thriftserver/ThriftServerWithSparkContextSuite.scala
@@ -214,7 +214,7 @@ trait ThriftServerWithSparkContextSuite extends
SharedThriftServer {
val sessionHandle = client.openSession(user, "")
val infoValue = client.getInfo(sessionHandle,
GetInfoType.CLI_ODBC_KEYWORDS)
// scalastyle:off line.size.limit
- assert(infoValue.getStringValue ==
"ADD,AFTER,AGGREGATE,ALIGN,ALL,ALTER,ALWAYS,ANALYZE,AND,ANTI,ANY,ANY_VALUE,APPLY,APPROX,ARCHIVE,ARRAY,AS,ASC,ASENSITIVE,ASOF,AT,ATOMIC,AUTHORIZATION,AUTO,BEGIN,BERNOULLI,BETWEEN,BIGINT,BIN,BINARY,BINDING,BIN_DISTRIBUTE_RATIO,BIN_END,BIN_START,BOOLEAN,BOTH,BUCKET,BUCKETS,BY,BYTE,CACHE,CALL,CALLED,CASCADE,CASE,CAST,CATALOG,CATALOGS,CDC,CHANGE,CHANGES,CHAR,CHARACTER,CHECK,CLEAR,CLOSE,CLUSTER,CLUSTERED,CODEGEN,COLLATE,COLLATION,COLLATIONS,COLLECTION,C
[...]
+ assert(infoValue.getStringValue ==
"ADD,AFTER,AGGREGATE,ALIGN,ALL,ALTER,ALWAYS,ANALYZE,AND,ANTI,ANY,ANY_VALUE,APPLY,APPROX,ARCHIVE,ARRAY,AS,ASC,ASENSITIVE,ASOF,AT,ATOMIC,AUTHORIZATION,AUTO,BEGIN,BERNOULLI,BETWEEN,BIGINT,BIN,BINARY,BINDING,BIN_DISTRIBUTE_RATIO,BIN_END,BIN_START,BOOLEAN,BOTH,BUCKET,BUCKETS,BY,BYTE,CACHE,CALL,CALLED,CASCADE,CASE,CAST,CATALOG,CATALOGS,CDC,CHANGE,CHANGES,CHAR,CHARACTER,CHECK,CLEAR,CLOSE,CLUSTER,CLUSTERED,CODEGEN,COLLATE,COLLATION,COLLATIONS,COLLECTION,C
[...]
// scalastyle:on line.size.limit
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]