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 fd6c05922151 [SPARK-58730][SQL] Support the ANSI SQL/JSON JSON_ARRAY 
constructor function
fd6c05922151 is described below

commit fd6c059221519fc50cb0c980fee1f4ca0b486ccc
Author: Ganesha S <[email protected]>
AuthorDate: Tue Sep 1 11:18:29 2026 +0800

    [SPARK-58730][SQL] Support the ANSI SQL/JSON JSON_ARRAY constructor function
    
    ### What changes were proposed in this pull request?
    
    Implement the ANSI SQL:2016 `JSON_ARRAY` constructor function (feature 
T811), which builds a JSON array from a list of argument values and returns it 
as JSON text.
    
    Syntax (a dedicated `primaryExpression` production, mirroring `JSON_TABLE`, 
because the
    argument list carries non-expression syntax):
    
        JSON_ARRAY ( [ value [ FORMAT JSON ] [, ...] ]
                     [ { NULL | ABSENT } ON NULL ]
                     [ RETURNING data_type ] )
    
    - New `JsonArray` Catalyst expression that serializes each element through 
the same
      Jackson writer as `to_json`, so numbers, decimals, datetimes, and nested
      structs/arrays/maps render consistently.
    - `(NULL | ABSENT) ON NULL`: `ABSENT ON NULL` (the standard default) drops 
NULL
      elements; `NULL ON NULL` keeps them as JSON `null`.
    - `RETURNING`: defaults to `STRING` and is restricted to string types 
(VARIANT deferred);
      `CHAR`/`VARCHAR` are normalized to `STRING`.
    - Per-element `FORMAT JSON`: splices already-JSON string text verbatim 
instead of quoting
      it (validated for well-formedness at eval). A nested JSON constructor 
carries it
      implicitly. The splice/quote decision is frozen at parse time so a later 
optimizer
      rewrite (e.g. `CollapseProject` inlining a `JSON_ARRAY` alias) cannot 
flip it.
    - `JSON_ARRAY` and `ABSENT` added as non-reserved keywords (matching 
`JSON_TABLE`/`JSON_VALUE`).
    
    Example:
    
        SELECT JSON_ARRAY(1, 'x', true);              -- [1,"x",true]
        SELECT JSON_ARRAY(1, NULL, 3);                -- [1,3]        (ABSENT 
ON NULL default)
        SELECT JSON_ARRAY(1, NULL, 3 NULL ON NULL);   -- [1,null,3]
        SELECT JSON_ARRAY(JSON_ARRAY(1, 2), 3);       -- [[1,2],3]    (nested: 
implicit FORMAT JSON)
        SELECT JSON_ARRAY('[1,2]');                   -- ["[1,2]"]    (plain 
string: quoted)
        SELECT JSON_ARRAY('[1,2]' FORMAT JSON);       -- [[1,2]]      (explicit 
FORMAT JSON: spliced)
    
    ### Why are the changes needed?
    
    Spark implements none of the ANSI SQL:2016 constructor JSON functions, 
forcing users migrating from Oracle, SQL Server, MySQL, Trino, or BigQuery to 
rewrite `JSON_ARRAY(a, b, c)` as `to_json(array(a, b, c))`, which is verbose 
and does not carry the standard's `ON NULL` / `RETURNING` / `FORMAT JSON` 
semantics.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. It adds the new `JSON_ARRAY` SQL constructor function and the new 
non-reserved keywords `JSON_ARRAY` and `ABSENT`. There is no change to existing 
queries (both keywords remain usable as identifiers, as they are non-reserved).
    
    ### How was this patch tested?
    
    - New `JsonArraySuite` (49 cases): scalar/mixed/nested values, both `ON 
NULL` modes, `RETURNING` (collated + CHAR/VARCHAR normalization), 
implicit/explicit `FORMAT JSON`  (splicing, per-row validation, error 
truncation), plan-stability of the splice  decision, 
foldable/throwable/collation behavior, and analysis-time type rejection.
    - Golden-file coverage in `json-functions.sql` (`SQLQueryTestSuite`).
    - `SQLKeywordSuite` and regenerated `keywords*.sql.out` for the new 
keywords;  `checkError` coverage for the new error conditions.
    - New reference doc page `sql-ref-syntax-qry-select-json-array.md`.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8)
    
    Closes #58005 from ganeshashree/SPARK-58730.
    
    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 949c8b457c2764208ea1e5b5051ac17747984e44)
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../src/main/resources/error/error-conditions.json |  16 +
 docs/sql-ref-ansi-compliance.md                    |   2 +
 docs/sql-ref-syntax-qry-select-json-array.md       | 125 ++++
 docs/sql-ref-syntax-qry-select.md                  |   1 +
 docs/sql-ref-syntax.md                             |   1 +
 .../spark/sql/catalyst/parser/SqlBaseLexer.g4      |   4 +-
 .../spark/sql/catalyst/parser/SqlBaseParser.g4     |  27 +-
 .../catalyst/analysis/ApplyDefaultCollation.scala  |  21 +-
 .../analysis/resolver/CoercesExpressionTypes.scala |  10 +-
 .../resolver/DefaultCollationTypeCoercion.scala    |  17 +-
 .../expressions/json/JsonExpressionEvalUtils.scala |  14 +-
 .../sql/catalyst/expressions/jsonExpressions.scala | 460 ++++++++++++++-
 .../spark/sql/catalyst/parser/AstBuilder.scala     |  54 ++
 .../spark/sql/catalyst/parser/ParserUtils.scala    |   2 +-
 .../spark/sql/errors/QueryExecutionErrors.scala    |  19 +
 .../jdbc/SparkConnectDatabaseMetaDataSuite.scala   |   2 +-
 .../analyzer-results/json-functions.sql.out        | 113 ++++
 .../resources/sql-tests/inputs/json-functions.sql  |  26 +
 .../sql-tests/results/json-functions.sql.out       | 138 +++++
 .../sql-tests/results/keywords-enforced.sql.out    |   2 +
 .../resources/sql-tests/results/keywords.sql.out   |   2 +
 .../sql-tests/results/nonansi/keywords.sql.out     |   2 +
 .../org/apache/spark/sql/JsonArraySuite.scala      | 648 +++++++++++++++++++++
 .../ThriftServerWithSparkContextSuite.scala        |   2 +-
 24 files changed, 1689 insertions(+), 19 deletions(-)

diff --git a/common/utils/src/main/resources/error/error-conditions.json 
b/common/utils/src/main/resources/error/error-conditions.json
index 2b73c1759733..0b1dbb183978 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -1978,6 +1978,11 @@
           "The <inputName> value must to be a <requireType> literal of 
<validValues>, but got <inputValue>."
         ]
       },
+      "INVALID_JSON_FORMAT_JSON_INPUT" : {
+        "message" : [
+          "<functionName> FORMAT JSON requires a string argument containing 
JSON text, but the argument at position <position> has type <inputType>."
+        ]
+      },
       "INVALID_JSON_MAP_KEY_TYPE" : {
         "message" : [
           "Input schema <schema> can only contain STRING as a key type for a 
MAP."
@@ -1998,6 +2003,11 @@
           "<functionName> cannot combine an OMIT QUOTES clause with a WITH 
ARRAY WRAPPER clause. OMIT QUOTES applies only to an unwrapped result."
         ]
       },
+      "INVALID_JSON_RETURNING_TYPE" : {
+        "message" : [
+          "<functionName> RETURNING clause must be a STRING type, but got 
<returningType>."
+        ]
+      },
       "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."
@@ -4514,6 +4524,12 @@
     ],
     "sqlState" : "2203G"
   },
+  "INVALID_JSON_FORMAT_JSON_VALUE" : {
+    "message" : [
+      "<functionName> FORMAT JSON argument at position <position> must contain 
exactly one well-formed JSON value, but got <value>."
+    ],
+    "sqlState" : "22032"
+  },
   "INVALID_JSON_RECORD_TYPE" : {
     "message" : [
       "Detected an invalid type of a JSON record while inferring a common 
schema in the mode <failFastMode>. Expected a STRUCT type, but found 
<invalidType>."
diff --git a/docs/sql-ref-ansi-compliance.md b/docs/sql-ref-ansi-compliance.md
index 5ab05fd026f7..2ec0b7872e52 100644
--- a/docs/sql-ref-ansi-compliance.md
+++ b/docs/sql-ref-ansi-compliance.md
@@ -411,6 +411,7 @@ Below is a list of all the keywords in Spark SQL.
 
 |Keyword|Spark SQL<br/>ANSI Mode|Spark SQL<br/>NonANSI Mode|SQL-2016|
 |--|----------------------|-------------------------|--------|
+|ABSENT|non-reserved|non-reserved|non-reserved|
 |ADD|non-reserved|non-reserved|non-reserved|
 |AFTER|non-reserved|non-reserved|non-reserved|
 |AGGREGATE|non-reserved|non-reserved|non-reserved|
@@ -619,6 +620,7 @@ Below is a list of all the keywords in Spark SQL.
 |ITERATE|non-reserved|non-reserved|non-reserved|
 |JOIN|reserved|strict-non-reserved|reserved|
 |JSON|non-reserved|non-reserved|non-reserved|
+|JSON_ARRAY|non-reserved|non-reserved|reserved|
 |JSON_EXISTS|non-reserved|non-reserved|reserved|
 |JSON_QUERY|non-reserved|non-reserved|reserved|
 |JSON_TABLE|non-reserved|non-reserved|reserved|
diff --git a/docs/sql-ref-syntax-qry-select-json-array.md 
b/docs/sql-ref-syntax-qry-select-json-array.md
new file mode 100644
index 000000000000..5220d3e5508c
--- /dev/null
+++ b/docs/sql-ref-syntax-qry-select-json-array.md
@@ -0,0 +1,125 @@
+---
+layout: global
+title: JSON_ARRAY
+displayTitle: JSON_ARRAY
+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_ARRAY` constructor function builds a JSON array from a list of 
argument values and
+returns it as JSON text. This is the SQL-standard way (SQL:2016) to assemble a 
JSON array inline,
+and is commonly used to migrate queries from other systems such as Oracle, 
DB2, and MySQL.
+`JSON_ARRAY` is an expression that can appear anywhere a value is allowed.
+
+Each argument is serialized with the same JSON writer as the built-in 
`to_json` function, so
+numbers, decimals, booleans, dates, timestamps, and nested structs/arrays/maps 
render the same way.
+Null-field handling inside a struct argument therefore follows
+`spark.sql.jsonGenerator.ignoreNullFields`, exactly as `to_json` does; the `ON 
NULL` clause below
+controls only the top-level array elements.
+
+### Syntax
+
+```sql
+JSON_ARRAY ( [ value [ FORMAT JSON ] [, ...] ]
+             [ { NULL | ABSENT } ON NULL ]
+             [ RETURNING data_type ] )
+```
+
+### Parameters
+
+* **value**
+
+    An expression producing an element of the array. Arguments may have 
different types and may be
+    nested `JSON_ARRAY` constructors. `JSON_ARRAY()` with no arguments 
produces the empty array
+    `[]`.
+
+* **FORMAT JSON**
+
+    Marks a string `value` as already-JSON text, so it is spliced into the 
array verbatim instead
+    of being quoted as a JSON string. For example, `JSON_ARRAY('[1,2]')` 
produces `["[1,2]"]`, while
+    `JSON_ARRAY('[1,2]' FORMAT JSON)` produces `[[1,2]]`. A nested 
`JSON_ARRAY` constructor carries
+    `FORMAT JSON` implicitly, so `JSON_ARRAY(JSON_ARRAY(1))` produces `[[1]]`. 
`FORMAT JSON` requires
+    a string argument (an untyped `NULL` literal is also accepted and follows 
the `ON NULL`
+    behavior, exactly as a non-`FORMAT JSON` `NULL` would). At runtime, a 
non-null `FORMAT JSON`
+    value must contain exactly one well-formed JSON value; malformed text or 
multiple top-level
+    values raise an error. This decision is fixed from the query text and does 
not depend on query
+    optimization: a `JSON_ARRAY` result that reaches an argument through a 
column reference is a
+    plain `STRING` and is quoted, whether or not the optimizer inlines it.
+
+* **{ NULL | ABSENT } ON NULL**
+
+    How to handle a `NULL` element:
+    * `ABSENT ON NULL` (the default) omits `NULL` elements from the array.
+    * `NULL ON NULL` keeps them as JSON `null` values.
+
+* **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`. `CHAR` / `VARCHAR` are normalized to 
`STRING` (the length
+    is not enforced, because the fragment is serialized directly).
+
+### Examples
+
+```sql
+-- Construct an array from a mixed value list
+SELECT json_array(1, 'x', true);
++---------------------------+
+|json_array(1, x, true)     |
++---------------------------+
+|[1,"x",true]               |
++---------------------------+
+
+-- ABSENT ON NULL (the default) drops NULL elements
+SELECT json_array(1, NULL, 3);
++------------------------+
+|json_array(1, NULL, 3)  |
++------------------------+
+|[1,3]                   |
++------------------------+
+
+-- NULL ON NULL keeps them as JSON null
+SELECT json_array(1, NULL, 3 NULL ON NULL);
++--------------------------------------+
+|json_array(1, NULL, 3 NULL ON NULL)   |
++--------------------------------------+
+|[1,null,3]                            |
++--------------------------------------+
+
+-- A nested JSON_ARRAY is spliced in raw (implicit FORMAT JSON)
+SELECT json_array(json_array(1, 2), 3);
++---------------------------------+
+|json_array(json_array(1, 2), 3)  |
++---------------------------------+
+|[[1,2],3]                        |
++---------------------------------+
+
+-- FORMAT JSON splices an already-JSON string verbatim
+SELECT json_array('[1,2]' FORMAT JSON);
++----------------------------------+
+|json_array([1,2] FORMAT JSON)     |
++----------------------------------+
+|[[1,2]]                           |
++----------------------------------+
+```
+
+### 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.md 
b/docs/sql-ref-syntax-qry-select.md
index 2d01b6123ac1..66e4ff7af2ec 100644
--- a/docs/sql-ref-syntax-qry-select.md
+++ b/docs/sql-ref-syntax-qry-select.md
@@ -214,6 +214,7 @@ SELECT [ hints , ... ] [ ALL | DISTINCT ] { [ [ 
named_expression | regex_column_
 * [TABLESAMPLE](sql-ref-syntax-qry-select-sampling.html)
 * [Table-valued Function](sql-ref-syntax-qry-select-tvf.html)
 * [JSON_QUERY](sql-ref-syntax-qry-select-json-query.html)
+* [JSON_ARRAY](sql-ref-syntax-qry-select-json-array.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 bc168fed6065..4be4055afbc5 100644
--- a/docs/sql-ref-syntax.md
+++ b/docs/sql-ref-syntax.md
@@ -84,6 +84,7 @@ ability to generate logical and physical plan for a given 
query using
    * [TABLESAMPLE](sql-ref-syntax-qry-select-sampling.html)
    * [Table-valued Function](sql-ref-syntax-qry-select-tvf.html)
    * [JSON_QUERY](sql-ref-syntax-qry-select-json-query.html)
+   * [JSON_ARRAY](sql-ref-syntax-qry-select-json-array.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 dcccba9edc04..885ae4dc1c3d 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
@@ -122,12 +122,13 @@ BANG: '!';
 
 // NOTE: If you add a new token in the list below, you should update the list 
of keywords
 // and reserved tag in `docs/sql-ref-ansi-compliance.md#sql-keywords`, and
-// modify `ParserUtils.toExprAlias()` which assumes all keywords are between 
`ADD` and `ZONE`.
+// modify `ParserUtils.toExprAlias()` which assumes all keywords are between 
`ABSENT` and `ZONE`.
 
 //============================
 // Start of the keywords list
 //============================
 //--SPARK-KEYWORD-LIST-START
+ABSENT: 'ABSENT';
 ADD: 'ADD';
 AFTER: 'AFTER';
 AGGREGATE: 'AGGREGATE';
@@ -336,6 +337,7 @@ ITEMS: 'ITEMS';
 ITERATE: 'ITERATE';
 JOIN: 'JOIN';
 JSON: 'JSON';
+JSON_ARRAY: 'JSON_ARRAY';
 JSON_EXISTS: 'JSON_EXISTS';
 JSON_QUERY: 'JSON_QUERY';
 JSON_TABLE: 'JSON_TABLE';
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 300318247188..7865f0842325 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
@@ -1474,6 +1474,11 @@ primaryExpression
       quotes=jsonQueryQuotes?
       (emptyBehavior=jsonQueryBehavior ON EMPTY)?
       (errorBehavior=jsonQueryBehavior ON ERROR)? RIGHT_PAREN                  
                #jsonQuery
+    | JSON_ARRAY LEFT_PAREN
+      (values+=jsonArrayValue (COMMA values+=jsonArrayValue)*)?
+      (nullBehavior=jsonConstructorNullBehavior ON NULL)?
+      (RETURNING returning=dataType)?
+      RIGHT_PAREN                                  #jsonArray
     | constant                                                                 
                #constantDefault
     | ASTERISK exceptClause?                                                   
                #star
     | qualifiedName DOT ASTERISK exceptClause?                                 
                #star
@@ -1540,6 +1545,20 @@ jsonQueryBehavior
     | EMPTY OBJECT                                                             
                #jsonQueryBehaviorEmptyObject
     ;
 
+// The behavior selected by JSON_ARRAY/JSON_OBJECT `... ON NULL` clause: NULL 
keeps nulls, ABSENT
+// drops null elements/pairs.
+jsonConstructorNullBehavior
+    : NULL                                                                     
                #jsonConstructorNullBehaviorNull
+    | ABSENT                                                                   
                #jsonConstructorNullBehaviorAbsent
+    ;
+
+// A JSON_ARRAY element. The optional `FORMAT JSON` clause marks a string 
argument as already-JSON
+// text, so it is spliced into the array verbatim instead of being quoted as a 
JSON string. A
+// lexically-nested JSON constructor (e.g. JSON_ARRAY(JSON_ARRAY(1))) carries 
this implicitly.
+jsonArrayValue
+    : value=expression (FORMAT JSON)?
+    ;
+
 semiStructuredExtractionPath
     : jsonPathFirstPart (jsonPathParts)*
     ;
@@ -2121,7 +2140,8 @@ operatorPipeSetAssignmentSeq
 // The non-reserved keywords are listed below. Keywords not in this list are 
reserved keywords.
 ansiNonReserved
 //--ANSI-NON-RESERVED-START
-    : ADD
+    : ABSENT
+    | ADD
     | AFTER
     | AGGREGATE
     | ALIGN
@@ -2285,6 +2305,7 @@ ansiNonReserved
     | ITEMS
     | ITERATE
     | JSON
+    | JSON_ARRAY
     | JSON_EXISTS
     | JSON_QUERY
     | JSON_TABLE
@@ -2531,7 +2552,8 @@ strictNonReserved
 
 nonReserved
 //--DEFAULT-NON-RESERVED-START
-    : ADD
+    : ABSENT
+    | ADD
     | AFTER
     | AGGREGATE
     | ALIGN
@@ -2733,6 +2755,7 @@ nonReserved
     | ITEMS
     | ITERATE
     | JSON
+    | JSON_ARRAY
     | JSON_EXISTS
     | JSON_QUERY
     | JSON_TABLE
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ApplyDefaultCollation.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ApplyDefaultCollation.scala
index ff43b3668839..42b27ba9408e 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ApplyDefaultCollation.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ApplyDefaultCollation.scala
@@ -424,8 +424,12 @@ object ApplyDefaultCollation extends Rule[LogicalPlan] {
       case cast @ Cast(e: DefaultStringProducingExpression, dt, _, _) if 
newType == dt =>
         cast.copy(child = e.withNewChildren(e.children.map(inner)))
 
-      // Add cast on top of [[DefaultStringProducingExpression]].
-      case e: DefaultStringProducingExpression =>
+      // Add cast on top of [[DefaultStringProducingExpression]], unless it 
carries an explicitly
+      // collated string type -- e.g. `JSON_ARRAY(... RETURNING STRING COLLATE 
UTF8_BINARY)`, a
+      // distinct instance the user chose deliberately (even for the default 
UTF8_BINARY) that the
+      // object/view default must not override. A plain `RETURNING STRING` is 
the companion (see
+      // `hasExplicitStringCollation`), indistinguishable from omission, and 
correctly recolored.
+      case e: DefaultStringProducingExpression if 
!hasExplicitStringCollation(e.dataType) =>
         Cast(e.withNewChildren(e.children.map(inner)), newType)
 
       case other =>
@@ -440,6 +444,19 @@ object ApplyDefaultCollation extends Rule[LogicalPlan] {
   private def hasDefaultStringCharOrVarcharType(dataType: DataType): Boolean =
     dataType.existsRecursively(isDefaultStringCharOrVarcharType)
 
+  /**
+   * A [[StringType]] written with an explicit `COLLATE` clause -- a distinct 
instance rather than
+   * the `StringType` companion, told apart by reference identity (matching 
the single-pass
+   * resolver's `DefaultCollationTypeCoercion`), even for the default 
UTF8_BINARY. Only the explicit
+   * `COLLATE` form is distinct: `DataTypeAstBuilder` returns the companion 
for a plain `STRING`, so
+   * a plain `RETURNING STRING` is indistinguishable from omission and 
recolored like it. An
+   * explicit collation is a deliberate choice the object/view default must 
not overwrite.
+   */
+  private def hasExplicitStringCollation(dataType: DataType): Boolean = 
dataType match {
+    case st: StringType => !st.eq(StringType)
+    case _ => false
+  }
+
   private def replaceColumnTypes(
       colTypes: Seq[QualifiedColType],
       collation: String): Seq[QualifiedColType] = {
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/resolver/CoercesExpressionTypes.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/resolver/CoercesExpressionTypes.scala
index 52d473acd90d..8a26575770cf 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/resolver/CoercesExpressionTypes.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/resolver/CoercesExpressionTypes.scala
@@ -91,7 +91,15 @@ trait CoercesExpressionTypes extends SQLConfHelper with 
ResolverMetricTracker {
 
         val coercionResult = expressionTreeTraversal.defaultCollation match {
           case Some(defaultCollation) =>
-            DefaultCollationTypeCoercion(expressionAfterTypeCoercion, 
defaultCollation)
+            // A `Cast` wrapped on here comes after `applyTypeCoercion` 
resolved child-cast
+            // timezones, so resolve its timezone too -- else it reaches 
`ResolutionValidator`
+            // unset (seen on the view re-resolution path).
+            DefaultCollationTypeCoercion(expressionAfterTypeCoercion, 
defaultCollation) match {
+              case cast: Cast =>
+                TimezoneAwareExpressionResolver
+                  .resolveTimezone(cast, 
expressionTreeTraversal.sessionLocalTimeZone)
+              case other => other
+            }
           case None =>
             expressionAfterTypeCoercion
         }
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/resolver/DefaultCollationTypeCoercion.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/resolver/DefaultCollationTypeCoercion.scala
index bda40f71ecd1..2bad19c7b9ef 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/resolver/DefaultCollationTypeCoercion.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/resolver/DefaultCollationTypeCoercion.scala
@@ -45,7 +45,15 @@ object DefaultCollationTypeCoercion {
   def apply(expression: Expression, collation: String): Expression = {
     val collatedStringType = StringType(collation)
     expression match {
-      case _: DefaultStringProducingExpression if collatedStringType != 
StringType =>
+      // Skip an expression carrying an explicitly collated string type (e.g. 
`JSON_ARRAY(...
+      // RETURNING STRING COLLATE UTF8_BINARY)`): a deliberate choice the 
object/view default must
+      // not overwrite, even for the default UTF8_BINARY. Only the explicit 
`COLLATE` form is a
+      // distinct instance; `DataTypeAstBuilder` returns the companion for a 
plain `STRING`, so a
+      // plain `RETURNING STRING` is indistinguishable from omission and 
recolored like it.
+      // `isExplicitlyCollatedStringType` tells the distinct instance from the 
companion by
+      // reference identity.
+      case e: DefaultStringProducingExpression
+          if collatedStringType != StringType && 
!isExplicitlyCollatedStringType(e.dataType) =>
         Cast(child = expression, dataType = collatedStringType)
       case literal: Literal if hasDefaultStringType(literal.dataType) =>
         literal.copy(dataType = replaceDefaultStringType(literal.dataType, 
collatedStringType))
@@ -86,6 +94,13 @@ object DefaultCollationTypeCoercion {
       case _ => false
     }
 
+  /**
+   * A [[StringType]] that was written explicitly -- a distinct instance, not 
the companion-object
+   * default -- even when its collation is the default UTF8_BINARY (see 
[[isDefaultStringType]]).
+   */
+  private def isExplicitlyCollatedStringType(dataType: DataType): Boolean =
+    dataType.isInstanceOf[StringType] && !isDefaultStringType(dataType)
+
   /**
    * When a default collation is specified for a View,
    * and the cast's dataType contains companion object [[StringType]],
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 c4eca772b178..8174a8911c27 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
@@ -177,14 +177,14 @@ case class StructsToJsonEvaluator(
   private lazy val gen = new JacksonGenerator(
     inputSchema, writer, new JSONOptions(options, timeZoneId.get))
 
-  // This converts rows to the JSON output according to the given schema.
+  // This converts rows to the JSON output (as a Java String) according to the 
given schema.
   @transient
-  private lazy val converter: Any => UTF8String = {
-    def getAndReset(): UTF8String = {
+  private lazy val converter: Any => String = {
+    def getAndReset(): String = {
       gen.flush()
       val json = writer.toString
       writer.reset()
-      UTF8String.fromString(json)
+      json
     }
 
     inputSchema match {
@@ -208,6 +208,12 @@ case class StructsToJsonEvaluator(
   }
 
   final def evaluate(value: Any): Any = {
+    UTF8String.fromString(converter(value))
+  }
+
+  // Serializes `value` to its JSON text as a Java String, letting callers 
that need a String skip
+  // the `UTF8String` round trip that `evaluate` performs.
+  final def evaluateString(value: Any): String = {
     converter(value)
   }
 }
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 a5f07d8eaa03..90867110c740 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
@@ -17,6 +17,8 @@
 
 package org.apache.spark.sql.catalyst.expressions
 
+import com.fasterxml.jackson.core.{JsonFactory, JsonProcessingException}
+
 import org.apache.spark.SparkException
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.analysis.TypeCheckResult
@@ -31,12 +33,13 @@ 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,
   RUNTIME_REPLACEABLE, TreePattern}
-import org.apache.spark.sql.catalyst.util.CaseInsensitiveMap
+import org.apache.spark.sql.catalyst.util.{CaseInsensitiveMap, 
GenericArrayData}
 import org.apache.spark.sql.errors.{QueryCompilationErrors, QueryErrorsBase, 
QueryExecutionErrors}
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.internal.types.StringTypeWithCollation
 import org.apache.spark.sql.types._
 import org.apache.spark.unsafe.types.UTF8String
+import org.apache.spark.util.Utils
 
 /**
  * Extracts json object from a json string based on json path specified, and 
returns json string
@@ -823,7 +826,12 @@ case class JsonValue(
   override def prettyName: String = "json_value"
 
   override def sql: String = {
-    val returningSQL = if (returning == StringType) "" else s" RETURNING 
${returning.sql}"
+    // Reference identity, not value equality: an explicitly collated 
`RETURNING STRING COLLATE
+    // UTF8_BINARY` is a distinct instance that compares `==` to the 
companion, so `==` would drop
+    // it. Only the companion (by reference) renders nothing -- reached by 
omission and by a plain
+    // `RETURNING STRING` (see `DataTypeAstBuilder`). Matches `JsonQuery.sql` 
/ `JsonArray.sql`,
+    // keeping a nested `JSON_VALUE` faithful when a JSON constructor renders 
it.
+    val returningSQL = if (returning.eq(StringType)) "" else s" RETURNING 
${returning.sql}"
     def behaviorSQL(b: JsonValueBehavior, default: Option[Expression]): String 
= b match {
       case JsonValueBehavior.Null => "NULL"
       case JsonValueBehavior.Error => "ERROR"
@@ -1060,10 +1068,17 @@ case class JsonQuery(
   extends UnaryExpression
   with CodegenFallback
   with ExpectsInputTypes
-  with QueryErrorsBase {
+  with QueryErrorsBase
+  with ImplicitlyFormattedAsJson {
 
   override def nullable: Boolean = true
 
+  // JSON_QUERY emits JSON text under the default KEEP QUOTES, so a lexically 
nested JSON_QUERY is
+  // an implicit `FORMAT JSON` argument that a JSON constructor splices 
verbatim. OMIT QUOTES
+  // instead returns a matched scalar string's decoded content (e.g. `Ada`, 
not `"Ada"`) -- an
+  // ordinary string -- so it must be quoted, not spliced (see 
[[ImplicitlyFormattedAsJson]]).
+  override def emitsImplicitJsonText: Boolean = quotes == JsonQueryQuotes.Keep
+
   // The JSON input must be a STRING; the result is JSON text.
   override def inputTypes: Seq[AbstractDataType] =
     Seq(StringTypeWithCollation(supportsTrimCollation = true))
@@ -1149,7 +1164,12 @@ case class JsonQuery(
   override def prettyName: String = "json_query"
 
   override def sql: String = {
-    val returningSQL = if (returning == StringType) "" else s" RETURNING 
${returning.sql}"
+    // Reference identity, not value equality, so an explicitly collated 
`RETURNING STRING COLLATE
+    // UTF8_BINARY` (a distinct instance that compares `==` to the companion) 
is still rendered.
+    // Only the companion (by reference) renders nothing -- reached by 
omission and by a plain
+    // `RETURNING STRING` (see `DataTypeAstBuilder`). Matches `JsonArray.sql`, 
keeping a nested
+    // `JSON_QUERY` faithful when a JSON constructor splices it.
+    val returningSQL = if (returning.eq(StringType)) "" else s" RETURNING 
${returning.sql}"
     val wrapperSQL = wrapper match {
       case JsonQueryWrapper.Without => ""
       case JsonQueryWrapper.Unconditional => " WITH UNCONDITIONAL ARRAY 
WRAPPER"
@@ -1202,7 +1222,437 @@ object JsonQuery {
 }
 
 /**
- * Converts an json input string to a [[StructType]], [[ArrayType]] or 
[[MapType]]
+ * Behavior of `JSON_ARRAY`'s `ON NULL` clause: what to do with NULL elements 
in the array.
+ */
+sealed trait JsonConstructorNullBehavior
+object JsonConstructorNullBehavior {
+  /** Include NULL elements as JSON `null` values. */
+  case object Null extends JsonConstructorNullBehavior
+  /** Omit NULL elements from the array. */
+  case object Absent extends JsonConstructorNullBehavior
+}
+
+/**
+ * Marker for expressions whose result is JSON text and therefore carry an 
implicit SQL/JSON
+ * `FORMAT JSON`: when such an expression appears as an argument of a JSON 
constructor (e.g.
+ * `JSON_ARRAY`), its value is spliced in verbatim rather than quoted as a 
JSON string:
+ *
+ * {{{
+ *   JSON_ARRAY(JSON_ARRAY(1))   -- '[[1]]'    (spliced; not the quoted string 
'["[1]"]')
+ * }}}
+ *
+ * Crucially, the constructor freezes this decision from the *lexical* 
argument at parse time (see
+ * `AstBuilder.visitJsonArray`) rather than re-deriving it from the child 
expression during
+ * evaluation, so a later optimizer rewrite (e.g. `CollapseProject` inlining a 
`JSON_ARRAY` alias
+ * into an argument position) cannot change whether a value is spliced or 
quoted. `JSON_OBJECT`
+ * should extend this as it is added.
+ *
+ * Most implementers always emit JSON text, so `emitsImplicitJsonText` 
defaults to true. An
+ * implementer with a mode that instead emits a plain (non-JSON) string 
overrides it so that mode
+ * takes the ordinary-string (quoted) path in a JSON constructor rather than 
being spliced:
+ * `JSON_QUERY(... OMIT QUOTES)` returns a matched scalar string's decoded 
content (e.g. `Ada`, not
+ * `"Ada"`), which is an ordinary string, so `JsonQuery` returns true only 
under the default
+ * `KEEP QUOTES`.
+ */
+trait ImplicitlyFormattedAsJson extends Expression {
+  /** Whether this specific instance actually emits JSON text (see the trait 
doc). */
+  def emitsImplicitJsonText: Boolean = true
+}
+
+// scalastyle:off line.size.limit
+/**
+ * The SQL:2016 `JSON_ARRAY` constructor function (feature T811) constructs a 
JSON array from
+ * a list of values, with optional `(NULL | ABSENT) ON NULL` control and 
`RETURNING` type clause.
+ *
+ * `NULL ON NULL` (non-default) keeps NULL elements as JSON `null` values.
+ * `ABSENT ON NULL` (default per the standard) omits NULL elements.
+ * RETURNING defaults to STRING.
+ *
+ * `formatJson(i)` marks element `i` as already-JSON text (SQL/JSON `FORMAT 
JSON`), so it is spliced
+ * in verbatim instead of quoted as a string. It is set once, at parse time, 
from the lexical
+ * argument -- implicitly for a nested JSON constructor and explicitly for a 
`... FORMAT JSON`
+ * clause -- and is a plain field (not a child), so optimizer rewrites that 
swap the child
+ * expression (e.g. `CollapseProject`) leave it unchanged. This keeps the 
output independent of plan
+ * shape: a value is spliced iff it was written as JSON in the source, never 
because a rewrite
+ * happened to substitute a JSON constructor into an argument position.
+ *
+ * {{{
+ *   JSON_ARRAY(1, 'x', true)                    -- '[1,"x",true]'
+ *   JSON_ARRAY(1, NULL, 3)                      -- '[1,3]'   (ABSENT ON NULL 
default)
+ *   JSON_ARRAY(1, NULL, 3 NULL ON NULL)         -- '[1,null,3]'
+ *   JSON_ARRAY()                                -- '[]'
+ *   JSON_ARRAY(JSON_ARRAY(1))                   -- '[[1]]'   (nested 
constructor: implicit FORMAT JSON)
+ *   JSON_ARRAY('[1,2]')                         -- '["[1,2]"]'   (plain 
string: quoted)
+ *   JSON_ARRAY('[1,2]' FORMAT JSON)             -- '[[1,2]]'   (explicit 
FORMAT JSON: spliced raw)
+ * }}}
+ */
+// scalastyle:on line.size.limit
+case class JsonArray(
+    values: Seq[Expression],
+    formatJson: Seq[Boolean],
+    needsValidation: Seq[Boolean],
+    nullBehavior: JsonConstructorNullBehavior,
+    returning: DataType,
+    timeZoneId: Option[String] = None)
+  extends Expression
+  with TimeZoneAwareExpression
+  with CodegenFallback
+  with ExpectsInputTypes
+  with QueryErrorsBase
+  with DefaultStringProducingExpression
+  with ImplicitlyFormattedAsJson {
+
+  // `formatJson(i)` freezes whether element `i` is spliced raw (vs quoted); 
`needsValidation(i)`
+  // freezes whether its raw text is arbitrary user input that must be 
JSON-validated at eval (an
+  // explicit `FORMAT JSON` on a non-constructor). Both are decided from the 
lexical argument at
+  // parse time (see `AstBuilder.visitJsonArray`) and never re-derived from 
the child expression,
+  // so an analyzer/optimizer rewrite that wraps a child (e.g. a 
default-collation `Cast` around a
+  // trusted nested constructor) cannot flip splicing or spuriously mark a 
trusted value as needing
+  // validation. A validated element implies a spliced one.
+  assert(values.length == formatJson.length && values.length == 
needsValidation.length,
+    "JsonArray requires one formatJson and one needsValidation flag per value")
+  assert(needsValidation.lazyZip(formatJson).forall((nv, fj) => !nv || fj),
+    "JsonArray needsValidation implies formatJson")
+
+  // True iff some element carries an *explicit* `FORMAT JSON` on a 
non-constructor: such a value is
+  // arbitrary user text validated at eval, so it can throw and must not be 
constant-folded. A
+  // nested (implicit) constructor produces well-formed JSON by construction 
and never throws here.
+  // Read straight off the frozen `needsValidation` flags -- never re-derived 
from the (possibly
+  // rewritten) child expressions -- so it drives both `throwable` and the 
`foldable` exclusion.
+  private lazy val hasExplicitFormatJson: Boolean = 
needsValidation.contains(true)
+
+  // Throwable only when the expression can actually throw at eval: when an 
element carries an
+  // explicit `FORMAT JSON` (validated, may throw on malformed text) or when a 
child is itself
+  // throwable. A plain `JSON_ARRAY(...)` with no explicit `FORMAT JSON` and 
no throwable children
+  // cannot throw (RETURNING is restricted to string types at analysis, so the 
result cast is
+  // STRING -> STRING), so leaving it non-throwable lets 
`PushPredicateThroughJoin` push safe
+  // filters like `JSON_ARRAY(k) = '[42]'` below a join.
+  override lazy val throwable: Boolean = children.exists(_.throwable) || 
hasExplicitFormatJson
+
+  // A non-throwing JSON array constructor always yields a value: NULL 
elements are dropped or
+  // rendered as JSON `null` (never propagated), the empty argument list 
yields `[]`, and the
+  // STRING -> STRING RETURNING cast cannot null a non-null input. For 
throwable shapes, report
+  // nullable conservatively so `NullPropagation` does not fold 
`JSON_ARRAY(...) IS [NOT] NULL` and
+  // accidentally skip evaluation-time validation or child exceptions.
+  override def nullable: Boolean = throwable
+
+  // The default RETURNING is a plain STRING, so mix in 
`DefaultStringProducingExpression` (above)
+  // to let `ApplyDefaultCollation` cast the result to a non-default 
object/session collation (e.g.
+  // `CREATE TABLE ... DEFAULT COLLATION UTF8_LCASE AS SELECT 
JSON_ARRAY(...)`). The `dataType`
+  // override below stays authoritative when RETURNING is given explicitly.
+
+  // A constant argument list has no per-row state, so let `ConstantFolding` 
evaluate the whole
+  // constructor once instead of serializing JSON row by row. But only fold 
shapes that cannot throw
+  // at eval: an explicit `FORMAT JSON` value is validated and may throw on 
malformed text, and
+  // `ConstantFolding` evaluates foldables outside conditional branches 
eagerly -- folding such a
+  // shape would surface the error at optimization even for rows a later 
filter/join would drop. A
+  // nested (implicit FORMAT JSON) value is produced by a constructor and is 
never malformed, so it
+  // stays foldable, and its rawness round-trips through `.sql` via an 
explicit `FORMAT JSON`.
+  override def foldable: Boolean = children.forall(_.foldable) && 
!hasExplicitFormatJson
+
+  override def children: Seq[Expression] = values
+
+  override def inputTypes: Seq[AbstractDataType] = values.map(_ => AnyDataType)
+
+  override def dataType: DataType = returning
+
+  override def withTimeZone(timeZoneId: String): TimeZoneAwareExpression =
+    copy(timeZoneId = Option(timeZoneId))
+
+  override def checkInputDataTypes(): TypeCheckResult = {
+    // A constructor emits JSON text, so RETURNING is restricted to string 
types (VARIANT is a
+    // deferred extension). CHAR/VARCHAR are normalized to STRING by the 
parser.
+    if (!JsonArray.isValidReturningType(returning)) {
+      DataTypeMismatch(
+        errorSubClass = "INVALID_JSON_RETURNING_TYPE",
+        messageParameters = Map(
+          "functionName" -> toSQLId(prettyName), "returningType" -> 
toSQLType(returning)))
+    } else {
+      // Validate each element up front rather than failing at runtime: a 
FORMAT JSON element must
+      // be a string carrying JSON text, and every other element must be 
serializable to JSON. The
+      // latter mirrors `to_json`'s analysis-time `JacksonUtils.verifyType` 
check.
+      var result: TypeCheckResult = TypeCheckResult.TypeCheckSuccess
+      var i = 0
+      while (i < values.length && result == TypeCheckResult.TypeCheckSuccess) {
+        val dt = values(i).dataType
+        if (formatJson(i) && !(dt.isInstanceOf[StringType] || dt == NullType)) 
{
+          // A FORMAT JSON element must carry JSON text (string), but an 
untyped NULL literal is
+          // allowed: `eval` handles nulls (ABSENT/NULL ON NULL) before it 
would ever splice, so
+          // `JSON_ARRAY(NULL FORMAT JSON)` behaves like any other NULL 
element.
+          result = DataTypeMismatch(
+            errorSubClass = "INVALID_JSON_FORMAT_JSON_INPUT",
+            messageParameters = Map(
+              "functionName" -> toSQLId(prettyName),
+              "position" -> (i + 1).toString,
+              "inputType" -> toSQLType(dt)))
+        } else {
+          val elemCheck = JacksonUtils.verifyType(prettyName, dt)
+          // `verifyType` accepts every `AtomicType`, but `JacksonGenerator` 
(the writer this shares
+          // with `to_json`) has no serializer for the spatial atomics and 
would fail at runtime.
+          // Reject them here so a passing analysis implies a serializable 
element. The scan mirrors
+          // `verifyType`'s traversal (struct fields, array elements, map 
*values* -- map keys are
+          // written via `toString`, so a spatial key is fine).
+          if (elemCheck.isFailure) {
+            result = elemCheck
+          } else if (JsonArray.containsUnsupportedJsonType(dt)) {
+            result = DataTypeMismatch(
+              errorSubClass = "CANNOT_CONVERT_TO_JSON",
+              messageParameters = Map(
+                "name" -> toSQLId(prettyName),
+                "type" -> toSQLType(dt)))
+          }
+        }
+        i += 1
+      }
+      result
+    }
+  }
+
+  // Reuses the mutable `castInput` row and per-element JSON writers, so it 
holds evaluation state
+  // and must be fresh-copied before interpreted execution (matches the 
neighboring JSON
+  // expressions).
+  override def stateful: Boolean = true
+
+  // A JSON array is heterogeneous, so each element is serialized with its own 
data type rather
+  // than a single shared element type. We build one serializer per child, 
each configured as a
+  // single-element `ArrayType(child.dataType)`; serializing `[value]` yields 
the text `[<frag>]`,
+  // whose outer brackets we strip to recover the element fragment `<frag>`. 
This reuses the same
+  // Jackson generation path as `to_json`, so numbers, decimals, datetimes, 
and nested structures
+  // are rendered correctly.
+  @transient private lazy val resolvedZoneId: String =
+    timeZoneId.getOrElse(SQLConf.get.sessionLocalTimeZone)
+
+  @transient private lazy val elementEvaluators: Array[StructsToJsonEvaluator] 
=
+    new Array[StructsToJsonEvaluator](values.length)
+
+  @transient private lazy val cachedValidatedFormatJsonTexts: Array[String] =
+    new Array[String](values.length)
+
+  @transient private lazy val singleElem: Array[Any] = new Array[Any](1)
+
+  // Wraps the reused `singleElem` array by reference (GenericArrayData does 
not copy), so a single
+  // instance is shared across elements and rows: `appendRenderedElement` 
mutates `singleElem` in
+  // place and the serializer reads it synchronously.
+  @transient private lazy val singleElemArray: GenericArrayData = new 
GenericArrayData(singleElem)
+
+  @transient private lazy val castInput: GenericInternalRow = new 
GenericInternalRow(1)
+
+  @transient private lazy val returningCast: Expression =
+    Cast(BoundReference(0, StringType, nullable = true), returning, 
timeZoneId, EvalMode.ANSI)
+
+  @transient private lazy val formatJsonFactory: JsonFactory = new 
JsonFactory()
+
+  // A FORMAT JSON element is spliced into the result verbatim, so it must 
itself be exactly one
+  // well-formed JSON value. `checkInputDataTypes` only guarantees the 
argument is string-typed; a
+  // string carrying `1,2` or `{bad` would otherwise corrupt the surrounding 
array into invalid or
+  // unintended JSON (e.g. `JSON_ARRAY('1,2' FORMAT JSON)` -> `[1,2]`). 
Validate the runtime value
+  // before appending: parse one value and require that nothing follows it.
+  private def validateJsonText(idx: Int, text: String): Unit = {
+    val valid =
+      try {
+        Utils.tryWithResource(formatJsonFactory.createParser(text)) { parser =>
+          if (parser.nextToken() == null) {
+            false // empty / whitespace-only input carries no JSON value
+          } else {
+            // For a scalar this is a no-op; for an array/object it advances 
to the matching close.
+            parser.skipChildren()
+            parser.nextToken() == null // reject anything trailing the first 
value
+          }
+        }
+      } catch {
+        case _: JsonProcessingException => false
+      }
+    if (!valid) {
+      throw QueryExecutionErrors.invalidJsonFormatJsonValueError(prettyName, 
idx + 1, text)
+    }
+  }
+
+  // Render a single non-null element to its JSON fragment via its own-typed 
serializer, appending
+  // it straight into the result builder.
+  // TODO(SPARK-58730): this serializes each element as a one-element array 
and strips the brackets,
+  // so a row with N values does N Jackson flushes plus an intermediate string 
allocation each. A
+  // JSON_ARRAY-specific evaluator that opens the top-level array once and 
writes each element into
+  // the shared generator (and codegen for the whole path) would avoid this 
per-element trip.
+  private def appendRenderedElement(sb: java.lang.StringBuilder, idx: Int, 
value: Any): Unit = {
+    singleElem(0) = value
+    var evaluator = elementEvaluators(idx)
+    if (evaluator == null) {
+      evaluator = StructsToJsonEvaluator(
+        Map.empty, ArrayType(values(idx).dataType), Some(resolvedZoneId))
+      elementEvaluators(idx) = evaluator
+    }
+    // Take the Java String directly (via `evaluateString`) rather than 
`evaluate`'s `UTF8String`,
+    // which we would immediately decode back to a String here.
+    val arrJson = evaluator.evaluateString(singleElemArray)
+    // `arrJson` is "[<frag>]" (compact, no spaces); append just the interior 
fragment so the
+    // serialized text is copied once, without materializing a per-element 
substring.
+    sb.append(arrJson, 1, arrJson.length - 1)
+  }
+
+  private def formatJsonText(idx: Int, value: Any): String = {
+    if (!needsValidation(idx)) {
+      // Nested JSON constructor: never validated, splice as-is.
+      value.asInstanceOf[UTF8String].toString
+    } else if (values(idx).foldable) {
+      // Foldable FORMAT JSON: validate once, cache. `JsonArray` is 
non-foldable when any element
+      // is FORMAT JSON, so this runs per row; check the cache before decoding 
the constant value.
+      val cached = cachedValidatedFormatJsonTexts(idx)
+      if (cached != null) {
+        cached
+      } else {
+        val text = value.asInstanceOf[UTF8String].toString
+        validateJsonText(idx, text)
+        cachedValidatedFormatJsonTexts(idx) = text
+        text
+      }
+    } else {
+      // Non-foldable FORMAT JSON: value varies per row, validate every row.
+      val text = value.asInstanceOf[UTF8String].toString
+      validateJsonText(idx, text)
+      text
+    }
+  }
+
+  override def eval(input: InternalRow): Any = {
+    val sb = new java.lang.StringBuilder("[")
+    var first = true
+    var i = 0
+    while (i < values.length) {
+      val v = values(i).eval(input)
+      // ABSENT ON NULL drops NULL elements; NULL ON NULL keeps them as JSON 
`null`.
+      if (v != null || nullBehavior == JsonConstructorNullBehavior.Null) {
+        if (!first) sb.append(",")
+        first = false
+        if (v == null) {
+          sb.append("null")
+        } else if (formatJson(i)) {
+          // Already-JSON text (a nested JSON constructor or an explicit 
FORMAT JSON): splice it in
+          // verbatim. `checkInputDataTypes` guarantees a FORMAT JSON element 
is string-typed. A
+          // nested JSON constructor emits well-formed JSON by construction, 
but an explicit FORMAT
+          // JSON string is arbitrary user input, so validate it is exactly 
one well-formed JSON
+          // value before splicing to avoid corrupting the surrounding array. 
Whether validation is
+          // needed is the frozen parse-time `needsValidation(i)`, not a 
re-derivation from the
+          // (possibly rewritten) child -- an analyzer cast around a trusted 
nested constructor must
+          // not turn into a spurious per-row validation.
+          sb.append(formatJsonText(i, v))
+        } else {
+          appendRenderedElement(sb, i, v)
+        }
+      }
+      i += 1
+    }
+    sb.append("]")
+    val jsonStr = UTF8String.fromString(sb.toString)
+    if (returning == StringType) {
+      jsonStr
+    } else {
+      castInput.update(0, jsonStr)
+      returningCast.eval(castInput)
+    }
+  }
+
+  override def prettyName: String = "json_array"
+
+  override def sql: String = {
+    val valuesSQL = values.zip(formatJson).map { case (v, isJson) =>
+      // Emit SQL that reparses to the same splice/quote decision as the 
frozen `formatJson` flag.
+      // The parser splices a value iff it is an explicit `FORMAT JSON` or a 
lexically-nested JSON
+      // constructor (see `AstBuilder.visitJsonArray`), so the rendering 
depends on the flag and the
+      // (possibly analyzer/optimizer-rewritten) child:
+      //  - spliced + child is not a *bare* JSON constructor: render an 
explicit `FORMAT JSON` so
+      //    reparse splices it. This covers a plain FORMAT JSON string, an 
inlined `Cast`, and a
+      //    `Collate`-wrapped nested constructor alike -- crucially without 
depending on reparse
+      //    re-deriving implicit JSON through the wrapper's rendering (e.g. 
`Collate.sql` renders
+      //    function-style `collate(child, c)`, which reparse would not 
recognize as implicit).
+      //  - quoted + child would reparse as implicit JSON (a bare or 
`Collate`-wrapped constructor
+      //    an optimizer inlined into a quoted position): render through a 
neutral
+      //    `CAST(... AS STRING)` so reparse keeps it quoted (splicing would 
flip ["[1]"] to [[1]]).
+      //  - otherwise the child's shape already reproduces the flag, so render 
it as-is.
+      // Note: the splice test uses the *direct* child (only a bare implicit 
value round-trips
+      // implicitly); the quote test sees through a value-preserving `Collate` 
(but not a `Cast`,
+      // since the neutralization above relies on a cast reparsing as 
not-implicit).
+      val directlyImplicit = v match {
+        case i: ImplicitlyFormattedAsJson => i.emitsImplicitJsonText
+        case _ => false
+      }
+      val transitivelyImplicit = JsonArray.isImplicitlyJson(v)
+      if (isJson && !directlyImplicit) {
+        s"${v.sql} FORMAT JSON"
+      } else if (!isJson && transitivelyImplicit) {
+        s"CAST(${v.sql} AS STRING)"
+      } else {
+        v.sql
+      }
+    }.mkString(", ")
+    // Reference identity, not value equality: an explicitly collated 
`RETURNING STRING COLLATE
+    // UTF8_BINARY` is a distinct instance that compares `==` to the 
companion, so `==` would drop
+    // it. Only the companion (by reference) renders nothing -- reached by 
omission and by a plain
+    // `RETURNING STRING`, which `DataTypeAstBuilder` maps to the companion. 
(A non-default
+    // collation is not `==` to the companion anyway, since StringType.equals 
compares constraint.)
+    val returningSQL = if (returning.eq(StringType)) "" else s" RETURNING 
${returning.sql}"
+    val nullSQL = nullBehavior match {
+      case JsonConstructorNullBehavior.Null => " NULL ON NULL"
+      case JsonConstructorNullBehavior.Absent => ""
+    }
+    s"JSON_ARRAY($valuesSQL$nullSQL$returningSQL)"
+  }
+
+  override protected def withNewChildrenInternal(
+      newChildren: IndexedSeq[Expression]): JsonArray =
+    copy(values = newChildren)
+}
+
+object JsonArray {
+  /**
+   * Whether `e` lexically emits JSON text -- i.e. carries an implicit 
SQL/JSON `FORMAT JSON` and
+   * should be spliced raw rather than quoted (a nested JSON constructor, or 
`JSON_QUERY` under
+   * `KEEP QUOTES`; see [[ImplicitlyFormattedAsJson]]). Sees through a 
value-preserving `Collate`
+   * (a postfix `... COLLATE c` only annotates the collation of the JSON text, 
e.g.
+   * `JSON_ARRAY(JSON_ARRAY(1) COLLATE UTF8_LCASE)`), but deliberately NOT 
through a `Cast`:
+   * `AstBuilder`/`JsonArray.sql` use a `CAST(... AS STRING)` wrapper 
specifically to detach a value
+   * from implicit `FORMAT JSON`, so unwrapping it would defeat that 
neutralization.
+   */
+  @scala.annotation.tailrec
+  def isImplicitlyJson(e: Expression): Boolean = e match {
+    case c: Collate => isImplicitlyJson(c.child)
+    case i: ImplicitlyFormattedAsJson => i.emitsImplicitJsonText
+    case _ => false
+  }
+
+  /**
+   * `JSON_ARRAY` returns JSON text, so RETURNING is restricted to a plain 
STRING (VARIANT is
+   * deferred). `CharType` / `VarcharType` extend `StringType` but carry a 
length that `JSON_ARRAY`
+   * does not enforce -- it serializes the fragment itself without a 
length-checking 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
+  }
+
+  /**
+   * Whether `dt` contains a spatial atomic (`GEOMETRY` / `GEOGRAPHY`) in a 
position that
+   * `JacksonGenerator` would have to serialize. These are `AtomicType`s that
+   * `JacksonUtils.verifyType` accepts but the JSON writer has no serializer 
for, so they must be
+   * rejected at analysis. The traversal mirrors `verifyType`: it descends 
into struct fields, array
+   * elements, and map *values* (map keys are written via `toString`, so a 
spatial key is fine) and
+   * unwraps UDTs.
+   */
+  def containsUnsupportedJsonType(dt: DataType): Boolean = dt match {
+    case _: GeometryType | _: GeographyType => true
+    case st: StructType => st.exists(f => 
containsUnsupportedJsonType(f.dataType))
+    case at: ArrayType => containsUnsupportedJsonType(at.elementType)
+    case mt: MapType => containsUnsupportedJsonType(mt.valueType)
+    case udt: UserDefinedType[_] => containsUnsupportedJsonType(udt.sqlType)
+    case _ => false
+  }
+}
+
+/**
+ * Converts a JSON input string to a [[StructType]], [[ArrayType]], 
[[MapType]] or [[VariantType]]
  * with the specified schema.
  */
 // scalastyle:off line.size.limit
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 5b9c69a16c30..73e9f2884b24 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
@@ -4233,6 +4233,60 @@ class AstBuilder extends DataTypeAstBuilder
     JsonQuery(jsonExpr, path, returning, wrapper, quotes, onEmpty, onError)
   }
 
+  /**
+   * Resolve a `jsonConstructorNullBehavior` clause (`NULL` / `ABSENT`) into a
+   * [[JsonConstructorNullBehavior]].
+   */
+  private def buildJsonConstructorNullBehavior(
+      ctx: JsonConstructorNullBehaviorContext): JsonConstructorNullBehavior =
+    ctx match {
+      case _: JsonConstructorNullBehaviorNullContext =>
+        JsonConstructorNullBehavior.Null
+      case _: JsonConstructorNullBehaviorAbsentContext =>
+        JsonConstructorNullBehavior.Absent
+    }
+
+  /**
+   * Create a [[JsonArray]] expression for the SQL:2016 `JSON_ARRAY` 
constructor function.
+   * The `ON NULL` clause defaults to `ABSENT ON NULL` (drops NULL elements), 
and RETURNING
+   * defaults to STRING.
+   */
+  override def visitJsonArray(ctx: JsonArrayContext): Expression = 
withOrigin(ctx) {
+    val arrayValues = ctx.values.asScala.map(v => expression(v.value)).toSeq
+    // Freeze the FORMAT JSON decisions here, from the lexical argument, so a 
later
+    // analyzer/optimizer rewrite that wraps or swaps the child cannot change 
them (see
+    // [[ImplicitlyFormattedAsJson]]). For each element:
+    //  - `formatJson`: whether it is already-JSON text spliced raw. True when 
it carries an
+    //    explicit `FORMAT JSON` clause, or is a (lexically) nested JSON 
constructor -- seen through
+    //    a value-preserving `COLLATE` via `JsonArray.isImplicitlyJson`.
+    //  - `needsValidation`: whether its raw text is arbitrary user input to 
JSON-validate at eval.
+    //    True only for an explicit `FORMAT JSON` on something that is NOT a 
JSON constructor; a
+    //    nested constructor emits well-formed JSON by construction and is 
trusted.
+    val formatArgs = ctx.values.asScala.zip(arrayValues).map { case (v, expr) 
=>
+      val explicit = v.FORMAT() != null
+      val implicitlyJson = JsonArray.isImplicitlyJson(expr)
+      (explicit || implicitlyJson, explicit && !implicitlyJson)
+    }.toSeq
+    val formatJson = formatArgs.map(_._1)
+    val needsValidation = formatArgs.map(_._2)
+    // Default RETURNING type is STRING; the result is JSON text. A 
CHAR/VARCHAR RETURNING is
+    // normalized to STRING unconditionally: JSON_ARRAY serializes the 
fragment itself and never
+    // advertises a CHAR/VARCHAR length it does not 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)
+    // Default ON NULL behavior is ABSENT ON NULL (drop NULL elements).
+    val nullBehavior = Option(ctx.nullBehavior)
+      .map(buildJsonConstructorNullBehavior)
+      .getOrElse(JsonConstructorNullBehavior.Absent)
+    JsonArray(arrayValues, formatJson, needsValidation, nullBehavior, 
returning)
+  }
+
   /**
    * Create a (windowed) Function expression.
    */
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/ParserUtils.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/ParserUtils.scala
index 174855cd69d0..3ae7f09e1e83 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/ParserUtils.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/ParserUtils.scala
@@ -124,7 +124,7 @@ object ParserUtils extends SparkParserUtils {
           case term: TerminalNodeImpl =>
             val termText = term.getText
             val tt = term.getSymbol.getType
-            val current = if ((SqlBaseParser.ADD <= tt && tt <= 
SqlBaseParser.ZONE) ||
+            val current = if ((SqlBaseParser.ABSENT <= tt && tt <= 
SqlBaseParser.ZONE) ||
               (SqlBaseParser.BIGINT_LITERAL <= tt && tt <= 
SqlBaseParser.BIGDECIMAL_LITERAL)) {
               termText.toUpperCase(Locale.ROOT)
             } else {
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 6816bb681f32..bf7a0d59e585 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
@@ -1396,6 +1396,25 @@ private[sql] object QueryExecutionErrors extends 
QueryErrorsBase with ExecutionE
       messageParameters = Map.empty)
   }
 
+  def invalidJsonFormatJsonValueError(
+      functionName: String, position: Int, value: String): 
SparkRuntimeException = {
+    // Bound the echoed value: a FORMAT JSON argument can be a large payload 
column, and inlining it
+    // whole would bloat task-failure messages, logs, and UI/event metadata. 
Show a capped preview
+    // plus the full length instead.
+    val maxPreviewLen = 100
+    val preview = if (value.length > maxPreviewLen) {
+      s"${value.take(maxPreviewLen)}... (${value.length} characters)"
+    } else {
+      value
+    }
+    new SparkRuntimeException(
+      errorClass = "INVALID_JSON_FORMAT_JSON_VALUE",
+      messageParameters = Map(
+        "functionName" -> toSQLId(functionName),
+        "position" -> position.toString,
+        "value" -> toSQLValue(preview, StringType)))
+  }
+
   def paramExceedOneCharError(paramName: String, actualValue: String): 
SparkRuntimeException = {
     new SparkRuntimeException(
       errorClass = "OPTION_VALUE_EXCEEDS_ONE_CHARACTER",
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 f9276f3cd715..ebc224ebbcde 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,CONDITIONAL,CONTAINS,CONTINUE,COST,CURRENT_DATABASE,CURRENT_SCHEMA,DATA,DATABASE,DATABASES,DATE
 [...]
+      assert(metadata.getSQLKeywords === 
"ABSENT,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,DATABAS
 [...]
       // 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 688014da6f2c..d21974b86640 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
@@ -1725,3 +1725,116 @@ org.apache.spark.sql.catalyst.ExtendedAnalysisException
     "fragment" : "json_query('{\"name\":\"Ada\"}', '$.name' WITH ARRAY WRAPPER 
OMIT QUOTES)"
   } ]
 }
+
+
+-- !query
+select json_array(1, 'x', true)
+-- !query analysis
+Project [json_array(1, x, true, false, false, false, false, false, false, 
Absent, StringType, Some(America/Los_Angeles)) AS JSON_ARRAY(1, x, true)#x]
++- OneRowRelation
+
+
+-- !query
+select json_array(1, NULL, 3)
+-- !query analysis
+Project [json_array(1, null, 3, false, false, false, false, false, false, 
Absent, StringType, Some(America/Los_Angeles)) AS JSON_ARRAY(1, NULL, 3)#x]
++- OneRowRelation
+
+
+-- !query
+select json_array(1, NULL, 3 NULL ON NULL)
+-- !query analysis
+Project [json_array(1, null, 3, false, false, false, false, false, false, 
Null, StringType, Some(America/Los_Angeles)) AS JSON_ARRAY(1, NULL, 3 NULL ON 
NULL)#x]
++- OneRowRelation
+
+
+-- !query
+select json_array()
+-- !query analysis
+Project [json_array(Absent, StringType, Some(America/Los_Angeles)) AS 
JSON_ARRAY()#x]
++- OneRowRelation
+
+
+-- !query
+select json_array(CAST(1.50 AS DECIMAL(5,2)), DATE'2020-01-02')
+-- !query analysis
+[Analyzer test output redacted due to nondeterminism]
+
+
+-- !query
+select json_array(json_array(1, 2), 3)
+-- !query analysis
+Project [json_array(json_array(1, 2, false, false, false, false, Absent, 
StringType, Some(America/Los_Angeles)), 3, true, false, false, false, Absent, 
StringType, Some(America/Los_Angeles)) AS JSON_ARRAY(JSON_ARRAY(1, 2), 3)#x]
++- OneRowRelation
+
+
+-- !query
+select json_array('[1,2]')
+-- !query analysis
+Project [json_array([1,2], false, false, Absent, StringType, 
Some(America/Los_Angeles)) AS JSON_ARRAY([1,2])#x]
++- OneRowRelation
+
+
+-- !query
+select json_array('[1,2]' FORMAT JSON)
+-- !query analysis
+Project [json_array([1,2], true, true, Absent, StringType, 
Some(America/Los_Angeles)) AS JSON_ARRAY([1,2] FORMAT JSON)#x]
++- OneRowRelation
+
+
+-- !query
+select json_array('{"a":1}' FORMAT JSON, 'x')
+-- !query analysis
+Project [json_array({"a":1}, x, true, false, true, false, Absent, StringType, 
Some(America/Los_Angeles)) AS JSON_ARRAY({"a":1} FORMAT JSON, x)#x]
++- OneRowRelation
+
+
+-- !query
+select json_array(123 FORMAT JSON)
+-- !query analysis
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+  "errorClass" : "DATATYPE_MISMATCH.INVALID_JSON_FORMAT_JSON_INPUT",
+  "sqlState" : "42K09",
+  "messageParameters" : {
+    "functionName" : "`json_array`",
+    "inputType" : "\"INT\"",
+    "position" : "1",
+    "sqlExpr" : "\"JSON_ARRAY(123 FORMAT JSON)\""
+  },
+  "queryContext" : [ {
+    "objectType" : "",
+    "objectName" : "",
+    "startIndex" : 8,
+    "stopIndex" : 34,
+    "fragment" : "json_array(123 FORMAT JSON)"
+  } ]
+}
+
+
+-- !query
+select json_array('1,2' FORMAT JSON)
+-- !query analysis
+Project [json_array(1,2, true, true, Absent, StringType, 
Some(America/Los_Angeles)) AS JSON_ARRAY(1,2 FORMAT JSON)#x]
++- OneRowRelation
+
+
+-- !query
+select json_array(NULL FORMAT JSON)
+-- !query analysis
+Project [json_array(null, true, true, Absent, StringType, 
Some(America/Los_Angeles)) AS JSON_ARRAY(NULL FORMAT JSON)#x]
++- OneRowRelation
+
+
+-- !query
+select json_array(NULL FORMAT JSON NULL ON NULL)
+-- !query analysis
+Project [json_array(null, true, true, Null, StringType, 
Some(America/Los_Angeles)) AS JSON_ARRAY(NULL FORMAT JSON NULL ON NULL)#x]
++- OneRowRelation
+
+
+-- !query
+select json_array(1 IS NULL, 2 > 1)
+-- !query analysis
+Project [json_array(isnull(1), (2 > 1), false, false, false, false, Absent, 
StringType, Some(America/Los_Angeles)) AS JSON_ARRAY((1 IS NULL), (2 > 1))#x]
++- OneRowRelation
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 43a0a5b62db9..f362aed3223f 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
@@ -285,3 +285,29 @@ select json_query('{"a":[1,2]}', '$.a[*]');
 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);
+
+-- JSON_ARRAY: construct a JSON array from an argument list
+select json_array(1, 'x', true);
+-- default ABSENT ON NULL drops NULL elements
+select json_array(1, NULL, 3);
+-- NULL ON NULL keeps NULL elements
+select json_array(1, NULL, 3 NULL ON NULL);
+-- empty array
+select json_array();
+-- decimals and dates render via the JSON generator
+select json_array(CAST(1.50 AS DECIMAL(5,2)), DATE'2020-01-02');
+-- nested JSON_ARRAY is spliced raw, not re-quoted (implicit FORMAT JSON)
+select json_array(json_array(1, 2), 3);
+-- a plain string is quoted; FORMAT JSON splices already-JSON text verbatim
+select json_array('[1,2]');
+select json_array('[1,2]' FORMAT JSON);
+select json_array('{"a":1}' FORMAT JSON, 'x');
+-- FORMAT JSON requires a string argument
+select json_array(123 FORMAT JSON);
+-- FORMAT JSON requires the string to be exactly one well-formed JSON value
+select json_array('1,2' FORMAT JSON);
+-- an untyped NULL under FORMAT JSON is allowed and follows ON NULL handling
+select json_array(NULL FORMAT JSON);
+select json_array(NULL FORMAT JSON NULL ON NULL);
+-- values accept unparenthesized predicate expressions
+select json_array(1 IS NULL, 2 > 1);
diff --git 
a/sql/core/src/test/resources/sql-tests/results/json-functions.sql.out 
b/sql/core/src/test/resources/sql-tests/results/json-functions.sql.out
index 71b94295d37d..0bf1cfc9241c 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
@@ -1985,3 +1985,141 @@ org.apache.spark.sql.catalyst.ExtendedAnalysisException
     "fragment" : "json_query('{\"name\":\"Ada\"}', '$.name' WITH ARRAY WRAPPER 
OMIT QUOTES)"
   } ]
 }
+
+
+-- !query
+select json_array(1, 'x', true)
+-- !query schema
+struct<JSON_ARRAY(1, x, true):string>
+-- !query output
+[1,"x",true]
+
+
+-- !query
+select json_array(1, NULL, 3)
+-- !query schema
+struct<JSON_ARRAY(1, NULL, 3):string>
+-- !query output
+[1,3]
+
+
+-- !query
+select json_array(1, NULL, 3 NULL ON NULL)
+-- !query schema
+struct<JSON_ARRAY(1, NULL, 3 NULL ON NULL):string>
+-- !query output
+[1,null,3]
+
+
+-- !query
+select json_array()
+-- !query schema
+struct<JSON_ARRAY():string>
+-- !query output
+[]
+
+
+-- !query
+select json_array(CAST(1.50 AS DECIMAL(5,2)), DATE'2020-01-02')
+-- !query schema
+struct<JSON_ARRAY(CAST(1.50 AS DECIMAL(5,2)), DATE '2020-01-02'):string>
+-- !query output
+[1.50,"2020-01-02"]
+
+
+-- !query
+select json_array(json_array(1, 2), 3)
+-- !query schema
+struct<JSON_ARRAY(JSON_ARRAY(1, 2), 3):string>
+-- !query output
+[[1,2],3]
+
+
+-- !query
+select json_array('[1,2]')
+-- !query schema
+struct<JSON_ARRAY([1,2]):string>
+-- !query output
+["[1,2]"]
+
+
+-- !query
+select json_array('[1,2]' FORMAT JSON)
+-- !query schema
+struct<JSON_ARRAY([1,2] FORMAT JSON):string>
+-- !query output
+[[1,2]]
+
+
+-- !query
+select json_array('{"a":1}' FORMAT JSON, 'x')
+-- !query schema
+struct<JSON_ARRAY({"a":1} FORMAT JSON, x):string>
+-- !query output
+[{"a":1},"x"]
+
+
+-- !query
+select json_array(123 FORMAT JSON)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+  "errorClass" : "DATATYPE_MISMATCH.INVALID_JSON_FORMAT_JSON_INPUT",
+  "sqlState" : "42K09",
+  "messageParameters" : {
+    "functionName" : "`json_array`",
+    "inputType" : "\"INT\"",
+    "position" : "1",
+    "sqlExpr" : "\"JSON_ARRAY(123 FORMAT JSON)\""
+  },
+  "queryContext" : [ {
+    "objectType" : "",
+    "objectName" : "",
+    "startIndex" : 8,
+    "stopIndex" : 34,
+    "fragment" : "json_array(123 FORMAT JSON)"
+  } ]
+}
+
+
+-- !query
+select json_array('1,2' FORMAT JSON)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.SparkRuntimeException
+{
+  "errorClass" : "INVALID_JSON_FORMAT_JSON_VALUE",
+  "sqlState" : "22032",
+  "messageParameters" : {
+    "functionName" : "`json_array`",
+    "position" : "1",
+    "value" : "'1,2'"
+  }
+}
+
+
+-- !query
+select json_array(NULL FORMAT JSON)
+-- !query schema
+struct<JSON_ARRAY(NULL FORMAT JSON):string>
+-- !query output
+[]
+
+
+-- !query
+select json_array(NULL FORMAT JSON NULL ON NULL)
+-- !query schema
+struct<JSON_ARRAY(NULL FORMAT JSON NULL ON NULL):string>
+-- !query output
+[null]
+
+
+-- !query
+select json_array(1 IS NULL, 2 > 1)
+-- !query schema
+struct<JSON_ARRAY((1 IS NULL), (2 > 1)):string>
+-- !query output
+[false,true]
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 84dbbaf041d2..232f89324b96 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
@@ -4,6 +4,7 @@ SELECT * from SQL_KEYWORDS()
 -- !query schema
 struct<keyword:string,reserved:boolean>
 -- !query output
+ABSENT false
 ADD    false
 AFTER  false
 AGGREGATE      false
@@ -213,6 +214,7 @@ ITEMS       false
 ITERATE        false
 JOIN   true
 JSON   false
+JSON_ARRAY     false
 JSON_EXISTS    false
 JSON_QUERY     false
 JSON_TABLE     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 4b57e254ef96..026c091eb4ce 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
@@ -4,6 +4,7 @@ SELECT * from SQL_KEYWORDS()
 -- !query schema
 struct<keyword:string,reserved:boolean>
 -- !query output
+ABSENT false
 ADD    false
 AFTER  false
 AGGREGATE      false
@@ -213,6 +214,7 @@ ITEMS       false
 ITERATE        false
 JOIN   false
 JSON   false
+JSON_ARRAY     false
 JSON_EXISTS    false
 JSON_QUERY     false
 JSON_TABLE     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 4b57e254ef96..026c091eb4ce 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
@@ -4,6 +4,7 @@ SELECT * from SQL_KEYWORDS()
 -- !query schema
 struct<keyword:string,reserved:boolean>
 -- !query output
+ABSENT false
 ADD    false
 AFTER  false
 AGGREGATE      false
@@ -213,6 +214,7 @@ ITEMS       false
 ITERATE        false
 JOIN   false
 JSON   false
+JSON_ARRAY     false
 JSON_EXISTS    false
 JSON_QUERY     false
 JSON_TABLE     false
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/JsonArraySuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/JsonArraySuite.scala
new file mode 100644
index 000000000000..c25322f114fe
--- /dev/null
+++ b/sql/core/src/test/scala/org/apache/spark/sql/JsonArraySuite.scala
@@ -0,0 +1,648 @@
+/*
+ * 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.{Cast, Collate, JsonArray, 
JsonConstructorNullBehavior, JsonQuery, JsonQueryBehavior, JsonQueryQuotes, 
JsonQueryWrapper, Literal, ResolvedCollation}
+import org.apache.spark.sql.catalyst.plans.logical.Project
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{CharType, GeographyType, GeometryType, 
IntegerType, MapType, StringType, VarcharType}
+
+/**
+ * Test suite for the `JSON_ARRAY` ANSI SQL:2016 constructor function.
+ */
+class JsonArraySuite extends QueryTest with SharedSparkSession {
+
+  import testImplicits._
+
+  test("JSON_ARRAY with simple scalar values") {
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(1, 'x', true)"),
+      Row("""[1,"x",true]"""))
+  }
+
+  test("JSON_ARRAY with NULL elements - ABSENT ON NULL (default)") {
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(1, NULL, 3)"),
+      Row("[1,3]"))
+  }
+
+  test("JSON_ARRAY with NULL elements - NULL ON NULL") {
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(1, NULL, 3 NULL ON NULL)"),
+      Row("[1,null,3]"))
+  }
+
+  test("JSON_ARRAY with NULL elements - explicit ABSENT ON NULL") {
+    // Exercise the explicit `ABSENT ON NULL` grammar branch (the default is 
implicit absent, so
+    // this spelling is otherwise untested); it drops NULL elements just like 
the default.
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(1, NULL, 3 ABSENT ON NULL)"),
+      Row("[1,3]"))
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(1, NULL, 3 ABSENT ON NULL RETURNING STRING)"),
+      Row("[1,3]"))
+  }
+
+  test("JSON_ARRAY with empty list") {
+    checkAnswer(
+      sql("SELECT JSON_ARRAY()"),
+      Row("[]"))
+  }
+
+  test("JSON_ARRAY with floating point numbers") {
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(1.5, 2.7)"),
+      Row("[1.5,2.7]"))
+  }
+
+  test("JSON_ARRAY with mixed types") {
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(1, 'text', 3.14, true, false)"),
+      Row("""[1,"text",3.14,true,false]"""))
+  }
+
+  test("JSON_ARRAY with all NULLs and ABSENT ON NULL") {
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(NULL, NULL)"),
+      Row("[]"))
+  }
+
+  test("JSON_ARRAY with RETURNING STRING (explicit)") {
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(1, 2, 3 RETURNING STRING)"),
+      Row("[1,2,3]"))
+  }
+
+  test("JSON_ARRAY with both NULL ON NULL and RETURNING clauses") {
+    // The grammar allows `... ON NULL` and `RETURNING` together, in that 
order; exercise both.
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(1, NULL, 3 NULL ON NULL RETURNING STRING)"),
+      Row("[1,null,3]"))
+  }
+
+  test("JSON_ARRAY over non-foldable columns exercises row-wise eval") {
+    val df = Seq((1, "a", true), (2, "b", false)).toDF("i", "s", "b")
+    checkAnswer(
+      df.selectExpr("JSON_ARRAY(i, s, b)"),
+      Seq(Row("""[1,"a",true]"""), Row("""[2,"b",false]""")))
+  }
+
+  test("JSON_ARRAY renders decimals and dates via Jackson, not toString") {
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(CAST(1.50 AS DECIMAL(5,2)), DATE'2020-01-02')"),
+      Row("""[1.50,"2020-01-02"]"""))
+  }
+
+  test("JSON_ARRAY renders a TIMESTAMP via to_json's writer in the session 
time zone") {
+    // The constructor is TimeZoneAware and shares to_json's writer, so a 
TIMESTAMP element must
+    // render identically to to_json of the singleton array, formatted in the 
session time zone.
+    // Assert agreement with that writer (rather than pinning a fragile format 
string), and that the
+    // rendering tracks the session time zone by differing between two zones.
+    def render(tz: String): String = 
withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> tz) {
+      val out =
+        sql("SELECT JSON_ARRAY(TIMESTAMP'2020-01-02 
03:04:05')").collect().head.getString(0)
+      val expected =
+        sql("SELECT to_json(array(TIMESTAMP'2020-01-02 
03:04:05'))").collect().head.getString(0)
+      assert(out == expected, s"for tz=$tz")
+      out
+    }
+    assert(render("UTC") != render("America/Los_Angeles"))
+  }
+
+  test("JSON_ARRAY renders array and map elements as JSON structures, like 
to_json") {
+    // The docs state array/map/struct arguments render via the same writer as 
to_json (as nested
+    // JSON structures, not quoted strings). Cover arrays and maps explicitly 
(structs are covered
+    // by the ignoreNullFields test); a nested array element serializes to 
[1,2], a map to {"k":1}.
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(array(1, 2), map('k', 1))"),
+      Row("""[[1,2],{"k":1}]"""))
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(array(array(1), array(2, 3)))"),
+      Row("[[[1],[2,3]]]"))
+  }
+
+  test("JSON_ARRAY strings are escaped") {
+    checkAnswer(
+      sql("""SELECT JSON_ARRAY('a"b', 'c\td')"""),
+      Row("""["a\"b","c\td"]"""))
+  }
+
+  test("nested JSON_ARRAY is spliced raw, not re-quoted (implicit FORMAT 
JSON)") {
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(JSON_ARRAY(1, 2), 3)"),
+      Row("[[1,2],3]"))
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(JSON_ARRAY(1))"),
+      Row("[[1]]"))
+  }
+
+  test("explicit FORMAT JSON splices a string verbatim; a plain string is 
quoted") {
+    // A plain string element is quoted and escaped like any other string 
value...
+    checkAnswer(sql("""SELECT JSON_ARRAY('[1,2]')"""), Row("""["[1,2]"]"""))
+    // ...while FORMAT JSON marks it as already-JSON text, spliced in verbatim.
+    checkAnswer(sql("""SELECT JSON_ARRAY('[1,2]' FORMAT JSON)"""), 
Row("[[1,2]]"))
+    checkAnswer(
+      sql("""SELECT JSON_ARRAY('{"a":1}' FORMAT JSON, 'x')"""),
+      Row("""[{"a":1},"x"]"""))
+  }
+
+  test("splicing is decided from the source, not the optimized plan shape") {
+    // A JSON_ARRAY result surfaced as a column is a plain STRING and must be 
quoted -- even though
+    // CollapseProject may inline the inner JSON_ARRAY into the outer argument 
position. The FORMAT
+    // JSON decision is frozen from the lexical argument at parse time, so it 
is independent of that
+    // inlining: the result is ["[0]"], never [[0]]. Use a non-foldable 
producer (JSON_ARRAY(id)):
+    // a foldable one is "cheap" and CollapseProject inlines it even when 
referenced twice, so both
+    // cases would otherwise cover the same shape.
+    def projectCount(df: DataFrame): Int =
+      df.queryExecution.optimizedPlan.collect { case _: Project => () }.size
+
+    // Single reference: CollapseProject inlines the inner JSON_ARRAY into the 
outer argument, so
+    // the producer Project collapses away.
+    val inlined = sql("SELECT JSON_ARRAY(a) AS r FROM (SELECT JSON_ARRAY(id) 
AS a FROM range(1)) t")
+    assert(projectCount(inlined) == 1)
+    checkAnswer(inlined, Row("""["[0]"]"""))
+
+    // Referencing the non-foldable alias twice blocks inlining, so the 
producer Project survives.
+    val notInlined =
+      sql("SELECT JSON_ARRAY(a) AS r, a FROM (SELECT JSON_ARRAY(id) AS a FROM 
range(1)) t")
+    assert(projectCount(notInlined) == 2)
+    // Same splice decision (quoted) despite the different plan shape.
+    checkAnswer(notInlined, Row("""["[0]"]""", "[0]"))
+  }
+
+  test("JSON_ARRAY column with NULL under both ON NULL modes") {
+    val df = Seq(Some(1), None).toDF("i")
+    checkAnswer(
+      df.selectExpr("JSON_ARRAY(i)"),
+      Seq(Row("[1]"), Row("[]")))
+    checkAnswer(
+      df.selectExpr("JSON_ARRAY(i NULL ON NULL)"),
+      Seq(Row("[1]"), Row("[null]")))
+  }
+
+  test("nested JSON_ARRAY with a collated STRING RETURNING is still spliced 
raw") {
+    // The inner array carries implicit FORMAT JSON regardless of its 
(collated) result collation,
+    // so it is spliced raw as [[1],2], not re-quoted as ["[1]",2].
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(JSON_ARRAY(1 RETURNING STRING COLLATE 
UTF8_LCASE), 2)"),
+      Row("[[1],2]"))
+  }
+
+  test("a nested constructor wrapped in a postfix COLLATE is still spliced 
raw") {
+    // `... COLLATE c` wraps the nested constructor in a value-preserving 
Collate. The implicit
+    // FORMAT JSON must be seen through that wrapper, so the inner array is 
spliced ([[1]]), not
+    // treated as a plain string and quoted (["[1]"]).
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(JSON_ARRAY(1) COLLATE UTF8_LCASE)"),
+      Row("[[1]]"))
+    checkAnswer(
+      sql("SELECT JSON_ARRAY(JSON_ARRAY(1, 2) COLLATE UTF8_LCASE, 3)"),
+      Row("[[1,2],3]"))
+  }
+
+  test("a nested JSON_QUERY is spliced under KEEP QUOTES and quoted under OMIT 
QUOTES") {
+    // JSON_QUERY emits JSON text under the default KEEP QUOTES, so a 
lexically nested JSON_QUERY
+    // carries implicit FORMAT JSON and is spliced raw: the matched object is 
[{"x":1}], not the
+    // quoted string ["{\"x\":1}"].
+    checkAnswer(
+      sql("""SELECT JSON_ARRAY(JSON_QUERY('{"a":{"x":1}}', '$.a'))"""),
+      Row("""[{"x":1}]"""))
+    checkAnswer(
+      sql("""SELECT JSON_ARRAY(JSON_QUERY('{"a":{"x":1}}', '$.a'), 2)"""),
+      Row("""[{"x":1},2]"""))
+    // OMIT QUOTES returns the matched scalar string's decoded content (Ada, 
not "Ada") -- an
+    // ordinary string -- so it takes the quoted path: ["Ada"], never the 
invalid splice [Ada].
+    checkAnswer(
+      sql("""SELECT JSON_ARRAY(JSON_QUERY('{"n":"Ada"}', '$.n' OMIT 
QUOTES))"""),
+      Row("""["Ada"]"""))
+  }
+
+  test("FORMAT JSON on a non-string argument is rejected at analysis") {
+    val e = intercept[AnalysisException] {
+      sql("SELECT JSON_ARRAY(123 FORMAT JSON)").collect()
+    }
+    assert(e.getCondition == 
"DATATYPE_MISMATCH.INVALID_JSON_FORMAT_JSON_INPUT")
+  }
+
+  test("explicit FORMAT JSON with valid but whitespaced JSON is spliced 
verbatim") {
+    // Validation only checks well-formedness; the original text (including 
insignificant
+    // whitespace) is spliced as-is, not re-serialized.
+    checkAnswer(sql("""SELECT JSON_ARRAY('[1,  2]' FORMAT JSON)"""), Row("[[1, 
 2]]"))
+    checkAnswer(sql("""SELECT JSON_ARRAY('  true ' FORMAT JSON)"""), Row("[  
true ]"))
+  }
+
+  test("explicit FORMAT JSON with a malformed value is rejected at runtime") {
+    // A single string-typed argument passes analysis, but a value that is not 
exactly one
+    // well-formed JSON value would corrupt the surrounding array, so it fails 
at eval.
+    Seq(
+      "'1,2'",            // two values, not one -- would splice as [1,2]
+      "'{\"a\":1'",       // truncated object
+      "'[1,'",            // truncated array
+      "'not json'",       // bare word
+      "''").foreach { arg => // empty string carries no JSON value
+      val e = intercept[SparkRuntimeException] {
+        sql(s"SELECT JSON_ARRAY($arg FORMAT JSON)").collect()
+      }
+      assert(e.getCondition == "INVALID_JSON_FORMAT_JSON_VALUE", s"for 
argument $arg")
+    }
+  }
+
+  test("malformed FORMAT JSON error truncates a long value to a bounded 
preview") {
+    // A large malformed payload must not be inlined whole into the error 
message. The preview is
+    // capped (100 chars) and the full length is reported instead.
+    val long = "z" * 500 // not valid JSON (bare word) and longer than the 
preview cap
+    val e = intercept[SparkRuntimeException] {
+      sql(s"SELECT JSON_ARRAY('$long' FORMAT JSON)").collect()
+    }
+    assert(e.getCondition == "INVALID_JSON_FORMAT_JSON_VALUE")
+    val msg = e.getMessage
+    assert(msg.contains("(500 characters)"), msg)
+    assert(!msg.contains("z" * 101), "the full value must not be inlined; 
preview is capped")
+  }
+
+  test("explicit FORMAT JSON validates per-row over non-foldable columns") {
+    val df = Seq("[1,2]", "1,2").toDF("s")
+    val e = intercept[SparkRuntimeException] {
+      df.selectExpr("JSON_ARRAY(s FORMAT JSON)").collect()
+    }
+    assert(e.getCondition == "INVALID_JSON_FORMAT_JSON_VALUE")
+  }
+
+  test("explicit FORMAT JSON over a nullable column follows ON NULL, 
validating only non-nulls") {
+    // A nullable string column: NULL rows must be handled by ON NULL (dropped 
/ kept as JSON null)
+    // before any validation, and only the non-null rows are validated as JSON 
text.
+    val df = Seq(Some("[1,2]"), None, Some("{\"a\":1}")).toDF("s")
+    checkAnswer(
+      df.selectExpr("JSON_ARRAY(s FORMAT JSON)"),
+      Seq(Row("[[1,2]]"), Row("[]"), Row("""[{"a":1}]""")))
+    checkAnswer(
+      df.selectExpr("JSON_ARRAY(s FORMAT JSON NULL ON NULL)"),
+      Seq(Row("[[1,2]]"), Row("[null]"), Row("""[{"a":1}]""")))
+    // A non-null but malformed row still fails; the NULL row does not shield 
it.
+    val bad = Seq(None, Some("1,2")).toDF("s")
+    val e = intercept[SparkRuntimeException] {
+      bad.selectExpr("JSON_ARRAY(s FORMAT JSON NULL ON NULL)").collect()
+    }
+    assert(e.getCondition == "INVALID_JSON_FORMAT_JSON_VALUE")
+  }
+
+  test("SQL round-trips FORMAT JSON and neutralizes an inlined implicit-JSON 
child") {
+    val inner = JsonArray(
+      Seq(Literal(1)), Seq(false), Seq(false), 
JsonConstructorNullBehavior.Absent, StringType)
+    // A nested constructor left in an implicit (formatJson = true, trusted) 
position round-trips
+    // as-is: reparse re-derives implicit FORMAT JSON.
+    val spliced = JsonArray(
+      Seq(inner), Seq(true), Seq(false), JsonConstructorNullBehavior.Absent, 
StringType)
+    assert(spliced.sql == "JSON_ARRAY(JSON_ARRAY(1))")
+    // But a constructor inlined into a quoted (formatJson = false) position 
must be wrapped so
+    // reparse keeps it quoted -- otherwise ["[1]"] would round-trip to [[1]].
+    val quoted = JsonArray(
+      Seq(inner), Seq(false), Seq(false), JsonConstructorNullBehavior.Absent, 
StringType)
+    assert(quoted.sql == "JSON_ARRAY(CAST(JSON_ARRAY(1) AS STRING))")
+  }
+
+  test("emitted SQL reparses and evaluates with raw-vs-quoted semantics 
preserved") {
+    // The .sql renderings above are round-trip contracts: reparsing and 
evaluating them must
+    // reproduce the original splicing. A bare nested constructor stays 
spliced; a cast-neutralized
+    // one stays quoted.
+    checkAnswer(sql("SELECT JSON_ARRAY(JSON_ARRAY(1))"), Row("[[1]]"))
+    checkAnswer(sql("SELECT JSON_ARRAY(CAST(JSON_ARRAY(1) AS STRING))"), 
Row("""["[1]"]"""))
+    // An explicit FORMAT JSON string literal round-trips through the emitted 
SQL too.
+    val spliced = JsonArray(
+      Seq(Literal("[1,2]")), Seq(true), Seq(true), 
JsonConstructorNullBehavior.Absent, StringType)
+    assert(spliced.sql == "JSON_ARRAY('[1,2]' FORMAT JSON)")
+    checkAnswer(sql(s"SELECT ${spliced.sql}"), Row("[[1,2]]"))
+  }
+
+  test("SQL forces FORMAT JSON for a spliced value whose child is not a bare 
constructor") {
+    // A spliced element whose direct child is a wrapper (e.g. a Collate 
around a nested
+    // constructor) must render an explicit `FORMAT JSON`, not rely on reparse 
re-deriving implicit
+    // JSON through the wrapper's rendering: `Collate.sql` renders 
function-style
+    // (collate(child, c)), which reparse would not recognize as an implicit 
nested constructor.
+    val inner = JsonArray(
+      Seq(Literal(1)), Seq(false), Seq(false), 
JsonConstructorNullBehavior.Absent, StringType)
+    val collated = JsonArray(
+      Seq(Collate(inner, ResolvedCollation("UTF8_LCASE"))),
+      Seq(true), Seq(false), JsonConstructorNullBehavior.Absent, StringType)
+    assert(collated.sql.contains("FORMAT JSON"),
+      s"expected FORMAT JSON to force the splice, got: ${collated.sql}")
+  }
+
+  test("SQL round-trips a nested JSON_QUERY per its quote mode") {
+    def jsonQuery(quotes: JsonQueryQuotes): JsonQuery = JsonQuery(
+      Literal("""{"a":{"x":1}}"""), "$.a", StringType, 
JsonQueryWrapper.Without, quotes,
+      JsonQueryBehavior.Null, JsonQueryBehavior.Null)
+    // KEEP QUOTES emits JSON text, so a nested JSON_QUERY left in an implicit 
(spliced) position
+    // round-trips as-is: reparse re-derives the implicit FORMAT JSON.
+    val keep = jsonQuery(JsonQueryQuotes.Keep)
+    val splicedKeep = JsonArray(
+      Seq(keep), Seq(true), Seq(false), JsonConstructorNullBehavior.Absent, 
StringType)
+    assert(splicedKeep.sql == """JSON_ARRAY(JSON_QUERY('{"a":{"x":1}}', 
'$.a'))""")
+    // A KEEP QUOTES JSON_QUERY inlined into a quoted position must be 
neutralized with a cast so
+    // reparse keeps it quoted rather than re-deriving implicit FORMAT JSON.
+    val quotedKeep = JsonArray(
+      Seq(keep), Seq(false), Seq(false), JsonConstructorNullBehavior.Absent, 
StringType)
+    assert(quotedKeep.sql == """JSON_ARRAY(CAST(JSON_QUERY('{"a":{"x":1}}', 
'$.a') AS STRING))""")
+    // OMIT QUOTES emits an ordinary string, so it is not implicit: in a 
quoted position it renders
+    // as-is, and in a spliced position it must render an explicit FORMAT JSON 
(it does not
+    // round-trip implicitly).
+    val omit = jsonQuery(JsonQueryQuotes.Omit)
+    val quotedOmit = JsonArray(
+      Seq(omit), Seq(false), Seq(false), JsonConstructorNullBehavior.Absent, 
StringType)
+    assert(quotedOmit.sql == """JSON_ARRAY(JSON_QUERY('{"a":{"x":1}}', '$.a' 
OMIT QUOTES))""")
+    val splicedOmit = JsonArray(
+      Seq(omit), Seq(true), Seq(true), JsonConstructorNullBehavior.Absent, 
StringType)
+    assert(
+      splicedOmit.sql ==
+        """JSON_ARRAY(JSON_QUERY('{"a":{"x":1}}', '$.a' OMIT QUOTES) FORMAT 
JSON)""")
+  }
+
+  test("SQL preserves an explicit collated RETURNING on a spliced nested 
JSON_QUERY") {
+    // A nested JSON_QUERY with an explicit `RETURNING STRING COLLATE 
UTF8_BINARY` renders via
+    // `JsonQuery.sql`. That clause is a distinct StringType instance that 
compares `==` to the
+    // default companion, so a value-equality check would drop it; reference 
identity keeps it, so
+    // the emitted SQL round-trips faithfully instead of losing the 
user-written collation.
+    val query = JsonQuery(
+      Literal("""{"a":{"x":1}}"""), "$.a", StringType("UTF8_BINARY"), 
JsonQueryWrapper.Without,
+      JsonQueryQuotes.Keep, JsonQueryBehavior.Null, JsonQueryBehavior.Null)
+    val spliced = JsonArray(
+      Seq(query), Seq(true), Seq(false), JsonConstructorNullBehavior.Absent, 
StringType)
+    assert(
+      spliced.sql ==
+        """JSON_ARRAY(JSON_QUERY('{"a":{"x":1}}', '$.a' RETURNING STRING 
COLLATE UTF8_BINARY))""")
+    // Reparsing and evaluating the emitted SQL reproduces the raw splice.
+    checkAnswer(sql(s"SELECT ${spliced.sql}"), Row("""[{"x":1}]"""))
+  }
+
+  test("SQL renders an explicit collated RETURNING and omits only the 
default") {
+    val collated = JsonArray(
+      Seq(Literal(1)), Seq(false), Seq(false),
+      JsonConstructorNullBehavior.Absent, StringType("UTF8_LCASE"))
+    assert(collated.sql.contains("RETURNING STRING COLLATE UTF8_LCASE"))
+    // The omitted default is the companion StringType (by reference) and 
renders no RETURNING.
+    val default = JsonArray(
+      Seq(Literal(1)), Seq(false), Seq(false), 
JsonConstructorNullBehavior.Absent, StringType)
+    assert(default.sql == "JSON_ARRAY(1)")
+  }
+
+  test("a constant JSON_ARRAY is foldable unless it has an explicit FORMAT 
JSON") {
+    assert(JsonArray(
+      Seq(Literal(1), Literal("x")), Seq(false, false), Seq(false, false),
+      JsonConstructorNullBehavior.Absent, StringType).foldable)
+    // An explicit FORMAT JSON value is validated at eval and can throw, so it 
must not be folded
+    // (which would move the error to optimization time, even for rows a 
filter would drop).
+    assert(!JsonArray(
+      Seq(Literal("[1]")), Seq(true), Seq(true),
+      JsonConstructorNullBehavior.Absent, StringType).foldable)
+  }
+
+  test("an explicit FORMAT JSON is not evaluated for rows a filter drops") {
+    // Because such a JSON_ARRAY is not foldable, its validation stays at 
runtime: a row the WHERE
+    // removes never triggers the malformed-JSON error (constant folding would 
have thrown eagerly).
+    checkAnswer(
+      sql("SELECT JSON_ARRAY('1,2' FORMAT JSON) AS x FROM VALUES (1) t(a) 
WHERE a > 100"),
+      Seq.empty)
+    // A surviving row still errors.
+    val e = intercept[SparkRuntimeException] {
+      sql("SELECT JSON_ARRAY('1,2' FORMAT JSON) AS x FROM VALUES (1) 
t(a)").collect()
+    }
+    assert(e.getCondition == "INVALID_JSON_FORMAT_JSON_VALUE")
+  }
+
+  test("IS NULL checks over malformed FORMAT JSON still evaluate the 
constructor") {
+    // JsonArray is conservatively nullable when it can throw, so 
NullPropagation must not fold
+    // these predicates to literals before the FORMAT JSON validation runs.
+    Seq("IS NULL", "IS NOT NULL").foreach { predicate =>
+      val e = intercept[SparkRuntimeException] {
+        sql(s"SELECT JSON_ARRAY('1,2' FORMAT JSON) $predicate").collect()
+      }
+      assert(e.getCondition == "INVALID_JSON_FORMAT_JSON_VALUE", s"for 
predicate $predicate")
+    }
+  }
+
+  test("CHAR/VARCHAR RETURNING is normalized to STRING regardless of 
preserveCharVarcharTypeInfo") {
+    Seq("CHAR(2)", "VARCHAR(2)").foreach { returning =>
+      Seq("true", "false").foreach { preserve =>
+        withSQLConf(SQLConf.PRESERVE_CHAR_VARCHAR_TYPE_INFO.key -> preserve) {
+          assert(
+            sql(s"SELECT JSON_ARRAY(1 RETURNING 
$returning)").schema.head.dataType === StringType,
+            s"for RETURNING $returning, preserveCharVarcharTypeInfo=$preserve")
+        }
+      }
+    }
+  }
+
+  test("object default collation applies only when RETURNING is not explicitly 
collated") {
+    withSQLConf(SQLConf.OBJECT_LEVEL_COLLATIONS_ENABLED.key -> "true") {
+      withTable("t") {
+        sql(
+          """CREATE TABLE t DEFAULT COLLATION UTF8_LCASE AS
+            |SELECT json_array(1) AS a,
+            |       json_array(1 RETURNING STRING COLLATE UTF8_BINARY) AS 
b""".stripMargin)
+        val schema = spark.table("t").schema
+        // Omitted RETURNING (default STRING) follows the table's default 
collation.
+        assert(schema("a").dataType === StringType("UTF8_LCASE"))
+        // Explicit RETURNING ... COLLATE is the user's choice and must not be 
overwritten.
+        assert(schema("b").dataType === StringType("UTF8_BINARY"))
+      }
+    }
+  }
+
+  test("default collation recurses into a nested JSON_ARRAY value") {
+    // The rule casts each DefaultStringProducingExpression, recursing through 
a nested constructor
+    // (the flat cases above only cover a top-level constructor). This CTAS 
runs the default
+    // analyzer (single-pass included). Confirm the schema collation and that 
raw splicing still
+    // produces well-formed nested JSON at runtime.
+    withSQLConf(SQLConf.OBJECT_LEVEL_COLLATIONS_ENABLED.key -> "true") {
+      withTable("t") {
+        sql(
+          """CREATE TABLE t DEFAULT COLLATION UTF8_LCASE AS
+            |SELECT json_array(json_array(1)) AS a""".stripMargin)
+        assert(spark.table("t").schema("a").dataType === 
StringType("UTF8_LCASE"))
+        checkAnswer(spark.table("t"), Row("[[1]]"))
+      }
+    }
+  }
+
+  test("view default collation preserves an explicit collated RETURNING") {
+    // Exercises the CREATE VIEW resolution path (in addition to the CTAS path 
above): the explicit
+    // RETURNING collation must survive the view's default collation. Runs 
under dual-run so the
+    // single-pass resolver's default-collation coercion (which wraps the 
constructor in a Cast to
+    // the view collation) is exercised for parity with the fixed-point 
analyzer.
+    withSQLConf(SQLConf.OBJECT_LEVEL_COLLATIONS_ENABLED.key -> "true") {
+      withView("v") {
+        sql(
+          """CREATE VIEW v DEFAULT COLLATION UTF8_LCASE AS
+            |SELECT json_array(1) AS a,
+            |       json_array(1 RETURNING STRING COLLATE UTF8_BINARY) AS 
b""".stripMargin)
+        val schema = spark.table("v").schema
+        assert(schema("a").dataType === StringType("UTF8_LCASE"))
+        assert(schema("b").dataType === StringType("UTF8_BINARY"))
+      }
+    }
+  }
+
+  test("throwable is set only when the constructor can actually throw at 
eval") {
+    // An explicit FORMAT JSON value is validated at eval and can throw on 
malformed text, so the
+    // constructor must be throwable even when its children are not.
+    assert(JsonArray(
+      Seq(Literal("[1]")), Seq(true), Seq(true),
+      JsonConstructorNullBehavior.Absent, StringType).throwable)
+    // A plain JSON_ARRAY with no explicit FORMAT JSON and no throwable 
children cannot throw
+    // (RETURNING is STRING -> STRING), so it stays non-throwable and remains 
eligible for predicate
+    // pushdown.
+    assert(!JsonArray(
+      Seq(Literal(1)), Seq(false), Seq(false), 
JsonConstructorNullBehavior.Absent, StringType)
+      .throwable)
+    // A nested (implicit FORMAT JSON) constructor emits well-formed JSON by 
construction and is not
+    // validated (needsValidation = false), so it alone does not make the 
outer throwable -- even
+    // though it sits in a spliced (formatJson = true) position.
+    val nested = JsonArray(
+      Seq(Literal(1)), Seq(false), Seq(false), 
JsonConstructorNullBehavior.Absent, StringType)
+    assert(!JsonArray(
+      Seq(nested), Seq(true), Seq(false), JsonConstructorNullBehavior.Absent, 
StringType).throwable)
+  }
+
+  test("frozen needsValidation survives an analyzer cast around a trusted 
nested constructor") {
+    // ApplyDefaultCollation / DefaultCollationTypeCoercion may wrap a trusted 
nested constructor in
+    // a Cast under a non-default object/view collation. The parse-time 
needsValidation = false must
+    // survive that rewrite (rather than being re-derived from the now-Cast 
child), so the outer
+    // stays foldable and non-throwable and does not spuriously validate the 
(trusted) nested output
+    // per row.
+    val nested = JsonArray(
+      Seq(Literal(1)), Seq(false), Seq(false), 
JsonConstructorNullBehavior.Absent, StringType)
+    val castWrapped = Cast(nested, StringType("UTF8_LCASE"))
+    val outer = JsonArray(
+      Seq(castWrapped), Seq(true), Seq(false), 
JsonConstructorNullBehavior.Absent, StringType)
+    assert(outer.foldable, "trusted nested value must stay foldable even when 
Cast-wrapped")
+    assert(!outer.throwable, "trusted nested value must not become throwable 
when Cast-wrapped")
+  }
+
+  test("throwable keeps a FORMAT JSON predicate above a filtering join") {
+    // The optimizer must not push a throwable predicate below the join 
(PushPredicateThroughJoin
+    // only pushes non-throwable conditions), so a malformed-JSON row the join 
eliminates is never
+    // evaluated and does not throw. Were the constructor not throwable, the 
predicate would push to
+    // the probe side and throw on the eliminated row.
+    withTempView("t", "u") {
+      Seq((1, "[1]"), (2, "1,2")).toDF("id", "s").createOrReplaceTempView("t")
+      Seq(1).toDF("id").createOrReplaceTempView("u")
+      // id=2 carries malformed FORMAT JSON text but does not join u, so it is 
dropped first and its
+      // predicate is never evaluated (it was not pushed below the join).
+      checkAnswer(
+        sql("""SELECT t.id FROM t JOIN u ON t.id = u.id
+              |WHERE JSON_ARRAY(t.s FORMAT JSON) = '[[1]]'""".stripMargin),
+        Row(1))
+      // With the malformed row surviving the join, evaluation still throws.
+      Seq(2).toDF("id").createOrReplaceTempView("u")
+      val e = intercept[SparkRuntimeException] {
+        sql("""SELECT t.id FROM t JOIN u ON t.id = u.id
+              |WHERE JSON_ARRAY(t.s FORMAT JSON) = 
'[[1]]'""".stripMargin).collect()
+      }
+      assert(e.getCondition == "INVALID_JSON_FORMAT_JSON_VALUE")
+    }
+  }
+
+  test("a spatial-typed element is rejected at analysis") {
+    // GEOMETRY / GEOGRAPHY are AtomicTypes that JacksonUtils.verifyType 
accepts but
+    // JacksonGenerator cannot serialize, so JSON_ARRAY rejects them up front, 
not at runtime.
+    Seq(GeometryType(4326), GeographyType(4326)).foreach { dt =>
+      val expr = JsonArray(
+        Seq(Literal.create(null, dt)), Seq(false), Seq(false),
+        JsonConstructorNullBehavior.Absent, StringType)
+      expr.checkInputDataTypes() match {
+        case DataTypeMismatch(errorSubClass, _) =>
+          assert(errorSubClass == "CANNOT_CONVERT_TO_JSON", s"for $dt")
+        case other => fail(s"expected DataTypeMismatch for $dt, got $other")
+      }
+    }
+  }
+
+  test("a spatial type is accepted when it appears only as a map key") {
+    // JacksonGenerator writes map keys via toString, so a spatial *key* is 
serializable; only map
+    // values (and struct fields / array elements / top-level) go through a 
typed writer. The guard
+    // must therefore mirror verifyType and not over-reject a spatial map key.
+    val ok = JsonArray(
+      Seq(Literal.create(null, MapType(GeometryType(4326), IntegerType))),
+      Seq(false), Seq(false), JsonConstructorNullBehavior.Absent, StringType)
+    assert(ok.checkInputDataTypes().isSuccess)
+    // But a spatial map *value* is still rejected.
+    val bad = JsonArray(
+      Seq(Literal.create(null, MapType(StringType, GeometryType(4326)))),
+      Seq(false), Seq(false), JsonConstructorNullBehavior.Absent, StringType)
+    assert(bad.checkInputDataTypes().isFailure)
+  }
+
+  test("NULL FORMAT JSON is accepted and follows ON NULL handling") {
+    // An untyped NULL under FORMAT JSON must not be rejected at analysis: 
eval handles nulls before
+    // it would ever splice, so it behaves like any other NULL element.
+    checkAnswer(sql("SELECT JSON_ARRAY(NULL FORMAT JSON)"), Row("[]"))
+    checkAnswer(sql("SELECT JSON_ARRAY(NULL FORMAT JSON NULL ON NULL)"), 
Row("[null]"))
+    checkAnswer(sql("SELECT JSON_ARRAY(1, NULL FORMAT JSON, 3 NULL ON NULL)"), 
Row("[1,null,3]"))
+  }
+
+  test("value accepts an unparenthesized predicate expression") {
+    // jsonArrayValue is parsed as a full `expression`, so predicates work 
without parentheses.
+    checkAnswer(sql("SELECT JSON_ARRAY(1 IS NULL, 2 > 1)"), 
Row("[false,true]"))
+  }
+
+  test("widening the value to expression does not change documented forms") {
+    // Design-doc examples where a value abuts ON NULL / FORMAT JSON must 
parse and evaluate
+    // identically after widening valueExpression -> expression.
+    checkAnswer(sql("SELECT JSON_ARRAY(1, NULL, 3 NULL ON NULL)"), 
Row("[1,null,3]"))
+    checkAnswer(sql("SELECT JSON_ARRAY('[1,2]' FORMAT JSON)"), Row("[[1,2]]"))
+    checkAnswer(sql("SELECT JSON_ARRAY(1, 'x', true)"), 
Row("""[1,"x",true]"""))
+  }
+
+  test("a non-string RETURNING type is rejected at analysis") {
+    val e = intercept[AnalysisException] {
+      sql("SELECT JSON_ARRAY(1 RETURNING INT)").collect()
+    }
+    assert(e.getCondition == "DATATYPE_MISMATCH.INVALID_JSON_RETURNING_TYPE")
+  }
+
+  test("a directly-constructed JsonArray with a CHAR/VARCHAR RETURNING is 
rejected") {
+    // The parser normalizes CHAR/VARCHAR RETURNING to STRING, but a raw 
CharType/VarcharType from
+    // direct Catalyst construction would advertise a length JSON_ARRAY does 
not enforce.
+    Seq(VarcharType(2), CharType(2)).foreach { returning =>
+      val expr = JsonArray(
+        Seq(Literal(1)), Seq(false), Seq(false), 
JsonConstructorNullBehavior.Absent, returning)
+      expr.checkInputDataTypes() match {
+        case DataTypeMismatch(errorSubClass, _) =>
+          assert(errorSubClass == "INVALID_JSON_RETURNING_TYPE", s"for 
$returning")
+        case other => fail(s"expected DataTypeMismatch for $returning, got 
$other")
+      }
+    }
+  }
+
+  test("JSON_ARRAY of a struct follows to_json null-field handling 
(ignoreNullFields)") {
+    // Nested struct field nulls are governed by 
spark.sql.jsonGenerator.ignoreNullFields, exactly
+    // as to_json -- JSON_ARRAY intentionally reuses that JSON writer. The 
(NULL | ABSENT) ON NULL
+    // clause controls only top-level array elements, not fields inside a 
struct element.
+    val q = "SELECT JSON_ARRAY(named_struct('a', 1, 'b', CAST(NULL AS INT)))"
+    withSQLConf(SQLConf.JSON_GENERATOR_IGNORE_NULL_FIELDS.key -> "true") {
+      checkAnswer(sql(q), Row("""[{"a":1}]"""))
+    }
+    withSQLConf(SQLConf.JSON_GENERATOR_IGNORE_NULL_FIELDS.key -> "false") {
+      checkAnswer(sql(q), Row("""[{"a":1,"b":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 f8fd426881e5..83b93c6460f9 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 == 
"ABSENT,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,COLLE
 [...]
       // scalastyle:on line.size.limit
     }
   }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to