This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new d3b3269b5cc5 [SPARK-58687][SQL] Support the ANSI SQL/JSON JSON_EXISTS
function
d3b3269b5cc5 is described below
commit d3b3269b5cc5e03498539acc2808f9bb1dc62408
Author: Ganesha S <[email protected]>
AuthorDate: Tue Aug 18 01:36:02 2026 +0800
[SPARK-58687][SQL] Support the ANSI SQL/JSON JSON_EXISTS function
**What changes were proposed in this pull request?**
Add the ANSI SQL:2016 JSON_EXISTS predicate (feature T821):
`JSON_EXISTS(jsonExpr, path [ { TRUE | FALSE | UNKNOWN | ERROR } ON ERROR
])`
It returns whether a SQL/JSON path matches at least one item in a JSON
document (BOOLEAN):
- a match (including a match whose value is JSON null) → true; no match →
false;
- a SQL NULL input → NULL (Unknown, per 9075-2 8.23);
- input that is not a single well-formed JSON value → the ON ERROR clause
(default FALSE).
Unlike `get_json_object(...) IS NOT NULL`, this distinguishes "present but
null" from "absent". Paths are evaluated in lax mode(strict mode is not yet
supported), matching Oracle and PostgreSQL 17: wildcards ([*], .*, embedded)
are supported, arrays are auto-wrapped/unwrapped, and a structural mismatch is
a non-match rather than an error. JSON_EXISTS is added as a non-reserved
keyword with a dedicated grammar production (parser-only, like JSON_TABLE), and
evaluation reuses JSON_TABLE' [...]
**Why are the changes needed?**
Spark implements none of the ANSI scalar JSON functions. `JSON_EXISTS` is
broadly supported (Oracle, PostgreSQL, SQL Server, DB2), so its absence forces
migrated queries to approximate it with get_json_object(...) IS NOT NULL, which
is string-only and conflates a present-null value with an absent path.
**Does this PR introduce any user-facing change?**
Yes. It adds the new JSON_EXISTS SQL predicate and a SQL reference page.
The new JSON_EXISTS keyword is non-reserved in both ANSI and default modes, so
existing identifiers named json_exists continue to work.
**How was this patch tested?**
New JsonExistsSuite and json-functions.sql golden files (path
present/absent, present-null vs absent, lax wildcards with auto-wrap/unwrap,
out-of-range index, malformed → each ON ERROR behavior, trailing garbage, NULL
input, invalid-path rejection). Regenerated keyword golden files and updated
the getSQLKeywords assertions; existing ExpressionParserSuite, SQLKeywordSuite,
SparkThrowableSuite, SparkConnectDatabaseMetaDataSuite, and
ThriftServerWithSparkContextSuite pass.
**Was this patch authored or co-authored using generative AI tooling?**
Generated-by: Claude Code (Claude Opus 4.8)
Closes #57920 from ganeshashree/SPARK-58687.
Lead-authored-by: Ganesha S <[email protected]>
Co-authored-by: Ganesha S <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
(cherry picked from commit 918a0fae518af1a7bc6a0ac7d8320af8d6801014)
Signed-off-by: Wenchen Fan <[email protected]>
---
.../src/main/resources/error/error-conditions.json | 8 +-
docs/sql-ref-ansi-compliance.md | 1 +
docs/sql-ref-syntax-qry-select-json-exists.md | 127 ++++++++++++++
docs/sql-ref-syntax-qry-select.md | 1 +
docs/sql-ref-syntax.md | 1 +
.../spark/sql/catalyst/parser/SqlBaseLexer.g4 | 1 +
.../spark/sql/catalyst/parser/SqlBaseParser.g4 | 13 ++
.../expressions/json/JsonExpressionEvalUtils.scala | 182 ++++++++++++++++++--
.../sql/catalyst/expressions/jsonExpressions.scala | 122 ++++++++++++-
.../spark/sql/catalyst/parser/AstBuilder.scala | 16 ++
.../spark/sql/errors/QueryExecutionErrors.scala | 6 +
.../catalyst/parser/ExpressionParserSuite.scala | 15 ++
.../jdbc/SparkConnectDatabaseMetaDataSuite.scala | 2 +-
.../analyzer-results/json-functions.sql.out | 148 ++++++++++++++++
.../resources/sql-tests/inputs/json-functions.sql | 34 ++++
.../sql-tests/results/json-functions.sql.out | 176 +++++++++++++++++++
.../sql-tests/results/keywords-enforced.sql.out | 1 +
.../resources/sql-tests/results/keywords.sql.out | 1 +
.../sql-tests/results/nonansi/keywords.sql.out | 1 +
.../org/apache/spark/sql/JsonExistsSuite.scala | 188 +++++++++++++++++++++
.../ThriftServerWithSparkContextSuite.scala | 2 +-
21 files changed, 1031 insertions(+), 15 deletions(-)
diff --git a/common/utils/src/main/resources/error/error-conditions.json
b/common/utils/src/main/resources/error/error-conditions.json
index c00dfc2ef3ed..1d29a257e312 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -1965,7 +1965,7 @@
},
"INVALID_JSON_PATH" : {
"message" : [
- "<functionName> has an invalid or unsupported JSON path <path>. Only
simple, wildcard-free paths are supported."
+ "<functionName> has an invalid or unsupported JSON path <path>."
]
},
"INVALID_JSON_SCALAR_RETURNING_TYPE" : {
@@ -5603,6 +5603,12 @@
],
"sqlState" : "42K0E"
},
+ "JSON_EXISTS_ON_ERROR" : {
+ "message" : [
+ "<functionName> could not evaluate the path <path>: the input is not
valid JSON. This error was requested by the ERROR ON ERROR clause."
+ ],
+ "sqlState" : "2203G"
+ },
"JSON_VALUE_ON_ERROR" : {
"message" : [
"<functionName> could not extract a scalar value at path <path>."
diff --git a/docs/sql-ref-ansi-compliance.md b/docs/sql-ref-ansi-compliance.md
index 77c4d96da1ea..c37aefc6473f 100644
--- a/docs/sql-ref-ansi-compliance.md
+++ b/docs/sql-ref-ansi-compliance.md
@@ -618,6 +618,7 @@ Below is a list of all the keywords in Spark SQL.
|ITERATE|non-reserved|non-reserved|non-reserved|
|JOIN|reserved|strict-non-reserved|reserved|
|JSON|non-reserved|non-reserved|non-reserved|
+|JSON_EXISTS|non-reserved|non-reserved|reserved|
|JSON_TABLE|non-reserved|non-reserved|reserved|
|JSON_VALUE|non-reserved|non-reserved|reserved|
|KEY|non-reserved|non-reserved|non-reserved|
diff --git a/docs/sql-ref-syntax-qry-select-json-exists.md
b/docs/sql-ref-syntax-qry-select-json-exists.md
new file mode 100644
index 000000000000..48d307d4fac3
--- /dev/null
+++ b/docs/sql-ref-syntax-qry-select-json-exists.md
@@ -0,0 +1,127 @@
+---
+layout: global
+title: JSON_EXISTS
+displayTitle: JSON_EXISTS
+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_EXISTS` predicate tests whether a SQL/JSON path matches at least one
item in a JSON
+document, returning a `BOOLEAN`. This is the SQL-standard (SQL:2016) way to
test for the presence
+of a JSON value, and is commonly used to migrate queries from other systems
such as Oracle, DB2,
+and PostgreSQL.
+
+Unlike `get_json_object(json_expr, path) IS NOT NULL`, `JSON_EXISTS`
distinguishes a path that is
+_present but whose value is JSON `null`_ (which is `true`) from a path that is
_absent_ (which is
+`false`).
+
+### Syntax
+
+```sql
+JSON_EXISTS ( json_expr, path [ { TRUE | FALSE | UNKNOWN | ERROR } ON ERROR ] )
+```
+
+### Parameters
+
+* **json_expr**
+
+ An expression that evaluates to a `STRING` containing the JSON document. A
`NULL` input yields
+ `NULL` (SQL Unknown), regardless of the `ON ERROR` clause.
+
+* **path**
+
+ A constant SQL/JSON path literal (for example `'$.a.b'`, `'$.tags[0]'`, or
`'$.a[*].b'`). Paths
+ are evaluated in **lax** mode, matching Oracle and PostgreSQL: array
wildcards (`[*]`) and member
+ wildcards (`.*` / `['*']`) are supported, and arrays are
auto-wrapped/unwrapped (a member, index,
+ or wildcard step applied to an array is applied to each element, and a
non-array value is treated
+ as a single-element array). A structural mismatch is a non-match, not an
error. A syntactically
+ invalid path is rejected during analysis.
+
+* **{ TRUE | FALSE | UNKNOWN | ERROR } ON ERROR**
+
+ Controls the result when `json_expr` is not a single well-formed JSON
value (malformed input,
+ or a valid value followed by trailing content). `TRUE`, `FALSE`, and
`UNKNOWN` produce that
+ value (`UNKNOWN` is a `BOOLEAN` `NULL`); `ERROR` raises an error. The
default is
+ `FALSE ON ERROR`.
+
+### Result
+
+* The path matches at least one item (including a match whose value is JSON
`null`) → `true`.
+* The path matches nothing → `false`.
+* `json_expr` is SQL `NULL` → `NULL`.
+* `json_expr` is not a single well-formed JSON value → the `ON ERROR`
behavior.
+
+A structural mismatch is treated as "no match" (`false`), not an error -- for
example reading an
+absent key, reading a key from a scalar, an out-of-range array index, or `[*]`
over an empty array.
+
+### Examples
+
+```sql
+SELECT json_exists('{"a":{"b":1}}', '$.a.b') AS matched;
++-------+
+|matched|
++-------+
+| true|
++-------+
+
+-- Present but JSON null -> true; absent -> false
+SELECT json_exists('{"a":null}', '$.a') AS present_null,
+ json_exists('{"a":1}', '$.b') AS absent;
++------------+------+
+|present_null|absent|
++------------+------+
+| true| false|
++------------+------+
+
+-- NULL input -> NULL (Unknown), regardless of the ON ERROR clause
+SELECT json_exists(CAST(NULL AS STRING), '$.a' TRUE ON ERROR) AS r;
++----+
+| r|
++----+
+|NULL|
++----+
+
+-- Malformed input follows the ON ERROR clause (default FALSE)
+SELECT json_exists('not json', '$.a') AS default_false,
+ json_exists('not json', '$.a' TRUE ON ERROR) AS true_on_error,
+ json_exists('not json', '$.a' UNKNOWN ON ERROR) AS unknown_on_error;
++-------------+-------------+----------------+
+|default_false|true_on_error|unknown_on_error|
++-------------+-------------+----------------+
+| false| true| NULL|
++-------------+-------------+----------------+
+
+-- Lax wildcards: [*] is true iff the array has elements; auto-unwrap applies
a step to each element
+SELECT json_exists('{"a":[1,2]}', '$.a[*]') AS has_elems,
+ json_exists('{"a":[]}', '$.a[*]') AS empty_array,
+ json_exists('{"a":[{"b":1},{"c":2}]}', '$.a[*].b') AS any_elem_has_b;
++---------+-----------+--------------+
+|has_elems|empty_array|any_elem_has_b|
++---------+-----------+--------------+
+| true| false| true|
++---------+-----------+--------------+
+
+-- Use as a predicate in WHERE
+SELECT id FROM docs WHERE json_exists(doc, '$.address.city');
+```
+
+### Related Statements
+
+* [SELECT](sql-ref-syntax-qry-select.html)
+* [WHERE Clause](sql-ref-syntax-qry-select-where.html)
+* [JSON_TABLE](sql-ref-syntax-qry-select-json-table.html)
diff --git a/docs/sql-ref-syntax-qry-select.md
b/docs/sql-ref-syntax-qry-select.md
index b2afd160c5a3..678fb8cf4de3 100644
--- a/docs/sql-ref-syntax-qry-select.md
+++ b/docs/sql-ref-syntax-qry-select.md
@@ -214,6 +214,7 @@ SELECT [ hints , ... ] [ ALL | DISTINCT ] { [ [
named_expression | regex_column_
* [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)
+* [JSON_EXISTS](sql-ref-syntax-qry-select-json-exists.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 c8ddecaf6bdb..9c5c4e3235d0 100644
--- a/docs/sql-ref-syntax.md
+++ b/docs/sql-ref-syntax.md
@@ -85,6 +85,7 @@ ability to generate logical and physical plan for a given
query using
* [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)
+ * [JSON_EXISTS](sql-ref-syntax-qry-select-json-exists.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 c042bca5e8ce..97e027b5e5c0 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
@@ -335,6 +335,7 @@ ITEMS: 'ITEMS';
ITERATE: 'ITERATE';
JOIN: 'JOIN';
JSON: 'JSON';
+JSON_EXISTS: 'JSON_EXISTS';
JSON_TABLE: 'JSON_TABLE';
JSON_VALUE: 'JSON_VALUE';
KEY: 'KEY';
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 ee7de7eac2bf..e702a3766fc9 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
@@ -1466,6 +1466,8 @@ primaryExpression
(RETURNING returning=dataType)?
(emptyBehavior=jsonValueBehavior ON EMPTY)?
(errorBehavior=jsonValueBehavior ON ERROR)? RIGHT_PAREN
#jsonValue
+ | JSON_EXISTS LEFT_PAREN jsonExpr=valueExpression COMMA path=stringLit
+ (errorBehavior=jsonExistsErrorBehavior ON ERROR)? RIGHT_PAREN
#jsonExists
| constant
#constantDefault
| ASTERISK exceptClause?
#star
| qualifiedName DOT ASTERISK exceptClause?
#star
@@ -1500,6 +1502,15 @@ jsonValueBehavior
| DEFAULT defaultExpr=expression
#jsonValueBehaviorDefault
;
+// The behavior selected by a JSON_EXISTS `... ON ERROR` clause: the boolean
(or UNKNOWN, i.e. a
+// BOOLEAN NULL) to produce when the input is not a single well-formed JSON
value.
+jsonExistsErrorBehavior
+ : TRUE
+ | FALSE
+ | UNKNOWN
+ | ERROR
+ ;
+
semiStructuredExtractionPath
: jsonPathFirstPart (jsonPathParts)*
;
@@ -2244,6 +2255,7 @@ ansiNonReserved
| ITEMS
| ITERATE
| JSON
+ | JSON_EXISTS
| JSON_TABLE
| JSON_VALUE
| KEY
@@ -2683,6 +2695,7 @@ nonReserved
| ITEMS
| ITERATE
| JSON
+ | JSON_EXISTS
| JSON_TABLE
| JSON_VALUE
| KEY
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 bf0891ef7236..0fca3b32f110 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
@@ -433,21 +433,26 @@ object PositionResult {
}
/**
- * Row-source and column extraction for the SQL `JSON_TABLE` function. Given
the JSON input, the
- * (wildcard-free) container path, and whether the row path ended in `[*]`, it
produces the
- * per-row JSON documents that the
[[org.apache.spark.sql.catalyst.expressions.JsonTable]]
- * generator then projects into columns via [[navigateColumns]].
+ * Token-aware navigation of a `containerPath` shared by the SQL/JSON
functions. Three entry
+ * points, each with its own path constraints -- so `containerPath` is NOT
wildcard-free in general:
*
- * - `$.items[*]` (containerPath `$.items`, `explodeRoot` = true): the
container must be an
- * array; each element becomes a row.
- * - `$` or `$.x` (`explodeRoot` = false): the matched value becomes exactly
one row.
+ * - [[evaluate]] -- `JSON_TABLE` row source: given the input, a
wildcard-free container path, and
+ * whether the row path ended in `[*]`, produces the per-row JSON
documents that the
+ * [[org.apache.spark.sql.catalyst.expressions.JsonTable]] generator
projects into columns via
+ * [[navigateColumns]]. `$.items[*]` (containerPath `$.items`,
`explodeRoot` = true) explodes an
+ * array into rows; `$` or `$.x` (`explodeRoot` = false) yields exactly
one row.
+ * - [[lookup]] -- `JSON_VALUE` single-scalar extraction, over a
wildcard-free path
+ * (`explodeRoot` = false).
+ * - [[pathExists]] -- `JSON_EXISTS` existence test. Here `containerPath`
MAY contain wildcards
+ * (`[*]`, `.*`, `['*']`) and is evaluated in SQL/JSON *lax* mode
(auto-wrap/unwrap, see
+ * [[anyMatch]]); `explodeRoot` is unused, so construct with `explodeRoot`
= false.
*
* Unlike `get_json_object`, navigation here is token-aware and distinguishes
missing keys from
- * JSON `null` values (see [[JsonPathResult]]).
+ * JSON `null` values (see [[JsonPathResult]] / [[PositionResult]]).
*
- * The input is required to be exactly one well-formed JSON value (no trailing
garbage, not
- * empty); anything else is treated as malformed, so the caller applies the ON
ERROR behavior
- * consistently in both modes.
+ * Every entry point requires the input to be exactly one well-formed JSON
value (no trailing
+ * garbage, not empty); anything else is treated as malformed, so the caller
applies the ON ERROR
+ * behavior consistently in both modes.
*/
case class JsonTableEvaluator(containerPath: Seq[PathInstruction],
explodeRoot: Boolean) {
import PathInstruction._
@@ -584,6 +589,161 @@ case class JsonTableEvaluator(containerPath:
Seq[PathInstruction], explodeRoot:
parser.nextToken() == null
}
+ /**
+ * Tests whether `containerPath` matches at least one item in a single JSON
document, evaluated in
+ * SQL/JSON *lax* mode (see [[anyMatch]]): wildcards are supported and a
structural mismatch is a
+ * non-match rather than an error. Returns `Some(true)` if the path matches
(including a match
+ * whose value is an explicit JSON `null`), `Some(false)` if it matches
nothing, and `None` if the
+ * input is not a single well-formed JSON value (malformed / trailing
garbage / empty). Does not
+ * serialize the matched value. A `null` input is the caller's
responsibility; `explodeRoot` is
+ * ignored (construct the evaluator with `explodeRoot = false`).
+ *
+ * A single parser both navigates the path and validates well-formedness:
after the match
+ * decision, [[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 malformed exactly as
+ * a fully bad document is. This avoids the extra O(document size) pass a
separate
+ * `isSingleWellFormedValue` scan would add per row.
+ */
+ final def pathExists(json: UTF8String): Option[Boolean] = {
+ Utils.tryWithResource(CreateJacksonParser.utf8String(jsonFactory, json)) {
parser =>
+ try {
+ if (parser.nextToken() == null) {
+ None // empty or whitespace-only
+ } else {
+ val exists = anyMatch(parser, containerPath)
+ if (drainToRootEnd(parser)) Some(exists) else None
+ }
+ } catch {
+ case _: JsonProcessingException => None
+ }
+ }
+ }
+
+ /**
+ * Returns whether `path` matches at least one item within the JSON value at
the parser's current
+ * token, evaluated in SQL/JSON *lax* mode (the `JSON_EXISTS` default,
matching Oracle and
+ * PostgreSQL):
+ * - wildcards are supported: `[*]` (any array element) and `.*` / `['*']`
(any object member);
+ * - arrays are auto-unwrapped, i.e. a member/index/wildcard step applied
to an array is applied
+ * to each element (so `$.a.b` matches when `a` is an array of objects
each having `b`);
+ * - a non-array is auto-wrapped as a single-element array, so `[*]` /
`[0]` match the value;
+ * - a structural mismatch (e.g. a member step on a scalar) is a
non-match, never an error.
+ *
+ * Invariant: the parser enters positioned on the first token of the current
value and leaves
+ * positioned on that value's last token -- the value is always fully
consumed, even after a match
+ * is found -- so wildcard branches compose and [[drainToRootEnd]] can
validate trailing content.
+ */
+ private def anyMatch(parser: JsonParser, path: Seq[PathInstruction]):
Boolean = {
+ path match {
+ case Nil =>
+ // End of path: the current value is present (including a JSON null)
-> a match. Consume it.
+ parser.skipChildren()
+ true
+
+ case Key :: Named(name) :: rest =>
+ parser.currentToken match {
+ case JsonToken.START_OBJECT =>
+ // First-match semantics for a named key: only the first member
with this name is
+ // followed, so a duplicate key later in the object is ignored.
This matches the
+ // first-match `positionAt` / `navigateAll` used by `JSON_VALUE` /
`JSON_TABLE`, so a
+ // path resolves consistently across the JSON functions. The whole
object is still
+ // drained (subsequent members skipped) to keep the
parser-position invariant.
+ var found = false
+ var matched = false
+ var token = parser.nextToken()
+ while (token != null && token != JsonToken.END_OBJECT) {
+ val matches = !matched && parser.currentName == name
+ parser.nextToken() // move onto the value; each branch consumes
it
+ if (matches) {
+ matched = true
+ if (anyMatch(parser, rest)) found = true
+ } else {
+ parser.skipChildren()
+ }
+ token = parser.nextToken()
+ }
+ found
+ case JsonToken.START_ARRAY =>
+ forEachElement(parser)(anyMatch(parser, path)) // lax auto-unwrap:
apply to each element
+ case _ =>
+ false // member accessor on a scalar: no match (the scalar is
already fully consumed)
+ }
+
+ case Subscript :: Index(index) :: rest =>
+ parser.currentToken match {
+ case JsonToken.START_ARRAY =>
+ var found = false
+ var i = 0L
+ var token = parser.nextToken()
+ while (token != null && token != JsonToken.END_ARRAY) {
+ if (i == index) {
+ if (anyMatch(parser, rest)) found = true
+ } else {
+ parser.skipChildren()
+ }
+ i += 1
+ token = parser.nextToken()
+ }
+ found
+ case _ =>
+ // lax auto-wrap: a non-array is a single-element array; [0]
matches, [i>0] does not.
+ if (index == 0) {
+ anyMatch(parser, rest)
+ } else {
+ parser.skipChildren() // consume the wrapped value to keep the
invariant
+ false
+ }
+ }
+
+ case Subscript :: Wildcard :: rest =>
+ parser.currentToken match {
+ case JsonToken.START_ARRAY =>
+ forEachElement(parser)(anyMatch(parser, rest))
+ case _ =>
+ anyMatch(parser, rest) // lax auto-wrap: a non-array is a
single-element array
+ }
+
+ case Wildcard :: rest =>
+ parser.currentToken match {
+ case JsonToken.START_OBJECT =>
+ var found = false
+ var token = parser.nextToken()
+ while (token != null && token != JsonToken.END_OBJECT) {
+ parser.nextToken() // move onto the member value
+ if (found) parser.skipChildren() else if (anyMatch(parser,
rest)) found = true
+ token = parser.nextToken()
+ }
+ found
+ case JsonToken.START_ARRAY =>
+ forEachElement(parser)(anyMatch(parser, path)) // lax auto-unwrap:
apply to each element
+ case _ =>
+ false // member wildcard on a scalar: no members
+ }
+
+ case _ =>
+ // Unreachable: JsonPathParser only produces the instruction pairs
handled above.
+ parser.skipChildren()
+ false
+ }
+ }
+
+ /**
+ * Iterates the array the parser is positioned on (its current token is
`START_ARRAY`), evaluating
+ * `matchElement` once per element with the parser positioned on that
element's first token; each
+ * call must fully consume its element. Returns whether any element matched,
and always drains the
+ * whole array, leaving the parser on the closing `END_ARRAY`. Once a match
is found the remaining
+ * elements are skipped, not matched (existence short-circuits, but the
array is still drained).
+ */
+ private def forEachElement(parser: JsonParser)(matchElement: => Boolean):
Boolean = {
+ var found = false
+ var token = parser.nextToken()
+ while (token != null && token != JsonToken.END_ARRAY) {
+ if (found) parser.skipChildren() else if (matchElement) found = true
+ token = parser.nextToken()
+ }
+ found
+ }
+
/**
* 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 c91221690091..f6239cd7f24f 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
@@ -725,7 +725,9 @@ case class JsonValue(
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).
+ // The path must parse and be wildcard-free (JSON_VALUE returns a single
scalar). The
+ // `INVALID_JSON_PATH` message is shared with `JSON_EXISTS`, which does
accept wildcards, so
+ // it must stay generic -- do not re-add wildcard-specific wording here.
DataTypeMismatch(
errorSubClass = "INVALID_JSON_PATH",
messageParameters = Map(
@@ -859,6 +861,124 @@ object JsonValue {
}
}
+/**
+ * Behavior of `JSON_EXISTS`'s `ON ERROR` clause: the value produced when the
input is not a single
+ * well-formed JSON value (malformed / trailing garbage). `Unknown` is a
BOOLEAN NULL.
+ */
+sealed trait JsonExistsBehavior
+object JsonExistsBehavior {
+ case object True extends JsonExistsBehavior
+ case object False extends JsonExistsBehavior
+ case object Unknown extends JsonExistsBehavior
+ case object Error extends JsonExistsBehavior
+}
+
+/**
+ * The SQL:2016 `JSON_EXISTS` predicate (feature T821): returns whether a
SQL/JSON `path` matches at
+ * least one item in a JSON input.
+ *
+ * - path matches (including an explicit JSON `null`) -> true
+ * - path matches nothing -> false
+ * - malformed / non-single-value input -> ON ERROR behavior
(default FALSE)
+ * - SQL NULL input -> SQL NULL
(Unknown, per 9075-2 8.23)
+ *
+ * This distinguishes "present but null" from "absent" (unlike
`get_json_object(...) IS NOT NULL`).
+ * The `ON ERROR` clause chooses TRUE / FALSE / UNKNOWN (a BOOLEAN NULL) /
ERROR; it defaults to
+ * FALSE ON ERROR.
+ *
+ * Paths are evaluated in SQL/JSON lax mode (matching Oracle / PostgreSQL):
wildcards are supported
+ * and arrays are auto-wrapped/unwrapped, while a structural mismatch is a
non-match, not an error.
+ *
+ * {{{
+ * JSON_EXISTS('{"a":{"b":1}}', '$.a.b') -- true
+ * JSON_EXISTS('{"a":null}', '$.a') -- true (present, value
is null)
+ * JSON_EXISTS('{"a":1}', '$.b') -- false (absent)
+ * JSON_EXISTS('{"a":[1,2]}', '$.a[*]') -- true (array has
elements)
+ * JSON_EXISTS('not json', '$.a' TRUE ON ERROR) -- true
+ * }}}
+ */
+case class JsonExists(
+ child: Expression,
+ path: String,
+ onError: JsonExistsBehavior)
+ extends UnaryExpression
+ with CodegenFallback
+ with ExpectsInputTypes
+ with QueryErrorsBase {
+
+ // The result is NULL only when the input is SQL NULL, or when `UNKNOWN ON
ERROR` turns malformed
+ // input into a BOOLEAN NULL. With a non-nullable input and any other ON
ERROR behavior the result
+ // is always a concrete boolean, which lets the optimizer treat e.g. a WHERE
predicate as such.
+ override def nullable: Boolean =
+ child.nullable || onError == JsonExistsBehavior.Unknown
+
+ override def inputTypes: Seq[AbstractDataType] =
+ Seq(StringTypeWithCollation(supportsTrimCollation = true))
+
+ override def dataType: DataType = BooleanType
+
+ // The path is a constant (the grammar makes it a string literal), so it is
parsed once and shared
+ // by `checkInputDataTypes` and `evaluator` rather than reparsed. `None`
means it did not parse.
+ @transient private lazy val parsedPath: Option[Seq[PathInstruction]] =
JsonPathParser.parse(path)
+
+ override def checkInputDataTypes(): TypeCheckResult = {
+ val inputCheck = super.checkInputDataTypes()
+ if (inputCheck.isFailure) {
+ inputCheck
+ } else if (parsedPath.isDefined) {
+ // A valid SQL/JSON path. Wildcards are allowed and evaluated in lax
mode at runtime.
+ TypeCheckResult.TypeCheckSuccess
+ } else {
+ // The path is not a valid SQL/JSON path. `JSON_EXISTS` reaches this
branch only for a
+ // syntactically malformed path -- wildcards parse and are accepted
above. The shared
+ // `INVALID_JSON_PATH` error is also raised by `JSON_VALUE` (which
additionally rejects
+ // wildcards, as it returns a single scalar); its message is worded
generically for both.
+ DataTypeMismatch(
+ errorSubClass = "INVALID_JSON_PATH",
+ messageParameters = Map(
+ "functionName" -> toSQLId(prettyName), "path" -> toSQLValue(path)))
+ }
+ }
+
+ // `checkInputDataTypes` guarantees the path parses before this is forced;
`Nil` is an unreachable
+ // fallback that would match the document root.
+ @transient private lazy val evaluator: JsonTableEvaluator =
+ JsonTableEvaluator(parsedPath.getOrElse(Nil), explodeRoot = false)
+
+ private def onErrorResult(): Any = onError match {
+ case JsonExistsBehavior.True => true
+ case JsonExistsBehavior.False => false
+ case JsonExistsBehavior.Unknown => null
+ case JsonExistsBehavior.Error =>
+ throw QueryExecutionErrors.jsonExistsOnError(prettyName, path)
+ }
+
+ override def eval(input: InternalRow): Any = {
+ val json = child.eval(input).asInstanceOf[UTF8String]
+ // SQL NULL input yields Unknown (BOOLEAN NULL), not the ON ERROR path,
per 9075-2 8.23.
+ if (json == null) return null
+ evaluator.pathExists(json) match {
+ case Some(exists) => exists
+ case None => onErrorResult() // malformed / non-single-value input
+ }
+ }
+
+ override def prettyName: String = "json_exists"
+
+ override def sql: String = {
+ val errorSQL = onError match {
+ case JsonExistsBehavior.False => "" // the default
+ case JsonExistsBehavior.True => " TRUE ON ERROR"
+ case JsonExistsBehavior.Unknown => " UNKNOWN ON ERROR"
+ case JsonExistsBehavior.Error => " ERROR ON ERROR"
+ }
+ s"JSON_EXISTS(${child.sql}, ${toSQLValue(path)}$errorSQL)"
+ }
+
+ override protected def withNewChildInternal(newChild: Expression):
JsonExists =
+ copy(child = newChild)
+}
+
/**
* 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 262c0882138e..5138fcece8a5 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
@@ -4186,6 +4186,22 @@ class AstBuilder extends DataTypeAstBuilder
JsonValue(jsonExpr, path, returning, onEmpty, onError, emptyDefault,
errorDefault)
}
+ /**
+ * Create a [[JsonExists]] expression for the SQL:2016 `JSON_EXISTS`
predicate. The `ON ERROR`
+ * clause defaults to `FALSE` when absent, per the standard.
+ */
+ override def visitJsonExists(ctx: JsonExistsContext): Expression =
withOrigin(ctx) {
+ val jsonExpr = expression(ctx.jsonExpr)
+ val path = string(visitStringLit(ctx.path))
+ val onError = Option(ctx.errorBehavior).map { b =>
+ if (b.TRUE != null) JsonExistsBehavior.True
+ else if (b.FALSE != null) JsonExistsBehavior.False
+ else if (b.UNKNOWN != null) JsonExistsBehavior.Unknown
+ else JsonExistsBehavior.Error
+ }.getOrElse(JsonExistsBehavior.False)
+ JsonExists(jsonExpr, path, onError)
+ }
+
/**
* 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 08eaf35b4f92..eaaeba83dc0e 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
@@ -1639,6 +1639,12 @@ private[sql] object QueryExecutionErrors extends
QueryErrorsBase with ExecutionE
cause = cause)
}
+ def jsonExistsOnError(functionName: String, path: String): Throwable = {
+ new SparkRuntimeException(
+ errorClass = "JSON_EXISTS_ON_ERROR",
+ messageParameters = Map("functionName" -> toSQLId(functionName), "path"
-> toSQLValue(path)))
+ }
+
def invalidKerberosConfigForHiveServer2Error(): Throwable = {
new SparkException(
errorClass = "_LEGACY_ERROR_TEMP_2179",
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 18a92a6496d3..5187c256bed7 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
@@ -331,6 +331,21 @@ class ExpressionParserSuite extends AnalysisTest {
Some(Literal("x")), Some(Literal("y"))))
}
+ test("JSON_EXISTS expressions") {
+ import org.apache.spark.sql.catalyst.expressions.JsonExistsBehavior
+ // Bare form defaults to FALSE ON ERROR.
+ assertEqual("json_exists(a, '$.b')", JsonExists($"a", "$.b",
JsonExistsBehavior.False))
+ assertEqual(
+ "json_exists(a, '$.b' TRUE ON ERROR)", JsonExists($"a", "$.b",
JsonExistsBehavior.True))
+ assertEqual(
+ "json_exists(a, '$.b' FALSE ON ERROR)", JsonExists($"a", "$.b",
JsonExistsBehavior.False))
+ assertEqual(
+ "json_exists(a, '$.b' UNKNOWN ON ERROR)",
+ JsonExists($"a", "$.b", JsonExistsBehavior.Unknown))
+ assertEqual(
+ "json_exists(a, '$.b' ERROR ON ERROR)", JsonExists($"a", "$.b",
JsonExistsBehavior.Error))
+ }
+
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 b07f9da119a4..17f3d77b77c5 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 9b33afd10db8..74145b7f3d42 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
@@ -1358,3 +1358,151 @@ org.apache.spark.sql.catalyst.ExtendedAnalysisException
"fragment" : "json_value('{}', '$.x' RETURNING INT DEFAULT array(1) ON
EMPTY)"
} ]
}
+
+
+-- !query
+select
json_exists('{"id":7,"addr":{"city":"NYC"},"score":null,"tags":["x","y"]}',
'$.addr.city')
+-- !query analysis
+Project
[json_exists({"id":7,"addr":{"city":"NYC"},"score":null,"tags":["x","y"]},
$.addr.city, False) AS
JSON_EXISTS({"id":7,"addr":{"city":"NYC"},"score":null,"tags":["x","y"]},
'$.addr.city')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('{"score":null}', '$.score')
+-- !query analysis
+Project [json_exists({"score":null}, $.score, False) AS
JSON_EXISTS({"score":null}, '$.score')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('{"addr":{"city":"NYC"}}', '$.addr.zip')
+-- !query analysis
+Project [json_exists({"addr":{"city":"NYC"}}, $.addr.zip, False) AS
JSON_EXISTS({"addr":{"city":"NYC"}}, '$.addr.zip')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('{"addr":{"city":"NYC"}}', '$.addr')
+-- !query analysis
+Project [json_exists({"addr":{"city":"NYC"}}, $.addr, False) AS
JSON_EXISTS({"addr":{"city":"NYC"}}, '$.addr')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('{"tags":["x","y"]}', '$.tags[0]')
+-- !query analysis
+Project [json_exists({"tags":["x","y"]}, $.tags[0], False) AS
JSON_EXISTS({"tags":["x","y"]}, '$.tags[0]')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists(cast(null as string), '$.a')
+-- !query analysis
+Project [json_exists(cast(null as string), $.a, False) AS
JSON_EXISTS(CAST(NULL AS STRING), '$.a')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('not json', '$.a')
+-- !query analysis
+Project [json_exists(not json, $.a, False) AS JSON_EXISTS(not json, '$.a')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('not json', '$.a' TRUE ON ERROR)
+-- !query analysis
+Project [json_exists(not json, $.a, True) AS JSON_EXISTS(not json, '$.a' TRUE
ON ERROR)#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('not json', '$.a' FALSE ON ERROR)
+-- !query analysis
+Project [json_exists(not json, $.a, False) AS JSON_EXISTS(not json, '$.a')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('not json', '$.a' UNKNOWN ON ERROR)
+-- !query analysis
+Project [json_exists(not json, $.a, Unknown) AS JSON_EXISTS(not json, '$.a'
UNKNOWN ON ERROR)#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('not json', '$.a' ERROR ON ERROR)
+-- !query analysis
+Project [json_exists(not json, $.a, Error) AS JSON_EXISTS(not json, '$.a'
ERROR ON ERROR)#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('{"a":[1,2]}', '$.a[*]')
+-- !query analysis
+Project [json_exists({"a":[1,2]}, $.a[*], False) AS JSON_EXISTS({"a":[1,2]},
'$.a[*]')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('{"a":[]}', '$.a[*]')
+-- !query analysis
+Project [json_exists({"a":[]}, $.a[*], False) AS JSON_EXISTS({"a":[]},
'$.a[*]')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('{"a":5}', '$.a[*]')
+-- !query analysis
+Project [json_exists({"a":5}, $.a[*], False) AS JSON_EXISTS({"a":5},
'$.a[*]')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('{"a":[{"b":1},{"c":2}]}', '$.a[*].b')
+-- !query analysis
+Project [json_exists({"a":[{"b":1},{"c":2}]}, $.a[*].b, False) AS
JSON_EXISTS({"a":[{"b":1},{"c":2}]}, '$.a[*].b')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('{"a":[1,2]}', '$.a[5]')
+-- !query analysis
+Project [json_exists({"a":[1,2]}, $.a[5], False) AS JSON_EXISTS({"a":[1,2]},
'$.a[5]')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('{"a":[{"b":1},{"b":2}]}', '$.a.b')
+-- !query analysis
+Project [json_exists({"a":[{"b":1},{"b":2}]}, $.a.b, False) AS
JSON_EXISTS({"a":[{"b":1},{"b":2}]}, '$.a.b')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('{"addr":{"city":"NYC"}}', '$.*')
+-- !query analysis
+Project [json_exists({"addr":{"city":"NYC"}}, $.*, False) AS
JSON_EXISTS({"addr":{"city":"NYC"}}, '$.*')#x]
++- OneRowRelation
+
+
+-- !query
+select json_exists('{"a":1}', '$[')
+-- !query analysis
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+ "errorClass" : "DATATYPE_MISMATCH.INVALID_JSON_PATH",
+ "sqlState" : "42K09",
+ "messageParameters" : {
+ "functionName" : "`json_exists`",
+ "path" : "'$['",
+ "sqlExpr" : "\"JSON_EXISTS({\"a\":1}, '$[')\""
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 8,
+ "stopIndex" : 35,
+ "fragment" : "json_exists('{\"a\":1}', '$[')"
+ } ]
+}
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 fa1cb6628ec9..93fcdaf5f478 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
@@ -211,3 +211,37 @@ select json_value('{"a":[1,2]}', '$.a[*]');
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);
+
+-- JSON_EXISTS: test path presence (ANSI SQL:2016)
+select
json_exists('{"id":7,"addr":{"city":"NYC"},"score":null,"tags":["x","y"]}',
'$.addr.city');
+-- present but JSON null -> true
+select json_exists('{"score":null}', '$.score');
+-- absent -> false
+select json_exists('{"addr":{"city":"NYC"}}', '$.addr.zip');
+-- matches an object / array -> true
+select json_exists('{"addr":{"city":"NYC"}}', '$.addr');
+select json_exists('{"tags":["x","y"]}', '$.tags[0]');
+-- NULL input -> NULL (unknown)
+select json_exists(cast(null as string), '$.a');
+-- malformed input -> FALSE ON ERROR (default)
+select json_exists('not json', '$.a');
+-- ON ERROR behaviors
+select json_exists('not json', '$.a' TRUE ON ERROR);
+select json_exists('not json', '$.a' FALSE ON ERROR);
+select json_exists('not json', '$.a' UNKNOWN ON ERROR);
+select json_exists('not json', '$.a' ERROR ON ERROR);
+-- lax wildcard [*]: true iff the array has elements
+select json_exists('{"a":[1,2]}', '$.a[*]');
+select json_exists('{"a":[]}', '$.a[*]');
+-- lax auto-wrap: [*] over a non-array treats it as a single-element array
+select json_exists('{"a":5}', '$.a[*]');
+-- embedded wildcard: any element has the field
+select json_exists('{"a":[{"b":1},{"c":2}]}', '$.a[*].b');
+-- out-of-range index -> false
+select json_exists('{"a":[1,2]}', '$.a[5]');
+-- lax auto-unwrap: a member step over an array applies to each element
+select json_exists('{"a":[{"b":1},{"b":2}]}', '$.a.b');
+-- member wildcard .* matches any member
+select json_exists('{"addr":{"city":"NYC"}}', '$.*');
+-- invalid: an unparseable path is rejected at analysis
+select json_exists('{"a":1}', '$[');
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 6656a7723cd1..8508369a6f1d 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
@@ -1546,3 +1546,179 @@ org.apache.spark.sql.catalyst.ExtendedAnalysisException
"fragment" : "json_value('{}', '$.x' RETURNING INT DEFAULT array(1) ON
EMPTY)"
} ]
}
+
+
+-- !query
+select
json_exists('{"id":7,"addr":{"city":"NYC"},"score":null,"tags":["x","y"]}',
'$.addr.city')
+-- !query schema
+struct<JSON_EXISTS({"id":7,"addr":{"city":"NYC"},"score":null,"tags":["x","y"]},
'$.addr.city'):boolean>
+-- !query output
+true
+
+
+-- !query
+select json_exists('{"score":null}', '$.score')
+-- !query schema
+struct<JSON_EXISTS({"score":null}, '$.score'):boolean>
+-- !query output
+true
+
+
+-- !query
+select json_exists('{"addr":{"city":"NYC"}}', '$.addr.zip')
+-- !query schema
+struct<JSON_EXISTS({"addr":{"city":"NYC"}}, '$.addr.zip'):boolean>
+-- !query output
+false
+
+
+-- !query
+select json_exists('{"addr":{"city":"NYC"}}', '$.addr')
+-- !query schema
+struct<JSON_EXISTS({"addr":{"city":"NYC"}}, '$.addr'):boolean>
+-- !query output
+true
+
+
+-- !query
+select json_exists('{"tags":["x","y"]}', '$.tags[0]')
+-- !query schema
+struct<JSON_EXISTS({"tags":["x","y"]}, '$.tags[0]'):boolean>
+-- !query output
+true
+
+
+-- !query
+select json_exists(cast(null as string), '$.a')
+-- !query schema
+struct<JSON_EXISTS(CAST(NULL AS STRING), '$.a'):boolean>
+-- !query output
+NULL
+
+
+-- !query
+select json_exists('not json', '$.a')
+-- !query schema
+struct<JSON_EXISTS(not json, '$.a'):boolean>
+-- !query output
+false
+
+
+-- !query
+select json_exists('not json', '$.a' TRUE ON ERROR)
+-- !query schema
+struct<JSON_EXISTS(not json, '$.a' TRUE ON ERROR):boolean>
+-- !query output
+true
+
+
+-- !query
+select json_exists('not json', '$.a' FALSE ON ERROR)
+-- !query schema
+struct<JSON_EXISTS(not json, '$.a'):boolean>
+-- !query output
+false
+
+
+-- !query
+select json_exists('not json', '$.a' UNKNOWN ON ERROR)
+-- !query schema
+struct<JSON_EXISTS(not json, '$.a' UNKNOWN ON ERROR):boolean>
+-- !query output
+NULL
+
+
+-- !query
+select json_exists('not json', '$.a' ERROR ON ERROR)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.SparkRuntimeException
+{
+ "errorClass" : "JSON_EXISTS_ON_ERROR",
+ "sqlState" : "2203G",
+ "messageParameters" : {
+ "functionName" : "`json_exists`",
+ "path" : "'$.a'"
+ }
+}
+
+
+-- !query
+select json_exists('{"a":[1,2]}', '$.a[*]')
+-- !query schema
+struct<JSON_EXISTS({"a":[1,2]}, '$.a[*]'):boolean>
+-- !query output
+true
+
+
+-- !query
+select json_exists('{"a":[]}', '$.a[*]')
+-- !query schema
+struct<JSON_EXISTS({"a":[]}, '$.a[*]'):boolean>
+-- !query output
+false
+
+
+-- !query
+select json_exists('{"a":5}', '$.a[*]')
+-- !query schema
+struct<JSON_EXISTS({"a":5}, '$.a[*]'):boolean>
+-- !query output
+true
+
+
+-- !query
+select json_exists('{"a":[{"b":1},{"c":2}]}', '$.a[*].b')
+-- !query schema
+struct<JSON_EXISTS({"a":[{"b":1},{"c":2}]}, '$.a[*].b'):boolean>
+-- !query output
+true
+
+
+-- !query
+select json_exists('{"a":[1,2]}', '$.a[5]')
+-- !query schema
+struct<JSON_EXISTS({"a":[1,2]}, '$.a[5]'):boolean>
+-- !query output
+false
+
+
+-- !query
+select json_exists('{"a":[{"b":1},{"b":2}]}', '$.a.b')
+-- !query schema
+struct<JSON_EXISTS({"a":[{"b":1},{"b":2}]}, '$.a.b'):boolean>
+-- !query output
+true
+
+
+-- !query
+select json_exists('{"addr":{"city":"NYC"}}', '$.*')
+-- !query schema
+struct<JSON_EXISTS({"addr":{"city":"NYC"}}, '$.*'):boolean>
+-- !query output
+true
+
+
+-- !query
+select json_exists('{"a":1}', '$[')
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+ "errorClass" : "DATATYPE_MISMATCH.INVALID_JSON_PATH",
+ "sqlState" : "42K09",
+ "messageParameters" : {
+ "functionName" : "`json_exists`",
+ "path" : "'$['",
+ "sqlExpr" : "\"JSON_EXISTS({\"a\":1}, '$[')\""
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 8,
+ "stopIndex" : 35,
+ "fragment" : "json_exists('{\"a\":1}', '$[')"
+ } ]
+}
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 37f174acfe53..2989a7373ffa 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
@@ -212,6 +212,7 @@ ITEMS false
ITERATE false
JOIN true
JSON false
+JSON_EXISTS false
JSON_TABLE false
JSON_VALUE false
KEY false
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 f6b910a54c3a..aa14278fe15a 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
@@ -212,6 +212,7 @@ ITEMS false
ITERATE false
JOIN false
JSON false
+JSON_EXISTS false
JSON_TABLE false
JSON_VALUE false
KEY 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 f6b910a54c3a..aa14278fe15a 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
@@ -212,6 +212,7 @@ ITEMS false
ITERATE false
JOIN false
JSON false
+JSON_EXISTS false
JSON_TABLE false
JSON_VALUE false
KEY false
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/JsonExistsSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/JsonExistsSuite.scala
new file mode 100644
index 000000000000..a4bac2c628a5
--- /dev/null
+++ b/sql/core/src/test/scala/org/apache/spark/sql/JsonExistsSuite.scala
@@ -0,0 +1,188 @@
+/*
+ * 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.{JsonExists,
JsonExistsBehavior, Literal}
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.BooleanType
+
+/**
+ * End-to-end tests for the SQL:2016 `JSON_EXISTS` predicate.
+ */
+class JsonExistsSuite extends QueryTest with SharedSparkSession {
+ import testImplicits._
+
+ private val doc =
+ """{"id":7,"addr":{"city":"NYC"},"score":null,"tags":["x","y"]}"""
+
+ test("returns BOOLEAN") {
+ assert(sql(s"SELECT json_exists('$doc', '$$.id')").schema.head.dataType
=== BooleanType)
+ }
+
+ test("path present -> true, absent -> false") {
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$.addr.city')"), Row(true))
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$.addr.zip')"), Row(false))
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$.missing')"), Row(false))
+ }
+
+ test("present but JSON null -> true (distinguishes present-null from
absent)") {
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$.score')"), Row(true))
+ }
+
+ test("a matched object or array -> true") {
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$.addr')"), Row(true))
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$.tags')"), Row(true))
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$.tags[1]')"), Row(true))
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$.tags[5]')"), Row(false))
+ }
+
+ test("NULL input yields NULL (Unknown), not the ON ERROR path") {
+ checkAnswer(
+ sql("SELECT json_exists(CAST(NULL AS STRING), '$.a' ERROR ON ERROR)"),
Row(null))
+ }
+
+ test("malformed input: FALSE ON ERROR by default, and each ON ERROR
behavior") {
+ checkAnswer(sql("SELECT json_exists('not json', '$.a')"), Row(false))
+ checkAnswer(sql("SELECT json_exists('not json', '$.a' FALSE ON ERROR)"),
Row(false))
+ checkAnswer(sql("SELECT json_exists('not json', '$.a' TRUE ON ERROR)"),
Row(true))
+ checkAnswer(sql("SELECT json_exists('not json', '$.a' UNKNOWN ON ERROR)"),
Row(null))
+ val e = intercept[SparkRuntimeException] {
+ sql("SELECT json_exists('not json', '$.a' ERROR ON ERROR)").collect()
+ }
+ assert(e.getCondition == "JSON_EXISTS_ON_ERROR")
+ }
+
+ test("empty or whitespace-only input is malformed -> ON ERROR") {
+ checkAnswer(sql("SELECT json_exists('', '$.a')"), Row(false))
+ checkAnswer(sql("SELECT json_exists('', '$.a' TRUE ON ERROR)"), Row(true))
+ checkAnswer(sql("SELECT json_exists(' ', '$.a')"), Row(false))
+ checkAnswer(sql("SELECT json_exists(' ', '$.a' TRUE ON ERROR)"),
Row(true))
+ }
+
+ test("a partial/prefix-garbage input is malformed -> ON ERROR") {
+ // A valid prefix followed by trailing garbage is not a single well-formed
value. This holds
+ // whether the path matches (drainToRootEnd runs after the match) or is
absent (drainToRootEnd
+ // runs after the miss) -- both surface the trailing content as malformed
input.
+ checkAnswer(sql("""SELECT json_exists('{"a":1} trailing', '$.a')"""),
Row(false))
+ checkAnswer(sql("""SELECT json_exists('{"a":1} trailing', '$.a' TRUE ON
ERROR)"""), Row(true))
+ checkAnswer(sql("""SELECT json_exists('{"a":1} trailing',
'$.missing')"""), Row(false))
+ checkAnswer(
+ sql("""SELECT json_exists('{"a":1} trailing', '$.missing' TRUE ON
ERROR)"""), Row(true))
+ // A second well-formed root value (not just garbage) is also more than
one value -> malformed;
+ // drainToRootEnd must reject the trailing root, even though the path
matched in the first one.
+ checkAnswer(sql("""SELECT json_exists('{"a":1} {"b":2}', '$.a')"""),
Row(false))
+ checkAnswer(sql("""SELECT json_exists('{"a":1} {"b":2}', '$.a' TRUE ON
ERROR)"""), Row(true))
+ }
+
+ test("works over a column of JSON documents") {
+ withTempView("docs") {
+ Seq(
+ (1, """{"a":1}"""),
+ (2, """{"b":2}"""), // absent -> false
+ (3, """{"a":null}"""), // present null -> true
+ (4, "not json")) // malformed -> false (default)
+ .toDF("k", "j").createOrReplaceTempView("docs")
+ checkAnswer(
+ sql("SELECT k, json_exists(j, '$.a') AS e FROM docs ORDER BY k"),
+ Seq(Row(1, true), Row(2, false), Row(3, true), Row(4, false)))
+ }
+ }
+
+ test("bracket and nested path syntax") {
+ checkAnswer(sql(s"SELECT json_exists('$doc',
'$$[\\'addr\\'][\\'city\\']')"), Row(true))
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$.addr.city')"), Row(true))
+ }
+
+ test("sql escapes a quoted path literal so the rendering re-parses") {
+ // A bracket-quoted path contains single quotes; the `sql` rendering must
escape them, otherwise
+ // it would emit invalid SQL such as JSON_EXISTS('{}', '$['a']['b']').
+ val e = JsonExists(Literal("{}"), "$['a']['b']", JsonExistsBehavior.False)
+ val parsed = spark.sessionState.sqlParser.parseExpression(e.sql)
+ assert(parsed.isInstanceOf[JsonExists])
+ assert(parsed.asInstanceOf[JsonExists].path === "$['a']['b']")
+ }
+
+ test("lax wildcard [*]: true iff the array has elements; auto-wraps a
non-array") {
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$.tags[*]')"), Row(true))
+ checkAnswer(sql("""SELECT json_exists('{"tags":[]}', '$.tags[*]')"""),
Row(false))
+ checkAnswer(sql("""SELECT json_exists('{"a":[1,2]}', '$.a[*]')"""),
Row(true))
+ // lax auto-wrap: a non-array value (scalar or object) is a single-element
array.
+ checkAnswer(sql("""SELECT json_exists('{"a":5}', '$.a[*]')"""), Row(true))
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$.addr[*]')"), Row(true))
+ // lax auto-wrap for an explicit index: over a non-array, [0] matches the
wrapped value and
+ // any [i>0] does not.
+ checkAnswer(sql("""SELECT json_exists('{"a":5}', '$.a[0]')"""), Row(true))
+ checkAnswer(sql("""SELECT json_exists('{"a":5}', '$.a[1]')"""), Row(false))
+ }
+
+ test("lax embedded wildcard $.a[*].b matches when any element has the
field") {
+ checkAnswer(sql("""SELECT json_exists('{"a":[{"b":1},{"c":2}]}',
'$.a[*].b')"""), Row(true))
+ // Match only in a later element (guards the short-circuit loop).
+ checkAnswer(sql("""SELECT json_exists('{"a":[{"c":1},{"b":2}]}',
'$.a[*].b')"""), Row(true))
+ checkAnswer(sql("""SELECT json_exists('{"a":[{"c":1}]}', '$.a[*].b')"""),
Row(false))
+ }
+
+ test("lax member wildcard .* and ['*'] match any member") {
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$.addr.*')"), Row(true))
+ checkAnswer(sql("""SELECT json_exists('{}', '$.*')"""), Row(false))
+ // ['*'] is the bracket-quoted spelling of the same member wildcard.
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$[\\'*\\']')"), Row(true))
+ checkAnswer(sql(s"SELECT json_exists('$doc', '$$.addr[\\'*\\']')"),
Row(true))
+ checkAnswer(sql("SELECT json_exists('{}', '$[\\'*\\']')"), Row(false))
+ // lax auto-unwrap: a member wildcard over an array applies to each
element, matching iff some
+ // element has a member.
+ checkAnswer(sql("""SELECT json_exists('[{"a":1}]', '$.*')"""), Row(true))
+ checkAnswer(sql("""SELECT json_exists('[1]', '$.*')"""), Row(false))
+ }
+
+ test("index step over a non-array member is skipped without corrupting the
traversal") {
+ // $.*[1] visits each member; [1] over the object member `a` must be fully
consumed (return
+ // false) so the traversal advances to member `b`, where b[1] matches.
+ checkAnswer(sql("""SELECT json_exists('{"a":{"x":1},"b":[0,1]}',
'$.*[1]')"""), Row(true))
+ checkAnswer(sql("""SELECT json_exists('{"a":{"x":1}}', '$.*[1]')"""),
Row(false))
+ }
+
+ test("lax auto-unwrap: a member step over an array applies to each element")
{
+ checkAnswer(sql("""SELECT json_exists('{"a":[{"b":1},{"b":2}]}',
'$.a.b')"""), Row(true))
+ checkAnswer(sql("""SELECT json_exists('{"a":[{"c":1}]}', '$.a.b')"""),
Row(false))
+ }
+
+ test("duplicate object keys: first-match, consistent with JSON_VALUE /
JSON_TABLE") {
+ // A named-key step follows only the first member with that name; a later
duplicate is ignored.
+ checkAnswer(sql("""SELECT json_exists('{"a":{},"a":{"b":1}}',
'$.a.b')"""), Row(false))
+ checkAnswer(sql("""SELECT json_exists('{"a":{"b":1},"a":{}}',
'$.a.b')"""), Row(true))
+ // The duplicate key itself still exists, so a path that stops at it
matches.
+ checkAnswer(sql("""SELECT json_exists('{"a":{},"a":{"b":1}}', '$.a')"""),
Row(true))
+ }
+
+ test("invalid: an unparseable path is rejected at analysis") {
+ val e = intercept[AnalysisException] {
+ sql(s"SELECT json_exists('$doc', '$$[')").collect()
+ }
+ assert(e.getCondition == "DATATYPE_MISMATCH.INVALID_JSON_PATH")
+ }
+
+ test("usable in a WHERE predicate") {
+ withTempView("docs") {
+ Seq((1, """{"a":1}"""), (2, """{"b":2}""")).toDF("k",
"j").createOrReplaceTempView("docs")
+ checkAnswer(
+ sql("SELECT k FROM docs WHERE json_exists(j, '$.a')"), Seq(Row(1)))
+ }
+ }
+}
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 e6aa18256c90..8249d8fcf4e9 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]