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 1dd80a368318 [SPARK-58723][SQL] Support the ANSI SQL/JSON_QUERY 
function
1dd80a368318 is described below

commit 1dd80a3683189b4e67022d794c288885263c68b6
Author: Ganesha S <[email protected]>
AuthorDate: Fri Aug 21 08:40:09 2026 +0800

    [SPARK-58723][SQL] Support the ANSI SQL/JSON_QUERY function
    
    ### What changes were proposed in this pull request?
    
    Add the ANSI SQL:2016 `JSON_QUERY` function (feature T828), which extracts 
the JSON value located by a SQL/JSON path from a JSON input and returns it as 
JSON text.
    
    ```
    JSON_QUERY(jsonExpr, path
               [ RETURNING type ]
               [ (WITHOUT | WITH [CONDITIONAL | UNCONDITIONAL]) [ARRAY] WRAPPER 
]
               [ (KEEP | OMIT) QUOTES ]
               [ (NULL | ERROR | EMPTY ARRAY | EMPTY OBJECT) ON EMPTY ]
               [ (NULL | ERROR | EMPTY ARRAY | EMPTY OBJECT) ON ERROR ])
    ```
    
    It is implemented as a parser-only production (no FunctionRegistry entry), 
mirroring the merged JSON_VALUE (SPARK-58685) and reusing the JSON path 
evaluator from JSON_TABLE (SPARK-58366). The matched object/array/scalar is 
serialized verbatim; a scalar is emitted as JSON text under the default WITHOUT 
ARRAY WRAPPER, OMIT QUOTES unquotes a scalar string, the array wrapper wraps 
per the standard (CONDITIONAL wraps only a scalar), and ON EMPTY / ON ERROR 
default to NULL. RETURNING is STR [...]
    
    Example (j = {"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"}}):
    
    ```
    SELECT json_query(j, '$.addr');                        -- {"city":"NYC"}
    SELECT json_query(j, '$.tags');                        -- ["x","y"]
    SELECT json_query(j, '$.id');                          -- 7        (scalar 
as JSON text)
    SELECT json_query(j, '$.name' OMIT QUOTES);            -- Ada
    SELECT json_query(j, '$.tags[0]' WITH ARRAY WRAPPER);  -- ["x"]
    SELECT json_query(j, '$.missing' EMPTY ARRAY ON EMPTY);-- []
    SELECT json_query('not json', '$.a');                  -- NULL     (NULL ON 
ERROR default)
    ```
    
    Follow-ups (shared with JSON_VALUE, out of scope here): default-collation 
rewrite of the result, whole-stage codegen, and cross-expression parse sharing 
for sibling projections.
    
    ### Why are the changes needed?
    
    JSON_QUERY is the SQL-standard way to extract an object/array/scalar 
fragment from JSON, supported by Oracle, SQL Server, PostgreSQL, Trino, Flink, 
and others. Spark had no equivalent, forcing migrated queries onto 
get_json_object, whose NULL/error semantics differ (it conflates "missing" and 
"parse error", always returns STRING, and offers no wrapper/quotes control). 
This is the natural follow-on to JSON_VALUE.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. It adds the new JSON_QUERY SQL function and its documentation, and 
adds JSON_QUERY, WRAPPER, QUOTES, KEEP, OMIT, CONDITIONAL, UNCONDITIONAL, and 
OBJECT as non-reserved keywords (usable as identifiers in both modes). No 
change to existing behavior.
    
    ### How was this patch tested?
    
    - New JsonQuerySuite (end-to-end): object/array/scalar extraction, JSON 
null, all wrapper modes, KEEP/OMIT QUOTES (incl. escapes), EMPTY ARRAY/OBJECT, 
ON EMPTY/ON ERROR, malformed/trailing input, RETURNING VARCHAR/CHAR 
normalization, direct-construction validation, and keyword-as-identifier.
    - Golden files in json-functions.sql; regenerated keywords*.sql.out.
    - ExpressionParserSuite for every clause spelling; SQLKeywordSuite; 
SparkThrowableSuite; and TableIdentifierParserSuite / JsonExpressionsSuite for 
regression.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 4.8)
    
    Closes #57957 from ganeshashree/SPARK-58723.
    
    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 89dc5ffe6d1729a195387582473a55d2e411df20)
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../src/main/resources/error/error-conditions.json |  28 +++
 docs/sql-ref-ansi-compliance.md                    |   8 +
 docs/sql-ref-syntax-qry-select-json-query.md       | 174 ++++++++++++++
 docs/sql-ref-syntax-qry-select-json-value.md       |   6 +-
 docs/sql-ref-syntax-qry-select.md                  |   1 +
 docs/sql-ref-syntax.md                             |   1 +
 .../spark/sql/catalyst/parser/SqlBaseLexer.g4      |   8 +
 .../spark/sql/catalyst/parser/SqlBaseParser.g4     |  45 ++++
 .../expressions/json/JsonExpressionEvalUtils.scala |  95 ++++++++
 .../sql/catalyst/expressions/jsonExpressions.scala | 222 ++++++++++++++++-
 .../spark/sql/catalyst/parser/AstBuilder.scala     |  52 ++++
 .../spark/sql/errors/QueryExecutionErrors.scala    |  14 ++
 .../catalyst/parser/ExpressionParserSuite.scala    |  45 ++++
 .../jdbc/SparkConnectDatabaseMetaDataSuite.scala   |   2 +-
 .../analyzer-results/json-functions.sql.out        | 219 +++++++++++++++++
 .../resources/sql-tests/inputs/json-functions.sql  |  40 ++++
 .../sql-tests/results/json-functions.sql.out       | 263 +++++++++++++++++++++
 .../sql-tests/results/keywords-enforced.sql.out    |   8 +
 .../resources/sql-tests/results/keywords.sql.out   |   8 +
 .../sql-tests/results/nonansi/keywords.sql.out     |   8 +
 .../org/apache/spark/sql/JsonQuerySuite.scala      | 228 ++++++++++++++++++
 .../ThriftServerWithSparkContextSuite.scala        |   2 +-
 22 files changed, 1470 insertions(+), 7 deletions(-)

diff --git a/common/utils/src/main/resources/error/error-conditions.json 
b/common/utils/src/main/resources/error/error-conditions.json
index c9df01610546..21fb4838d620 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -1968,6 +1968,16 @@
           "<functionName> has an invalid or unsupported JSON path <path>."
         ]
       },
+      "INVALID_JSON_QUERY_RETURNING_TYPE" : {
+        "message" : [
+          "<functionName> cannot return a value of type <returningType>. The 
RETURNING type must be a string type."
+        ]
+      },
+      "INVALID_JSON_QUERY_WRAPPER_AND_QUOTES" : {
+        "message" : [
+          "<functionName> cannot combine an OMIT QUOTES clause with a WITH 
ARRAY WRAPPER clause. OMIT QUOTES applies only to an unwrapped result."
+        ]
+      },
       "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."
@@ -5621,6 +5631,24 @@
     ],
     "sqlState" : "2203G"
   },
+  "JSON_QUERY_ON_ERROR" : {
+    "message" : [
+      "<functionName> could not extract a 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. 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 c37aefc6473f..5ab05fd026f7 100644
--- a/docs/sql-ref-ansi-compliance.md
+++ b/docs/sql-ref-ansi-compliance.md
@@ -484,6 +484,7 @@ Below is a list of all the keywords in Spark SQL.
 |COMPUTE|non-reserved|non-reserved|non-reserved|
 |CONCATENATE|non-reserved|non-reserved|non-reserved|
 |CONDITION|non-reserved|non-reserved|non-reserved|
+|CONDITIONAL|non-reserved|non-reserved|non-reserved|
 |CONSTRAINT|reserved|non-reserved|reserved|
 |CONTAINS|non-reserved|non-reserved|non-reserved|
 |CONTINUE|non-reserved|non-reserved|non-reserved|
@@ -619,8 +620,10 @@ 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_EXISTS|non-reserved|non-reserved|reserved|
+|JSON_QUERY|non-reserved|non-reserved|reserved|
 |JSON_TABLE|non-reserved|non-reserved|reserved|
 |JSON_VALUE|non-reserved|non-reserved|reserved|
+|KEEP|non-reserved|non-reserved|non-reserved|
 |KEY|non-reserved|non-reserved|non-reserved|
 |KEYS|non-reserved|non-reserved|non-reserved|
 |LANGUAGE|non-reserved|non-reserved|reserved|
@@ -680,8 +683,10 @@ Below is a list of all the keywords in Spark SQL.
 |NULL|reserved|non-reserved|reserved|
 |NULLS|non-reserved|non-reserved|non-reserved|
 |NUMERIC|non-reserved|non-reserved|non-reserved|
+|OBJECT|non-reserved|non-reserved|non-reserved|
 |OF|non-reserved|non-reserved|reserved|
 |OFFSET|reserved|non-reserved|reserved|
+|OMIT|non-reserved|non-reserved|reserved|
 |ON|reserved|strict-non-reserved|reserved|
 |ONLY|reserved|non-reserved|reserved|
 |OPEN|non-reserved|non-reserved|reserved|
@@ -715,6 +720,7 @@ Below is a list of all the keywords in Spark SQL.
 |QUALIFY|non-reserved|non-reserved|non-reserved|
 |QUARTER|non-reserved|non-reserved|non-reserved|
 |QUERY|non-reserved|non-reserved|non-reserved|
+|QUOTES|non-reserved|non-reserved|non-reserved|
 |RANGE|non-reserved|non-reserved|reserved|
 |READ|non-reserved|non-reserved|non-reserved|
 |READS|non-reserved|non-reserved|non-reserved|
@@ -825,6 +831,7 @@ Below is a list of all the keywords in Spark SQL.
 |UNARCHIVE|non-reserved|non-reserved|non-reserved|
 |UNBOUNDED|non-reserved|non-reserved|non-reserved|
 |UNCACHE|non-reserved|non-reserved|non-reserved|
+|UNCONDITIONAL|non-reserved|non-reserved|non-reserved|
 |UNIFORM|non-reserved|non-reserved|non-reserved|
 |UNION|reserved|strict-non-reserved|reserved|
 |UNIQUE|reserved|non-reserved|reserved|
@@ -859,6 +866,7 @@ Below is a list of all the keywords in Spark SQL.
 |WITH|reserved|non-reserved|reserved|
 |WITHIN|reserved|non-reserved|reserved|
 |WITHOUT|non-reserved|non-reserved|non-reserved|
+|WRAPPER|non-reserved|non-reserved|non-reserved|
 |X|non-reserved|non-reserved|non-reserved|
 |YEAR|non-reserved|non-reserved|non-reserved|
 |YEARS|non-reserved|non-reserved|non-reserved|
diff --git a/docs/sql-ref-syntax-qry-select-json-query.md 
b/docs/sql-ref-syntax-qry-select-json-query.md
new file mode 100644
index 000000000000..2cbc60674dfb
--- /dev/null
+++ b/docs/sql-ref-syntax-qry-select-json-query.md
@@ -0,0 +1,174 @@
+---
+layout: global
+title: JSON_QUERY
+displayTitle: JSON_QUERY
+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_QUERY` function extracts the JSON value located by a SQL/JSON path 
from a JSON document
+and returns it as JSON text (a `STRING`). This is the SQL-standard way 
(SQL:2016) to pull an object,
+array, or scalar fragment out of JSON, and is commonly used to migrate queries 
from other systems
+such as Oracle, SQL Server, and Trino. Unlike
+[JSON_TABLE](sql-ref-syntax-qry-select-json-table.html), which produces rows 
in a `FROM` clause,
+`JSON_QUERY` is an expression that can appear anywhere a value is allowed.
+
+Where [JSON_VALUE](sql-ref-syntax-qry-select-json-value.html) returns a single 
scalar (and treats an
+object or array match as an error), `JSON_QUERY` returns the matched value 
serialized as JSON text,
+whether it is an object, an array, or a scalar.
+
+This implementation supports simple, wildcard-free SQL/JSON paths only. The 
`PASSING` clause, path
+predicates and filters, and explicit `lax` / `strict` path modes defined by 
SQL:2016 are not
+supported.
+
+### Syntax
+
+```sql
+JSON_QUERY ( json_expr, path
+             [ RETURNING data_type ]
+             [ wrapper_behavior ]
+             [ quotes_behavior ]
+             [ empty_behavior ON EMPTY ]
+             [ error_behavior ON ERROR ] )
+
+wrapper_behavior
+    { WITHOUT [ ARRAY ] WRAPPER
+    | WITH [ CONDITIONAL | UNCONDITIONAL ] [ ARRAY ] WRAPPER }
+
+quotes_behavior
+    { KEEP QUOTES | OMIT QUOTES }
+
+empty_behavior
+    { NULL | ERROR | EMPTY ARRAY | EMPTY OBJECT }
+
+error_behavior
+    { NULL | ERROR | EMPTY ARRAY | EMPTY OBJECT }
+```
+
+### 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; a path containing `[*]` is rejected at 
analysis time.
+
+* **RETURNING data_type**
+
+    The type of the result. It must be a string type; the result is JSON text. 
If `RETURNING` is
+    omitted, the result type is `STRING`.
+
+* **wrapper_behavior**
+
+    Whether to wrap the result in a JSON array:
+    * `WITHOUT ARRAY WRAPPER` (the default) returns the value unwrapped.
+    * `WITH UNCONDITIONAL ARRAY WRAPPER` (or simply `WITH ARRAY WRAPPER`) 
always wraps the value in a
+      one-element array.
+    * `WITH CONDITIONAL ARRAY WRAPPER` wraps the value only when it is a 
scalar; an object or array
+      is returned unwrapped.
+
+* **quotes_behavior**
+
+    Whether to keep the surrounding quotes of a scalar string result:
+    * `KEEP QUOTES` (the default) leaves them, so a string is returned as a 
quoted JSON string.
+    * `OMIT QUOTES` strips them, returning the raw string content. It is a 
no-op for objects,
+      arrays, and non-string scalars, and cannot be combined with an array 
wrapper.
+
+* **empty_behavior ON EMPTY**
+
+    What to produce when `path` matches nothing:
+    * `NULL` (the default) returns SQL `NULL`.
+    * `ERROR` raises an error.
+    * `EMPTY ARRAY` returns the JSON text `[]`.
+    * `EMPTY OBJECT` returns the JSON text `{}`.
+
+* **error_behavior ON ERROR**
+
+    What to produce when the input is not well-formed JSON. The same four 
choices as `ON EMPTY`
+    apply, defaulting to `NULL`.
+
+A path that matches an explicit JSON `null` is a present scalar value and 
returns the JSON text
+`null` (it is neither the `ON EMPTY` nor the `ON ERROR` case).
+
+Returning a scalar under the default `WITHOUT ARRAY WRAPPER` is an intentional 
convenience: the
+matched scalar is emitted as JSON text (for example, `JSON_QUERY('{"id":7}', 
'$.id')` returns `7`),
+whereas strict SQL:2016 treats a scalar without a wrapper as an error. The 
wrapper clauses behave the
+standard way: `WITH CONDITIONAL ARRAY WRAPPER` wraps a scalar in a one-element 
array (`7` becomes
+`[7]`) while leaving a single object or array unwrapped, and `WITH 
UNCONDITIONAL ARRAY WRAPPER`
+always wraps.
+
+### Examples
+
+```sql
+-- Extract an object as JSON text
+SELECT json_query('{"id":7,"addr":{"city":"NYC"}}', '$.addr');
++---------------------------------------------------+
+|json_query({"id":7,"addr":{"city":"NYC"}}, $.addr) |
++---------------------------------------------------+
+|{"city":"NYC"}                                     |
++---------------------------------------------------+
+
+-- Extract an array
+SELECT json_query('{"tags":["x","y"]}', '$.tags');
++-------------------------------------------+
+|json_query({"tags":["x","y"]}, $.tags)     |
++-------------------------------------------+
+|["x","y"]                                  |
++-------------------------------------------+
+
+-- Wrap a scalar in an array with WITH ARRAY WRAPPER
+-- (WITH ARRAY WRAPPER is a shorthand; the column name shows the canonical
+--  WITH UNCONDITIONAL ARRAY WRAPPER form)
+SELECT json_query('{"tags":["x","y"]}', '$.tags[0]' WITH ARRAY WRAPPER);
++----------------------------------------------------------------------------+
+|json_query({"tags":["x","y"]}, $.tags[0] WITH UNCONDITIONAL ARRAY WRAPPER)  |
++----------------------------------------------------------------------------+
+|["x"]                                                                       |
++----------------------------------------------------------------------------+
+
+-- Strip the quotes from a scalar string with OMIT QUOTES
+SELECT json_query('{"name":"Ada"}', '$.name' OMIT QUOTES);
++---------------------------------------------------+
+|json_query({"name":"Ada"}, $.name OMIT QUOTES)     |
++---------------------------------------------------+
+|Ada                                                |
++---------------------------------------------------+
+
+-- A missing path defaults to NULL; supply a fallback with EMPTY ARRAY ON EMPTY
+SELECT json_query('{"id":7}', '$.missing' EMPTY ARRAY ON EMPTY);
++---------------------------------------------------------+
+|json_query({"id":7}, $.missing EMPTY ARRAY ON EMPTY)     |
++---------------------------------------------------------+
+|[]                                                       |
++---------------------------------------------------------+
+
+-- ERROR ON ERROR raises instead of returning a value
+SELECT json_query('not json', '$.a' ERROR ON ERROR);
+[JSON_QUERY_ON_ERROR.ERROR] ...
+```
+
+### Related Statements
+
+* [SELECT](sql-ref-syntax-qry-select.html)
+* [JSON_VALUE](sql-ref-syntax-qry-select-json-value.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-json-value.md 
b/docs/sql-ref-syntax-qry-select-json-value.md
index deb0be82c2b5..6f45a80417b5 100644
--- a/docs/sql-ref-syntax-qry-select-json-value.md
+++ b/docs/sql-ref-syntax-qry-select-json-value.md
@@ -29,8 +29,10 @@ migrate queries from other systems such as Oracle, DB2, and 
MySQL. Unlike
 `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.
+`ON ERROR`), not a value. To extract an object or array as a JSON fragment, use
+[JSON_QUERY](sql-ref-syntax-qry-select-json-query.html); to produce rows from 
a JSON array, use
+[JSON_TABLE](sql-ref-syntax-qry-select-json-table.html) (the built-in 
`get_json_object` function
+also extracts fragments).
 
 ### Syntax
 
diff --git a/docs/sql-ref-syntax-qry-select.md 
b/docs/sql-ref-syntax-qry-select.md
index 678fb8cf4de3..2d01b6123ac1 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_QUERY](sql-ref-syntax-qry-select-json-query.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)
diff --git a/docs/sql-ref-syntax.md b/docs/sql-ref-syntax.md
index 9c5c4e3235d0..bc168fed6065 100644
--- a/docs/sql-ref-syntax.md
+++ b/docs/sql-ref-syntax.md
@@ -83,6 +83,7 @@ ability to generate logical and physical plan for a given 
query using
    * [SORT BY Clause](sql-ref-syntax-qry-select-sortby.html)
    * [TABLESAMPLE](sql-ref-syntax-qry-select-sampling.html)
    * [Table-valued Function](sql-ref-syntax-qry-select-tvf.html)
+   * [JSON_QUERY](sql-ref-syntax-qry-select-json-query.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)
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 97e027b5e5c0..dcccba9edc04 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
@@ -201,6 +201,7 @@ COMPENSATION: 'COMPENSATION';
 COMPUTE: 'COMPUTE';
 CONCATENATE: 'CONCATENATE';
 CONDITION: 'CONDITION';
+CONDITIONAL: 'CONDITIONAL';
 CONSTRAINT: 'CONSTRAINT';
 CONTAINS: 'CONTAINS';
 CONTINUE: 'CONTINUE';
@@ -336,8 +337,10 @@ ITERATE: 'ITERATE';
 JOIN: 'JOIN';
 JSON: 'JSON';
 JSON_EXISTS: 'JSON_EXISTS';
+JSON_QUERY: 'JSON_QUERY';
 JSON_TABLE: 'JSON_TABLE';
 JSON_VALUE: 'JSON_VALUE';
+KEEP: 'KEEP';
 KEY: 'KEY';
 KEYS: 'KEYS';
 LANGUAGE: 'LANGUAGE';
@@ -396,8 +399,10 @@ NULL: 'NULL';
 NULLS: 'NULLS';
 NUMERIC: 'NUMERIC';
 NORELY: 'NORELY';
+OBJECT: 'OBJECT';
 OF: 'OF';
 OFFSET: 'OFFSET';
+OMIT: 'OMIT';
 ON: 'ON';
 ONLY: 'ONLY';
 OPEN: 'OPEN';
@@ -431,6 +436,7 @@ PURGE: 'PURGE';
 QUALIFY: 'QUALIFY';
 QUARTER: 'QUARTER';
 QUERY: 'QUERY';
+QUOTES: 'QUOTES';
 RANGE: 'RANGE';
 READ: 'READ';
 READS: 'READS';
@@ -541,6 +547,7 @@ TYPE: 'TYPE';
 UNARCHIVE: 'UNARCHIVE';
 UNBOUNDED: 'UNBOUNDED';
 UNCACHE: 'UNCACHE';
+UNCONDITIONAL: 'UNCONDITIONAL';
 UNIFORM: 'UNIFORM';
 UNION: 'UNION';
 UNIQUE: 'UNIQUE';
@@ -575,6 +582,7 @@ WINDOW: 'WINDOW';
 WITH: 'WITH';
 WITHIN: 'WITHIN';
 WITHOUT: 'WITHOUT';
+WRAPPER: 'WRAPPER';
 YEAR: 'YEAR';
 YEARS: 'YEARS';
 ZONE: 'ZONE';
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 e702a3766fc9..300318247188 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
@@ -1468,6 +1468,12 @@ primaryExpression
       (errorBehavior=jsonValueBehavior ON ERROR)? RIGHT_PAREN                  
                #jsonValue
     | JSON_EXISTS LEFT_PAREN jsonExpr=valueExpression COMMA path=stringLit
       (errorBehavior=jsonExistsErrorBehavior ON ERROR)? RIGHT_PAREN            
                 #jsonExists
+    | JSON_QUERY LEFT_PAREN jsonExpr=valueExpression COMMA path=stringLit
+      (RETURNING returning=dataType)?
+      wrapper=jsonQueryArrayWrapper?
+      quotes=jsonQueryQuotes?
+      (emptyBehavior=jsonQueryBehavior ON EMPTY)?
+      (errorBehavior=jsonQueryBehavior ON ERROR)? RIGHT_PAREN                  
                #jsonQuery
     | constant                                                                 
                #constantDefault
     | ASTERISK exceptClause?                                                   
                #star
     | qualifiedName DOT ASTERISK exceptClause?                                 
                #star
@@ -1511,6 +1517,29 @@ jsonExistsErrorBehavior
     | ERROR
     ;
 
+// The JSON_QUERY array-wrapper clause. `WITH [UNCONDITIONAL]` always wraps 
the result in `[...]`;
+// `WITH CONDITIONAL` wraps only a non-array/object (scalar) result; `WITHOUT` 
(default) never wraps.
+// The `ARRAY` word is optional, matching the SQL standard (`WITH WRAPPER` == 
`WITH ARRAY WRAPPER`).
+jsonQueryArrayWrapper
+    : WITHOUT ARRAY? WRAPPER                                                   
                 #jsonQueryWrapperWithout
+    | WITH wrapperType=(CONDITIONAL | UNCONDITIONAL)? ARRAY? WRAPPER           
                 #jsonQueryWrapperWith
+    ;
+
+// The JSON_QUERY quotes clause: `OMIT QUOTES` strips the surrounding quotes 
from a scalar string
+// result; `KEEP QUOTES` (default) leaves them.
+jsonQueryQuotes
+    : KEEP QUOTES                                                              
                #jsonQueryQuotesKeep
+    | OMIT QUOTES                                                              
                #jsonQueryQuotesOmit
+    ;
+
+// The behavior selected by a JSON_QUERY `... ON EMPTY` / `... ON ERROR` 
clause.
+jsonQueryBehavior
+    : NULL                                                                     
                #jsonQueryBehaviorNull
+    | ERROR                                                                    
                 #jsonQueryBehaviorError
+    | EMPTY ARRAY                                                              
                #jsonQueryBehaviorEmptyArray
+    | EMPTY OBJECT                                                             
                #jsonQueryBehaviorEmptyObject
+    ;
+
 semiStructuredExtractionPath
     : jsonPathFirstPart (jsonPathParts)*
     ;
@@ -2152,6 +2181,7 @@ ansiNonReserved
     | COMPUTE
     | CONCATENATE
     | CONDITION
+    | CONDITIONAL
     | CONTAINS
     | CONTINUE
     | COST
@@ -2256,8 +2286,10 @@ ansiNonReserved
     | ITERATE
     | JSON
     | JSON_EXISTS
+    | JSON_QUERY
     | JSON_TABLE
     | JSON_VALUE
+    | KEEP
     | KEY
     | KEYS
     | LANGUAGE
@@ -2309,7 +2341,9 @@ ansiNonReserved
     | NORELY
     | NULLS
     | NUMERIC
+    | OBJECT
     | OF
+    | OMIT
     | OPEN
     | OPTION
     | OPTIONS
@@ -2336,6 +2370,7 @@ ansiNonReserved
     | QUALIFY
     | QUARTER
     | QUERY
+    | QUOTES
     | RANGE
     | READ
     | READS
@@ -2433,6 +2468,7 @@ ansiNonReserved
     | UNARCHIVE
     | UNBOUNDED
     | UNCACHE
+    | UNCONDITIONAL
     | UNIFORM
     | UNLOCK
     | UNNEST
@@ -2458,6 +2494,7 @@ ansiNonReserved
     | WIDTH
     | WINDOW
     | WITHOUT
+    | WRAPPER
     | YEAR
     | YEARS
     | ZONE
@@ -2567,6 +2604,7 @@ nonReserved
     | COMPUTE
     | CONCATENATE
     | CONDITION
+    | CONDITIONAL
     | CONSTRAINT
     | CONTAINS
     | CONTINUE
@@ -2696,8 +2734,10 @@ nonReserved
     | ITERATE
     | JSON
     | JSON_EXISTS
+    | JSON_QUERY
     | JSON_TABLE
     | JSON_VALUE
+    | KEEP
     | KEY
     | KEYS
     | LANGUAGE
@@ -2754,8 +2794,10 @@ nonReserved
     | NULL
     | NULLS
     | NUMERIC
+    | OBJECT
     | OF
     | OFFSET
+    | OMIT
     | ONLY
     | OPEN
     | OPTION
@@ -2788,6 +2830,7 @@ nonReserved
     | QUALIFY
     | QUARTER
     | QUERY
+    | QUOTES
     | RANGE
     | READ
     | READS
@@ -2894,6 +2937,7 @@ nonReserved
     | UNARCHIVE
     | UNBOUNDED
     | UNCACHE
+    | UNCONDITIONAL
     | UNIFORM
     | UNIQUE
     | UNKNOWN
@@ -2926,6 +2970,7 @@ nonReserved
     | WITH
     | WITHIN
     | WITHOUT
+    | WRAPPER
     | YEAR
     | YEARS
     | ZONE
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 0fca3b32f110..c4eca772b178 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
@@ -392,6 +392,36 @@ object JsonValueLookup {
   case class Scalar(text: UTF8String) extends JsonValueLookup
 }
 
+/**
+ * The result of a single-value [[JsonTableEvaluator.queryLookup]] for 
`JSON_QUERY`. Unlike
+ * [[JsonValueLookup]] -- which reports an object/array match as `NonScalar` 
without serializing it,
+ * since `JSON_VALUE` never returns a non-scalar -- `Found` here always 
carries the matched value's
+ * serialized JSON text (`JSON_QUERY` returns objects, arrays, and scalars 
alike). `structural`
+ * distinguishes an object/array match from a scalar match, which the caller 
needs for the array
+ * wrapper (`WITH CONDITIONAL`) and quotes (`OMIT QUOTES`) behaviors. A JSON 
`null` literal is a
+ * scalar match whose text is `null`, not a distinct case.
+ */
+sealed trait JsonQueryLookup
+object JsonQueryLookup {
+  /** The path did not match (routes to ON EMPTY). */
+  case object Missing extends JsonQueryLookup
+  /**
+   * The path matched a value; `raw` is its serialized JSON text (a string is 
still quoted) and
+   * `structural` is true iff the value is an object or array. `unquoted` is 
the `OMIT QUOTES`
+   * form -- a matched JSON string's decoded content (read straight from the 
parser), and `raw`
+   * itself for every other value (objects, arrays, numbers, booleans, and 
JSON `null`, for which
+   * `OMIT QUOTES` is a no-op). Carrying it here lets the caller apply `OMIT 
QUOTES` without
+   * re-parsing the serialized fragment. It differs from `raw` only for a 
matched string, and only
+   * when [[JsonTableEvaluator.queryLookup]] is called with `omitQuotes = 
true`; the default
+   * `KEEP QUOTES` path discards `unquoted` and leaves it equal to `raw`, so 
the string decode is
+   * not paid for. As an optimization, a matched string under `OMIT QUOTES` -- 
whose `raw` the
+   * caller would discard, since `OMIT QUOTES` cannot be combined with a 
wrapper -- skips
+   * serialization entirely, so `raw` then also holds the decoded content.
+   */
+  case class Found(raw: UTF8String, structural: Boolean, unquoted: UTF8String)
+    extends JsonQueryLookup
+}
+
 /**
  * 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
@@ -550,6 +580,71 @@ case class JsonTableEvaluator(containerPath: 
Seq[PathInstruction], explodeRoot:
     }
   }
 
+  /**
+   * Resolves `containerPath` against a single JSON value for `JSON_QUERY`, 
serializing the matched
+   * value to JSON text. Returns:
+   *
+   *   - `None` if the input is not a single well-formed JSON value (malformed 
/ trailing garbage /
+   *     empty), which the caller maps to ON ERROR;
+   *   - `Some(Missing)` if the path matches nothing (ON EMPTY);
+   *   - `Some(Found(raw, structural, unquoted))` if the path matches, where 
`raw` is the value's
+   *     serialized JSON text, `structural` is true for an object or array (as 
opposed to a scalar,
+   *     including a JSON `null`, whose text is `null`), and `unquoted` is the 
`OMIT QUOTES` form
+   *     (a matched JSON string's decoded content; `raw` for every other 
value).
+   *
+   * `omitQuotes` mirrors the caller's `OMIT QUOTES` clause: only a matched 
JSON string's `unquoted`
+   * form differs from `raw`, and only `OMIT QUOTES` consumes it, so the 
decode-and-allocate is done
+   * for a string only when `omitQuotes` is true -- the default `KEEP QUOTES` 
path leaves `unquoted`
+   * equal to `raw` and skips the work.
+   *
+   * A `null` input is the caller's responsibility. Like [[lookup]] this 
navigates and validates
+   * with a single parser: after the matched value is serialized (which 
consumes it),
+   * [[drainToRootEnd]]
+   * walks out of the enclosing containers and rejects any trailing content, 
so a valid prefix
+   * followed by garbage is rejected exactly as a fully malformed document is.
+   */
+  final def queryLookup(json: UTF8String, omitQuotes: Boolean): 
Option[JsonQueryLookup] = {
+    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 => JsonQueryLookup.Missing
+            // A JSON `null` literal is a scalar value for JSON_QUERY: 
serialize it to the text
+            // `null` rather than reporting it specially. The parser is 
positioned on the token.
+            case PositionResult.NullValue =>
+              val raw = serializeCurrentValue(parser)
+              JsonQueryLookup.Found(raw, structural = false, unquoted = raw)
+            case PositionResult.AtValue =>
+              parser.currentToken match {
+                case JsonToken.START_OBJECT | JsonToken.START_ARRAY =>
+                  val raw = serializeCurrentValue(parser)
+                  JsonQueryLookup.Found(raw, structural = true, unquoted = raw)
+                case JsonToken.VALUE_STRING if omitQuotes =>
+                  // `OMIT QUOTES` returns the string's decoded content and 
cannot be combined with
+                  // an array wrapper (rejected at analysis time), so the 
serialized (re-quoted)
+                  // `raw` form would be discarded on this branch. Decode 
straight from the parser
+                  // and carry it as both fields, skipping the wasted 
`serializeCurrentValue`. Only
+                  // `OMIT QUOTES` reaches here; a `KEEP QUOTES` string falls 
through below.
+                  val unquoted = UTF8String.fromString(parser.getText)
+                  JsonQueryLookup.Found(unquoted, structural = false, unquoted)
+                case _ =>
+                  // A non-string scalar (number/boolean), for which `OMIT 
QUOTES` is a no-op, or a
+                  // string under the default `KEEP QUOTES` (its decoded form 
is never used): the
+                  // `unquoted` form is just `raw`, so no separate decode is 
needed.
+                  val raw = serializeCurrentValue(parser)
+                  JsonQueryLookup.Found(raw, structural = false, unquoted = 
raw)
+              }
+          }
+          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
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 fbd6b9decb5e..cb8988af4235 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
@@ -24,9 +24,9 @@ import 
org.apache.spark.sql.catalyst.analysis.TypeCheckResult.DataTypeMismatch
 import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext, 
CodeGenerator, CodegenFallback, ExprCode}
 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, JsonValueLookup, MultiGetJsonObjectEvaluator, 
PathInstruction,
-  SchemaOfJsonEvaluator, StructsToJsonEvaluator}
+  JsonPathParser, JsonPathResult, JsonQueryLookup, JsonTableEvaluator, 
JsonTablePathTrie,
+  JsonToStructsEvaluator, 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,
@@ -984,6 +984,222 @@ case class JsonExists(
     copy(child = newChild)
 }
 
+/**
+ * Behavior of `JSON_QUERY`'s `ON EMPTY` / `ON ERROR` clause: what to produce 
when the path matches
+ * nothing (`ON EMPTY`) or the input is not valid JSON (`ON ERROR`).
+ */
+sealed trait JsonQueryBehavior
+object JsonQueryBehavior {
+  /** Produce SQL NULL (the SQL-standard default for both clauses). */
+  case object Null extends JsonQueryBehavior
+  /** Raise an error. */
+  case object Error extends JsonQueryBehavior
+  /** Produce an empty JSON array `[]`. */
+  case object EmptyArray extends JsonQueryBehavior
+  /** Produce an empty JSON object `{}`. */
+  case object EmptyObject extends JsonQueryBehavior
+}
+
+/**
+ * The array-wrapper behavior of `JSON_QUERY` (SQL:2016 `... ARRAY WRAPPER`). 
This implementation
+ * resolves a single value per path (wildcard-free paths only), so a wrapper 
wraps that value in a
+ * one-element array:
+ *   - `Without` (default): return the value unwrapped;
+ *   - `Unconditional` (`WITH [UNCONDITIONAL] ARRAY WRAPPER`): always wrap;
+ *   - `Conditional` (`WITH CONDITIONAL ARRAY WRAPPER`): wrap only a scalar; 
leave an object or
+ *     array as is.
+ */
+sealed trait JsonQueryWrapper
+object JsonQueryWrapper {
+  case object Without extends JsonQueryWrapper
+  case object Conditional extends JsonQueryWrapper
+  case object Unconditional extends JsonQueryWrapper
+}
+
+/** The quotes behavior of `JSON_QUERY`: `KEEP QUOTES` (default) or `OMIT 
QUOTES`. */
+sealed trait JsonQueryQuotes
+object JsonQueryQuotes {
+  case object Keep extends JsonQueryQuotes
+  case object Omit extends JsonQueryQuotes
+}
+
+// scalastyle:off line.size.limit
+/**
+ * The SQL:2016 `JSON_QUERY` function (feature T828): extracts the JSON value 
located by `path` from
+ * a JSON input and returns it as JSON text (STRING):
+ *
+ *   - missing path                        -> ON EMPTY behavior
+ *   - malformed / non-single-value input  -> ON ERROR behavior
+ *   - matched object / array / scalar     -> its serialized JSON text, after 
applying the array
+ *                                            wrapper and quotes clauses
+ *
+ * A matched scalar (including a JSON `null`) is not an error under the 
default `WITHOUT ARRAY
+ * WRAPPER`; it is emitted as JSON text (`JSON_QUERY('{"id":7}', '$.id')` -> 
`7`). `OMIT QUOTES`
+ * strips the surrounding quotes from a scalar string result (and cannot be 
combined with a wrapper).
+ * Both `ON EMPTY` and `ON ERROR` default to NULL per the standard, and a 
`null` JSON input yields
+ * SQL NULL directly. `RETURNING` is restricted to string types here (VARIANT 
is deferred); the
+ * result is always JSON text.
+ *
+ * {{{
+ *   JSON_QUERY('{"a":{"x":1}}', '$.a')                          -- '{"x":1}'
+ *   JSON_QUERY('{"t":["x","y"]}', '$.t')                        -- '["x","y"]'
+ *   JSON_QUERY('{"t":["x","y"]}', '$.t[0]' WITH ARRAY WRAPPER)  -- '["x"]'
+ *   JSON_QUERY('{"n":"Ada"}', '$.n' OMIT QUOTES)                -- 'Ada'
+ * }}}
+ */
+// scalastyle:on line.size.limit
+case class JsonQuery(
+    child: Expression,
+    path: String,
+    returning: DataType,
+    wrapper: JsonQueryWrapper,
+    quotes: JsonQueryQuotes,
+    onEmpty: JsonQueryBehavior,
+    onError: JsonQueryBehavior)
+  extends UnaryExpression
+  with CodegenFallback
+  with ExpectsInputTypes
+  with QueryErrorsBase {
+
+  override def nullable: Boolean = true
+
+  // The JSON input must be a STRING; the result is JSON text.
+  override def inputTypes: Seq[AbstractDataType] =
+    Seq(StringTypeWithCollation(supportsTrimCollation = true))
+
+  override def dataType: DataType = returning
+
+  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 (a single value is resolved).
+      DataTypeMismatch(
+        errorSubClass = "INVALID_JSON_PATH",
+        messageParameters = Map(
+          "functionName" -> toSQLId(prettyName), "path" -> toSQLValue(path)))
+    } else if (!JsonQuery.isValidReturningType(returning)) {
+      // RETURNING is restricted to string types (the result is JSON text; 
VARIANT is deferred).
+      DataTypeMismatch(
+        errorSubClass = "INVALID_JSON_QUERY_RETURNING_TYPE",
+        messageParameters = Map(
+          "functionName" -> toSQLId(prettyName), "returningType" -> 
toSQLType(returning)))
+    } else if (quotes == JsonQueryQuotes.Omit && wrapper != 
JsonQueryWrapper.Without) {
+      // OMIT QUOTES applies only to an unwrapped scalar; the SQL standard 
forbids pairing it with
+      // an array wrapper. Enforced here (not only in the parser) so a 
directly-constructed
+      // expression cannot silently ignore the quotes clause.
+      DataTypeMismatch(
+        errorSubClass = "INVALID_JSON_QUERY_WRAPPER_AND_QUOTES",
+        messageParameters = Map("functionName" -> toSQLId(prettyName)))
+    } else {
+      TypeCheckResult.TypeCheckSuccess
+    }
+  }
+
+  // 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)
+
+  // Handle the ON EMPTY / ON ERROR case per the configured behavior.
+  private def onEmptyResult(): Any = behaviorResult(onEmpty, isEmpty = true)
+  private def onErrorResult(): Any = behaviorResult(onError, isEmpty = false)
+
+  private def behaviorResult(behavior: JsonQueryBehavior, isEmpty: Boolean): 
Any = behavior match {
+    case JsonQueryBehavior.Null => null
+    case JsonQueryBehavior.EmptyArray => JsonQuery.EmptyArrayText
+    case JsonQueryBehavior.EmptyObject => JsonQuery.EmptyObjectText
+    case JsonQueryBehavior.Error =>
+      if (isEmpty) throw 
QueryExecutionErrors.jsonQueryOnEmptyError(prettyName, path, cause = null)
+      else throw QueryExecutionErrors.jsonQueryOnErrorError(prettyName, path, 
cause = null)
+  }
+
+  // Apply the array-wrapper and quotes clauses to a matched value. `raw` is 
its serialized JSON
+  // text, `unquoted` is the OMIT QUOTES form (a string's decoded content; 
`raw` otherwise, so OMIT
+  // QUOTES is a no-op for objects, arrays, and non-string scalars), and 
`structural` is true for an
+  // object or array match (rather than a scalar, incl. JSON null).
+  private def wrapAndQuote(raw: UTF8String, unquoted: UTF8String, structural: 
Boolean): UTF8String =
+    wrapper match {
+      case JsonQueryWrapper.Without =>
+        // OMIT QUOTES reuses the string decoded during the lookup rather than 
re-parsing the
+        // serialized fragment; OMIT QUOTES combined with a wrapper is 
rejected at analysis time.
+        if (quotes == JsonQueryQuotes.Omit) unquoted else raw
+      case JsonQueryWrapper.Unconditional => JsonQuery.wrapInArray(raw)
+      // CONDITIONAL wraps only a scalar; an object or array is already a 
structural result.
+      case JsonQueryWrapper.Conditional => if (structural) raw else 
JsonQuery.wrapInArray(raw)
+    }
+
+  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.queryLookup(json, omitQuotes = quotes == JsonQueryQuotes.Omit) 
match {
+      // Malformed / non-single-value input.
+      case None => onErrorResult()
+      // Path matched nothing.
+      case Some(JsonQueryLookup.Missing) => onEmptyResult()
+      // Matched a value: serialize it, applying the wrapper and quotes 
clauses.
+      case Some(JsonQueryLookup.Found(raw, structural, unquoted)) =>
+        wrapAndQuote(raw, unquoted, structural)
+    }
+  }
+
+  override def prettyName: String = "json_query"
+
+  override def sql: String = {
+    val returningSQL = if (returning == StringType) "" else s" RETURNING 
${returning.sql}"
+    val wrapperSQL = wrapper match {
+      case JsonQueryWrapper.Without => ""
+      case JsonQueryWrapper.Unconditional => " WITH UNCONDITIONAL ARRAY 
WRAPPER"
+      case JsonQueryWrapper.Conditional => " WITH CONDITIONAL ARRAY WRAPPER"
+    }
+    val quotesSQL = quotes match {
+      case JsonQueryQuotes.Keep => ""
+      case JsonQueryQuotes.Omit => " OMIT QUOTES"
+    }
+    def behaviorSQL(b: JsonQueryBehavior): String = b match {
+      case JsonQueryBehavior.Null => "NULL"
+      case JsonQueryBehavior.Error => "ERROR"
+      case JsonQueryBehavior.EmptyArray => "EMPTY ARRAY"
+      case JsonQueryBehavior.EmptyObject => "EMPTY OBJECT"
+    }
+    val emptySQL =
+      if (onEmpty == JsonQueryBehavior.Null) "" else s" 
${behaviorSQL(onEmpty)} ON EMPTY"
+    val errorSQL =
+      if (onError == JsonQueryBehavior.Null) "" else s" 
${behaviorSQL(onError)} ON ERROR"
+    // Render the path as a properly escaped string literal so bracket-quoted 
paths round-trip.
+    val pathSQL = Literal(UTF8String.fromString(path), StringType).sql
+    s"JSON_QUERY(${child.sql}, 
$pathSQL$returningSQL$wrapperSQL$quotesSQL$emptySQL$errorSQL)"
+  }
+
+  override protected def withNewChildInternal(newChild: Expression): JsonQuery 
=
+    copy(child = newChild)
+}
+
+object JsonQuery {
+  private val EmptyArrayText: UTF8String = UTF8String.fromString("[]")
+  private val EmptyObjectText: UTF8String = UTF8String.fromString("{}")
+  private val ArrayOpen: UTF8String = UTF8String.fromString("[")
+  private val ArrayClose: UTF8String = UTF8String.fromString("]")
+
+  private def wrapInArray(raw: UTF8String): UTF8String =
+    UTF8String.concat(ArrayOpen, raw, ArrayClose)
+
+  /**
+   * `JSON_QUERY` returns a JSON fragment as text, so RETURNING is restricted 
to a plain STRING here
+   * (VARIANT is deferred). `CharType` / `VarcharType` extend `StringType` but 
carry a length that
+   * `JSON_QUERY` does not enforce -- it returns the fragment verbatim without 
a cast -- so they are
+   * rejected: the parser normalizes a SQL `CHAR`/`VARCHAR` RETURNING to 
STRING before construction,
+   * and this guards a raw `CharType`/`VarcharType` supplied by direct 
Catalyst construction.
+   */
+  def isValidReturningType(dt: DataType): Boolean = dt match {
+    case _: CharType | _: VarcharType => false
+    case _: StringType => 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 5138fcece8a5..609c9bd36be8 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
@@ -4202,6 +4202,58 @@ class AstBuilder extends DataTypeAstBuilder
     JsonExists(jsonExpr, path, onError)
   }
 
+  /**
+   * Resolve a `jsonQueryBehavior` clause (`NULL` / `ERROR` / `EMPTY ARRAY` / 
`EMPTY OBJECT`) into a
+   * [[JsonQueryBehavior]].
+   */
+  private def buildJsonQueryBehavior(ctx: JsonQueryBehaviorContext): 
JsonQueryBehavior = ctx match {
+    case _: JsonQueryBehaviorNullContext => JsonQueryBehavior.Null
+    case _: JsonQueryBehaviorErrorContext => JsonQueryBehavior.Error
+    case _: JsonQueryBehaviorEmptyArrayContext => JsonQueryBehavior.EmptyArray
+    case _: JsonQueryBehaviorEmptyObjectContext => 
JsonQueryBehavior.EmptyObject
+  }
+
+  /**
+   * Create a [[JsonQuery]] expression for the SQL:2016 `JSON_QUERY` function. 
The array wrapper
+   * defaults to `WITHOUT ARRAY WRAPPER`, quotes to `KEEP QUOTES`, and both 
`ON EMPTY` / `ON ERROR`
+   * to `NULL`, per the standard. `OMIT QUOTES` cannot be combined with an 
array wrapper.
+   */
+  override def visitJsonQuery(ctx: JsonQueryContext): Expression = 
withOrigin(ctx) {
+    val jsonExpr = expression(ctx.jsonExpr)
+    val path = string(visitStringLit(ctx.path))
+    // Default RETURNING type is STRING; the result is JSON text. A 
CHAR/VARCHAR RETURNING is
+    // normalized to STRING truly unconditionally: JSON_QUERY returns the 
fragment verbatim without
+    // a length-enforcing cast, so the result type must never advertise a 
CHAR/VARCHAR length it
+    // cannot enforce. The CharVarcharUtils helpers cannot be used here: they 
honor
+    // spark.sql.preserveCharVarcharTypeInfo and would leave a VARCHAR(n) 
length in the output type
+    // when that flag is set. A non-string RETURNING is left intact for 
checkInputDataTypes to fail.
+    val returning = Option(ctx.returning).map(typedVisit[DataType]).map {
+      case c: CharType => c.toStringType
+      case v: VarcharType => v.toStringType
+      case other => other
+    }.getOrElse(StringType)
+    val wrapper = Option(ctx.wrapper).map {
+      case _: JsonQueryWrapperWithoutContext => JsonQueryWrapper.Without
+      case w: JsonQueryWrapperWithContext =>
+        if (w.wrapperType != null && w.wrapperType.getType == 
SqlBaseParser.CONDITIONAL) {
+          JsonQueryWrapper.Conditional
+        } else {
+          JsonQueryWrapper.Unconditional
+        }
+    }.getOrElse(JsonQueryWrapper.Without)
+    val quotes = Option(ctx.quotes).map {
+      case _: JsonQueryQuotesKeepContext => JsonQueryQuotes.Keep
+      case _: JsonQueryQuotesOmitContext => JsonQueryQuotes.Omit
+    }.getOrElse(JsonQueryQuotes.Keep)
+    // The OMIT QUOTES + array-wrapper invariant is enforced in 
JsonQuery.checkInputDataTypes so it
+    // holds for directly-constructed expressions too, not only this parser 
path.
+    val onEmpty =
+      
Option(ctx.emptyBehavior).map(buildJsonQueryBehavior).getOrElse(JsonQueryBehavior.Null)
+    val onError =
+      
Option(ctx.errorBehavior).map(buildJsonQueryBehavior).getOrElse(JsonQueryBehavior.Null)
+    JsonQuery(jsonExpr, path, returning, wrapper, quotes, onEmpty, 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 eaaeba83dc0e..262eef8b6843 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 jsonQueryOnEmptyError(functionName: String, path: String, cause: 
Throwable): Throwable = {
+    new SparkRuntimeException(
+      errorClass = "JSON_QUERY_ON_ERROR.EMPTY",
+      messageParameters = Map("functionName" -> toSQLId(functionName), "path" 
-> toSQLValue(path)),
+      cause = cause)
+  }
+
+  def jsonQueryOnErrorError(functionName: String, path: String, cause: 
Throwable): Throwable = {
+    new SparkRuntimeException(
+      errorClass = "JSON_QUERY_ON_ERROR.ERROR",
+      messageParameters = Map("functionName" -> toSQLId(functionName), "path" 
-> toSQLValue(path)),
+      cause = cause)
+  }
+
   def jsonValueOnEmptyError(functionName: String, path: String, cause: 
Throwable): Throwable = {
     new SparkRuntimeException(
       errorClass = "JSON_VALUE_ON_ERROR.EMPTY",
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 5187c256bed7..d46c85d8ed09 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
@@ -346,6 +346,51 @@ class ExpressionParserSuite extends AnalysisTest {
       "json_exists(a, '$.b' ERROR ON ERROR)", JsonExists($"a", "$.b", 
JsonExistsBehavior.Error))
   }
 
+  test("JSON_QUERY expressions") {
+    import org.apache.spark.sql.catalyst.expressions.{JsonQueryBehavior, 
JsonQueryQuotes,
+      JsonQueryWrapper}
+    // Bare form: default STRING RETURNING, WITHOUT wrapper, KEEP quotes, NULL 
ON EMPTY / ON ERROR.
+    assertEqual(
+      "json_query(a, '$.b')",
+      JsonQuery($"a", "$.b", StringType, JsonQueryWrapper.Without, 
JsonQueryQuotes.Keep,
+        JsonQueryBehavior.Null, JsonQueryBehavior.Null))
+    // WITH ARRAY WRAPPER defaults to UNCONDITIONAL; the ARRAY word is 
optional.
+    assertEqual(
+      "json_query(a, '$.b' WITH ARRAY WRAPPER)",
+      JsonQuery($"a", "$.b", StringType, JsonQueryWrapper.Unconditional, 
JsonQueryQuotes.Keep,
+        JsonQueryBehavior.Null, JsonQueryBehavior.Null))
+    assertEqual(
+      "json_query(a, '$.b' WITH CONDITIONAL WRAPPER)",
+      JsonQuery($"a", "$.b", StringType, JsonQueryWrapper.Conditional, 
JsonQueryQuotes.Keep,
+        JsonQueryBehavior.Null, JsonQueryBehavior.Null))
+    // WITHOUT ARRAY WRAPPER with OMIT QUOTES.
+    assertEqual(
+      "json_query(a, '$.b' WITHOUT ARRAY WRAPPER OMIT QUOTES)",
+      JsonQuery($"a", "$.b", StringType, JsonQueryWrapper.Without, 
JsonQueryQuotes.Omit,
+        JsonQueryBehavior.Null, JsonQueryBehavior.Null))
+    // EMPTY ARRAY ON EMPTY / EMPTY OBJECT ON ERROR, plus RETURNING STRING.
+    assertEqual(
+      "json_query(a, '$.b' RETURNING STRING EMPTY ARRAY ON EMPTY EMPTY OBJECT 
ON ERROR)",
+      JsonQuery($"a", "$.b", StringType, JsonQueryWrapper.Without, 
JsonQueryQuotes.Keep,
+        JsonQueryBehavior.EmptyArray, JsonQueryBehavior.EmptyObject))
+    // The ARRAY word is optional in every wrapper spelling, and WITH alone 
means UNCONDITIONAL.
+    Seq("WITH WRAPPER", "WITH UNCONDITIONAL WRAPPER").foreach { spelling =>
+      assertEqual(
+        s"json_query(a, '$$.b' $spelling)",
+        JsonQuery($"a", "$.b", StringType, JsonQueryWrapper.Unconditional, 
JsonQueryQuotes.Keep,
+          JsonQueryBehavior.Null, JsonQueryBehavior.Null))
+    }
+    assertEqual(
+      "json_query(a, '$.b' WITHOUT WRAPPER)",
+      JsonQuery($"a", "$.b", StringType, JsonQueryWrapper.Without, 
JsonQueryQuotes.Keep,
+        JsonQueryBehavior.Null, JsonQueryBehavior.Null))
+    // Explicit NULL ON EMPTY / NULL ON ERROR (same as the omitted default) 
and KEEP QUOTES.
+    assertEqual(
+      "json_query(a, '$.b' KEEP QUOTES NULL ON EMPTY NULL ON ERROR)",
+      JsonQuery($"a", "$.b", StringType, JsonQueryWrapper.Without, 
JsonQueryQuotes.Keep,
+        JsonQueryBehavior.Null, JsonQueryBehavior.Null))
+  }
+
   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 de5099714b87..f9276f3cd715 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,CONDITIONAL,CONTAINS,CONTINUE,COST,CURRENT_DATABASE,CURRENT_SCHEMA,DATA,DATABASE,DATABASES,DATE
 [...]
       // 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 74145b7f3d42..688014da6f2c 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
@@ -1506,3 +1506,222 @@ org.apache.spark.sql.catalyst.ExtendedAnalysisException
     "fragment" : "json_exists('{\"a\":1}', '$[')"
   } ]
 }
+
+
+-- !query
+select 
json_query('{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null}',
 '$.addr')
+-- !query analysis
+Project 
[json_query({"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null},
 $.addr, StringType, Without, Keep, Null, Null) AS 
JSON_QUERY({"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null},
 '$.addr')#x]
++- OneRowRelation
+
+
+-- !query
+select 
json_query('{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null}',
 '$.tags')
+-- !query analysis
+Project 
[json_query({"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null},
 $.tags, StringType, Without, Keep, Null, Null) AS 
JSON_QUERY({"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null},
 '$.tags')#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"id":7}', '$.id')
+-- !query analysis
+Project [json_query({"id":7}, $.id, StringType, Without, Keep, Null, Null) AS 
JSON_QUERY({"id":7}, '$.id')#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"name":"Ada"}', '$.name')
+-- !query analysis
+Project [json_query({"name":"Ada"}, $.name, StringType, Without, Keep, Null, 
Null) AS JSON_QUERY({"name":"Ada"}, '$.name')#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"score":null}', '$.score')
+-- !query analysis
+Project [json_query({"score":null}, $.score, StringType, Without, Keep, Null, 
Null) AS JSON_QUERY({"score":null}, '$.score')#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"id":7}', '$.missing')
+-- !query analysis
+Project [json_query({"id":7}, $.missing, StringType, Without, Keep, Null, 
Null) AS JSON_QUERY({"id":7}, '$.missing')#x]
++- OneRowRelation
+
+
+-- !query
+select json_query(cast(null as string), '$.a')
+-- !query analysis
+Project [json_query(cast(null as string), $.a, StringType, Without, Keep, 
Null, Null) AS JSON_QUERY(CAST(NULL AS STRING), '$.a')#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"tags":["x","y"]}', '$.tags[0]' WITH ARRAY WRAPPER)
+-- !query analysis
+Project [json_query({"tags":["x","y"]}, $.tags[0], StringType, Unconditional, 
Keep, Null, Null) AS JSON_QUERY({"tags":["x","y"]}, '$.tags[0]' WITH 
UNCONDITIONAL ARRAY WRAPPER)#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"tags":["x","y"]}', '$.tags' WITH UNCONDITIONAL ARRAY 
WRAPPER)
+-- !query analysis
+Project [json_query({"tags":["x","y"]}, $.tags, StringType, Unconditional, 
Keep, Null, Null) AS JSON_QUERY({"tags":["x","y"]}, '$.tags' WITH UNCONDITIONAL 
ARRAY WRAPPER)#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"id":7}', '$.id' WITH ARRAY WRAPPER)
+-- !query analysis
+Project [json_query({"id":7}, $.id, StringType, Unconditional, Keep, Null, 
Null) AS JSON_QUERY({"id":7}, '$.id' WITH UNCONDITIONAL ARRAY WRAPPER)#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"id":7}', '$.id' WITH CONDITIONAL ARRAY WRAPPER)
+-- !query analysis
+Project [json_query({"id":7}, $.id, StringType, Conditional, Keep, Null, Null) 
AS JSON_QUERY({"id":7}, '$.id' WITH CONDITIONAL ARRAY WRAPPER)#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"addr":{"city":"NYC"}}', '$.addr' WITH CONDITIONAL ARRAY 
WRAPPER)
+-- !query analysis
+Project [json_query({"addr":{"city":"NYC"}}, $.addr, StringType, Conditional, 
Keep, Null, Null) AS JSON_QUERY({"addr":{"city":"NYC"}}, '$.addr' WITH 
CONDITIONAL ARRAY WRAPPER)#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"name":"Ada"}', '$.name' OMIT QUOTES)
+-- !query analysis
+Project [json_query({"name":"Ada"}, $.name, StringType, Without, Omit, Null, 
Null) AS JSON_QUERY({"name":"Ada"}, '$.name' OMIT QUOTES)#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"name":"Ada"}', '$.name' KEEP QUOTES)
+-- !query analysis
+Project [json_query({"name":"Ada"}, $.name, StringType, Without, Keep, Null, 
Null) AS JSON_QUERY({"name":"Ada"}, '$.name')#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"id":7}', '$.missing' EMPTY ARRAY ON EMPTY)
+-- !query analysis
+Project [json_query({"id":7}, $.missing, StringType, Without, Keep, 
EmptyArray, Null) AS JSON_QUERY({"id":7}, '$.missing' EMPTY ARRAY ON EMPTY)#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"id":7}', '$.missing' EMPTY OBJECT ON EMPTY)
+-- !query analysis
+Project [json_query({"id":7}, $.missing, StringType, Without, Keep, 
EmptyObject, Null) AS JSON_QUERY({"id":7}, '$.missing' EMPTY OBJECT ON EMPTY)#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"id":7}', '$.missing' ERROR ON EMPTY)
+-- !query analysis
+Project [json_query({"id":7}, $.missing, StringType, Without, Keep, Error, 
Null) AS JSON_QUERY({"id":7}, '$.missing' ERROR ON EMPTY)#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('not json', '$.a')
+-- !query analysis
+Project [json_query(not json, $.a, StringType, Without, Keep, Null, Null) AS 
JSON_QUERY(not json, '$.a')#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('not json', '$.a' EMPTY ARRAY ON ERROR)
+-- !query analysis
+Project [json_query(not json, $.a, StringType, Without, Keep, Null, 
EmptyArray) AS JSON_QUERY(not json, '$.a' EMPTY ARRAY ON ERROR)#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('not json', '$.a' EMPTY OBJECT ON ERROR)
+-- !query analysis
+Project [json_query(not json, $.a, StringType, Without, Keep, Null, 
EmptyObject) AS JSON_QUERY(not json, '$.a' EMPTY OBJECT ON ERROR)#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('not json', '$.a' ERROR ON ERROR)
+-- !query analysis
+Project [json_query(not json, $.a, StringType, Without, Keep, Null, Error) AS 
JSON_QUERY(not json, '$.a' ERROR ON ERROR)#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"addr":{"city":"NYC"}}', '$.addr' RETURNING STRING)
+-- !query analysis
+Project [json_query({"addr":{"city":"NYC"}}, $.addr, StringType, Without, 
Keep, Null, Null) AS JSON_QUERY({"addr":{"city":"NYC"}}, '$.addr')#x]
++- OneRowRelation
+
+
+-- !query
+select json_query('{"a":[1,2]}', '$.a[*]')
+-- !query analysis
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+  "errorClass" : "DATATYPE_MISMATCH.INVALID_JSON_PATH",
+  "sqlState" : "42K09",
+  "messageParameters" : {
+    "functionName" : "`json_query`",
+    "path" : "'$.a[*]'",
+    "sqlExpr" : "\"JSON_QUERY({\"a\":[1,2]}, '$.a[*]')\""
+  },
+  "queryContext" : [ {
+    "objectType" : "",
+    "objectName" : "",
+    "startIndex" : 8,
+    "stopIndex" : 42,
+    "fragment" : "json_query('{\"a\":[1,2]}', '$.a[*]')"
+  } ]
+}
+
+
+-- !query
+select json_query('{"a":1}', '$.a' RETURNING INT)
+-- !query analysis
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+  "errorClass" : "DATATYPE_MISMATCH.INVALID_JSON_QUERY_RETURNING_TYPE",
+  "sqlState" : "42K09",
+  "messageParameters" : {
+    "functionName" : "`json_query`",
+    "returningType" : "\"INT\"",
+    "sqlExpr" : "\"JSON_QUERY({\"a\":1}, '$.a' RETURNING INT)\""
+  },
+  "queryContext" : [ {
+    "objectType" : "",
+    "objectName" : "",
+    "startIndex" : 8,
+    "stopIndex" : 49,
+    "fragment" : "json_query('{\"a\":1}', '$.a' RETURNING INT)"
+  } ]
+}
+
+
+-- !query
+select json_query('{"name":"Ada"}', '$.name' WITH ARRAY WRAPPER OMIT QUOTES)
+-- !query analysis
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+  "errorClass" : "DATATYPE_MISMATCH.INVALID_JSON_QUERY_WRAPPER_AND_QUOTES",
+  "sqlState" : "42K09",
+  "messageParameters" : {
+    "functionName" : "`json_query`",
+    "sqlExpr" : "\"JSON_QUERY({\"name\":\"Ada\"}, '$.name' WITH UNCONDITIONAL 
ARRAY WRAPPER OMIT QUOTES)\""
+  },
+  "queryContext" : [ {
+    "objectType" : "",
+    "objectName" : "",
+    "startIndex" : 8,
+    "stopIndex" : 76,
+    "fragment" : "json_query('{\"name\":\"Ada\"}', '$.name' WITH ARRAY WRAPPER 
OMIT QUOTES)"
+  } ]
+}
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 93fcdaf5f478..43a0a5b62db9 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
@@ -245,3 +245,43 @@ select json_exists('{"a":[{"b":1},{"b":2}]}', '$.a.b');
 select json_exists('{"addr":{"city":"NYC"}}', '$.*');
 -- invalid: an unparseable path is rejected at analysis
 select json_exists('{"a":1}', '$[');
+
+-- JSON_QUERY: extract an object or array as JSON text (ANSI SQL:2016)
+select 
json_query('{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null}',
 '$.addr');
+select 
json_query('{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null}',
 '$.tags');
+-- a scalar result is emitted as JSON text (not an error) under the default 
WITHOUT ARRAY WRAPPER
+select json_query('{"id":7}', '$.id');
+select json_query('{"name":"Ada"}', '$.name');
+-- present but JSON null -> the JSON text null
+select json_query('{"score":null}', '$.score');
+-- missing path -> NULL ON EMPTY (default)
+select json_query('{"id":7}', '$.missing');
+-- NULL input propagates to NULL
+select json_query(cast(null as string), '$.a');
+-- ARRAY WRAPPER
+select json_query('{"tags":["x","y"]}', '$.tags[0]' WITH ARRAY WRAPPER);
+select json_query('{"tags":["x","y"]}', '$.tags' WITH UNCONDITIONAL ARRAY 
WRAPPER);
+select json_query('{"id":7}', '$.id' WITH ARRAY WRAPPER);
+-- CONDITIONAL wraps only a scalar; an object/array is left as is
+select json_query('{"id":7}', '$.id' WITH CONDITIONAL ARRAY WRAPPER);
+select json_query('{"addr":{"city":"NYC"}}', '$.addr' WITH CONDITIONAL ARRAY 
WRAPPER);
+-- OMIT QUOTES strips the quotes from a scalar string result
+select json_query('{"name":"Ada"}', '$.name' OMIT QUOTES);
+select json_query('{"name":"Ada"}', '$.name' KEEP QUOTES);
+-- ON EMPTY behaviors
+select json_query('{"id":7}', '$.missing' EMPTY ARRAY ON EMPTY);
+select json_query('{"id":7}', '$.missing' EMPTY OBJECT ON EMPTY);
+select json_query('{"id":7}', '$.missing' ERROR ON EMPTY);
+-- ON ERROR behaviors (malformed input)
+select json_query('not json', '$.a');
+select json_query('not json', '$.a' EMPTY ARRAY ON ERROR);
+select json_query('not json', '$.a' EMPTY OBJECT ON ERROR);
+select json_query('not json', '$.a' ERROR ON ERROR);
+-- RETURNING STRING is allowed (the result is JSON text)
+select json_query('{"addr":{"city":"NYC"}}', '$.addr' RETURNING STRING);
+-- invalid: wildcard path
+select json_query('{"a":[1,2]}', '$.a[*]');
+-- invalid: non-string RETURNING type
+select json_query('{"a":1}', '$.a' RETURNING INT);
+-- invalid: OMIT QUOTES combined with an array wrapper
+select json_query('{"name":"Ada"}', '$.name' WITH ARRAY WRAPPER OMIT QUOTES);
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 8508369a6f1d..71b94295d37d 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
@@ -1722,3 +1722,266 @@ org.apache.spark.sql.catalyst.ExtendedAnalysisException
     "fragment" : "json_exists('{\"a\":1}', '$[')"
   } ]
 }
+
+
+-- !query
+select 
json_query('{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null}',
 '$.addr')
+-- !query schema
+struct<JSON_QUERY({"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null},
 '$.addr'):string>
+-- !query output
+{"city":"NYC"}
+
+
+-- !query
+select 
json_query('{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null}',
 '$.tags')
+-- !query schema
+struct<JSON_QUERY({"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null},
 '$.tags'):string>
+-- !query output
+["x","y"]
+
+
+-- !query
+select json_query('{"id":7}', '$.id')
+-- !query schema
+struct<JSON_QUERY({"id":7}, '$.id'):string>
+-- !query output
+7
+
+
+-- !query
+select json_query('{"name":"Ada"}', '$.name')
+-- !query schema
+struct<JSON_QUERY({"name":"Ada"}, '$.name'):string>
+-- !query output
+"Ada"
+
+
+-- !query
+select json_query('{"score":null}', '$.score')
+-- !query schema
+struct<JSON_QUERY({"score":null}, '$.score'):string>
+-- !query output
+null
+
+
+-- !query
+select json_query('{"id":7}', '$.missing')
+-- !query schema
+struct<JSON_QUERY({"id":7}, '$.missing'):string>
+-- !query output
+NULL
+
+
+-- !query
+select json_query(cast(null as string), '$.a')
+-- !query schema
+struct<JSON_QUERY(CAST(NULL AS STRING), '$.a'):string>
+-- !query output
+NULL
+
+
+-- !query
+select json_query('{"tags":["x","y"]}', '$.tags[0]' WITH ARRAY WRAPPER)
+-- !query schema
+struct<JSON_QUERY({"tags":["x","y"]}, '$.tags[0]' WITH UNCONDITIONAL ARRAY 
WRAPPER):string>
+-- !query output
+["x"]
+
+
+-- !query
+select json_query('{"tags":["x","y"]}', '$.tags' WITH UNCONDITIONAL ARRAY 
WRAPPER)
+-- !query schema
+struct<JSON_QUERY({"tags":["x","y"]}, '$.tags' WITH UNCONDITIONAL ARRAY 
WRAPPER):string>
+-- !query output
+[["x","y"]]
+
+
+-- !query
+select json_query('{"id":7}', '$.id' WITH ARRAY WRAPPER)
+-- !query schema
+struct<JSON_QUERY({"id":7}, '$.id' WITH UNCONDITIONAL ARRAY WRAPPER):string>
+-- !query output
+[7]
+
+
+-- !query
+select json_query('{"id":7}', '$.id' WITH CONDITIONAL ARRAY WRAPPER)
+-- !query schema
+struct<JSON_QUERY({"id":7}, '$.id' WITH CONDITIONAL ARRAY WRAPPER):string>
+-- !query output
+[7]
+
+
+-- !query
+select json_query('{"addr":{"city":"NYC"}}', '$.addr' WITH CONDITIONAL ARRAY 
WRAPPER)
+-- !query schema
+struct<JSON_QUERY({"addr":{"city":"NYC"}}, '$.addr' WITH CONDITIONAL ARRAY 
WRAPPER):string>
+-- !query output
+{"city":"NYC"}
+
+
+-- !query
+select json_query('{"name":"Ada"}', '$.name' OMIT QUOTES)
+-- !query schema
+struct<JSON_QUERY({"name":"Ada"}, '$.name' OMIT QUOTES):string>
+-- !query output
+Ada
+
+
+-- !query
+select json_query('{"name":"Ada"}', '$.name' KEEP QUOTES)
+-- !query schema
+struct<JSON_QUERY({"name":"Ada"}, '$.name'):string>
+-- !query output
+"Ada"
+
+
+-- !query
+select json_query('{"id":7}', '$.missing' EMPTY ARRAY ON EMPTY)
+-- !query schema
+struct<JSON_QUERY({"id":7}, '$.missing' EMPTY ARRAY ON EMPTY):string>
+-- !query output
+[]
+
+
+-- !query
+select json_query('{"id":7}', '$.missing' EMPTY OBJECT ON EMPTY)
+-- !query schema
+struct<JSON_QUERY({"id":7}, '$.missing' EMPTY OBJECT ON EMPTY):string>
+-- !query output
+{}
+
+
+-- !query
+select json_query('{"id":7}', '$.missing' ERROR ON EMPTY)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.SparkRuntimeException
+{
+  "errorClass" : "JSON_QUERY_ON_ERROR.EMPTY",
+  "sqlState" : "2203G",
+  "messageParameters" : {
+    "functionName" : "`json_query`",
+    "path" : "'$.missing'"
+  }
+}
+
+
+-- !query
+select json_query('not json', '$.a')
+-- !query schema
+struct<JSON_QUERY(not json, '$.a'):string>
+-- !query output
+NULL
+
+
+-- !query
+select json_query('not json', '$.a' EMPTY ARRAY ON ERROR)
+-- !query schema
+struct<JSON_QUERY(not json, '$.a' EMPTY ARRAY ON ERROR):string>
+-- !query output
+[]
+
+
+-- !query
+select json_query('not json', '$.a' EMPTY OBJECT ON ERROR)
+-- !query schema
+struct<JSON_QUERY(not json, '$.a' EMPTY OBJECT ON ERROR):string>
+-- !query output
+{}
+
+
+-- !query
+select json_query('not json', '$.a' ERROR ON ERROR)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.SparkRuntimeException
+{
+  "errorClass" : "JSON_QUERY_ON_ERROR.ERROR",
+  "sqlState" : "2203G",
+  "messageParameters" : {
+    "functionName" : "`json_query`",
+    "path" : "'$.a'"
+  }
+}
+
+
+-- !query
+select json_query('{"addr":{"city":"NYC"}}', '$.addr' RETURNING STRING)
+-- !query schema
+struct<JSON_QUERY({"addr":{"city":"NYC"}}, '$.addr'):string>
+-- !query output
+{"city":"NYC"}
+
+
+-- !query
+select json_query('{"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_query`",
+    "path" : "'$.a[*]'",
+    "sqlExpr" : "\"JSON_QUERY({\"a\":[1,2]}, '$.a[*]')\""
+  },
+  "queryContext" : [ {
+    "objectType" : "",
+    "objectName" : "",
+    "startIndex" : 8,
+    "stopIndex" : 42,
+    "fragment" : "json_query('{\"a\":[1,2]}', '$.a[*]')"
+  } ]
+}
+
+
+-- !query
+select json_query('{"a":1}', '$.a' RETURNING INT)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+  "errorClass" : "DATATYPE_MISMATCH.INVALID_JSON_QUERY_RETURNING_TYPE",
+  "sqlState" : "42K09",
+  "messageParameters" : {
+    "functionName" : "`json_query`",
+    "returningType" : "\"INT\"",
+    "sqlExpr" : "\"JSON_QUERY({\"a\":1}, '$.a' RETURNING INT)\""
+  },
+  "queryContext" : [ {
+    "objectType" : "",
+    "objectName" : "",
+    "startIndex" : 8,
+    "stopIndex" : 49,
+    "fragment" : "json_query('{\"a\":1}', '$.a' RETURNING INT)"
+  } ]
+}
+
+
+-- !query
+select json_query('{"name":"Ada"}', '$.name' WITH ARRAY WRAPPER OMIT QUOTES)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+  "errorClass" : "DATATYPE_MISMATCH.INVALID_JSON_QUERY_WRAPPER_AND_QUOTES",
+  "sqlState" : "42K09",
+  "messageParameters" : {
+    "functionName" : "`json_query`",
+    "sqlExpr" : "\"JSON_QUERY({\"name\":\"Ada\"}, '$.name' WITH UNCONDITIONAL 
ARRAY WRAPPER OMIT QUOTES)\""
+  },
+  "queryContext" : [ {
+    "objectType" : "",
+    "objectName" : "",
+    "startIndex" : 8,
+    "stopIndex" : 76,
+    "fragment" : "json_query('{\"name\":\"Ada\"}', '$.name' WITH ARRAY WRAPPER 
OMIT QUOTES)"
+  } ]
+}
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 2989a7373ffa..84dbbaf041d2 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
@@ -77,6 +77,7 @@ COMPENSATION  false
 COMPUTE        false
 CONCATENATE    false
 CONDITION      false
+CONDITIONAL    false
 CONSTRAINT     true
 CONTAINS       false
 CONTINUE       false
@@ -213,8 +214,10 @@ ITERATE    false
 JOIN   true
 JSON   false
 JSON_EXISTS    false
+JSON_QUERY     false
 JSON_TABLE     false
 JSON_VALUE     false
+KEEP   false
 KEY    false
 KEYS   false
 LANGUAGE       false
@@ -273,8 +276,10 @@ NOT        true
 NULL   true
 NULLS  false
 NUMERIC        false
+OBJECT false
 OF     false
 OFFSET true
+OMIT   false
 ON     true
 ONLY   true
 OPEN   false
@@ -308,6 +313,7 @@ PURGE       false
 QUALIFY        false
 QUARTER        false
 QUERY  false
+QUOTES false
 RANGE  false
 READ   false
 READS  false
@@ -414,6 +420,7 @@ TYPE        false
 UNARCHIVE      false
 UNBOUNDED      false
 UNCACHE        false
+UNCONDITIONAL  false
 UNIFORM        false
 UNION  true
 UNIQUE true
@@ -448,6 +455,7 @@ WINDOW      false
 WITH   true
 WITHIN true
 WITHOUT        false
+WRAPPER        false
 X      false
 YEAR   false
 YEARS  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 aa14278fe15a..4b57e254ef96 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
@@ -77,6 +77,7 @@ COMPENSATION  false
 COMPUTE        false
 CONCATENATE    false
 CONDITION      false
+CONDITIONAL    false
 CONSTRAINT     false
 CONTAINS       false
 CONTINUE       false
@@ -213,8 +214,10 @@ ITERATE    false
 JOIN   false
 JSON   false
 JSON_EXISTS    false
+JSON_QUERY     false
 JSON_TABLE     false
 JSON_VALUE     false
+KEEP   false
 KEY    false
 KEYS   false
 LANGUAGE       false
@@ -273,8 +276,10 @@ NOT        false
 NULL   false
 NULLS  false
 NUMERIC        false
+OBJECT false
 OF     false
 OFFSET false
+OMIT   false
 ON     false
 ONLY   false
 OPEN   false
@@ -308,6 +313,7 @@ PURGE       false
 QUALIFY        false
 QUARTER        false
 QUERY  false
+QUOTES false
 RANGE  false
 READ   false
 READS  false
@@ -414,6 +420,7 @@ TYPE        false
 UNARCHIVE      false
 UNBOUNDED      false
 UNCACHE        false
+UNCONDITIONAL  false
 UNIFORM        false
 UNION  false
 UNIQUE false
@@ -448,6 +455,7 @@ WINDOW      false
 WITH   false
 WITHIN false
 WITHOUT        false
+WRAPPER        false
 X      false
 YEAR   false
 YEARS  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 aa14278fe15a..4b57e254ef96 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
@@ -77,6 +77,7 @@ COMPENSATION  false
 COMPUTE        false
 CONCATENATE    false
 CONDITION      false
+CONDITIONAL    false
 CONSTRAINT     false
 CONTAINS       false
 CONTINUE       false
@@ -213,8 +214,10 @@ ITERATE    false
 JOIN   false
 JSON   false
 JSON_EXISTS    false
+JSON_QUERY     false
 JSON_TABLE     false
 JSON_VALUE     false
+KEEP   false
 KEY    false
 KEYS   false
 LANGUAGE       false
@@ -273,8 +276,10 @@ NOT        false
 NULL   false
 NULLS  false
 NUMERIC        false
+OBJECT false
 OF     false
 OFFSET false
+OMIT   false
 ON     false
 ONLY   false
 OPEN   false
@@ -308,6 +313,7 @@ PURGE       false
 QUALIFY        false
 QUARTER        false
 QUERY  false
+QUOTES false
 RANGE  false
 READ   false
 READS  false
@@ -414,6 +420,7 @@ TYPE        false
 UNARCHIVE      false
 UNBOUNDED      false
 UNCACHE        false
+UNCONDITIONAL  false
 UNIFORM        false
 UNION  false
 UNIQUE false
@@ -448,6 +455,7 @@ WINDOW      false
 WITH   false
 WITHIN false
 WITHOUT        false
+WRAPPER        false
 X      false
 YEAR   false
 YEARS  false
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/JsonQuerySuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/JsonQuerySuite.scala
new file mode 100644
index 000000000000..083d319f6c27
--- /dev/null
+++ b/sql/core/src/test/scala/org/apache/spark/sql/JsonQuerySuite.scala
@@ -0,0 +1,228 @@
+/*
+ * 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.analysis.TypeCheckResult.DataTypeMismatch
+import org.apache.spark.sql.catalyst.expressions.{JsonQuery, 
JsonQueryBehavior, JsonQueryQuotes,
+  JsonQueryWrapper, Literal}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{CharType, StringType, VarcharType}
+
+/**
+ * End-to-end tests for the SQL:2016 `JSON_QUERY` function.
+ */
+class JsonQuerySuite extends QueryTest with SharedSparkSession {
+  import testImplicits._
+
+  private val doc =
+    
"""{"id":7,"name":"Ada","tags":["x","y"],"addr":{"city":"NYC"},"score":null}"""
+
+  test("extract an object or array as verbatim JSON text") {
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.addr')"), 
Row("""{"city":"NYC"}"""))
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.tags')"), 
Row("""["x","y"]"""))
+  }
+
+  test("default RETURNING type is STRING") {
+    assert(sql(s"SELECT json_query('$doc', '$$.addr')").schema.head.dataType 
=== StringType)
+  }
+
+  test("RETURNING VARCHAR/CHAR is normalized to STRING and does not truncate") 
{
+    // JSON_QUERY returns the fragment verbatim (no length-enforcing cast), so 
a CHAR/VARCHAR
+    // RETURNING must not advertise a length it cannot enforce. The result 
type is STRING and the
+    // value is not truncated -- including when char/varchar type info is 
otherwise preserved.
+    Seq("false", "true").foreach { preserve =>
+      withSQLConf(SQLConf.PRESERVE_CHAR_VARCHAR_TYPE_INFO.key -> preserve) {
+        // VARCHAR(n) and CHAR(n) exercise the two separate normalization 
branches.
+        Seq("VARCHAR(2)", "CHAR(2)").foreach { returning =>
+          val df = sql(s"SELECT json_query('$doc', '$$.addr' RETURNING 
$returning)")
+          assert(df.schema.head.dataType === StringType, s"$returning 
preserve=$preserve")
+          checkAnswer(df, Row("""{"city":"NYC"}"""))
+        }
+      }
+    }
+  }
+
+  test("a directly-constructed JsonQuery with a CHAR/VARCHAR RETURNING is 
rejected") {
+    // The parser normalizes CHAR/VARCHAR to STRING, but a raw 
CharType/VarcharType supplied by
+    // direct Catalyst construction would otherwise advertise a length 
JSON_QUERY does not enforce.
+    // isValidReturningType rejects it, so checkInputDataTypes fails.
+    Seq(VarcharType(2), CharType(2)).foreach { returning =>
+      val expr = JsonQuery(Literal("{}"), "$.a", returning, 
JsonQueryWrapper.Without,
+        JsonQueryQuotes.Keep, JsonQueryBehavior.Null, JsonQueryBehavior.Null)
+      expr.checkInputDataTypes() match {
+        case DataTypeMismatch(errorSubClass, _) =>
+          assert(errorSubClass == "INVALID_JSON_QUERY_RETURNING_TYPE", s"for 
$returning")
+        case other => fail(s"expected DataTypeMismatch for $returning, got 
$other")
+      }
+    }
+  }
+
+  test("RETURNING STRING is allowed (the result is JSON text)") {
+    checkAnswer(
+      sql(s"SELECT json_query('$doc', '$$.addr' RETURNING STRING)"), 
Row("""{"city":"NYC"}"""))
+  }
+
+  test("a scalar result is emitted as JSON text under the default WITHOUT 
ARRAY WRAPPER") {
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.id')"), Row("7"))
+    // A string scalar keeps its surrounding quotes by default (KEEP QUOTES).
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.name')"), Row("\"Ada\""))
+  }
+
+  test("a present JSON null yields the JSON text null") {
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.score')"), Row("null"))
+  }
+
+  test("a missing path is an ON EMPTY case, NULL by default") {
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.missing')"), Row(null))
+  }
+
+  test("NULL JSON input propagates to NULL (not ON EMPTY / ON ERROR)") {
+    checkAnswer(sql("SELECT json_query(CAST(NULL AS STRING), '$.a')"), 
Row(null))
+  }
+
+  test("WITH [UNCONDITIONAL] ARRAY WRAPPER wraps the result in a one-element 
array") {
+    checkAnswer(
+      sql(s"SELECT json_query('$doc', '$$.tags[0]' WITH ARRAY WRAPPER)"), 
Row("""["x"]"""))
+    checkAnswer(
+      sql(s"SELECT json_query('$doc', '$$.tags' WITH UNCONDITIONAL ARRAY 
WRAPPER)"),
+      Row("""[["x","y"]]"""))
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.id' WITH ARRAY WRAPPER)"), 
Row("[7]"))
+  }
+
+  test("WITH CONDITIONAL ARRAY WRAPPER wraps only a scalar") {
+    checkAnswer(
+      sql(s"SELECT json_query('$doc', '$$.id' WITH CONDITIONAL ARRAY 
WRAPPER)"), Row("[7]"))
+    checkAnswer(
+      sql(s"SELECT json_query('$doc', '$$.addr' WITH CONDITIONAL ARRAY 
WRAPPER)"),
+      Row("""{"city":"NYC"}"""))
+    checkAnswer(
+      sql(s"SELECT json_query('$doc', '$$.tags' WITH CONDITIONAL ARRAY 
WRAPPER)"),
+      Row("""["x","y"]"""))
+  }
+
+  test("OMIT QUOTES strips the quotes from a scalar string result") {
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.name' OMIT QUOTES)"), 
Row("Ada"))
+    // KEEP QUOTES is the default and keeps them.
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.name' KEEP QUOTES)"), 
Row("\"Ada\""))
+    // OMIT QUOTES is a no-op for a non-string scalar and for structural 
results.
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.id' OMIT QUOTES)"), 
Row("7"))
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.addr' OMIT QUOTES)"), 
Row("""{"city":"NYC"}"""))
+  }
+
+  test("OMIT QUOTES unescapes an escaped string scalar") {
+    // Pass the JSON via a column so SQL string-literal escaping does not 
rewrite it first. The
+    // stored JSON is {"s":"a\"b\\c\n"}; s decodes to a, quote, b, backslash, 
c, newline.
+    val df = Seq("""{"s":"a\"b\\c\n"}""").toDF("j")
+    // KEEP QUOTES (default) returns the verbatim, re-escaped JSON string.
+    checkAnswer(df.selectExpr("json_query(j, '$.s')"), Row(""""a\"b\\c\n""""))
+    // OMIT QUOTES returns the raw unescaped content, which is intentionally 
no longer valid JSON.
+    checkAnswer(df.selectExpr("json_query(j, '$.s' OMIT QUOTES)"), 
Row("a\"b\\c\n"))
+  }
+
+  test("the JSON_QUERY keyword is non-reserved and usable as an identifier") {
+    // JSON_QUERY and the OBJECT keyword introduced for the ON EMPTY / ON 
ERROR clause are
+    // non-reserved in both modes, so they remain usable as column names.
+    withTable("t") {
+      sql("CREATE TABLE t (json_query INT, object STRING) USING parquet")
+      sql("INSERT INTO t VALUES (1, 'x')")
+      checkAnswer(sql("SELECT json_query, object FROM t"), Row(1, "x"))
+    }
+  }
+
+  test("EMPTY ARRAY / EMPTY OBJECT ON EMPTY") {
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.missing' EMPTY ARRAY ON 
EMPTY)"), Row("[]"))
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.missing' EMPTY OBJECT ON 
EMPTY)"), Row("{}"))
+  }
+
+  test("ERROR ON EMPTY raises for a missing path") {
+    val e = intercept[SparkRuntimeException] {
+      sql(s"SELECT json_query('$doc', '$$.missing' ERROR ON EMPTY)").collect()
+    }
+    assert(e.getCondition == "JSON_QUERY_ON_ERROR.EMPTY")
+  }
+
+  test("malformed input is an ON ERROR case, NULL by default") {
+    checkAnswer(sql("SELECT json_query('not json', '$.a')"), Row(null))
+    checkAnswer(sql("SELECT json_query('not json', '$.a' EMPTY ARRAY ON 
ERROR)"), Row("[]"))
+    checkAnswer(sql("SELECT json_query('not json', '$.a' EMPTY OBJECT ON 
ERROR)"), Row("{}"))
+  }
+
+  test("ERROR ON ERROR raises for malformed input") {
+    val e = intercept[SparkRuntimeException] {
+      sql("SELECT json_query('not json', '$.a' ERROR ON ERROR)").collect()
+    }
+    assert(e.getCondition == "JSON_QUERY_ON_ERROR.ERROR")
+  }
+
+  test("a valid JSON prefix followed by trailing content is an ON ERROR case") 
{
+    checkAnswer(sql("""SELECT json_query('{"a":{"b":1}} trailing', '$.a')"""), 
Row(null))
+    checkAnswer(sql("""SELECT json_query('{"a":1}{"a":2}', '$.a')"""), 
Row(null))
+    val e = intercept[SparkRuntimeException] {
+      sql("""SELECT json_query('{"a":{"b":1}} trailing', '$.a' ERROR ON 
ERROR)""").collect()
+    }
+    assert(e.getCondition == "JSON_QUERY_ON_ERROR.ERROR")
+  }
+
+  test("nested path into an object and array index") {
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.addr.city')"), 
Row("\"NYC\""))
+    checkAnswer(sql(s"SELECT json_query('$doc', '$$.tags[1]')"), Row("\"y\""))
+  }
+
+  test("invalid: wildcard path is rejected at analysis") {
+    val e = intercept[AnalysisException] {
+      sql(s"SELECT json_query('$doc', '$$.tags[*]')").collect()
+    }
+    assert(e.getCondition == "DATATYPE_MISMATCH.INVALID_JSON_PATH")
+  }
+
+  test("invalid: non-string RETURNING type is rejected at analysis") {
+    val e = intercept[AnalysisException] {
+      sql(s"SELECT json_query('$doc', '$$.id' RETURNING INT)").collect()
+    }
+    assert(e.getCondition == 
"DATATYPE_MISMATCH.INVALID_JSON_QUERY_RETURNING_TYPE")
+  }
+
+  test("invalid: OMIT QUOTES combined with an array wrapper is rejected at 
analysis") {
+    val e = intercept[AnalysisException] {
+      sql(s"SELECT json_query('$doc', '$$.name' WITH ARRAY WRAPPER OMIT 
QUOTES)").collect()
+    }
+    assert(e.getCondition == 
"DATATYPE_MISMATCH.INVALID_JSON_QUERY_WRAPPER_AND_QUOTES")
+  }
+
+  test("sql renders a bracket-quoted path as a valid, re-parseable string 
literal") {
+    val df = sql(s"SELECT json_query('$doc', '$$[\\'addr\\']')")
+    val jsonQuery = df.queryExecution.analyzed.expressions
+      .flatMap(_.collect { case jq: JsonQuery => jq }).head
+    val rendered = jsonQuery.sql
+    assert(rendered.contains("\\'addr\\'"), s"path was not escaped in: 
$rendered")
+    checkAnswer(sql(s"SELECT $rendered"), Row("""{"city":"NYC"}"""))
+  }
+
+  test("works over a column of JSON documents") {
+    val df = Seq(
+      """{"a":{"x":1}}""",
+      """{"a":[1,2]}""",
+      """{"b":2}""",
+      "not json").toDF("j")
+    checkAnswer(
+      df.selectExpr("json_query(j, '$.a')"),
+      Seq(Row("""{"x":1}"""), Row("[1,2]"), Row(null), Row(null)))
+  }
+}
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 8249d8fcf4e9..f8fd426881e5 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]

Reply via email to