This is an automated email from the ASF dual-hosted git repository.

cloud-fan pushed a commit to branch branch-4.3
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.3 by this push:
     new 4e111b4dadca [SPARK-58933][4.3][SQL] Resolve expressions in INSERT 
target IDENTIFIER clauses
4e111b4dadca is described below

commit 4e111b4dadca06177122077eca80e84bde270952
Author: Wenchen Fan <[email protected]>
AuthorDate: Fri Aug 28 16:13:42 2026 +0800

    [SPARK-58933][4.3][SQL] Resolve expressions in INSERT target IDENTIFIER 
clauses
    
    ### What changes were proposed in this pull request?
    
    This backports #58204 (`3bc29d9d0ae031dfe0e793886ba652ca98ae7f29`) to 
branch-4.3.
    
    It introduces a temporary binary `UnresolvedInsert` plan for INSERT 
statements whose target uses a
    dynamic `IDENTIFIER(...)` expression. This lets normal analyzer traversal 
resolve parameters,
    functions, and SQL variables in the target before lowering the plan to 
`InsertIntoStatement`.
    
    The parse-metadata changes from the original commit are omitted because the 
corresponding
    `ParseSqlResult` and `SqlStatementCodes` infrastructure is not present on 
branch-4.3.
    
    ### Why are the changes needed?
    
    `InsertIntoStatement.table` is not a logical-plan child, so analyzer rules 
do not normally visit a
    dynamic target expression stored there. Nested functions, parameters, or 
SQL variables can
    therefore remain unresolved.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. INSERT statements on branch-4.3 can now resolve expressions inside a 
target `IDENTIFIER`
    clause, matching the behavior fixed on master by #58204.
    
    ### How was this patch tested?
    
    The backported parser, analyzer, end-to-end, and transactional-catalog 
tests were included. The
    following checks passed locally with Java 17:
    
    ```bash
    build/sbt -java-home /usr/lib/jvm/java-17-openjdk-amd64 \
      catalyst/Test/compile \
      'sql/testOnly org.apache.spark.sql.ParametersSuite' \
      'sql/testOnly org.apache.spark.sql.connector.AppendDataTransactionSuite' \
      pipelines/compile connect/compile
    ```
    
    `ParametersSuite`: 162 tests passed. `AppendDataTransactionSuite`: 19 tests 
passed.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: OpenAI Codex (GPT-5)
    
    Closes #58372 from cloud-fan/SPARK-58933-branch-4.3.
    
    Authored-by: Wenchen Fan <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../spark/sql/catalyst/analysis/Analyzer.scala     |  65 ++++++----
 .../analysis/ResolveIdentifierClause.scala         |  18 +--
 .../catalyst/analysis/ResolveInlineTables.scala    |  15 ++-
 .../spark/sql/catalyst/analysis/parameters.scala   |  13 +-
 .../spark/sql/catalyst/analysis/unresolved.scala   |  22 +++-
 .../spark/sql/catalyst/parser/AstBuilder.scala     |  47 +++----
 .../sql/catalyst/plans/logical/statements.scala    |  64 +++++++--
 .../sql/catalyst/rules/RuleIdCollection.scala      |   1 +
 .../spark/sql/catalyst/trees/TreePatterns.scala    |   1 +
 .../sql/connect/pipelines/PipelinesHandler.scala   |   3 +-
 .../spark/sql/execution/QueryExecution.scala       |  22 +++-
 .../org/apache/spark/sql/ParametersSuite.scala     | 144 +++++++++++----------
 .../sql/connector/AppendDataTransactionSuite.scala |  24 ++++
 .../graph/SqlGraphRegistrationContext.scala        |  59 +++++----
 14 files changed, 305 insertions(+), 193 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
index 176980e1fafd..a1fc8012d0c3 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
@@ -392,6 +392,36 @@ class Analyzer(
 
   def getRelationResolution: RelationResolution = relationResolution
 
+  private def toUnresolvedRelation(target: UnresolvedInsertTarget): 
UnresolvedRelation = {
+    val relation = withOrigin(target.origin) {
+      UnresolvedRelation(target.multipartIdentifier, target.options)
+        .requireWritePrivileges(target.writePrivileges)
+    }
+    relation.copyTagsFrom(target)
+    relation
+  }
+
+  /** Resolves only the dynamic identifier of an INSERT so transaction 
detection can inspect it. */
+  private[sql] def resolveUnresolvedInsert(insert: UnresolvedInsert): 
LogicalPlan = {
+    runWithSessionConf {
+      val identifierResolvedTarget = insert.table match {
+        case p: PlanWithUnresolvedIdentifier => execute(p)
+        case other => other
+      }
+      withOrigin(insert.origin) {
+        identifierResolvedTarget match {
+          case target: UnresolvedInsertTarget =>
+            insert.toInsertIntoStatement(toUnresolvedRelation(target))
+          case target if !target.fastEquals(insert.table) =>
+            val updated = insert.copy(table = target)
+            updated.copyTagsFrom(insert)
+            updated
+          case _ => insert
+        }
+      }
+    }
+  }
+
   def executeAndCheck(plan: LogicalPlan, tracker: QueryPlanningTracker): 
LogicalPlan = {
     if (plan.analyzed) {
       plan
@@ -569,6 +599,7 @@ class Analyzer(
 
   override def batches: Seq[Batch] = earlyBatches ++ Seq(
     Batch("Resolution", fixedPoint,
+      ResolveUnresolvedInsert ::
       new ResolveCatalogs(catalogManager) ::
       ResolveInsertInto ::
       ResolveRelations ::
@@ -1352,6 +1383,15 @@ class Analyzer(
     }
   }
 
+  /** Lower an INSERT as soon as its target identifier expression has been 
evaluated. */
+  object ResolveUnresolvedInsert extends Rule[LogicalPlan] {
+    override def apply(plan: LogicalPlan): LogicalPlan = 
plan.resolveOperatorsUpWithPruning(
+      _.containsPattern(UNRESOLVED_INSERT), ruleId) {
+      case insert @ UnresolvedInsert(target: UnresolvedInsertTarget, _, _, _, 
_, _, _, _, _) =>
+        insert.toInsertIntoStatement(toUnresolvedRelation(target))
+    }
+  }
+
   /** Handle INSERT INTO for DSv2 */
   object ResolveInsertInto extends ResolveInsertionBase {
     override def apply(plan: LogicalPlan): LogicalPlan = 
plan.resolveOperatorsWithPruning(
@@ -1630,29 +1670,8 @@ class Analyzer(
     }
 
     def doApply(plan: LogicalPlan): LogicalPlan = plan.resolveOperatorsUp {
-      // `InsertIntoStatement.table` and `V2WriteCommand.table` are non-child 
`LogicalPlan`
-      // slots (`child = query`), so the default `resolveOperatorsUp` + 
`mapExpressions`
-      // traversal never resolves expressions placed inside them. For a
-      // `PlanWithUnresolvedIdentifier`, `identifierExpr` (e.g. an 
`UnresolvedAttribute`
-      // referring to a SQL variable in `INSERT INTO IDENTIFIER(target_table) 
...`) must
-      // be resolved here before `ResolveIdentifierClause` can materialize the 
relation.
-      // Mirror the structural recursion into the non-child `.table` slot that
-      // `BindParameters` and `ResolveIdentifierClause` already do for the 
same shape
-      // (SPARK-46625); unlike those rules, this one performs attribute 
resolution rather
-      // than parameter binding or placeholder materialization. Resolve 
against `p` (whose
-      // `children` are `Nil` on the INSERT / `OverwriteByExpression` path 
built by
-      // `buildWriteTableSlot`) so the IDENTIFIER expression cannot see query 
output
-      // columns -- only the last-resort variable resolution path fires. The
-      // `!identifierExpr.resolved` guard makes the case idempotent under 
bottom-up
-      // traversal.
-      case i: InsertIntoStatement
-          if i.table.isInstanceOf[PlanWithUnresolvedIdentifier] &&
-             
!i.table.asInstanceOf[PlanWithUnresolvedIdentifier].identifierExpr.resolved =>
-        val p = i.table.asInstanceOf[PlanWithUnresolvedIdentifier]
-        val resolvedExpr = resolveExpressionByPlanChildren(
-          p.identifierExpr, p, includeLastResort = true)
-        i.copy(table = p.copy(identifierExpr = resolvedExpr))
-
+      // V2WriteCommand.table is a non-child LogicalPlan slot, so recurse 
explicitly when it
+      // contains an identifier expression that may refer to a SQL variable.
       case w: V2WriteCommand
           if w.table.isInstanceOf[PlanWithUnresolvedIdentifier] &&
              
!w.table.asInstanceOf[PlanWithUnresolvedIdentifier].identifierExpr.resolved =>
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveIdentifierClause.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveIdentifierClause.scala
index 2bccc99f31d8..320cfc0a77c3 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveIdentifierClause.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveIdentifierClause.scala
@@ -21,7 +21,7 @@ import scala.collection.mutable
 
 import org.apache.spark.SparkException
 import org.apache.spark.sql.catalyst.expressions.{Expression, 
SubqueryExpression, VariableReference}
-import org.apache.spark.sql.catalyst.plans.logical.{CreateView, 
InsertIntoStatement, LogicalPlan, V2WriteCommand}
+import org.apache.spark.sql.catalyst.plans.logical.{CreateView, LogicalPlan, 
V2WriteCommand}
 import org.apache.spark.sql.catalyst.rules.{Rule, RuleExecutor}
 import org.apache.spark.sql.catalyst.trees.TreePattern._
 import org.apache.spark.sql.errors.QueryCompilationErrors
@@ -71,22 +71,6 @@ class ResolveIdentifierClause(earlyBatches: 
Seq[RuleExecutor[LogicalPlan]#Batch]
 
         executor.execute(p.planBuilder.apply(
           IdentifierResolution.evalIdentifierExpr(p.identifierExpr), 
p.children))
-      // `InsertIntoStatement.table` and `V2WriteCommand.table` are non-child 
LogicalPlan slots
-      // (`child = query`), so the standard `resolveOperatorsUp` traversal 
never visits
-      // placeholders inside them. Materialize them explicitly. Only 
`InsertIntoStatement`
-      // carries a parse-time placeholder today, but matching the 
`V2WriteCommand` trait keeps
-      // the rule consistent across the family.
-      case i: InsertIntoStatement if 
i.table.isInstanceOf[PlanWithUnresolvedIdentifier] =>
-        val p = i.table.asInstanceOf[PlanWithUnresolvedIdentifier]
-        if (p.identifierExpr.resolved && p.childrenResolved) {
-          if (referredTempVars.isDefined) {
-            referredTempVars.get ++= collectTemporaryVariablesInLogicalPlan(p)
-          }
-          i.copy(table = executor.execute(p.planBuilder.apply(
-            IdentifierResolution.evalIdentifierExpr(p.identifierExpr), 
p.children)))
-        } else {
-          i
-        }
       case w: V2WriteCommand if 
w.table.isInstanceOf[PlanWithUnresolvedIdentifier] =>
         val p = w.table.asInstanceOf[PlanWithUnresolvedIdentifier]
         if (p.identifierExpr.resolved && p.childrenResolved) {
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveInlineTables.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveInlineTables.scala
index 1c9bf9dd22bc..800b34627350 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveInlineTables.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveInlineTables.scala
@@ -18,7 +18,7 @@
 package org.apache.spark.sql.catalyst.analysis
 
 import org.apache.spark.sql.catalyst.expressions.EvalHelper
-import org.apache.spark.sql.catalyst.plans.logical.{InsertIntoStatement, 
LogicalPlan, OverwriteByExpression, SubqueryAlias}
+import org.apache.spark.sql.catalyst.plans.logical.{InsertIntoStatement, 
LogicalPlan, OverwriteByExpression, SubqueryAlias, UnresolvedInsert}
 import org.apache.spark.sql.catalyst.rules.Rule
 import org.apache.spark.sql.catalyst.trees.CurrentOrigin
 import org.apache.spark.sql.catalyst.trees.TreePattern.INLINE_TABLE_EVAL
@@ -34,6 +34,19 @@ object ResolveInlineTables extends Rule[LogicalPlan] with 
EvalHelper {
       // coercion will cast to the target column's collation.
       // Preserve the inline table's origin so error messages point to the 
VALUES clause,
       // not the INSERT statement.
+      case i @ UnresolvedInsert(_, _, _, table: UnresolvedInlineTable, _, _, 
_, _, _)
+          if table.expressionsResolved =>
+        CurrentOrigin.withOrigin(table.origin) {
+          i.copy(query = EvaluateUnresolvedInlineTable
+            .evaluateUnresolvedInlineTable(table, ignoreCollation = true))
+        }
+      case i @ UnresolvedInsert(
+          _, _, _, sa @ SubqueryAlias(_, table: UnresolvedInlineTable), _, _, 
_, _, _)
+          if table.expressionsResolved =>
+        CurrentOrigin.withOrigin(table.origin) {
+          i.copy(query = sa.copy(child = EvaluateUnresolvedInlineTable
+            .evaluateUnresolvedInlineTable(table, ignoreCollation = true)))
+        }
       case i @ InsertIntoStatement(_, _, _, table: UnresolvedInlineTable, _, 
_, _, _, _)
           if table.expressionsResolved =>
         CurrentOrigin.withOrigin(table.origin) {
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/parameters.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/parameters.scala
index deb63a7d9789..1dac9a7b9f7d 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/parameters.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/parameters.scala
@@ -19,7 +19,7 @@ package org.apache.spark.sql.catalyst.analysis
 
 import org.apache.spark.SparkException
 import org.apache.spark.sql.catalyst.expressions.{Expression, LeafExpression, 
SubqueryExpression, Unevaluable}
-import org.apache.spark.sql.catalyst.plans.logical.{InsertIntoStatement, 
LogicalPlan, SupervisingCommand, V2WriteCommand}
+import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, 
SupervisingCommand, V2WriteCommand}
 import org.apache.spark.sql.catalyst.rules.Rule
 import org.apache.spark.sql.catalyst.trees.TreePattern.{COMMAND, PARAMETER, 
PARAMETERIZED_QUERY, TreePattern, UNRESOLVED_WITH}
 import org.apache.spark.sql.errors.{QueryCompilationErrors, QueryErrorsBase}
@@ -179,16 +179,9 @@ object BindParameters extends Rule[LogicalPlan] with 
QueryErrorsBase {
     p0.resolveOperatorsDownWithPruning(_.containsPattern(PARAMETER) && !stop) {
       case p1 =>
         stop = p1.isInstanceOf[ParameterizedQuery]
-        // `InsertIntoStatement.table` and `V2WriteCommand.table` are 
non-child LogicalPlan
-        // slots, so the standard `resolveOperatorsDown` traversal never 
visits parameter
-        // markers inside them. Recurse explicitly so `INSERT ... 
IDENTIFIER(:p)` and
-        // `INSERT INTO IDENTIFIER(:p) REPLACE WHERE ...` resolve under the 
legacy
-        // parameter-substitution mode (SPARK-46625). The parser places the 
placeholder only in
-        // `InsertIntoStatement.table`; the `V2WriteCommand` trait match keeps 
the rule
-        // consistent for any analyzer-built node in the same shape.
+        // V2WriteCommand.table is a non-child LogicalPlan slot, so recurse 
explicitly when it
+        // contains parameter markers.
         val withBoundTable = p1 match {
-          case i: InsertIntoStatement if i.table.containsPattern(PARAMETER) =>
-            i.copy(table = bind(i.table)(f))
           case w: V2WriteCommand if w.table.containsPattern(PARAMETER) =>
             bind(w.table)(f) match {
               case nr: NamedRelation => w.withNewTable(nr)
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/unresolved.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/unresolved.scala
index 50b2970544dc..19a7cbc62ee8 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/unresolved.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/unresolved.scala
@@ -63,12 +63,9 @@ trait UnresolvedUnaryNode extends UnaryNode with 
UnresolvedNode
  * Extends `NamedRelation` so it can occupy a `NamedRelation`-typed slot (e.g.
  * `OverwriteByExpression.table`) directly at parse time, instead of wrapping 
the whole command.
  *
- * The parser always places this node inside the command's identifier slot (a 
child slot for
- * DELETE/UPDATE/MERGE/CTAS/RTAS, or a non-child slot for 
`InsertIntoStatement.table` and
- * `OverwriteByExpression.table` -- handled via explicit cases in 
`ResolveIdentifierClause` and
- * `BindParameters`). It is never the substitution root of a `WITH ... 
<command>` subtree, so
- * `CTEInChildren` semantics are not needed: any surrounding `WithCTE` 
produced by
- * `CTESubstitution` targets the inner command directly.
+ * Depending on the command shape, the parser uses this node either as the 
plan root or within the
+ * command's identifier slot. For INSERT, the slot is a child of 
`UnresolvedInsert` until the
+ * identifier expression has been evaluated.
  */
 case class PlanWithUnresolvedIdentifier(
     identifierExpr: Expression,
@@ -123,6 +120,19 @@ case class ExpressionWithUnresolvedIdentifier(
   }
 }
 
+/**
+ * Holds the raw table identifier and lookup context for an unresolved INSERT 
target.
+ *
+ * This is deliberately not a [[NamedRelation]]: while it is a child of
+ * [[org.apache.spark.sql.catalyst.plans.logical.UnresolvedInsert]], it must 
not be interpreted as
+ * a readable relation or substituted with a same-named CTE. It is converted 
to an
+ * [[UnresolvedRelation]] when the enclosing INSERT is lowered to 
`InsertIntoStatement`.
+ */
+case class UnresolvedInsertTarget(
+    multipartIdentifier: Seq[String],
+    options: CaseInsensitiveStringMap,
+    writePrivileges: Set[TableWritePrivilege]) extends UnresolvedLeafNode
+
 /**
  * Holds the name of a relation that has yet to be looked up in a catalog.
  *
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 534f87c1e7eb..6930a13a0b62 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
@@ -939,10 +939,8 @@ class AstBuilder extends DataTypeAstBuilder
       query: LogicalPlan,
       queryAliasCtx: TableAliasContext): LogicalPlan = withOrigin(ctx) {
     ctx match {
-      // For all `InsertIntoStatement`-producing branches, build the `table` 
slot directly via
-      // `buildWriteTableSlot` so that any `PlanWithUnresolvedIdentifier` 
lives *inside* the
-      // command's identifier slot. This preserves the `CTEInChildren` shape 
and lets
-      // `CTESubstitution` place `WithCTE` on the command's children correctly 
(SPARK-46625).
+      // A dynamic target is exposed only by the temporary UnresolvedInsert 
plan. Once its
+      // identifier expression has been evaluated, analysis continues with 
InsertIntoStatement.
       case table: InsertIntoTableContext =>
         val insertParams = visitInsertIntoTable(table)
         val privileges = Set(TableWritePrivilege.INSERT)
@@ -1170,16 +1168,14 @@ class AstBuilder extends DataTypeAstBuilder
       replaceCriteriaOpt = replaceCriteriaOpt)
   }
 
-  /**
-   * Creates an [[InsertIntoStatement]] from [[InsertTableParams]].
-   */
+  /** Creates an INSERT plan from [[InsertTableParams]]. */
   private def createInsertIntoStatement(
       insertParams: InsertTableParams,
       tableSlot: LogicalPlan,
       query: LogicalPlan,
       overwrite: Boolean,
-      withSchemaEvolution: Boolean): InsertIntoStatement = {
-    InsertIntoStatement(
+      withSchemaEvolution: Boolean): LogicalPlan = {
+    val unresolvedInsert = UnresolvedInsert(
       table = tableSlot,
       partitionSpec = insertParams.partitionSpec,
       userSpecifiedCols = insertParams.userSpecifiedCols,
@@ -1189,27 +1185,32 @@ class AstBuilder extends DataTypeAstBuilder
       byName = insertParams.byName,
       replaceCriteriaOpt = insertParams.replaceCriteriaOpt,
       withSchemaEvolution = withSchemaEvolution)
+
+    tableSlot match {
+      case target: UnresolvedInsertTarget =>
+        val relation = CurrentOrigin.withOrigin(target.origin) {
+          new UnresolvedRelation(target.multipartIdentifier, target.options, 
isStreaming = false)
+            .requireWritePrivileges(target.writePrivileges)
+        }
+        relation.copyTagsFrom(target)
+        unresolvedInsert.toInsertIntoStatement(relation)
+      case _ => unresolvedInsert
+    }
   }
 
   /**
-   * Build the `table` slot of a write command. If the identifier reference is 
a constant string,
-   * returns an [[UnresolvedRelation]] directly; otherwise returns a
-   * [[PlanWithUnresolvedIdentifier]] that materializes into an 
[[UnresolvedRelation]] once the
-   * identifier expression is resolved. Both branches produce a 
[[NamedRelation]], which occupies
-   * the `InsertIntoStatement.table` slot (a general `LogicalPlan` slot, since 
`NamedRelation`
-   * extends `LogicalPlan`).
-   *
-   * Placing the placeholder in the identifier slot (rather than wrapping the 
entire write command)
-   * preserves the `CTEInChildren` shape at parse time, so `CTESubstitution` 
places `WithCTE` on the
-   * command's children correctly. See SPARK-46625.
+   * Build the target of an INSERT. A dynamic IDENTIFIER remains inside 
`UnresolvedInsert` so its
+   * expression receives normal analyzer traversal. The builder produces 
`UnresolvedInsertTarget`
+   * rather than `UnresolvedRelation` so CTE substitution cannot interpret the 
target as a source.
    */
   private def buildWriteTableSlot(
       ctx: IdentifierReferenceContext,
       optionsClause: Option[OptionsClauseContext],
-      writePrivileges: Set[TableWritePrivilege]): NamedRelation = {
-    withIdentClause(ctx, parts =>
-      createUnresolvedRelation(ctx, parts, optionsClause, writePrivileges, 
isStreaming = false))
-      .asInstanceOf[NamedRelation]
+      writePrivileges: Set[TableWritePrivilege]): LogicalPlan = {
+    val options = withOrigin(ctx) { resolveOptions(optionsClause) }
+    withIdentClause(ctx, parts => withOrigin(ctx) {
+      UnresolvedInsertTarget(parts, options, writePrivileges)
+    })
   }
 
   /**
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/statements.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/statements.scala
index e10fa680ddda..75a06a39b841 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/statements.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/statements.scala
@@ -20,10 +20,10 @@ package org.apache.spark.sql.catalyst.plans.logical
 import org.apache.spark.sql.catalyst.analysis.{FieldName, FieldPosition, 
UnresolvedException}
 import org.apache.spark.sql.catalyst.expressions.{Attribute, Expression, 
Unevaluable}
 import org.apache.spark.sql.catalyst.trees.{LeafLike, UnaryLike}
+import org.apache.spark.sql.catalyst.trees.TreePattern._
 import org.apache.spark.sql.connector.catalog.ColumnDefaultValue
 import org.apache.spark.sql.errors.QueryExecutionErrors
 import org.apache.spark.sql.types.DataType
-import org.apache.spark.util.collection.BitSet
 
 /**
  * A logical plan node that contains exactly what was parsed from SQL.
@@ -158,7 +158,55 @@ case class QualifiedColType(
 }
 
 /**
- * An INSERT INTO statement, as parsed from SQL.
+ * A temporary INSERT plan whose target identifier expression still needs 
analyzer traversal.
+ *
+ * The target is exposed as a child only until it becomes an 
`UnresolvedInsertTarget`. The analyzer
+ * then converts this node to the unary [[InsertIntoStatement]], before 
resolving the target as a
+ * relation.
+ */
+case class UnresolvedInsert(
+    table: LogicalPlan,
+    partitionSpec: Map[String, Option[String]],
+    userSpecifiedCols: Seq[String],
+    query: LogicalPlan,
+    overwrite: Boolean,
+    ifPartitionNotExists: Boolean,
+    byName: Boolean = false,
+    replaceCriteriaOpt: Option[InsertReplaceCriteria] = None,
+    withSchemaEvolution: Boolean = false)
+  extends ParsedStatement with TransactionalWrite {
+
+  final override val nodePatterns: Seq[TreePattern] = Seq(UNRESOLVED_INSERT)
+
+  override def children: Seq[LogicalPlan] = Seq(table, query)
+
+  override def withCTEDefs(cteDefs: Seq[CTERelationDef]): LogicalPlan = {
+    copy(query = WithCTE(query, cteDefs))
+  }
+
+  def toInsertIntoStatement(target: LogicalPlan): InsertIntoStatement = {
+    val statement = InsertIntoStatement(
+      table = target,
+      partitionSpec = partitionSpec,
+      userSpecifiedCols = userSpecifiedCols,
+      query = query,
+      overwrite = overwrite,
+      ifPartitionNotExists = ifPartitionNotExists,
+      byName = byName,
+      replaceCriteriaOpt = replaceCriteriaOpt,
+      withSchemaEvolution = withSchemaEvolution)
+    statement.copyTagsFrom(this)
+    statement
+  }
+
+  override protected def withNewChildrenInternal(
+      newChildren: IndexedSeq[LogicalPlan]): UnresolvedInsert = {
+    copy(table = newChildren(0), query = newChildren(1))
+  }
+}
+
+/**
+ * An INSERT INTO statement whose target identifier is ready for relation 
resolution.
  *
  * @param table                the logical plan representing the table.
  * @param userSpecifiedCols    the user specified list of columns that belong 
to the table.
@@ -212,18 +260,6 @@ case class InsertIntoStatement(
   override def child: LogicalPlan = query
   override protected def withNewChildInternal(newChild: LogicalPlan): 
InsertIntoStatement =
     copy(query = newChild)
-
-  // `table` is a non-child LogicalPlan slot (`child = query`), so the default 
tree-pattern
-  // propagation in TreeNode/QueryPlan does not see patterns inside it. Add 
`table`'s bits here
-  // so that `containsPattern(...)` pruning correctly reports patterns living 
in `table`
-  // (e.g. `PARAMETER`, `PLAN_WITH_UNRESOLVED_IDENTIFIER`). The REPLACE 
criteria condition is an
-  // `Expression` child (`InsertReplaceCriteria` extends `Expression`), so its 
bits already
-  // propagate through the normal expression traversal.
-  override protected def getDefaultTreePatternBits: BitSet = {
-    val bits = super.getDefaultTreePatternBits
-    bits.union(table.treePatternBits)
-    bits
-  }
 }
 
 sealed abstract class InsertReplaceCriteria extends Expression with 
Unevaluable {
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/rules/RuleIdCollection.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/rules/RuleIdCollection.scala
index d2c1ab9a8bc2..907e7c6cc051 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/rules/RuleIdCollection.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/rules/RuleIdCollection.scala
@@ -73,6 +73,7 @@ object RuleIdCollection {
       "org.apache.spark.sql.catalyst.analysis.Analyzer$ResolveTempViews" ::
       "org.apache.spark.sql.catalyst.analysis.Analyzer$ResolveTranspose" ::
       "org.apache.spark.sql.catalyst.analysis.Analyzer$ResolveUnpivot" ::
+      
"org.apache.spark.sql.catalyst.analysis.Analyzer$ResolveUnresolvedInsert" ::
       "org.apache.spark.sql.catalyst.analysis.Analyzer$ResolveUpCast" ::
       "org.apache.spark.sql.catalyst.analysis.Analyzer$ResolveWindowFrame" ::
       "org.apache.spark.sql.catalyst.analysis.Analyzer$ResolveWindowOrder" ::
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
index 0679d0c332df..7bb61fea6658 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
@@ -203,6 +203,7 @@ object TreePattern extends Enumeration  {
   val UNRESOLVED_EVENT_TIME_WATERMARK: Value = Value
   val UNRESOLVED_HAVING: Value = Value
   val UNRESOLVED_HINT: Value = Value
+  val UNRESOLVED_INSERT: Value = Value
   val UNRESOLVED_QUALIFY: Value = Value
   val UNRESOLVED_FUNC: Value = Value
   val UNRESOLVED_PROCEDURE: Value = Value
diff --git 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/pipelines/PipelinesHandler.scala
 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/pipelines/PipelinesHandler.scala
index ca41281a812b..718d1670bf8c 100644
--- 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/pipelines/PipelinesHandler.scala
+++ 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/pipelines/PipelinesHandler.scala
@@ -31,7 +31,7 @@ import org.apache.spark.sql.{AnalysisException, Column}
 import org.apache.spark.sql.catalyst.TableIdentifier
 import org.apache.spark.sql.catalyst.analysis.{UnresolvedAttribute, 
UnresolvedRelation}
 import org.apache.spark.sql.catalyst.expressions.Expression
-import org.apache.spark.sql.catalyst.plans.logical.{Command, CreateNamespace, 
CreateTable, CreateTableAsSelect, CreateView, DescribeRelation, 
DescribeTablePartition, DropView, InsertIntoStatement, LogicalPlan, 
RenameTable, ShowColumns, ShowCreateTable, ShowFunctions, ShowTableProperties, 
ShowTables, ShowViews}
+import org.apache.spark.sql.catalyst.plans.logical.{Command, CreateNamespace, 
CreateTable, CreateTableAsSelect, CreateView, DescribeRelation, 
DescribeTablePartition, DropView, InsertIntoStatement, LogicalPlan, 
RenameTable, ShowColumns, ShowCreateTable, ShowFunctions, ShowTableProperties, 
ShowTables, ShowViews, UnresolvedInsert}
 import org.apache.spark.sql.classic.ClassicConversions._
 import org.apache.spark.sql.connect.common.DataTypeProtoConverter
 import org.apache.spark.sql.connect.service.SessionHolder
@@ -173,6 +173,7 @@ private[connect] object PipelinesHandler extends Logging {
       queryPlan.isInstanceOf[CreateTable] ||
       queryPlan.isInstanceOf[CreateView] ||
       queryPlan.isInstanceOf[InsertIntoStatement] ||
+      queryPlan.isInstanceOf[UnresolvedInsert] ||
       queryPlan.isInstanceOf[RenameTable] ||
       queryPlan.isInstanceOf[CreateNamespace] ||
       queryPlan.isInstanceOf[DropView]
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/QueryExecution.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/QueryExecution.scala
index 1fa70351f2d0..586cb5226173 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/QueryExecution.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/QueryExecution.scala
@@ -34,7 +34,7 @@ import org.apache.spark.sql.catalyst.{InternalRow, 
QueryPlanningTracker}
 import org.apache.spark.sql.catalyst.analysis.{Analyzer, LazyExpression, 
NameParameterizedQuery, UnsupportedOperationChecker}
 import org.apache.spark.sql.catalyst.expressions.codegen.ByteCodeStats
 import org.apache.spark.sql.catalyst.plans.QueryPlan
-import org.apache.spark.sql.catalyst.plans.logical.{AppendData, Command, 
CommandResult, CompoundBody, CreateTableAsSelect, LogicalPlan, 
OverwriteByExpression, OverwritePartitionsDynamic, ReplaceTableAsSelect, 
ReturnAnswer, Union, UnresolvedWith, WithCTE}
+import org.apache.spark.sql.catalyst.plans.logical.{AppendData, Command, 
CommandResult, CompoundBody, CreateTableAsSelect, LogicalPlan, 
OverwriteByExpression, OverwritePartitionsDynamic, ReplaceTableAsSelect, 
ReturnAnswer, Union, UnresolvedInsert, UnresolvedWith, WithCTE}
 import org.apache.spark.sql.catalyst.rules.{PlanChangeLogger, Rule, 
RuleExecutor}
 import org.apache.spark.sql.catalyst.transactions.TransactionUtils
 import org.apache.spark.sql.catalyst.util.StringUtils.PlanStringConcat
@@ -99,6 +99,22 @@ class QueryExecution(
     
logical.exists(_.expressions.exists(_.exists(_.isInstanceOf[LazyExpression])))
   }
 
+  // Legacy constant-only parameter substitution wraps the plan in a 
ParameterizedQuery. Its
+  // parameters bind during analysis, after transaction selection, so INSERT 
IDENTIFIER parameters
+  // are intentionally not handled here and remain a known limitation.
+  private val lazyLogicalWithResolvedInsert = LazyTry {
+    def resolve(insert: UnresolvedInsert): LogicalPlan = {
+      
analyzerOpt.getOrElse(sparkSession.sessionState.analyzer).resolveUnresolvedInsert(insert)
+    }
+
+    logical match {
+      case unresolvedWith @ UnresolvedWith(insert: UnresolvedInsert, _, _) =>
+        unresolvedWith.copy(child = resolve(insert))
+      case insert: UnresolvedInsert => resolve(insert)
+      case other => other
+    }
+  }
+  private def logicalWithResolvedInsert: LogicalPlan = 
lazyLogicalWithResolvedInsert.get
 
   // 1. At the pre-Analyzed plan we look for nodes that implement the 
TransactionalWrite trait.
   //    When a plan contains such a node we initiate a transaction. Note, we 
should never start
@@ -116,7 +132,7 @@ class QueryExecution(
     analyzerOpt.flatMap(_.catalogManager.transaction).orElse {
       // Only begin a new transaction for outer QEs that lead to execution.
       if (mode != CommandExecutionMode.SKIP) {
-        val catalog = logical match {
+        val catalog = logicalWithResolvedInsert match {
           case UnresolvedWith(TransactionalWrite(c), _, _) => Some(c)
           case TransactionalWrite(c) => Some(c)
           case _ => None
@@ -181,7 +197,7 @@ class QueryExecution(
         SqlScriptingExecution.executeSqlScript(
           session = sparkSession,
           script = compoundBody)
-      case _ => logical
+      case _ => logicalWithResolvedInsert
     }
   }
 
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/ParametersSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/ParametersSuite.scala
index edca2c36f256..58cdc03607d0 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/ParametersSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/ParametersSuite.scala
@@ -20,10 +20,10 @@ package org.apache.spark.sql
 import java.time.{Instant, LocalDate, LocalDateTime, ZoneId}
 
 import org.apache.spark.sql.catalyst.ExtendedAnalysisException
-import org.apache.spark.sql.catalyst.analysis.{BindParameters, 
CTESubstitution, ExpressionWithUnresolvedIdentifier, NameParameterizedQuery, 
PlanWithUnresolvedIdentifier}
+import org.apache.spark.sql.catalyst.analysis.{BindParameters, 
CTESubstitution, ExpressionWithUnresolvedIdentifier, NameParameterizedQuery, 
PlanWithUnresolvedIdentifier, UnresolvedRelation}
 import org.apache.spark.sql.catalyst.expressions.Literal
 import org.apache.spark.sql.catalyst.parser.ParseException
-import org.apache.spark.sql.catalyst.plans.logical.{CacheTableAsSelect, 
CTEInChildren, InsertIntoStatement, Limit, ReplaceTableAsSelect, WithCTE}
+import org.apache.spark.sql.catalyst.plans.logical.{CacheTableAsSelect, 
CTEInChildren, InsertIntoStatement, Limit, ReplaceTableAsSelect, 
UnresolvedInsert, WithCTE}
 import org.apache.spark.sql.catalyst.trees.SQLQueryContext
 import org.apache.spark.sql.catalyst.trees.TreePattern.PARAMETER
 import org.apache.spark.sql.catalyst.util.CharVarcharUtils
@@ -2474,9 +2474,8 @@ class ParametersSuite extends SharedSparkSession {
   }
 
   // SPARK-46625: WITH ... <write-with-IDENTIFIER> SELECT ... FROM cte
-  // The placeholder is pushed into the command's identifier slot at parse 
time, so
-  // `CTESubstitution` sees the `CTEInChildren` directly and never produces 
the invalid
-  // `WithCTE(InsertIntoStatement, ...)` / `WithCTE(CreateTableAsSelect, ...)` 
shape.
+  // The placeholder stays inside a CTEInChildren command at parse time, so 
CTESubstitution never
+  // produces a WithCTE around the command itself.
   private def assertNoWithCTEAroundCTEInChildren(df: DataFrame): Unit = {
     df.queryExecution.analyzed.foreach {
       case WithCTE(_: CTEInChildren, _) =>
@@ -2512,6 +2511,17 @@ class ParametersSuite extends SharedSparkSession {
     }
   }
 
+  test("INSERT target is not substituted by a same-named CTE") {
+    withTable("t_cte_target") {
+      sql("CREATE TABLE t_cte_target (a INT) USING PARQUET")
+      sql(
+        """WITH t_cte_target AS (SELECT 99 AS a)
+          |INSERT INTO IDENTIFIER(lower('T_CTE_TARGET'))
+          |SELECT 1 AS a""".stripMargin)
+      checkAnswer(spark.table("t_cte_target"), Row(1))
+    }
+  }
+
   test("SPARK-46625: CREATE TABLE IDENTIFIER(:p) AS WITH ... SELECT ... FROM 
cte") {
     withTable("t_cte_ctas") {
       val df = spark.sql(
@@ -2524,29 +2534,8 @@ class ParametersSuite extends SharedSparkSession {
     }
   }
 
-  // SPARK-46625: legacy parameter-substitution mode triggers the 
parameters.scala traversal
-  // path. The placeholder lives in `InsertIntoStatement.table`, which is 
*not* a child, so this
-  // exercises the `InsertIntoStatement` special-case in `BindParameters.bind` 
that recurses into
-  // the `table` slot, and the `getDefaultTreePatternBits` override on 
`InsertIntoStatement` that
-  // exposes `table`'s tree-pattern bits for pruning.
-  test("SPARK-46625: INSERT IDENTIFIER(:p) under legacy parameter 
substitution") {
-    withSQLConf(SQLConf.LEGACY_PARAMETER_SUBSTITUTION_CONSTANTS_ONLY.key -> 
"true") {
-      withTable("t_legacy_param") {
-        sql("CREATE TABLE t_legacy_param (a INT) USING PARQUET")
-        spark.sql(
-          """WITH transformation AS (SELECT 11 AS a)
-            |INSERT INTO IDENTIFIER(:tname)
-            |SELECT * FROM transformation""".stripMargin,
-          Map("tname" -> "t_legacy_param"))
-        checkAnswer(spark.table("t_legacy_param"), Row(11))
-      }
-    }
-  }
-
-  // SPARK-46625: INSERT INTO REPLACE WHERE parses into `InsertIntoStatement`, 
whose `table`
-  // slot is a non-child `LogicalPlan`. `PlanWithUnresolvedIdentifier` sits in 
the slot directly.
-  // Verify on the parsed plan that the placeholder lives in 
`InsertIntoStatement.table` rather
-  // than wrapping the whole command -- running the analyzer fully would 
require a v2 catalog.
+  // The temporary binary node exposes only a dynamic target to analyzer 
traversal and keeps CTE
+  // definitions on the input query.
   test("SPARK-46625: WITH ... INSERT INTO IDENTIFIER(:p) REPLACE WHERE ... 
parser") {
     // Use a non-literal-string expression so `withIdentClause` produces
     // `PlanWithUnresolvedIdentifier` rather than short-circuiting to 
`UnresolvedRelation`.
@@ -2554,14 +2543,18 @@ class ParametersSuite extends SharedSparkSession {
       """WITH transformation AS (SELECT 99 AS a)
         |INSERT INTO IDENTIFIER('some' || '_table') REPLACE WHERE a = 10
         |SELECT * FROM transformation""".stripMargin)
-    val insert = parsedPlan.collectFirst { case i: InsertIntoStatement => i 
}.getOrElse(
-      fail(s"Expected InsertIntoStatement in parsed plan:\n$parsedPlan"))
+    val insert = parsedPlan.collectFirst { case i: UnresolvedInsert => i 
}.getOrElse(
+      fail(s"Expected UnresolvedInsert in parsed plan:\n$parsedPlan"))
     assert(insert.table.isInstanceOf[PlanWithUnresolvedIdentifier],
-      s"Expected InsertIntoStatement.table to be PlanWithUnresolvedIdentifier, 
" +
+      s"Expected UnresolvedInsert.table to be PlanWithUnresolvedIdentifier, " +
         s"got ${insert.table.getClass.getSimpleName}:\n$parsedPlan")
-    // After CTESubstitution runs, the CTE defs should land on the command's 
children (because
-    // InsertIntoStatement is a CTEInChildren) -- never as 
`WithCTE(InsertIntoStatement, _)`.
+    assert(insert.children === Seq(insert.table, insert.query))
+
     val substituted = CTESubstitution.apply(parsedPlan)
+    val substitutedInsert = substituted.collectFirst { case i: 
UnresolvedInsert => i }.getOrElse(
+      fail(s"Expected UnresolvedInsert after CTE substitution:\n$substituted"))
+    assert(!substitutedInsert.table.exists(_.isInstanceOf[WithCTE]),
+      s"CTE definitions must not wrap the target table:\n$substituted")
     substituted.foreach {
       case WithCTE(_: CTEInChildren, _) =>
         fail(s"Found invalid WithCTE(CTEInChildren, _) shape after 
CTESubstitution:\n$substituted")
@@ -2569,30 +2562,40 @@ class ParametersSuite extends SharedSparkSession {
     }
   }
 
-  // SPARK-46625: Parameter inside `IDENTIFIER(:p)` on REPLACE WHERE lives in
-  // `InsertIntoStatement.table`, which is a non-child slot. Verify that
-  // `BindParameters.bind` reaches into the slot via the explicit 
`InsertIntoStatement`
-  // recursion (parameters.scala) and that the `getDefaultTreePatternBits` 
override on
-  // `InsertIntoStatement` exposes the PARAMETER bit for pruning. Done at the 
rule level
-  // because driving REPLACE WHERE through full analysis would require a v2 
catalog.
-  test("SPARK-46625: BindParameters recurses into InsertIntoStatement.table") {
+  test("dynamic INSERT targets use a temporary binary plan") {
+    val regularInsert = spark.sessionState.sqlParser
+      .parsePlan("INSERT INTO target_table SELECT 1")
+    assert(regularInsert.isInstanceOf[InsertIntoStatement])
+
+    val identifierInsert = spark.sessionState.sqlParser
+      .parsePlan("INSERT INTO IDENTIFIER(lower('TESTCAT.NS.TARGET_TABLE')) 
SELECT 1")
+      .asInstanceOf[UnresolvedInsert]
+    assert(identifierInsert.table.isInstanceOf[PlanWithUnresolvedIdentifier])
+    assert(identifierInsert.children === Seq(identifierInsert.table, 
identifierInsert.query))
+
+    val lowered = 
spark.sessionState.analyzer.resolveUnresolvedInsert(identifierInsert)
+      .asInstanceOf[InsertIntoStatement]
+    assert(lowered.table.asInstanceOf[UnresolvedRelation].multipartIdentifier 
===
+      Seq("testcat", "ns", "target_table"))
+    assert(lowered.children === Seq(lowered.query))
+  }
+
+  // BindParameters reaches a dynamic target through normal child traversal.
+  test("SPARK-46625: BindParameters visits UnresolvedInsert.table") {
     val parsedPlan = spark.sessionState.sqlParser.parsePlan(
       """INSERT INTO IDENTIFIER(:tname) REPLACE WHERE a = 10
         |SELECT 1 AS a""".stripMargin)
-    val insert = parsedPlan.collectFirst { case i: InsertIntoStatement => i 
}.getOrElse(
-      fail(s"Expected InsertIntoStatement in parsed plan:\n$parsedPlan"))
-    // Pruning prerequisite: the PARAMETER bit must be visible at the 
InsertIntoStatement
-    // level (it lives inside `table`, which is not a child); this exercises 
the
-    // `getDefaultTreePatternBits` override.
+    val insert = parsedPlan.collectFirst { case i: UnresolvedInsert => i 
}.getOrElse(
+      fail(s"Expected UnresolvedInsert in parsed plan:\n$parsedPlan"))
     assert(insert.containsPattern(PARAMETER),
-      "InsertIntoStatement.getDefaultTreePatternBits must propagate `table`'s 
PARAMETER bit")
+      "UnresolvedInsert must propagate the table child's PARAMETER bit")
 
     val bound = BindParameters.apply(
       NameParameterizedQuery(parsedPlan, Seq("tname"), 
Seq(Literal("foo_table"))))
-    val boundInsert = bound.collectFirst { case i: InsertIntoStatement => i 
}.getOrElse(
-      fail(s"Expected InsertIntoStatement in bound plan:\n$bound"))
+    val boundInsert = bound.collectFirst { case i: UnresolvedInsert => i 
}.getOrElse(
+      fail(s"Expected UnresolvedInsert in bound plan:\n$bound"))
     assert(!boundInsert.table.containsPattern(PARAMETER),
-      s"Expected :tname inside InsertIntoStatement.table to be bound, 
got:\n$boundInsert")
+      s"Expected :tname inside UnresolvedInsert.table to be bound, 
got:\n$boundInsert")
   }
 
   test("BindParameters binds IDENTIFIER(:p) in the table slot for REPLACE 
WHERE with a column " +
@@ -2600,31 +2603,37 @@ class ParametersSuite extends SharedSparkSession {
     val parsedPlan = spark.sessionState.sqlParser.parsePlan(
       """INSERT INTO IDENTIFIER(:tname) (id, name) REPLACE WHERE id = 1
         |SELECT 1 AS id, 'x' AS name""".stripMargin)
-    val insert = parsedPlan.collectFirst { case i: InsertIntoStatement => i 
}.getOrElse(
-      fail(s"Expected InsertIntoStatement in parsed plan:\n$parsedPlan"))
+    val insert = parsedPlan.collectFirst { case i: UnresolvedInsert => i 
}.getOrElse(
+      fail(s"Expected UnresolvedInsert in parsed plan:\n$parsedPlan"))
     assert(insert.containsPattern(PARAMETER),
-      "InsertIntoStatement must expose the table slot's PARAMETER bit for 
pruning")
+      "UnresolvedInsert must expose the table child's PARAMETER bit for 
pruning")
 
     val bound = BindParameters.apply(
       NameParameterizedQuery(parsedPlan, Seq("tname"), 
Seq(Literal("foo_table"))))
 
-    val boundInsert = bound.collectFirst { case i: InsertIntoStatement => i 
}.getOrElse(
-      fail(s"Expected InsertIntoStatement in bound plan:\n$bound"))
+    val boundInsert = bound.collectFirst { case i: UnresolvedInsert => i 
}.getOrElse(
+      fail(s"Expected UnresolvedInsert in bound plan:\n$bound"))
     assert(!boundInsert.table.containsPattern(PARAMETER),
-      s"Expected :tname inside InsertIntoStatement.table to be bound, 
got:\n$boundInsert")
+      s"Expected :tname inside UnresolvedInsert.table to be bound, 
got:\n$boundInsert")
     assert(boundInsert.userSpecifiedCols === Seq("id", "name"),
       s"Expected the column list to survive table-slot binding, got: " +
         s"${boundInsert.userSpecifiedCols}")
   }
 
-  // SPARK-46625 followup: `INSERT INTO IDENTIFIER(<sql-variable>) ...` places 
a
-  // `PlanWithUnresolvedIdentifier` in `InsertIntoStatement.table`, whose 
`identifierExpr`
-  // holds an `UnresolvedAttribute` for the variable name. That slot is a 
non-child
-  // `LogicalPlan`, so the default `ResolveReferences` traversal never 
resolves the
-  // attribute, `ResolveIdentifierClause` cannot fire (it waits on 
`identifierExpr.resolved`),
-  // and analysis fails. Verify that the explicit `InsertIntoStatement` case 
added to
-  // `ResolveReferences` rewrites the attribute to a `VariableReference` and 
the insert
-  // completes end-to-end.
+  test("ResolveFunctions resolves expressions in an UnresolvedInsert target") {
+    val parsedPlan = spark.sessionState.sqlParser.parsePlan(
+      """INSERT INTO IDENTIFIER(lower(regexp_replace(
+        |  'TESTCAT.PLACEHOLDER', 'PLACEHOLDER', 'TAB'))) REPLACE WHERE id = 1
+        |SELECT 1 AS id""".stripMargin)
+    val insert = parsedPlan.asInstanceOf[UnresolvedInsert]
+
+    val resolvedInsert = spark.sessionState.analyzer.ResolveFunctions(insert)
+      .asInstanceOf[UnresolvedInsert]
+    assert(resolvedInsert.table.asInstanceOf[PlanWithUnresolvedIdentifier]
+      .identifierExpr.resolved)
+  }
+
+  // Normal child traversal resolves a SQL variable in the target IDENTIFIER 
expression.
   test("SPARK-46625: INSERT INTO IDENTIFIER(<sql-variable>) resolves variable 
in table slot") {
     withTable("t_var_insert") {
       sql("CREATE TABLE t_var_insert (a INT) USING PARQUET")
@@ -2639,13 +2648,8 @@ class ParametersSuite extends SharedSparkSession {
     }
   }
 
-  // SPARK-46625 followup: when the SQL variable name in `IDENTIFIER(<name>)` 
collides
-  // with a query output column, the IDENTIFIER expression must still bind to 
the
-  // variable, not to the column. The `ResolveReferences` case for 
`InsertIntoStatement`
-  // resolves `identifierExpr` against the `PlanWithUnresolvedIdentifier` 
itself (whose
-  // `children` are `Nil` on this path), not against the surrounding 
`InsertIntoStatement`
-  // (whose child is `query`), so query output columns are out of scope and 
only the
-  // last-resort variable resolution path fires.
+  // The identifier leaf has no children, so a colliding query output column 
is out of scope and
+  // variable resolution remains the last resort.
   test("SPARK-46625: INSERT INTO IDENTIFIER(<sql-variable>) ignores colliding 
query columns") {
     withTable("t_shadow") {
       sql("CREATE TABLE t_shadow (a INT) USING PARQUET")
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/AppendDataTransactionSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/AppendDataTransactionSuite.scala
index e48d99061671..88235686d70c 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/AppendDataTransactionSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/AppendDataTransactionSuite.scala
@@ -175,6 +175,30 @@ class AppendDataTransactionSuite extends 
RowLevelOperationSuiteBase {
         Row(4, 400, "finance")))
   }
 
+  test("SQL INSERT INTO with function-valued IDENTIFIER and transactional 
checks") {
+    createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+      """{ "pk": 1, "salary": 100, "dep": "hr" }""")
+
+    val baseTargetLoadCount = catalog.loadTableCalls.count {
+      case (context, _) => !context.writePrivileges().isEmpty
+    }
+    val (txn, txnTables) = executeTransaction {
+      sql(s"INSERT INTO IDENTIFIER(lower('$tableNameAsString')) 
$targetOptionsClause " +
+        "VALUES (2, 200, 'software')")
+    }
+
+    assert(txn.currentState === Committed)
+    assert(txn.isClosed)
+    assert(txnTables.isEmpty)
+    assert(catalog.loadTableCalls.count {
+      case (context, _) => !context.writePrivileges().isEmpty
+    } === baseTargetLoadCount)
+    assertTargetLoadAndWriteOptions(txn, 
java.util.Set.of(TableWritePrivilege.INSERT))
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Seq(Row(1, 100, "hr"), Row(2, 200, "software")))
+  }
+
   for (isDynamic <- Seq(false, true))
   test(s"SQL INSERT OVERWRITE with transactional checks - isDynamic: 
$isDynamic") {
     // create table with initial data; table is partitioned by dep
diff --git 
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/SqlGraphRegistrationContext.scala
 
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/SqlGraphRegistrationContext.scala
index a8dd94040b91..daef65a00ccd 100644
--- 
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/SqlGraphRegistrationContext.scala
+++ 
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/SqlGraphRegistrationContext.scala
@@ -24,7 +24,7 @@ import org.apache.spark.sql.Column
 import org.apache.spark.sql.catalyst.{QueryPlanningTracker, TableIdentifier}
 import org.apache.spark.sql.catalyst.analysis.{UnresolvedAttribute, 
UnresolvedRelation}
 import org.apache.spark.sql.catalyst.expressions.Expression
-import org.apache.spark.sql.catalyst.plans.logical.{AutoCdcInto, 
CreateFlowCommand, CreateMaterializedViewAsSelect, CreateStreamingTable, 
CreateStreamingTableAsSelect, CreateStreamingTableAutoCdc, CreateView, 
InsertIntoStatement, LogicalPlan}
+import org.apache.spark.sql.catalyst.plans.logical.{AutoCdcInto, 
CreateFlowCommand, CreateMaterializedViewAsSelect, CreateStreamingTable, 
CreateStreamingTableAsSelect, CreateStreamingTableAutoCdc, CreateView, 
InsertIntoStatement, LogicalPlan, UnresolvedInsert}
 import org.apache.spark.sql.catalyst.util.StringUtils
 import org.apache.spark.sql.classic.ClassicConversions._
 import org.apache.spark.sql.execution.command.{CreateViewCommand, 
SetCatalogCommand, SetCommand, SetNamespaceCommand}
@@ -590,30 +590,9 @@ class SqlGraphRegistrationContext(
 
       cf.flowOperation match {
         case i: InsertIntoStatement =>
-          validateInsertIntoFlow(i, queryOrigin)
-          val flowTargetDatasetName = i.table match {
-            case u: UnresolvedRelation =>
-              IdentifierHelper.toTableIdentifier(u.multipartIdentifier)
-            case _ =>
-              throw SqlGraphElementRegistrationException(
-                msg = "Unable to resolve target dataset name for INSERT INTO 
flow",
-                queryOrigin = queryOrigin
-              )
-          }
-          graphRegistrationContext.registerFlow(
-            UntypedFlow(
-              identifier = flowIdentifier,
-              destinationIdentifier = 
qualifyDestinationIdentifier(flowTargetDatasetName),
-              func = FlowAnalysis.createFlowFunctionFromLogicalPlan(i.query),
-              sqlConf = context.getSqlConf,
-              once = false,
-              queryContext = QueryContext(
-                currentCatalog = context.getCurrentCatalogOpt,
-                currentDatabase = context.getCurrentDatabaseOpt
-              ),
-              origin = queryOrigin
-            )
-          )
+          handleInsert(i, flowIdentifier, queryOrigin)
+        case i: UnresolvedInsert =>
+          handleInsert(i.toInsertIntoStatement(i.table), flowIdentifier, 
queryOrigin)
         case a: AutoCdcInto =>
           val flowTargetDatasetName = 
IdentifierHelper.toTableIdentifier(a.targetTable)
           graphRegistrationContext.registerFlow(
@@ -648,6 +627,36 @@ class SqlGraphRegistrationContext(
       }
     }
 
+    private def handleInsert(
+        insert: InsertIntoStatement,
+        flowIdentifier: TableIdentifier,
+        queryOrigin: QueryOrigin): Unit = {
+      validateInsertIntoFlow(insert, queryOrigin)
+      val flowTargetDatasetName = insert.table match {
+        case u: UnresolvedRelation =>
+          IdentifierHelper.toTableIdentifier(u.multipartIdentifier)
+        case _ =>
+          throw SqlGraphElementRegistrationException(
+            msg = "Unable to resolve target dataset name for INSERT INTO flow",
+            queryOrigin = queryOrigin
+          )
+      }
+      graphRegistrationContext.registerFlow(
+        UntypedFlow(
+          identifier = flowIdentifier,
+          destinationIdentifier = 
qualifyDestinationIdentifier(flowTargetDatasetName),
+          func = FlowAnalysis.createFlowFunctionFromLogicalPlan(insert.query),
+          sqlConf = context.getSqlConf,
+          once = false,
+          queryContext = QueryContext(
+            currentCatalog = context.getCurrentCatalogOpt,
+            currentDatabase = context.getCurrentDatabaseOpt
+          ),
+          origin = queryOrigin
+        )
+      )
+    }
+
     /** Qualifies a raw flow target dataset identifier against the current 
catalog/database. */
     private def qualifyDestinationIdentifier(
         flowTargetDatasetIdentifier: TableIdentifier): TableIdentifier =


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

Reply via email to