srielau commented on code in PR #59873:
URL: https://github.com/apache/spark/pull/59873#discussion_r4429937668


##########
sql/core/src/test/scala/org/apache/spark/sql/catalyst/expressions/ParseSqlAnalyzeSuite.scala:
##########
@@ -0,0 +1,412 @@
+/*
+ * 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.catalyst.expressions
+
+import java.util.Collections
+import java.util.concurrent.atomic.AtomicInteger
+
+import org.json4s._
+import org.json4s.jackson.JsonMethods.parse
+
+import org.apache.spark.sql.{AnalysisException, QueryTest}
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.connector.catalog.{Identifier, InMemoryCatalog}
+import org.apache.spark.sql.connector.catalog.procedures.{
+  BoundProcedure,
+  ProcedureParameter,
+  UnboundProcedure
+}
+import org.apache.spark.sql.connector.read.Scan
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{DataTypes, StructType}
+
+class ParseSqlAnalyzeSuite extends QueryTest with SharedSparkSession {
+
+  private def evalJson(sql: String, mode: String = "ANALYZE"): JValue = {
+    val result = spark.sql(s"SELECT parse_sql($sql, 
'$mode')").head().getString(0)
+    parse(result)
+  }
+
+  private def evalStatement(sql: String, mode: String = "ANALYZE"): JObject = {
+    evalJson("'" + sql.replace("'", "''") + "'", mode) match {
+      case JArray(List(value: JObject)) => value
+      case other => fail(s"expected one statement object, got: $other")
+    }
+  }
+
+  private def jsonType(name: String): JObject = JObject("name" -> 
JString(name))
+  private val jsonInt: JObject = jsonType("int")
+  private val jsonBigint: JObject = jsonType("bigint")
+  private val jsonBoolean: JObject = jsonType("boolean")
+  private val jsonString: JObject = JObject(
+    "name" -> JString("string"),
+    "collation" -> JString("UTF8_BINARY"))
+
+  test("ANALYZE types selected columns and PARSE does not") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      val analyzed = evalStatement("SELECT 1 AS a", "ANALYZE")
+      assert(analyzed \ "parse_success" === JBool(true))
+      assert(analyzed \ "analyze_success" === JBool(true))
+      assert(analyzed \ "select_list" === JArray(List(
+        JObject("name" -> JArray(List(JString("a"))), "type" -> jsonInt))))
+
+      val parsed = evalStatement("SELECT 1 AS a", "PARSE")
+      assert(parsed \ "analyze_success" === JNothing)
+      assert(parsed \ "select_list" === JArray(List(
+        JObject("name" -> JArray(List(JString("a")))))))
+    }
+  }
+
+  test("ANALYZE fully qualifies tables and builtin routines") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      withTable("parse_sql_analyze_t") {
+        sql("CREATE TABLE parse_sql_analyze_t (a INT, b STRING) USING parquet")
+        val j = evalStatement(
+          "SELECT count(a) AS c FROM parse_sql_analyze_t", "analyze")
+        assert(j \ "analyze_success" === JBool(true))
+        assert(j \ "source_table_references" === JArray(List(JArray(List(
+          JString("spark_catalog"), JString("default"), 
JString("parse_sql_analyze_t"))))))
+        assert(j \ "function_references" === JArray(List(JArray(List(
+          JString("system"), JString("builtin"), JString("count"))))))
+        assert(j \ "select_list" === JArray(List(
+          JObject("name" -> JArray(List(JString("c"))), "type" -> 
jsonBigint))))
+      }
+    }
+  }
+
+  test("ANALYZE qualifies local temp views as system.session") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      withTempView("parse_sql_analyze_v") {
+        sql("CREATE OR REPLACE TEMP VIEW parse_sql_analyze_v AS SELECT 1 AS a, 
'x' AS b")
+        val j = evalStatement("SELECT a, b FROM parse_sql_analyze_v")
+        assert(j \ "analyze_success" === JBool(true))
+        assert(j \ "source_table_references" === JArray(List(JArray(List(
+          JString("system"), JString("session"), 
JString("parse_sql_analyze_v"))))))
+        assert(j \ "select_list" === JArray(List(
+          JObject("name" -> JArray(List(JString("a"))), "type" -> jsonInt),
+          JObject("name" -> JArray(List(JString("b"))), "type" -> 
jsonString))))
+      }
+    }
+  }
+
+  test("ANALYZE keeps parse metadata when analysis fails") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      val j = evalStatement("SELECT a FROM missing_parse_sql_analyze_t")
+      assert(j \ "parse_success" === JBool(true))
+      assert(j \ "analyze_success" === JBool(false))
+      assert(j \ "statement_identifier" === JString("SELECT"))
+      assert(j \ "source_table_references" === JArray(List(JArray(List(
+        JString("spark_catalog"),
+        JString("default"),
+        JString("missing_parse_sql_analyze_t"))))))
+      val errorClass = (j \ "error" \ "errorClass").asInstanceOf[JString].s
+      assert(errorClass == "TABLE_OR_VIEW_NOT_FOUND" ||
+        errorClass.contains("TABLE_OR_VIEW_NOT_FOUND"))
+    }
+  }
+
+  test("ANALYZE inherits invoker schema and can override current_schema") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      sql("CREATE DATABASE parse_sql_ctx_a")
+      sql("CREATE DATABASE parse_sql_ctx_b")
+      try {
+        sql("CREATE TABLE parse_sql_ctx_a.t (a INT) USING parquet")
+        sql("USE parse_sql_ctx_b")
+        try {
+          val missing = evalStatement("SELECT a FROM t")
+          assert(missing \ "analyze_success" === JBool(false))
+          assert(missing \ "source_table_references" === 
JArray(List(JArray(List(
+            JString("spark_catalog"), JString("parse_sql_ctx_b"), 
JString("t"))))))
+
+          val json = spark.sql(
+            """SELECT parse_sql(
+              |  'SELECT a FROM t',
+              |  'ANALYZE',
+              |  current_schema => 
'parse_sql_ctx_a')""".stripMargin).head().getString(0)
+          val analyzed = parse(json) match {
+            case JArray(List(value: JObject)) => value
+            case other => fail(s"expected one statement object, got: $other")
+          }
+          assert(analyzed \ "analyze_success" === JBool(true))
+          assert(analyzed \ "source_table_references" === 
JArray(List(JArray(List(
+            JString("spark_catalog"), JString("parse_sql_ctx_a"), 
JString("t"))))))
+          assert(spark.sql("SELECT current_schema()").head().getString(0) ===
+            "parse_sql_ctx_b")
+        } finally {
+          sql("USE default")
+        }
+      } finally {
+        sql("DROP TABLE IF EXISTS parse_sql_ctx_a.t")
+        sql("DROP DATABASE IF EXISTS parse_sql_ctx_a CASCADE")
+        sql("DROP DATABASE IF EXISTS parse_sql_ctx_b CASCADE")
+      }
+    }
+  }
+
+  test("ANALYZE named mode and current_catalog inherit from invoker") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      val json = spark.sql(
+        """SELECT parse_sql(
+          |  'SELECT 1 AS a',
+          |  mode => 'ANALYZE',
+          |  current_catalog => 
current_catalog())""".stripMargin).head().getString(0)

Review Comment:
   Those override tests are gone with the arguments. Replaced with a real 
USE-schema inherit test that asserts both the missing and found identities, and 
that current_schema() is unchanged by ANALYZE itself.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogManager.scala:
##########
@@ -230,6 +244,32 @@ private[sql] class DefaultCatalogManager(
     }
   }
 
+  override private[sql] def storedCurrentCatalogName: Option[String] =
+    synchronized(_currentCatalogName)
+
+  override private[sql] def restoreStoredCurrentCatalogName(name: 
Option[String]): Unit =
+    synchronized {
+      _currentCatalogName = name
+    }
+
+  override private[sql] def storedCurrentNamespace: Option[Array[String]] =
+    synchronized(_currentNamespace.map(_.clone()))
+
+  override private[sql] def restoreStoredCurrentNamespace(
+      namespace: Option[Array[String]]): Unit = {
+    val isSession = synchronized(isSessionCatalog(currentCatalog))
+    namespace match {
+      case Some(ns) if isSession && ns.length == 1 =>
+        v1SessionCatalog.setCurrentDatabase(ns.head)

Review Comment:
   Agreed. The four CatalogManager restore methods are reverted. This PR no 
longer mutates the session.



##########
sql/core/src/main/scala/org/apache/spark/sql/catalyst/parser/ParseSqlResult.scala:
##########
@@ -64,70 +122,325 @@ object ParseSqlResult {
     ThreadLocal.withInitial(() => new SparkSqlParser())
 
   /** Parse `sql` as a batch and render the JSON result array. */
-  def fromSql(sql: String): String = {
+  def fromSql(
+      sql: String,
+      mode: String = ParseSqlMode.Parse,
+      context: ParseSqlAnalyzeContext = ParseSqlAnalyzeContext.empty): String 
= {
     val split = parser.get().splitStatementsWithPositions(sql)
     val statements = split.completeStatements ++ split.partialStatement
-    compact(render(JArray(statements.map(parseStatement).toList)))
+    val spans = statementSpans(sql, statements)
+    val json = if (mode == ParseSqlMode.Analyze) {
+      analyzeBatch(sql, statements, spans, context)
+    } else {
+      JArray(statements.zip(spans).map { case (segment, span) =>
+        parsePlanOrError(segment, span) match {
+          case ParseFailed(errorObj) => errorObj
+          case Parsed(_, plan) =>
+            successJson(plan, analyzed = None, analyzeError = None, None, span)
+        }
+      }.toList)
+    }
+    compact(render(json))
+  }
+
+  private sealed trait ParsedStatement
+  private case class Parsed(segment: PositionedSqlStatement, plan: LogicalPlan)
+    extends ParsedStatement
+  private case class ParseFailed(errorObj: JObject) extends ParsedStatement
+
+  /**
+   * Parse every statement, then analyze each with optional context overrides.
+   * Statements are analyzed against the session as it is, without being
+   * executed, so one does not see the effect of an earlier one.
+   * Analysis / context failures stay JSON (`analyze_success` false).
+   * After a context override failure, references stay unqualified parse names
+   * rather than the invoker or the attempted context.
+   */
+  private def analyzeBatch(
+      batch: String,
+      statements: Seq[PositionedSqlStatement],
+      spans: Seq[StatementSpan],
+      context: ParseSqlAnalyzeContext): JArray = {
+    val parsed = statements.zip(spans).map { case (segment, span) =>
+      parsePlanOrError(segment, span)
+    }
+    val session = ClassicSparkSession.getActiveSession
+      .getOrElse(throw QueryExecutionErrors.parseSqlAnalyzeRequiresSession())
+    // The batch is analyzed on its own: a SQL script that calls parse_sql does
+    // not lend it its locals, and CALL is not run.
+    SqlScriptingContextManager.create(null).runWith {
+      InvokeProcedures.withoutInvocation {
+        try {
+          withAnalyzeContext(session, context) {
+            JArray(parsed.zip(spans).map {
+              case (ParseFailed(errorObj), _) => errorObj
+              case (Parsed(_, plan), span) =>
+                val (analyzed, error) = analyze(session, plan)
+                successJson(plan, analyzed, error, Some(session), span)
+            }.toList)
+          }
+        } catch {
+          case e: SparkThrowable with Throwable if !isInternalError(e) =>
+            JArray(parsed.zip(spans).map {
+              case (ParseFailed(errorObj), _) => errorObj
+              case (Parsed(_, plan), span) =>
+                successJson(plan, None, Some(e), Some(session), span, qualify 
= false)
+            }.toList)
+        }
+      }
+    }
+  }
+
+  /** INTERNAL_ERROR is a Spark bug, not an analysis result: it fails the 
call. */
+  private def isInternalError(e: SparkThrowable): Boolean =
+    Option(e.getCondition).exists(_.startsWith("INTERNAL_ERROR"))
+
+  /** Character span of a statement object in the original batch. */
+  private case class StatementSpan(start: Int, length: Int)
+
+  /**
+   * Code-point `start` for each statement in one forward pass over `batch`.
+   * Statements are in source order, so prefix counts are not repeated.
+   */
+  private def statementSpans(
+      batch: String,
+      statements: Seq[PositionedSqlStatement]): Seq[StatementSpan] = {
+    var lastUtf16 = 0
+    var lastCp = 0
+    statements.map { segment =>
+      val utf16 = math.max(0, math.min(segment.start, batch.length))
+      if (utf16 >= lastUtf16) {
+        lastCp += batch.codePointCount(lastUtf16, utf16)
+        lastUtf16 = utf16
+      } else {
+        lastCp = batch.codePointCount(0, utf16)
+        lastUtf16 = utf16
+      }
+      StatementSpan(lastCp + 1, charLength(segment))
+    }
   }
 
-  private def parseStatement(segment: PositionedSqlStatement): JObject = {
+  /** Parsed plan, or parse-failure JSON for this statement. */
+  private def parsePlanOrError(
+      segment: PositionedSqlStatement,
+      span: StatementSpan): ParsedStatement = {
     val sql = segment.statement
     try {
       // Do not inherit the outer query's origin from the parse_sql expression.
       // Errors and parsed nodes refer to the individual statement.
       val origin = if (sql.nonEmpty) {
-        Origin(startIndex = Some(0), stopIndex = Some(sql.length - 1), sqlText 
= Some(sql))
+        Origin(
+          startIndex = Some(0),
+          stopIndex = Some(sql.codePointCount(0, sql.length) - 1),
+          sqlText = Some(sql))
       } else {
         Origin(sqlText = Some(sql))
       }
       CurrentOrigin.withOrigin(origin) {
-        val plan = parser.get().parsePlan(sql)
-        successJson(plan, segment)
+        Parsed(segment, parser.get().parsePlan(sql))
       }
     } catch {
-      // User-facing parse / scripting failures become JSON; everything else 
fails.
       case e: ParseException =>
-        errorJson(e, segment)
+        ParseFailed(errorJson(e, span))
       case e: SqlScriptingException =>
-        errorJson(e, segment)
+        ParseFailed(errorJson(e, span))
+    }
+  }
+
+  /** Analyze `plan` without executing it. Scripts never reach QueryExecution. 
*/
+  private def analyze(
+      session: ClassicSparkSession,
+      plan: LogicalPlan): (Option[LogicalPlan], Option[SparkThrowable with 
Throwable]) = {
+    plan match {
+      case _: CompoundBody =>
+        (None, Some(QueryExecutionErrors.parseSqlAnalyzeUnsupportedScript()))
+      case _ =>
+        try {
+          val analyzed =
+            session.sessionState.executePlan(plan, 
CommandExecutionMode.SKIP).analyzed

Review Comment:
   ANALYZE now runs withTransformedSupervisedPlan on SupervisingCommand so 
EXPLAIN / DESCRIBE QUERY type the inner query. Covered by a new test.



##########
sql/core/src/main/scala/org/apache/spark/sql/catalyst/expressions/ParseSql.scala:
##########
@@ -18,71 +18,172 @@
 package org.apache.spark.sql.catalyst.expressions
 
 import org.apache.spark.sql.AnalysisException
-import org.apache.spark.sql.catalyst.analysis.{FunctionRegistry, 
FunctionRegistryBase, TypeCheckResult}
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.analysis.{
+  ExpressionBuilder, FunctionRegistry, FunctionRegistryBase, TypeCheckResult
+}
+import org.apache.spark.sql.catalyst.analysis.TypeCheckResult.DataTypeMismatch
 import org.apache.spark.sql.catalyst.expressions.codegen.CodegenFallback
-import org.apache.spark.sql.catalyst.parser.ParseSqlResult
+import org.apache.spark.sql.catalyst.parser.{
+  ParseSqlAnalyzeContext, ParseSqlMode, ParseSqlResult
+}
+import org.apache.spark.sql.catalyst.plans.logical.{FunctionSignature, 
InputParameter}
+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.{AbstractDataType, DataType, StringType}
 import org.apache.spark.unsafe.types.UTF8String
 
 /**
  * Parses a SQL batch string and returns a compact JSON array describing its
- * unresolved statements (source position, identifier/code, lineage references,
- * select-list names, parameters). A statement that does not parse is 
represented
- * by a STANDARD-format error object at its position in the array.
+ * statements (source position, identifier/code, lineage references,
+ * select-list names, parameters). `PARSE` mode is unresolved. `ANALYZE`
+ * mode also types the select list and fully qualifies resolved relations
+ * and routines. A statement that does not parse is represented by a
+ * STANDARD-format error object at its position in the array.
  *
  * Behind [[SQLConf.PARSE_SQL_ENABLED]] while the JSON contract is still
  * evolving. Designed for batch evaluation over DataFrames of SQL text.
- * User-facing parse errors become JSON; unexpected internal failures 
propagate.
+ * User-facing parse / analyze errors become JSON; unexpected internal
+ * failures propagate. `ANALYZE` needs an active Spark session (catalog)
+ * and inherits the invoker's current catalog, schema, and SQL path.
+ * Optional named `current_catalog`, `current_schema`, and `current_path`
+ * override that context for the duration of analysis.
  */
 // scalastyle:off line.size.limit
 // scalastyle:off nonascii
 @ExpressionDescription(
-  usage = """_FUNC_(sqlStmt) - Splits `sqlStmt` into SQL statements, parses 
each with
-    the stock Spark SQL parser, and returns a JSON array describing them 
(1-based
-    UTF-16 code-unit `start` and UTF-16 code-unit `length`, parse success, 
Table 39
-    statement identifier/code, target and source table references for lineage,
-    select-list column names, and parameter markers).
-    Statement length excludes surrounding whitespace and the terminating 
semicolon.
-    Session parser extensions are not applied.
-    Requires spark.sql.function.parseSql.enabled=true. On syntax / parse error 
returns JSON
-    for that statement with `parse_success` false, source location, and a 
nested STANDARD
-    error object instead of throwing or stopping the remaining statements. 
Nested error
-    locations are statement-relative. An empty or closed-comment-only batch 
returns `[]`.""",
+  usage = """_FUNC_(sqlStmt[, mode[, current_catalog[, current_schema[, 
current_path]]]]) -
+    Splits `sqlStmt` into SQL statements, parses each with the stock Spark SQL 
parser, and
+    returns a JSON array describing them so callers can inspect SQL text 
(lineage, statement
+    kind, select-list types) without running the statements. Each object has 
1-based
+    character `start` and character `length` (Unicode code points; a 
single-code-point
+    character, including a BMP glyph or one supplementary emoji, counts as 
one),
+    parse success, Table 39 statement identifier/code, target and source
+    table references, select-list column names, and parameter markers. Every 
parameter
+    may be passed by name (`sqlStmt => ...`, `mode => ...`).
+    `mode` is `PARSE` (default) or `ANALYZE` (case-insensitive). `PARSE` is 
unresolved and
+    does not need a catalog. `ANALYZE` needs an active session; it types 
selected columns,
+    fully qualifies resolved relations and routines, and reports analysis 
failures as JSON
+    without aborting the batch. Each statement is analyzed against the session 
as it is,
+    without being executed, so a DECLARE, CREATE, USE, or SET does not affect 
later
+    statements. `CALL` is analyzed but the procedure is not run. A SQL script 
calling
+    `parse_sql` does not lend it its locals or cursors. `ANALYZE` inherits the 
invoker's
+    current catalog, schema, and SQL path. Named `current_catalog`, 
`current_schema`, and
+    `current_path` override that context for analysis only. Omitted or NULL 
keeps the
+    invoker value except that `current_catalog` alone uses the new catalog's 
default
+    namespace. The invoker catalog, namespace, path, and PATH flag are 
restored after
+    the call. An override changes the session for the duration of the call, so 
other
+    queries running concurrently on the same session can observe it; 
`current_path`
+    also enables the SQL path for the call.
+    `PARSE` ignores the catalog arguments. Statement length excludes 
surrounding
+    whitespace and the terminating semicolon. Session parser extensions are 
not applied.
+    The JSON contract may still change while 
spark.sql.function.parseSql.enabled remains
+    off by default. On syntax / parse error returns JSON for that statement 
with
+    `parse_success` false, source location, and a nested STANDARD error object 
instead of
+    throwing or stopping later statements. Nested `queryContext` is the 
STANDARD
+    renderer output (view and EXECUTE IMMEDIATE bodies keep their own text).
+    Unexpected internal failures still fail the outer query. An
+    empty or closed-comment-only batch returns `[]`. NULL `sqlStmt` or `mode` 
returns
+    NULL.""",
   arguments = """
     Arguments:
       * sqlStmt - A SQL batch string to split and parse.
-        An expression that evaluates to a string.
+        An expression that evaluates to a string. NULL returns NULL.
+      * mode - Optional `PARSE` or `ANALYZE` (case-insensitive). Defaults to 
`PARSE`.
+        An expression that evaluates to a string. NULL returns NULL. Any other 
value
+        fails the outer query with INVALID_PARAMETER_VALUE.PARSE_SQL_MODE.
+      * current_catalog - Optional catalog name used as current catalog during 
`ANALYZE`.
+        NULL or omitted inherits the invoker session. Ignored in `PARSE`. The 
invoker
+        catalog, schema, and path are restored after analysis.
+      * current_schema - Optional schema / namespace used as current schema 
during `ANALYZE`.
+        NULL or omitted inherits the invoker session. Ignored in `PARSE`.
+      * current_path - Optional SQL PATH used during `ANALYZE` (SET PATH 
grammar, or the
+        string returned by `current_path()`). NULL or omitted inherits the 
invoker session.
+        Ignored in `PARSE`.
   """,
   examples = """
     Examples:
       > SELECT _FUNC_('SELECT 1;SELECT 2');
        
[{"start":1,"length":8,"parse_success":true,"statement_identifier":"SELECT","statement_code":21,"select_list":[{"name":[]}]},{"start":10,"length":8,"parse_success":true,"statement_identifier":"SELECT","statement_code":21,"select_list":[{"name":[]}]}]
       > SELECT get_json_object(_FUNC_('SELECT ''😀'';SELECT 2'), '$[1].start');
-       13
+       12
       > SELECT get_json_object(_FUNC_('SELEC'), '$[0].error.errorClass');
        PARSE_SYNTAX_ERROR
+      > SELECT get_json_object(_FUNC_('SELECT 1 AS a', 'ANALYZE'), 
'$[0].select_list[0].type.name');
+       int
+      > SELECT get_json_object(_FUNC_('SELECT a FROM missing_t', 'ANALYZE'), 
'$[0].analyze_success');
+       false
+  """,
+  note = """
+    Requires spark.sql.function.parseSql.enabled=true. The result type is 
STRING JSON.
+    ANALYZE needs an active session, and every argument must be constant for 
the query
+    (DATATYPE_MISMATCH.NON_FOLDABLE_INPUT otherwise); a DataFrame column of 
statements
+    can use PARSE only. An invalid `current_catalog`, `current_schema`, or 
`current_path`
+    is reported as JSON (`analyze_success` false, 
INVALID_PARAMETER_VALUE.PARSE_SQL_CONTEXT)
+    and does not fail the outer query. Each JSON object has `start` (1-based 
character
+    offset in the batch), `length`, and `parse_success`. Parse success also has
+    `statement_identifier` / `statement_code` (ISO/IEC 9075-2:2023 Table 39). 
ANALYZE
+    adds `analyze_success`. Optional fields are omitted when empty:
+    `target_table_references`, `source_table_references`, 
`function_references` (arrays
+    of name parts; ANALYZE qualifies persistent names to 
catalog.schema.object, local
+    temp views and temp functions as `system.session`, builtins as 
`system.builtin`;
+    CTE aliases in scope are not listed as tables), `select_list` (`{name}` 
parts;
+    ANALYZE lists the output columns, with `*` expanded, and adds `type` as a
+    DESCRIBE TABLE EXTENDED AS JSON type object), `parameter_markers` (`named` 
and/or
+    `unnamed_count`), and `error` (STANDARD format). Later statements are still
+    processed after a per-statement parse or analysis failure. ANALYZE of a SQL
+    script reports `analyze_success` false without executing the script.
   """,
   group = "misc_funcs",
   since = "4.4.0")
 // scalastyle:on nonascii
 // scalastyle:on line.size.limit
-case class ParseSql(child: Expression)
-  extends UnaryExpression
+case class ParseSql(
+    sqlStmt: Expression,
+    mode: Expression,
+    currentCatalog: Expression,
+    currentSchema: Expression,
+    currentPath: Expression)
+  extends Expression
   with ImplicitCastInputTypes
-  with CodegenFallback {
+  with CodegenFallback
+  with QueryErrorsBase {
+
+  def this(
+      sqlStmt: Expression,
+      mode: Expression,
+      currentCatalog: Expression,
+      currentSchema: Expression) =
+    this(sqlStmt, mode, currentCatalog, currentSchema, Literal.create(null, 
StringType))
+
+  def this(sqlStmt: Expression, mode: Expression, currentCatalog: Expression) =
+    this(sqlStmt, mode, currentCatalog, Literal.create(null, StringType))
+
+  def this(sqlStmt: Expression, mode: Expression) =
+    this(sqlStmt, mode, Literal.create(null, StringType))
+
+  def this(sqlStmt: Expression) = this(sqlStmt, Literal("PARSE"))
 
   override def prettyName: String = "parse_sql"
 
-  override def nullable: Boolean = true
+  override def children: Seq[Expression] =
+    Seq(sqlStmt, mode, currentCatalog, currentSchema, currentPath)
+
+  override def nullable: Boolean = sqlStmt.nullable || mode.nullable
+
+  // Foldable so constant SQL is evaluated on the driver, where ANALYZE can 
see the session.
+  override def foldable: Boolean = children.forall(_.foldable)

Review Comment:
   With the context args gone, ANALYZE only requires sqlStmt and mode to fold. 
concat of literals still folds. A DataFrame column of statements is still 
rejected for ANALYZE.



##########
sql/core/src/main/scala/org/apache/spark/sql/catalyst/parser/ParseSqlResult.scala:
##########
@@ -64,70 +122,325 @@ object ParseSqlResult {
     ThreadLocal.withInitial(() => new SparkSqlParser())
 
   /** Parse `sql` as a batch and render the JSON result array. */
-  def fromSql(sql: String): String = {
+  def fromSql(
+      sql: String,
+      mode: String = ParseSqlMode.Parse,
+      context: ParseSqlAnalyzeContext = ParseSqlAnalyzeContext.empty): String 
= {
     val split = parser.get().splitStatementsWithPositions(sql)
     val statements = split.completeStatements ++ split.partialStatement
-    compact(render(JArray(statements.map(parseStatement).toList)))
+    val spans = statementSpans(sql, statements)
+    val json = if (mode == ParseSqlMode.Analyze) {
+      analyzeBatch(sql, statements, spans, context)
+    } else {
+      JArray(statements.zip(spans).map { case (segment, span) =>
+        parsePlanOrError(segment, span) match {
+          case ParseFailed(errorObj) => errorObj
+          case Parsed(_, plan) =>
+            successJson(plan, analyzed = None, analyzeError = None, None, span)
+        }
+      }.toList)
+    }
+    compact(render(json))
+  }
+
+  private sealed trait ParsedStatement
+  private case class Parsed(segment: PositionedSqlStatement, plan: LogicalPlan)
+    extends ParsedStatement
+  private case class ParseFailed(errorObj: JObject) extends ParsedStatement
+
+  /**
+   * Parse every statement, then analyze each with optional context overrides.
+   * Statements are analyzed against the session as it is, without being
+   * executed, so one does not see the effect of an earlier one.
+   * Analysis / context failures stay JSON (`analyze_success` false).
+   * After a context override failure, references stay unqualified parse names
+   * rather than the invoker or the attempted context.
+   */
+  private def analyzeBatch(
+      batch: String,
+      statements: Seq[PositionedSqlStatement],
+      spans: Seq[StatementSpan],
+      context: ParseSqlAnalyzeContext): JArray = {
+    val parsed = statements.zip(spans).map { case (segment, span) =>
+      parsePlanOrError(segment, span)
+    }
+    val session = ClassicSparkSession.getActiveSession
+      .getOrElse(throw QueryExecutionErrors.parseSqlAnalyzeRequiresSession())
+    // The batch is analyzed on its own: a SQL script that calls parse_sql does
+    // not lend it its locals, and CALL is not run.
+    SqlScriptingContextManager.create(null).runWith {
+      InvokeProcedures.withoutInvocation {
+        try {
+          withAnalyzeContext(session, context) {
+            JArray(parsed.zip(spans).map {
+              case (ParseFailed(errorObj), _) => errorObj
+              case (Parsed(_, plan), span) =>
+                val (analyzed, error) = analyze(session, plan)
+                successJson(plan, analyzed, error, Some(session), span)
+            }.toList)
+          }
+        } catch {
+          case e: SparkThrowable with Throwable if !isInternalError(e) =>

Review Comment:
   The batch-wide catch around context overrides is gone. Each statement's 
analyze() failure stays on that statement.



##########
sql/core/src/main/scala/org/apache/spark/sql/catalyst/parser/ParseSqlResult.scala:
##########
@@ -64,70 +122,325 @@ object ParseSqlResult {
     ThreadLocal.withInitial(() => new SparkSqlParser())
 
   /** Parse `sql` as a batch and render the JSON result array. */
-  def fromSql(sql: String): String = {
+  def fromSql(
+      sql: String,
+      mode: String = ParseSqlMode.Parse,
+      context: ParseSqlAnalyzeContext = ParseSqlAnalyzeContext.empty): String 
= {
     val split = parser.get().splitStatementsWithPositions(sql)
     val statements = split.completeStatements ++ split.partialStatement
-    compact(render(JArray(statements.map(parseStatement).toList)))
+    val spans = statementSpans(sql, statements)
+    val json = if (mode == ParseSqlMode.Analyze) {
+      analyzeBatch(sql, statements, spans, context)
+    } else {
+      JArray(statements.zip(spans).map { case (segment, span) =>
+        parsePlanOrError(segment, span) match {
+          case ParseFailed(errorObj) => errorObj
+          case Parsed(_, plan) =>
+            successJson(plan, analyzed = None, analyzeError = None, None, span)
+        }
+      }.toList)
+    }
+    compact(render(json))
+  }
+
+  private sealed trait ParsedStatement
+  private case class Parsed(segment: PositionedSqlStatement, plan: LogicalPlan)
+    extends ParsedStatement
+  private case class ParseFailed(errorObj: JObject) extends ParsedStatement
+
+  /**
+   * Parse every statement, then analyze each with optional context overrides.
+   * Statements are analyzed against the session as it is, without being
+   * executed, so one does not see the effect of an earlier one.
+   * Analysis / context failures stay JSON (`analyze_success` false).
+   * After a context override failure, references stay unqualified parse names
+   * rather than the invoker or the attempted context.
+   */
+  private def analyzeBatch(
+      batch: String,
+      statements: Seq[PositionedSqlStatement],
+      spans: Seq[StatementSpan],
+      context: ParseSqlAnalyzeContext): JArray = {
+    val parsed = statements.zip(spans).map { case (segment, span) =>
+      parsePlanOrError(segment, span)
+    }
+    val session = ClassicSparkSession.getActiveSession
+      .getOrElse(throw QueryExecutionErrors.parseSqlAnalyzeRequiresSession())
+    // The batch is analyzed on its own: a SQL script that calls parse_sql does
+    // not lend it its locals, and CALL is not run.
+    SqlScriptingContextManager.create(null).runWith {
+      InvokeProcedures.withoutInvocation {
+        try {
+          withAnalyzeContext(session, context) {
+            JArray(parsed.zip(spans).map {
+              case (ParseFailed(errorObj), _) => errorObj
+              case (Parsed(_, plan), span) =>
+                val (analyzed, error) = analyze(session, plan)
+                successJson(plan, analyzed, error, Some(session), span)
+            }.toList)
+          }
+        } catch {
+          case e: SparkThrowable with Throwable if !isInternalError(e) =>
+            JArray(parsed.zip(spans).map {
+              case (ParseFailed(errorObj), _) => errorObj
+              case (Parsed(_, plan), span) =>
+                successJson(plan, None, Some(e), Some(session), span, qualify 
= false)
+            }.toList)
+        }
+      }
+    }
+  }
+
+  /** INTERNAL_ERROR is a Spark bug, not an analysis result: it fails the 
call. */
+  private def isInternalError(e: SparkThrowable): Boolean =
+    Option(e.getCondition).exists(_.startsWith("INTERNAL_ERROR"))
+
+  /** Character span of a statement object in the original batch. */
+  private case class StatementSpan(start: Int, length: Int)
+
+  /**
+   * Code-point `start` for each statement in one forward pass over `batch`.
+   * Statements are in source order, so prefix counts are not repeated.
+   */
+  private def statementSpans(
+      batch: String,
+      statements: Seq[PositionedSqlStatement]): Seq[StatementSpan] = {
+    var lastUtf16 = 0
+    var lastCp = 0
+    statements.map { segment =>
+      val utf16 = math.max(0, math.min(segment.start, batch.length))
+      if (utf16 >= lastUtf16) {
+        lastCp += batch.codePointCount(lastUtf16, utf16)
+        lastUtf16 = utf16
+      } else {
+        lastCp = batch.codePointCount(0, utf16)
+        lastUtf16 = utf16
+      }
+      StatementSpan(lastCp + 1, charLength(segment))
+    }
   }
 
-  private def parseStatement(segment: PositionedSqlStatement): JObject = {
+  /** Parsed plan, or parse-failure JSON for this statement. */
+  private def parsePlanOrError(
+      segment: PositionedSqlStatement,
+      span: StatementSpan): ParsedStatement = {
     val sql = segment.statement
     try {
       // Do not inherit the outer query's origin from the parse_sql expression.
       // Errors and parsed nodes refer to the individual statement.
       val origin = if (sql.nonEmpty) {
-        Origin(startIndex = Some(0), stopIndex = Some(sql.length - 1), sqlText 
= Some(sql))
+        Origin(
+          startIndex = Some(0),
+          stopIndex = Some(sql.codePointCount(0, sql.length) - 1),
+          sqlText = Some(sql))
       } else {
         Origin(sqlText = Some(sql))
       }
       CurrentOrigin.withOrigin(origin) {
-        val plan = parser.get().parsePlan(sql)
-        successJson(plan, segment)
+        Parsed(segment, parser.get().parsePlan(sql))
       }
     } catch {
-      // User-facing parse / scripting failures become JSON; everything else 
fails.
       case e: ParseException =>
-        errorJson(e, segment)
+        ParseFailed(errorJson(e, span))
       case e: SqlScriptingException =>
-        errorJson(e, segment)
+        ParseFailed(errorJson(e, span))
+    }
+  }
+
+  /** Analyze `plan` without executing it. Scripts never reach QueryExecution. 
*/
+  private def analyze(
+      session: ClassicSparkSession,
+      plan: LogicalPlan): (Option[LogicalPlan], Option[SparkThrowable with 
Throwable]) = {
+    plan match {
+      case _: CompoundBody =>
+        (None, Some(QueryExecutionErrors.parseSqlAnalyzeUnsupportedScript()))
+      case _ =>
+        try {
+          val analyzed =
+            session.sessionState.executePlan(plan, 
CommandExecutionMode.SKIP).analyzed
+          (Some(analyzed), None)
+        } catch {
+          case e: SparkThrowable with Throwable if !isInternalError(e) => 
(None, Some(e))
+        }
+    }
+  }
+
+  /**
+   * Run `f` with optional catalog / schema / SQL path overrides, then restore
+   * the invoker session. `None` on a field keeps the current value.
+   * Restore writes back the stored catalog name, namespace and PATH flag
+   * without re-validating, and unsets PATH when it was unset.
+   */
+  private def withAnalyzeContext[T](
+      session: ClassicSparkSession,
+      context: ParseSqlAnalyzeContext)(f: => T): T = {
+    if (context.isEmpty) {
+      f
+    } else {
+      val mgr = session.sessionState.catalogManager
+      val conf = session.sessionState.conf
+      val prevCatalog = mgr.storedCurrentCatalogName
+      val prevNamespace = mgr.storedCurrentNamespace
+      val prevPath = mgr.storedSessionPathEntries
+      val pathEnabledWasSet = conf.contains(SQLConf.PATH_ENABLED.key)
+      val prevPathEnabled =
+        if (pathEnabledWasSet) Some(conf.getConf(SQLConf.PATH_ENABLED)) else 
None
+      try {
+        context.catalog.foreach { catalog =>
+          asContextError("current_catalog", catalog) {
+            if (catalog.trim.isEmpty) {
+              throw 
QueryExecutionErrors.invalidParseSqlContext("current_catalog", catalog)
+            }
+            mgr.setCurrentCatalog(catalog)
+          }
+        }
+        context.schema.foreach { schema =>
+          asContextError("current_schema", schema) {
+            val parts = try {
+              parser.get().parseMultipartIdentifier(schema)
+            } catch {
+              case _: ParseException =>
+                throw 
QueryExecutionErrors.invalidParseSqlContext("current_schema", schema)
+            }
+            if (parts.isEmpty) {
+              throw 
QueryExecutionErrors.invalidParseSqlContext("current_schema", schema)
+            }
+            mgr.setCurrentNamespace(parts.toArray)
+          }
+        }
+        context.path.foreach { path =>
+          asContextError("current_path", path) {
+            if (!conf.getConf(SQLConf.PATH_ENABLED)) {
+              conf.setConf(SQLConf.PATH_ENABLED, true)

Review Comment:
   current_path is no longer on this PR, so PATH_ENABLED is not touched.



##########
sql/core/src/test/scala/org/apache/spark/sql/catalyst/expressions/ParseSqlAnalyzeSuite.scala:
##########
@@ -0,0 +1,412 @@
+/*
+ * 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.catalyst.expressions
+
+import java.util.Collections
+import java.util.concurrent.atomic.AtomicInteger
+
+import org.json4s._
+import org.json4s.jackson.JsonMethods.parse
+
+import org.apache.spark.sql.{AnalysisException, QueryTest}
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.connector.catalog.{Identifier, InMemoryCatalog}
+import org.apache.spark.sql.connector.catalog.procedures.{
+  BoundProcedure,
+  ProcedureParameter,
+  UnboundProcedure
+}
+import org.apache.spark.sql.connector.read.Scan
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{DataTypes, StructType}
+
+class ParseSqlAnalyzeSuite extends QueryTest with SharedSparkSession {
+
+  private def evalJson(sql: String, mode: String = "ANALYZE"): JValue = {
+    val result = spark.sql(s"SELECT parse_sql($sql, 
'$mode')").head().getString(0)
+    parse(result)
+  }
+
+  private def evalStatement(sql: String, mode: String = "ANALYZE"): JObject = {
+    evalJson("'" + sql.replace("'", "''") + "'", mode) match {
+      case JArray(List(value: JObject)) => value
+      case other => fail(s"expected one statement object, got: $other")
+    }
+  }
+
+  private def jsonType(name: String): JObject = JObject("name" -> 
JString(name))
+  private val jsonInt: JObject = jsonType("int")
+  private val jsonBigint: JObject = jsonType("bigint")
+  private val jsonBoolean: JObject = jsonType("boolean")
+  private val jsonString: JObject = JObject(
+    "name" -> JString("string"),
+    "collation" -> JString("UTF8_BINARY"))
+
+  test("ANALYZE types selected columns and PARSE does not") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      val analyzed = evalStatement("SELECT 1 AS a", "ANALYZE")
+      assert(analyzed \ "parse_success" === JBool(true))
+      assert(analyzed \ "analyze_success" === JBool(true))
+      assert(analyzed \ "select_list" === JArray(List(
+        JObject("name" -> JArray(List(JString("a"))), "type" -> jsonInt))))
+
+      val parsed = evalStatement("SELECT 1 AS a", "PARSE")
+      assert(parsed \ "analyze_success" === JNothing)
+      assert(parsed \ "select_list" === JArray(List(
+        JObject("name" -> JArray(List(JString("a")))))))
+    }
+  }
+
+  test("ANALYZE fully qualifies tables and builtin routines") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      withTable("parse_sql_analyze_t") {
+        sql("CREATE TABLE parse_sql_analyze_t (a INT, b STRING) USING parquet")
+        val j = evalStatement(
+          "SELECT count(a) AS c FROM parse_sql_analyze_t", "analyze")
+        assert(j \ "analyze_success" === JBool(true))
+        assert(j \ "source_table_references" === JArray(List(JArray(List(
+          JString("spark_catalog"), JString("default"), 
JString("parse_sql_analyze_t"))))))
+        assert(j \ "function_references" === JArray(List(JArray(List(
+          JString("system"), JString("builtin"), JString("count"))))))
+        assert(j \ "select_list" === JArray(List(
+          JObject("name" -> JArray(List(JString("c"))), "type" -> 
jsonBigint))))
+      }
+    }
+  }
+
+  test("ANALYZE qualifies local temp views as system.session") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      withTempView("parse_sql_analyze_v") {
+        sql("CREATE OR REPLACE TEMP VIEW parse_sql_analyze_v AS SELECT 1 AS a, 
'x' AS b")
+        val j = evalStatement("SELECT a, b FROM parse_sql_analyze_v")
+        assert(j \ "analyze_success" === JBool(true))
+        assert(j \ "source_table_references" === JArray(List(JArray(List(
+          JString("system"), JString("session"), 
JString("parse_sql_analyze_v"))))))
+        assert(j \ "select_list" === JArray(List(
+          JObject("name" -> JArray(List(JString("a"))), "type" -> jsonInt),
+          JObject("name" -> JArray(List(JString("b"))), "type" -> 
jsonString))))
+      }
+    }
+  }
+
+  test("ANALYZE keeps parse metadata when analysis fails") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      val j = evalStatement("SELECT a FROM missing_parse_sql_analyze_t")
+      assert(j \ "parse_success" === JBool(true))
+      assert(j \ "analyze_success" === JBool(false))
+      assert(j \ "statement_identifier" === JString("SELECT"))
+      assert(j \ "source_table_references" === JArray(List(JArray(List(
+        JString("spark_catalog"),
+        JString("default"),
+        JString("missing_parse_sql_analyze_t"))))))
+      val errorClass = (j \ "error" \ "errorClass").asInstanceOf[JString].s
+      assert(errorClass == "TABLE_OR_VIEW_NOT_FOUND" ||
+        errorClass.contains("TABLE_OR_VIEW_NOT_FOUND"))
+    }
+  }
+
+  test("ANALYZE inherits invoker schema and can override current_schema") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      sql("CREATE DATABASE parse_sql_ctx_a")
+      sql("CREATE DATABASE parse_sql_ctx_b")
+      try {
+        sql("CREATE TABLE parse_sql_ctx_a.t (a INT) USING parquet")
+        sql("USE parse_sql_ctx_b")
+        try {
+          val missing = evalStatement("SELECT a FROM t")
+          assert(missing \ "analyze_success" === JBool(false))
+          assert(missing \ "source_table_references" === 
JArray(List(JArray(List(
+            JString("spark_catalog"), JString("parse_sql_ctx_b"), 
JString("t"))))))
+
+          val json = spark.sql(
+            """SELECT parse_sql(
+              |  'SELECT a FROM t',
+              |  'ANALYZE',
+              |  current_schema => 
'parse_sql_ctx_a')""".stripMargin).head().getString(0)
+          val analyzed = parse(json) match {
+            case JArray(List(value: JObject)) => value
+            case other => fail(s"expected one statement object, got: $other")
+          }
+          assert(analyzed \ "analyze_success" === JBool(true))
+          assert(analyzed \ "source_table_references" === 
JArray(List(JArray(List(
+            JString("spark_catalog"), JString("parse_sql_ctx_a"), 
JString("t"))))))
+          assert(spark.sql("SELECT current_schema()").head().getString(0) ===
+            "parse_sql_ctx_b")
+        } finally {
+          sql("USE default")
+        }
+      } finally {
+        sql("DROP TABLE IF EXISTS parse_sql_ctx_a.t")
+        sql("DROP DATABASE IF EXISTS parse_sql_ctx_a CASCADE")
+        sql("DROP DATABASE IF EXISTS parse_sql_ctx_b CASCADE")
+      }
+    }
+  }
+
+  test("ANALYZE named mode and current_catalog inherit from invoker") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      val json = spark.sql(
+        """SELECT parse_sql(
+          |  'SELECT 1 AS a',
+          |  mode => 'ANALYZE',
+          |  current_catalog => 
current_catalog())""".stripMargin).head().getString(0)
+      val analyzed = parse(json) match {
+        case JArray(List(value: JObject)) => value
+        case other => fail(s"expected one statement object, got: $other")
+      }
+      assert(analyzed \ "analyze_success" === JBool(true))
+      assert(analyzed \ "select_list" === JArray(List(
+        JObject("name" -> JArray(List(JString("a"))), "type" -> jsonInt))))
+    }
+  }
+
+  test("ANALYZE current_path override is restored on the invoker") {
+    withSQLConf(
+        SQLConf.PARSE_SQL_ENABLED.key -> "true",
+        SQLConf.PATH_ENABLED.key -> "true") {
+      sql("SET PATH = spark_catalog.default, system.builtin")
+      try {
+        val before = spark.sql("SELECT current_path()").head().getString(0)
+        val json = spark.sql(
+          """SELECT parse_sql(
+            |  'SELECT 1 AS a',
+            |  'ANALYZE',
+            |  current_path => 'system.builtin, 
spark_catalog.default')""".stripMargin)
+          .head().getString(0)
+        val analyzed = parse(json) match {
+          case JArray(List(value: JObject)) => value
+          case other => fail(s"expected one statement object, got: $other")
+        }
+        assert(analyzed \ "analyze_success" === JBool(true))
+        assert(spark.sql("SELECT current_path()").head().getString(0) === 
before)
+      } finally {
+        sql("SET PATH = DEFAULT_PATH")
+      }
+    }
+  }
+
+  test("ANALYZE reports invalid current_schema as JSON instead of failing the 
query") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      val json = spark.sql(
+        "SELECT parse_sql('SELECT 1 AS a', 'ANALYZE', current_schema => 
'!!!')")
+        .head().getString(0)
+      val analyzed = parse(json) match {
+        case JArray(List(value: JObject)) => value
+        case other => fail(s"expected one statement object, got: $other")
+      }
+      assert(analyzed \ "parse_success" === JBool(true))
+      assert(analyzed \ "analyze_success" === JBool(false))
+      assert(analyzed \ "select_list" === JArray(List(
+        JObject("name" -> JArray(List(JString("a")))))))
+      assert((analyzed \ "error" \ "errorClass").asInstanceOf[JString].s ===
+        "INVALID_PARAMETER_VALUE.PARSE_SQL_CONTEXT")
+    }
+  }
+
+  test("ANALYZE without parameter marker types leaves the query unresolved") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      val json = spark.sql("SELECT parse_sql('SELECT abs(?) AS x', 'ANALYZE')")
+        .head().getString(0)
+      val analyzed = parse(json) match {
+        case JArray(List(value: JObject)) => value
+        case other => fail(s"expected one statement object, got: $other")
+      }
+      assert(analyzed \ "parse_success" === JBool(true))
+      assert(analyzed \ "analyze_success" === JBool(false))
+    }
+  }
+
+  private def evalBatch(sql: String): List[JObject] =
+    evalJson("'" + sql.replace("'", "''") + "'") match {
+      case JArray(values) => values.map(_.asInstanceOf[JObject])
+      case other => fail(s"expected a JSON array, got: $other")
+    }
+
+  private def typedColumns(columns: (String, JObject)*): JArray = 
JArray(columns.map {
+    case (name, dataType) => JObject("name" -> JArray(List(JString(name))), 
"type" -> dataType)
+  }.toList)
+
+  private def ref(parts: String*): JArray = JArray(parts.map(JString).toList)
+
+  /** Run a full `SELECT parse_sql(...)` and return its statement objects. */
+  private def evalCall(query: String): List[JObject] =
+    parse(spark.sql(query).head().getString(0)) match {
+      case JArray(values) => values.map(_.asInstanceOf[JObject])
+      case other => fail(s"expected a JSON array, got: $other")
+    }
+
+  private def errorClass(statement: JObject): JValue = statement \ "error" \ 
"errorClass"
+
+  test("ANALYZE does not run CALL, directly or through EXECUTE IMMEDIATE") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      spark.conf.set("spark.sql.catalog.parse_sql_cat", 
classOf[InMemoryCatalog].getName)
+      try {
+        val catalog = 
spark.sessionState.catalogManager.catalog("parse_sql_cat")
+          .asInstanceOf[InMemoryCatalog]
+        catalog.createProcedure(
+          Identifier.of(Array("ns"), "record"), 
ParseSqlAnalyzeSuite.UnboundRecord)
+        ParseSqlAnalyzeSuite.calls.set(0)
+        val results = evalBatch(
+          """CALL parse_sql_cat.ns.record(1);
+            |EXECUTE IMMEDIATE 'CALL 
parse_sql_cat.ns.record(2)'""".stripMargin)
+        assert(results.map(_ \ "parse_success") === List(JBool(true), 
JBool(true)))
+        assert(results.map(_ \ "analyze_success") === List(JBool(true), 
JBool(true)))
+        assert(ParseSqlAnalyzeSuite.calls.get === 0)
+        sql("CALL parse_sql_cat.ns.record(3)").collect()
+        assert(ParseSqlAnalyzeSuite.calls.get === 1)
+      } finally {
+        spark.sessionState.catalogManager.reset()
+        spark.sessionState.conf.unsetConf("spark.sql.catalog.parse_sql_cat")
+      }
+    }
+  }
+
+  test("ANALYZE inside a SQL script does not see the script's locals") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      val json = spark.sql(
+        """BEGIN
+          |  DECLARE parse_sql_outer INT DEFAULT 5;
+          |  SELECT parse_sql('SELECT parse_sql_outer AS x', 'ANALYZE');
+          |END""".stripMargin).head().getString(0)
+      val statement = parse(json) match {
+        case JArray(List(value: JObject)) => value
+        case other => fail(s"expected one statement object, got: $other")
+      }
+      assert(statement \ "analyze_success" === JBool(false))
+      val errorClass = (statement \ "error" \ 
"errorClass").asInstanceOf[JString].s
+      assert(errorClass.contains("UNRESOLVED") || 
errorClass.contains("PARSE_SQL_OUTER") ||
+        errorClass.contains("COLUMN") || errorClass.contains("TABLE_OR_VIEW"))
+    }
+  }
+
+  test("ANALYZE requires constant arguments; PARSE does not") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      val query = "SELECT parse_sql(c, '%s') FROM VALUES ('SELECT 1') AS t(c)"
+      val e = 
intercept[AnalysisException](spark.sql(query.format("ANALYZE")).collect())
+      assert(e.getCondition === "DATATYPE_MISMATCH.NON_FOLDABLE_INPUT")
+      assert(spark.sql(query.format("PARSE")).collect().length === 1)
+      val modeCol = intercept[AnalysisException](
+        spark.sql("SELECT parse_sql(c, m) FROM VALUES ('SELECT 1', 'PARSE') AS 
t(c, m)")
+          .collect())
+      assert(modeCol.getCondition === "DATATYPE_MISMATCH.NON_FOLDABLE_INPUT")
+    }
+  }
+
+  test("ANALYZE does not execute SQL scripts") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      withTable("parse_sql_script_t", "parse_sql_script_u") {
+        sql("CREATE TABLE parse_sql_script_t (a INT) USING parquet")
+        sql("CREATE TABLE parse_sql_script_u (a INT) USING parquet")
+        val schema = spark.sql("SELECT current_schema()").head().getString(0)
+        val j = evalStatement(
+          """BEGIN
+            |  INSERT INTO parse_sql_script_t VALUES (1);
+            |  DROP TABLE parse_sql_script_u;
+            |END""".stripMargin)
+        assert(j \ "parse_success" === JBool(true))
+        assert(j \ "analyze_success" === JBool(false))
+        assert(errorClass(j) === 
JString("PARSE_SQL_ANALYZE_UNSUPPORTED_SCRIPT"))
+        assert(spark.table("parse_sql_script_t").count() === 0)
+        assert(spark.catalog.tableExists("parse_sql_script_u"))
+        assert(spark.sql("SELECT current_schema()").head().getString(0) === 
schema)
+      }
+    }
+  }
+
+  test("ANALYZE reports range as system.builtin and de-duplicates count") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      withTable("parse_sql_dedup_t") {
+        sql("CREATE TABLE parse_sql_dedup_t (a INT, b INT) USING parquet")
+        val rangeStmt = evalStatement("SELECT * FROM range(3)")
+        assert(rangeStmt \ "analyze_success" === JBool(true))
+        assert(rangeStmt \ "function_references" === JArray(List(
+          ref("system", "builtin", "range"))))
+        val counts = evalStatement(
+          "SELECT count(a), COUNT(b) FROM parse_sql_dedup_t")
+        assert(counts \ "analyze_success" === JBool(true))
+        assert(counts \ "function_references" === JArray(List(
+          ref("system", "builtin", "count"))))
+      }
+    }
+  }
+
+  test("ANALYZE qualifies DDL targets by statement kind") {
+    withSQLConf(SQLConf.PARSE_SQL_ENABLED.key -> "true") {
+      val tempView = evalStatement("CREATE TEMPORARY VIEW parse_sql_ddl_v AS 
SELECT 1 AS a")

Review Comment:
   Added a mixed INSERT / CREATE / DROP batch that asserts analyze_success and 
that none of the statements ran. CREATE TEMP VIEW also asserts the view was not 
created. Scripts already asserted the tables were unchanged.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to