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

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


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 442b2ab64c67 [SPARK-58065][SQL] Support column lists for `INSERT 
REPLACE WHERE`
442b2ab64c67 is described below

commit 442b2ab64c671fa05aca16e5eed296a6c62c2a6a
Author: Leon Windheuser <[email protected]>
AuthorDate: Wed Jul 22 22:40:07 2026 +0800

    [SPARK-58065][SQL] Support column lists for `INSERT REPLACE WHERE`
    
    ### What changes were proposed in this pull request?
    * Refactor the `REPLACE WHERE` `OverwriteByExpression` into the 
`InsertIntoStatement` abstraction.
    * Add support for column lists for `INSERT REPLACE WHERE` which was lacking.
    
    ### Why are the changes needed?
    * The `InsertIntoStatement` gives us the `userSpecifiedCols` field to 
express column lists more easily. The `OverwriteByExpression` did not have that.
    * Other `INSERT INTO` statements support a column list.
    
    ### Does this PR introduce _any_ user-facing change?
    * Allows providing column lists in an `INSERT INTO ... REPLACE WHERE` 
statement.
    
    ### How was this patch tested?
    New unit tests.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Generated-by: Claude Code with Opus 4.8
    
    Closes #57142 from leonwind/lw/replace-where-col-list.
    
    Authored-by: Leon Windheuser <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
    (cherry picked from commit aed53d9d445c28e2e94be10371410c377ec354f6)
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../src/main/resources/error/error-conditions.json |  12 ++
 .../spark/sql/catalyst/parser/SqlBaseParser.g4     |   2 +-
 .../spark/sql/errors/QueryParsingErrors.scala      |  14 ++
 .../spark/sql/catalyst/analysis/Analyzer.scala     |  28 +++-
 .../analysis/ResolveIdentifierClause.scala         |   6 +-
 .../spark/sql/catalyst/analysis/parameters.scala   |   6 +-
 .../spark/sql/catalyst/parser/AstBuilder.scala     |  96 +++++++------
 .../sql/catalyst/plans/logical/statements.scala    |  42 +++++-
 .../spark/sql/errors/QueryCompilationErrors.scala  |   4 +
 .../org/apache/spark/sql/internal/SQLConf.scala    |  10 ++
 .../spark/sql/catalyst/parser/DDLParserSuite.scala | 151 +++++++++++++++++---
 .../spark/sql/execution/datasources/rules.scala    |   8 +-
 .../identifier-clause-legacy.sql.out               |  12 +-
 .../postgreSQL/window_part3.sql.out                |   2 +-
 .../results/identifier-clause-legacy.sql.out       |  12 +-
 .../results/postgreSQL/window_part3.sql.out        |   2 +-
 .../org/apache/spark/sql/ParametersSuite.scala     |  71 ++++++----
 .../spark/sql/connector/DataSourceV2SQLSuite.scala | 155 +++++++++++++++++++++
 .../execution/command/PlanResolutionSuite.scala    |  17 +++
 .../org/apache/spark/sql/sources/InsertSuite.scala |   9 +-
 20 files changed, 537 insertions(+), 122 deletions(-)

diff --git a/common/utils/src/main/resources/error/error-conditions.json 
b/common/utils/src/main/resources/error/error-conditions.json
index 9f6fb5ee99b2..854923c02eb9 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -3252,6 +3252,12 @@
     ],
     "sqlState" : "0A000"
   },
+  "INSERT_REPLACE_ON_COLUMN_LIST_NOT_ALLOWED" : {
+    "message" : [
+      "A column list is not allowed with INSERT INTO ... REPLACE ON."
+    ],
+    "sqlState" : "42000"
+  },
   "INSERT_REPLACE_ON_NOT_ENABLED" : {
     "message" : [
       "INSERT INTO ... REPLACE ON is not enabled. Set 
'spark.sql.insertIntoReplaceOn.enabled' to true to enable it."
@@ -3270,6 +3276,12 @@
     ],
     "sqlState" : "0A000"
   },
+  "INSERT_REPLACE_WHERE_COLUMN_LIST_NOT_ENABLED" : {
+    "message" : [
+      "INSERT INTO ... (column_list) REPLACE WHERE is not enabled. Set 
'spark.sql.insertIntoReplaceWhereColumnList.enabled' to true to enable it."
+    ],
+    "sqlState" : "0A000"
+  },
   "INSERT_REPLACE_WHERE_TABLE_ALIAS_NOT_ALLOWED" : {
     "message" : [
       "Table alias is not allowed with INSERT INTO ... REPLACE WHERE because 
the WHERE condition is evaluated against the target table directly.",
diff --git 
a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4 
b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
index ca5de63ecb0b..29094679e0fb 100644
--- 
a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
+++ 
b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
@@ -597,7 +597,7 @@ query
 insertInto
     : INSERT (WITH SCHEMA EVOLUTION)? OVERWRITE TABLE? identifierReference 
optionsClause? (partitionSpec (IF errorCapturingNot EXISTS)?)?  ((BY NAME) | 
identifierList)? #insertOverwriteTable
     | INSERT (WITH SCHEMA EVOLUTION)? INTO TABLE? identifierReference 
optionsClause? partitionSpec? (IF errorCapturingNot EXISTS)? ((BY NAME) | 
identifierList)?   #insertIntoTable
-    | INSERT (WITH SCHEMA EVOLUTION)? INTO TABLE? identifierReference 
tableAlias optionsClause? (BY NAME)?
+    | INSERT (WITH SCHEMA EVOLUTION)? INTO TABLE? identifierReference 
tableAlias optionsClause? ((BY NAME) | identifierList)?
         REPLACE (WHERE | ON) replaceCondition=booleanExpression        
#insertIntoReplaceBooleanCond
     | INSERT (WITH SCHEMA EVOLUTION)? INTO TABLE? identifierReference 
tableAlias optionsClause? (BY NAME)?
         REPLACE USING identifierList                                   
#insertIntoReplaceUsing
diff --git 
a/sql/api/src/main/scala/org/apache/spark/sql/errors/QueryParsingErrors.scala 
b/sql/api/src/main/scala/org/apache/spark/sql/errors/QueryParsingErrors.scala
index cb1fd7ad066d..7cf205e75ac4 100644
--- 
a/sql/api/src/main/scala/org/apache/spark/sql/errors/QueryParsingErrors.scala
+++ 
b/sql/api/src/main/scala/org/apache/spark/sql/errors/QueryParsingErrors.scala
@@ -58,6 +58,13 @@ private[sql] object QueryParsingErrors extends 
DataTypeErrorsBase {
       ctx)
   }
 
+  def insertReplaceOnColumnListNotAllowed(ctx: ParserRuleContext): Throwable = 
{
+    new ParseException(
+      errorClass = "INSERT_REPLACE_ON_COLUMN_LIST_NOT_ALLOWED",
+      messageParameters = Map.empty,
+      ctx)
+  }
+
   def insertReplaceUsingNotEnabled(ctx: ParserRuleContext): Throwable = {
     new ParseException(
       errorClass = "INSERT_REPLACE_USING_NOT_ENABLED",
@@ -72,6 +79,13 @@ private[sql] object QueryParsingErrors extends 
DataTypeErrorsBase {
       ctx)
   }
 
+  def insertReplaceWhereColumnListNotEnabled(ctx: ParserRuleContext): 
Throwable = {
+    new ParseException(
+      errorClass = "INSERT_REPLACE_WHERE_COLUMN_LIST_NOT_ENABLED",
+      messageParameters = Map.empty,
+      ctx)
+  }
+
   def insertReplaceWhereTableAliasNotAllowed(ctx: TableAliasContext): 
Throwable = {
     new ParseException(
       errorClass = "INSERT_REPLACE_WHERE_TABLE_ALIAS_NOT_ALLOWED",
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 e921d0e77c63..c629de82c6ff 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
@@ -1160,6 +1160,8 @@ class Analyzer(
         // Thus, we need to look at the raw plan if `relation` is a temporary 
view.
         // unwrapRelationPlan also resolves V2TableReference nodes in temp 
view plans.
         unwrapRelationPlan(relation) match {
+          case v: View if i.replaceCriteriaOpt.exists(_.isReplaceWhere) =>
+            throw 
QueryCompilationErrors.writeIntoViewNotAllowedError(v.desc.identifier, i)
           case v: View =>
             throw 
QueryCompilationErrors.insertIntoViewNotAllowedError(v.desc.identifier, table)
           case other => i.copy(table = other)
@@ -1351,14 +1353,14 @@ class Analyzer(
       case i: InsertIntoStatement
           if i.table.isInstanceOf[DataSourceV2Relation] &&
             i.query.resolved &&
-            i.replaceCriteriaOpt.isDefined =>
+            i.replaceCriteriaOpt.exists(_.isReplaceOnOrUsing) =>
         throw QueryCompilationErrors.unsupportedInsertReplaceOnOrUsing(
           i.table.asInstanceOf[DataSourceV2Relation].table.name())
 
       case i: InsertIntoStatement
           if i.table.isInstanceOf[DataSourceV2Relation] &&
             i.query.resolved &&
-            i.replaceCriteriaOpt.isEmpty =>
+            (i.replaceCriteriaOpt.isEmpty || 
i.replaceCriteriaOpt.exists(_.isReplaceWhere)) =>
         val r = i.table.asInstanceOf[DataSourceV2Relation]
         // ifPartitionNotExists is append with validation, but validation is 
not supported
         if (i.ifPartitionNotExists) {
@@ -1392,7 +1394,11 @@ class Analyzer(
               query,
               withSchemaEvolution = i.withSchemaEvolution)
           }
-        } else if (conf.partitionOverwriteMode == 
PartitionOverwriteMode.DYNAMIC) {
+        // Dynamic partition overwrite applies only to plain INSERT OVERWRITE. 
REPLACE WHERE always
+        // deletes by its condition, so it falls through to the 
OverwriteByExpression branch below
+        // even when the session is in DYNAMIC partition-overwrite mode.
+        } else if (i.replaceCriteriaOpt.isEmpty &&
+            conf.partitionOverwriteMode == PartitionOverwriteMode.DYNAMIC) {
           if (isByName) {
             OverwritePartitionsDynamic.byName(
               r,
@@ -1405,17 +1411,29 @@ class Analyzer(
               withSchemaEvolution = i.withSchemaEvolution)
           }
         } else {
+          val deleteExpr = i.replaceCriteriaOpt match {
+            case Some(InsertReplaceWhere(condition)) =>
+              if (staticPartitions.nonEmpty) {
+                throw SparkException.internalError(
+                  s"REPLACE WHERE must not carry static partitions, but got: 
$staticPartitions")
+              }
+              condition
+            case Some(other) => throw SparkException.internalError(
+              s"Replace criteria ${other.getClass.getSimpleName} must not 
reach " +
+                "ResolveInsertInto; REPLACE ON/USING are rejected earlier.")
+            case None => staticDeleteExpression(r, staticPartitions)
+          }
           if (isByName) {
             OverwriteByExpression.byName(
               table = r,
               df = query,
-              deleteExpr = staticDeleteExpression(r, staticPartitions),
+              deleteExpr = deleteExpr,
               withSchemaEvolution = i.withSchemaEvolution)
           } else {
             OverwriteByExpression.byPosition(
               table = r,
               query = query,
-              deleteExpr = staticDeleteExpression(r, staticPartitions),
+              deleteExpr = deleteExpr,
               withSchemaEvolution = i.withSchemaEvolution)
           }
         }
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 cfa6f3358806..2bccc99f31d8 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
@@ -73,9 +73,9 @@ class ResolveIdentifierClause(earlyBatches: 
Seq[RuleExecutor[LogicalPlan]#Batch]
           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` and
-      // `OverwriteByExpression` carry a parse-time placeholder today, but 
matching the
-      // `V2WriteCommand` trait keeps the rule consistent across the family.
+      // 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) {
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 31c835986e20..deb63a7d9789 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
@@ -183,9 +183,9 @@ object BindParameters extends Rule[LogicalPlan] with 
QueryErrorsBase {
         // 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). Today only the 
`OverwriteByExpression`
-        // variant of `V2WriteCommand` is parser-built with a placeholder in 
`table`; the trait
-        // match keeps the rule consistent for any future analyzer-built node 
in the same shape.
+        // 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.
         val withBoundTable = p1 match {
           case i: InsertIntoStatement if i.table.containsPattern(PARAMETER) =>
             i.copy(table = bind(i.table)(f))
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 2100241ce9db..6b60cb9ebd3a 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,11 +939,10 @@ class AstBuilder extends DataTypeAstBuilder
       query: LogicalPlan,
       queryAliasCtx: TableAliasContext): LogicalPlan = withOrigin(ctx) {
     ctx match {
-      // For all `InsertIntoStatement` / `OverwriteByExpression`-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).
+      // 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).
       case table: InsertIntoTableContext =>
         val insertParams = visitInsertIntoTable(table)
         val privileges = Set(TableWritePrivilege.INSERT)
@@ -973,36 +972,15 @@ class AstBuilder extends DataTypeAstBuilder
         // while REPLACE WHERE still can.
         val isInsertReplaceWhere = ctx.WHERE() != null
         if (isInsertReplaceWhere) {
-          // The unified grammar rule for REPLACE WHERE | ON accepts a table 
alias for
-          // symmetry with REPLACE ON (whose condition can reference the 
target via the
-          // alias, e.g. `t.col`). The REPLACE WHERE branch has no use for the 
alias
-          // because the WHERE condition is evaluated against the target table 
directly.
-          // Reject explicitly so users get a clear parse error instead of a 
confusing
-          // column-not-found at analysis time.
-          if (ctx.tableAlias() != null && ctx.tableAlias().strictIdentifier() 
!= null) {
-            throw 
QueryParsingErrors.insertReplaceWhereTableAliasNotAllowed(ctx.tableAlias())
-          }
-          val options = Option(ctx.optionsClause())
+          val insertParams = visitInsertIntoReplaceWhere(ctx)
           val privileges = Set(TableWritePrivilege.INSERT, 
TableWritePrivilege.DELETE)
-          // `PlanWithUnresolvedIdentifier` is a `NamedRelation`, so it can 
occupy
-          // `OverwriteByExpression.table` directly; the materialization 
happens in
-          // `ResolveIdentifierClause` via its `OverwriteByExpression` 
special-case.
-          val table = buildWriteTableSlot(ctx.identifierReference, options, 
privileges)
-          val deleteExpr = expression(ctx.replaceCondition)
-          val isByName = ctx.NAME() != null
-          if (isByName) {
-            OverwriteByExpression.byName(
-              table,
-              df = query,
-              deleteExpr,
-              withSchemaEvolution = ctx.EVOLUTION() != null)
-          } else {
-            OverwriteByExpression.byPosition(
-              table,
-              query = query,
-              deleteExpr,
-              withSchemaEvolution = ctx.EVOLUTION() != null)
-          }
+          createInsertIntoStatement(
+            insertParams = insertParams,
+            tableSlot = buildWriteTableSlot(
+              insertParams.relationCtx, insertParams.options, privileges),
+            query = query,
+            overwrite = true,
+            withSchemaEvolution = ctx.EVOLUTION() != null)
         } else {
           val insertParams = visitInsertIntoReplaceOn(ctx)
           val privileges = Set(TableWritePrivilege.INSERT, 
TableWritePrivilege.DELETE)
@@ -1109,9 +1087,10 @@ class AstBuilder extends DataTypeAstBuilder
     }
     val replaceUsingCols = visitIdentifierList(ctx.identifierList())
 
-    createInsertIntoReplaceOnOrUsingParams(
+    createInsertIntoReplaceParams(
       relationCtx = ctx.identifierReference(),
       optionsCtx = ctx.optionsClause(),
+      userSpecifiedCols = Seq.empty,
       byName = byName,
       replaceCriteriaOpt = Some(InsertReplaceUsing(replaceUsingCols))
     )
@@ -1129,27 +1108,62 @@ class AstBuilder extends DataTypeAstBuilder
     if (byName && 
!SQLConf.get.getConf(SQLConf.INSERT_INTO_REPLACE_ON_BY_NAME_ENABLED)) {
       throw QueryParsingErrors.insertReplaceOnByNameNotEnabled(ctx)
     }
+    // Only REPLACE WHERE supports a column list, but the grammar rule is 
shared with REPLACE ON,
+    // so reject the column list here.
+    if (ctx.identifierList() != null) {
+      throw 
QueryParsingErrors.insertReplaceOnColumnListNotAllowed(ctx.identifierList())
+    }
     val replaceOnCond = expression(ctx.replaceCondition)
     val tableAliasOpt =
       getTableAliasWithoutColumnAlias(ctx.tableAlias(), "INSERT REPLACE ON")
 
-    createInsertIntoReplaceOnOrUsingParams(
+    createInsertIntoReplaceParams(
       relationCtx = ctx.identifierReference(),
       optionsCtx = ctx.optionsClause(),
+      userSpecifiedCols = Seq.empty,
       byName = byName,
       replaceCriteriaOpt = Some(InsertReplaceOn(replaceOnCond, tableAliasOpt))
     )
   }
 
-  private def createInsertIntoReplaceOnOrUsingParams(
+  /**
+   * Add an INSERT INTO REPLACE WHERE operation to the logical plan.
+   */
+  def visitInsertIntoReplaceWhere(
+      ctx: InsertIntoReplaceBooleanCondContext): InsertTableParams = 
withOrigin(ctx) {
+    // The grammar rule shared with REPLACE ON accepts a table alias, but 
REPLACE WHERE has no
+    // use for it. Reject it explicitly for a clear parse error.
+    if (ctx.tableAlias() != null && ctx.tableAlias().strictIdentifier() != 
null) {
+      throw 
QueryParsingErrors.insertReplaceWhereTableAliasNotAllowed(ctx.tableAlias())
+    }
+    // BY NAME and a column list are mutually exclusive in the grammar.
+    val userSpecifiedCols = 
Option(ctx.identifierList()).map(visitIdentifierList).getOrElse(Nil)
+    if (userSpecifiedCols.nonEmpty &&
+        
!SQLConf.get.getConf(SQLConf.INSERT_INTO_REPLACE_WHERE_COLUMN_LIST_ENABLED)) {
+      throw 
QueryParsingErrors.insertReplaceWhereColumnListNotEnabled(ctx.identifierList())
+    }
+
+    val deleteExpr = expression(ctx.replaceCondition)
+
+    createInsertIntoReplaceParams(
+      relationCtx = ctx.identifierReference(),
+      optionsCtx = ctx.optionsClause(),
+      userSpecifiedCols = userSpecifiedCols,
+      byName = ctx.NAME() != null,
+      replaceCriteriaOpt = Some(InsertReplaceWhere(deleteExpr))
+    )
+  }
+
+  private def createInsertIntoReplaceParams(
       relationCtx: IdentifierReferenceContext,
       optionsCtx: OptionsClauseContext,
+      userSpecifiedCols: Seq[String],
       byName: Boolean,
       replaceCriteriaOpt: Option[InsertReplaceCriteria]): InsertTableParams = {
     InsertTableParams(
       relationCtx = relationCtx,
       options = Option(optionsCtx),
-      userSpecifiedCols = Seq.empty,
+      userSpecifiedCols = userSpecifiedCols,
       partitionSpec = Map[String, Option[String]](),
       ifPartitionNotExists = false,
       byName = byName,
@@ -1181,9 +1195,9 @@ class AstBuilder extends DataTypeAstBuilder
    * 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]], so the result
-   * fits `NamedRelation`-typed slots (e.g. `OverwriteByExpression.table`) as 
well as the more
-   * general `LogicalPlan` slot of `InsertIntoStatement.table`.
+   * 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
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 4fbe71ed7d3e..e10fa680ddda 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
@@ -174,7 +174,7 @@ case class QualifiedColType(
  *                             Only valid for static partitions.
  * @param byName               If true, reorder the data columns to match the 
column names of the
  *                             target table.
- * @param replaceCriteriaOpt   If specified, indicates an INSERT REPLACE 
ON/USING operation,
+ * @param replaceCriteriaOpt   If specified, indicates an INSERT REPLACE 
ON/USING/WHERE operation,
  *                             which atomically deletes existing rows that 
satisfy the replace
  *                             criteria and then inserts the query result rows 
into the table.
  * @param withSchemaEvolution  If true, enables automatic schema evolution for 
the operation.
@@ -201,12 +201,13 @@ case class InsertIntoStatement(
     "IF NOT EXISTS is only valid with static partitions")
   require(userSpecifiedCols.isEmpty || !byName,
     "BY NAME is only valid without specified cols")
-  require(replaceCriteriaOpt.isEmpty || userSpecifiedCols.isEmpty,
-    "userSpecifiedCols is not compatible with REPLACE USING/ON")
+  require(
+    !replaceCriteriaOpt.exists(_.isInstanceOf[InsertReplaceUsing]) || 
userSpecifiedCols.isEmpty,
+    "userSpecifiedCols is not compatible with REPLACE USING")
   require(replaceCriteriaOpt.isEmpty || partitionSpec.isEmpty,
-    "partitionSpec is not compatible with REPLACE USING/ON")
+    "partitionSpec is not compatible with REPLACE USING/ON/WHERE")
   require(replaceCriteriaOpt.isEmpty || overwrite,
-    "REPLACE USING/ON requires overwrite to be true")
+    "REPLACE USING/ON/WHERE requires overwrite to be true")
 
   override def child: LogicalPlan = query
   override protected def withNewChildInternal(newChild: LogicalPlan): 
InsertIntoStatement =
@@ -215,7 +216,9 @@ case class InsertIntoStatement(
   // `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`).
+  // (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)
@@ -226,6 +229,23 @@ case class InsertIntoStatement(
 sealed abstract class InsertReplaceCriteria extends Expression with 
Unevaluable {
   override def nullable: Boolean = false
   override def dataType: DataType = throw new UnresolvedException("dataType")
+
+  /**
+   * True for INSERT ... REPLACE WHERE, which `ResolveInsertInto` rewrites 
into an
+   * [[OverwriteByExpression]].
+   */
+  def isReplaceWhere: Boolean = this match {
+    case _: InsertReplaceWhere => true
+    case _: InsertReplaceOn | _: InsertReplaceUsing => false
+  }
+
+  /**
+   * True for INSERT ... REPLACE ON / REPLACE USING.
+   */
+  def isReplaceOnOrUsing: Boolean = this match {
+    case _: InsertReplaceOn | _: InsertReplaceUsing => true
+    case _: InsertReplaceWhere => false
+  }
 }
 
 /**
@@ -249,3 +269,13 @@ case class InsertReplaceOn(
       newChildren: IndexedSeq[Expression]): InsertReplaceOn =
     copy(cond = newChildren.head)
 }
+
+/**
+ * Rows are matched based on the specified boolean condition.
+ */
+case class InsertReplaceWhere(cond: Expression) extends InsertReplaceCriteria {
+  override def children: Seq[Expression] = Seq(cond)
+  override protected def withNewChildrenInternal(
+      newChildren: IndexedSeq[Expression]): InsertReplaceWhere =
+    copy(cond = newChildren.head)
+}
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
index 13fdb7b8756f..5f0f23b6de55 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
@@ -682,6 +682,10 @@ private[sql] object QueryCompilationErrors extends 
QueryErrorsBase with Compilat
     unsupportedTableOperationError(tableName, "INSERT INTO ... REPLACE 
ON/USING")
   }
 
+  def unsupportedInsertReplaceWhere(tableName: String): Throwable = {
+    unsupportedTableOperationError(tableName, "INSERT INTO ... REPLACE WHERE")
+  }
+
   def writeIntoViewNotAllowedError(identifier: TableIdentifier, t: 
TreeNode[_]): Throwable = {
     new AnalysisException(
       errorClass = "VIEW_WRITE_NOT_ALLOWED",
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index f71f27de5c6a..5cb317a52b33 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -5467,6 +5467,16 @@ object SQLConf {
       .booleanConf
       .createWithDefault(true)
 
+  val INSERT_INTO_REPLACE_WHERE_COLUMN_LIST_ENABLED =
+    buildConf("spark.sql.insertIntoReplaceWhereColumnList.enabled")
+      .doc("Enable the SQL syntax INSERT INTO ... (column_list) REPLACE WHERE 
(...). " +
+        "Allows using a column list with INSERT INTO REPLACE WHERE.")
+      .internal()
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.SESSION)
+      .booleanConf
+      .createWithDefault(true)
+
   val BIN_BY_ENABLED =
     buildConf("spark.sql.binByRelationOperator.enabled")
       .doc("Enable the BIN BY relation operator for aligning range-typed rows 
to " +
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/DDLParserSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/DDLParserSuite.scala
index f33c11c8a2d4..5d8ff7d2155b 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/DDLParserSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/DDLParserSuite.scala
@@ -1846,23 +1846,30 @@ class DDLParserSuite extends AnalysisTest {
   test("insert table: REPLACE WHERE with BY NAME") {
     parseCompare(
       "INSERT INTO testcat.ns1.ns2.tbl BY NAME REPLACE WHERE a > 5 SELECT * 
FROM source",
-      OverwriteByExpression.byName(
-        UnresolvedRelation(Seq("testcat", "ns1", "ns2", "tbl")),
-        Project(Seq(UnresolvedStar(None)), UnresolvedRelation(Seq("source"))),
-        GreaterThan(
-          UnresolvedAttribute("a"),
-          Literal(5))))
+      InsertIntoStatement(
+        table = UnresolvedRelation(Seq("testcat", "ns1", "ns2", "tbl")),
+        partitionSpec = Map.empty,
+        userSpecifiedCols = Nil,
+        query = Project(Seq(UnresolvedStar(None)), 
UnresolvedRelation(Seq("source"))),
+        overwrite = true,
+        ifPartitionNotExists = false,
+        byName = true,
+        replaceCriteriaOpt = Some(InsertReplaceWhere(
+          GreaterThan(UnresolvedAttribute("a"), Literal(5))))))
   }
 
   test("insert table: REPLACE WHERE without BY NAME") {
     parseCompare(
       "INSERT INTO testcat.ns1.ns2.tbl REPLACE WHERE a > 5 SELECT * FROM 
source",
-      OverwriteByExpression.byPosition(
-        UnresolvedRelation(Seq("testcat", "ns1", "ns2", "tbl")),
-        Project(Seq(UnresolvedStar(None)), UnresolvedRelation(Seq("source"))),
-        GreaterThan(
-          UnresolvedAttribute("a"),
-          Literal(5))))
+      InsertIntoStatement(
+        table = UnresolvedRelation(Seq("testcat", "ns1", "ns2", "tbl")),
+        partitionSpec = Map.empty,
+        userSpecifiedCols = Nil,
+        query = Project(Seq(UnresolvedStar(None)), 
UnresolvedRelation(Seq("source"))),
+        overwrite = true,
+        ifPartitionNotExists = false,
+        replaceCriteriaOpt = Some(InsertReplaceWhere(
+          GreaterThan(UnresolvedAttribute("a"), Literal(5))))))
   }
 
   test("insert table: REPLACE WHERE rejects tableAlias with BY NAME") {
@@ -1889,6 +1896,99 @@ class DDLParserSuite extends AnalysisTest {
         start = 0, stop = 55))
   }
 
+  test("insert table: REPLACE WHERE rejects tableAlias with column list") {
+    checkError(
+      exception = parseException(
+        "INSERT INTO testcat.ns1.ns2.tbl AS t (a, b) REPLACE WHERE a > 5 
SELECT * FROM source"),
+      condition = "INSERT_REPLACE_WHERE_TABLE_ALIAS_NOT_ALLOWED",
+      parameters = Map.empty,
+      context = ExpectedContext(
+        fragment = "INSERT INTO testcat.ns1.ns2.tbl AS t (a, b) REPLACE WHERE 
a > 5",
+        start = 0, stop = 62))
+  }
+
+  test("insert table: REPLACE WHERE with column list") {
+    parseCompare(
+      "INSERT INTO testcat.ns1.ns2.tbl (a, b) REPLACE WHERE a > 5 SELECT * 
FROM source",
+      InsertIntoStatement(
+        table = UnresolvedRelation(Seq("testcat", "ns1", "ns2", "tbl")),
+        partitionSpec = Map.empty,
+        userSpecifiedCols = Seq("a", "b"),
+        query = Project(Seq(UnresolvedStar(None)), 
UnresolvedRelation(Seq("source"))),
+        overwrite = true,
+        ifPartitionNotExists = false,
+        replaceCriteriaOpt = Some(InsertReplaceWhere(
+          GreaterThan(UnresolvedAttribute("a"), Literal(5))))))
+  }
+
+  test("insert table: REPLACE WHERE rejects BY NAME with column list") {
+    checkError(
+      exception = parseException(
+        "INSERT INTO testcat.ns1.ns2.tbl BY NAME (a, b) REPLACE WHERE a > 5 
SELECT * FROM source"),
+      condition = "PARSE_SYNTAX_ERROR",
+      parameters = Map("error" -> "'a'", "hint" -> ""))
+
+    checkError(
+      exception = parseException(
+        "INSERT INTO testcat.ns1.ns2.tbl (a, b) BY NAME REPLACE WHERE a > 5 
SELECT * FROM source"),
+      condition = "PARSE_SYNTAX_ERROR",
+      parameters = Map("error" -> "'BY'", "hint" -> ""))
+  }
+
+  test("insert table: REPLACE WHERE rejects column list when the feature flag 
is disabled") {
+    withSQLConf(SQLConf.INSERT_INTO_REPLACE_WHERE_COLUMN_LIST_ENABLED.key -> 
"false") {
+      checkError(
+        exception = parseException(
+          "INSERT INTO testcat.ns1.ns2.tbl (a, b) REPLACE WHERE a > 5 SELECT * 
FROM source"),
+        condition = "INSERT_REPLACE_WHERE_COLUMN_LIST_NOT_ENABLED",
+        parameters = Map.empty,
+        context = ExpectedContext(
+          fragment = "INSERT INTO testcat.ns1.ns2.tbl (a, b) REPLACE WHERE a > 
5",
+          start = 0,
+          stop = 57))
+    }
+  }
+
+  test("insert table: REPLACE WHERE rejects empty column list") {
+    checkError(
+      exception = parseException(
+        "INSERT INTO testcat.ns1.ns2.tbl () REPLACE WHERE a > 5 SELECT * FROM 
source"),
+      condition = "PARSE_SYNTAX_ERROR",
+      parameters = Map("error" -> "')'", "hint" -> ""))
+  }
+
+  test("insert table: INSERT WITH SCHEMA EVOLUTION INTO ... (cols) REPLACE 
WHERE") {
+    parseCompare(
+      "INSERT WITH SCHEMA EVOLUTION INTO testcat.ns1.ns2.tbl (a, b) " +
+        "REPLACE WHERE a > 5 SELECT * FROM source",
+      InsertIntoStatement(
+        table = UnresolvedRelation(Seq("testcat", "ns1", "ns2", "tbl")),
+        partitionSpec = Map.empty,
+        userSpecifiedCols = Seq("a", "b"),
+        query = Project(Seq(UnresolvedStar(None)), 
UnresolvedRelation(Seq("source"))),
+        overwrite = true,
+        ifPartitionNotExists = false,
+        replaceCriteriaOpt = Some(InsertReplaceWhere(
+          GreaterThan(UnresolvedAttribute("a"), Literal(5)))),
+        withSchemaEvolution = true))
+  }
+
+  test("insert table: REPLACE WHERE with column list and options") {
+    val opts = new CaseInsensitiveStringMap(java.util.Map.of("key", "value"))
+    parseCompare(
+      "INSERT INTO testcat.ns1.ns2.tbl WITH (key = 'value') (a, b) " +
+        "REPLACE WHERE a > 5 SELECT * FROM source",
+      InsertIntoStatement(
+        table = UnresolvedRelation(Seq("testcat", "ns1", "ns2", "tbl"), opts),
+        partitionSpec = Map.empty,
+        userSpecifiedCols = Seq("a", "b"),
+        query = Project(Seq(UnresolvedStar(None)), 
UnresolvedRelation(Seq("source"))),
+        overwrite = true,
+        ifPartitionNotExists = false,
+        replaceCriteriaOpt = Some(InsertReplaceWhere(
+          GreaterThan(UnresolvedAttribute("a"), Literal(5))))))
+  }
+
   for {
     isByName <- Seq(true, false)
     userSpecifiedCols <- if (!isByName) {
@@ -2082,6 +2182,18 @@ class DDLParserSuite extends AnalysisTest {
     )
   }
 
+  test("insert table: REPLACE ON rejects a column list") {
+    checkError(
+      exception = parseException(
+        "INSERT INTO testcat.ns1.ns2.tbl (a, b) REPLACE ON col1 = col2 SELECT 
* FROM source"),
+      condition = "INSERT_REPLACE_ON_COLUMN_LIST_NOT_ALLOWED",
+      parameters = Map.empty,
+      context = ExpectedContext(
+        fragment = "INSERT INTO testcat.ns1.ns2.tbl (a, b) REPLACE ON col1 = 
col2",
+        start = 0,
+        stop = 60))
+  }
+
   test("INSERT INTO REPLACE ON with source query alias") {
     val table = "testcat.ns1.ns2.tbl"
     parseCompare(
@@ -2136,12 +2248,15 @@ class DDLParserSuite extends AnalysisTest {
         """INSERT INTO testcat.ns1.ns2.tbl
           |REPLACE WHERE a > 5
           |(SELECT * FROM source) AS s""".stripMargin,
-      expected = OverwriteByExpression.byPosition(
-        UnresolvedRelation(Seq("testcat", "ns1", "ns2", "tbl")),
-        Project(Seq(UnresolvedStar(None)), UnresolvedRelation(Seq("source"))),
-        GreaterThan(
-          UnresolvedAttribute("a"),
-          Literal(5))))
+      expected = InsertIntoStatement(
+        table = UnresolvedRelation(Seq("testcat", "ns1", "ns2", "tbl")),
+        partitionSpec = Map.empty,
+        userSpecifiedCols = Nil,
+        query = Project(Seq(UnresolvedStar(None)), 
UnresolvedRelation(Seq("source"))),
+        overwrite = true,
+        ifPartitionNotExists = false,
+        replaceCriteriaOpt = Some(InsertReplaceWhere(
+          GreaterThan(UnresolvedAttribute("a"), Literal(5))))))
   }
 
   test("INSERT INTO REPLACE ON with compound condition") {
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/rules.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/rules.scala
index 3e20900da55b..040a067ec820 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/rules.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/rules.scala
@@ -468,8 +468,12 @@ object PreprocessTableInsertion extends 
ResolveInsertionBase {
       throw QueryCompilationErrors.unsupportedInsertWithSchemaEvolution()
     }
 
-    if (insert.replaceCriteriaOpt.isDefined) {
-      throw QueryCompilationErrors.unsupportedInsertReplaceOnOrUsing(tblName)
+    insert.replaceCriteriaOpt match {
+      case Some(_: InsertReplaceWhere) =>
+        throw QueryCompilationErrors.unsupportedInsertReplaceWhere(tblName)
+      case Some(_) =>
+        throw QueryCompilationErrors.unsupportedInsertReplaceOnOrUsing(tblName)
+      case None =>
     }
 
     val normalizedPartSpec = normalizePartitionSpec(
diff --git 
a/sql/core/src/test/resources/sql-tests/analyzer-results/identifier-clause-legacy.sql.out
 
b/sql/core/src/test/resources/sql-tests/analyzer-results/identifier-clause-legacy.sql.out
index 4cecec03578a..6164ff65dabc 100644
--- 
a/sql/core/src/test/resources/sql-tests/analyzer-results/identifier-clause-legacy.sql.out
+++ 
b/sql/core/src/test/resources/sql-tests/analyzer-results/identifier-clause-legacy.sql.out
@@ -1358,7 +1358,7 @@ org.apache.spark.sql.catalyst.parser.ParseException
   "errorClass" : "PARSE_SYNTAX_ERROR",
   "sqlState" : "42601",
   "messageParameters" : {
-    "error" : "''my_table''",
+    "error" : "'('",
     "hint" : ""
   }
 }
@@ -1630,7 +1630,7 @@ org.apache.spark.sql.catalyst.parser.ParseException
   "errorClass" : "PARSE_SYNTAX_ERROR",
   "sqlState" : "42601",
   "messageParameters" : {
-    "error" : "''schema.table''",
+    "error" : "'('",
     "hint" : ""
   }
 }
@@ -2124,8 +2124,8 @@ org.apache.spark.sql.catalyst.parser.ParseException
   "errorClass" : "PARSE_SYNTAX_ERROR",
   "sqlState" : "42601",
   "messageParameters" : {
-    "error" : "':'",
-    "hint" : ": extra input ':'"
+    "error" : "'('",
+    "hint" : ""
   },
   "queryContext" : [ {
     "objectType" : "EXECUTE IMMEDIATE",
@@ -2292,8 +2292,8 @@ org.apache.spark.sql.catalyst.parser.ParseException
   "errorClass" : "PARSE_SYNTAX_ERROR",
   "sqlState" : "42601",
   "messageParameters" : {
-    "error" : "':'",
-    "hint" : ": extra input ':'"
+    "error" : "'('",
+    "hint" : ""
   },
   "queryContext" : [ {
     "objectType" : "EXECUTE IMMEDIATE",
diff --git 
a/sql/core/src/test/resources/sql-tests/analyzer-results/postgreSQL/window_part3.sql.out
 
b/sql/core/src/test/resources/sql-tests/analyzer-results/postgreSQL/window_part3.sql.out
index ea75c4414243..ab9b354ea7bd 100644
--- 
a/sql/core/src/test/resources/sql-tests/analyzer-results/postgreSQL/window_part3.sql.out
+++ 
b/sql/core/src/test/resources/sql-tests/analyzer-results/postgreSQL/window_part3.sql.out
@@ -364,7 +364,7 @@ org.apache.spark.sql.catalyst.parser.ParseException
   "errorClass" : "PARSE_SYNTAX_ERROR",
   "sqlState" : "42601",
   "messageParameters" : {
-    "error" : "'BY'",
+    "error" : "'('",
     "hint" : ""
   }
 }
diff --git 
a/sql/core/src/test/resources/sql-tests/results/identifier-clause-legacy.sql.out
 
b/sql/core/src/test/resources/sql-tests/results/identifier-clause-legacy.sql.out
index cc8bd806365b..3b3bc11999c7 100644
--- 
a/sql/core/src/test/resources/sql-tests/results/identifier-clause-legacy.sql.out
+++ 
b/sql/core/src/test/resources/sql-tests/results/identifier-clause-legacy.sql.out
@@ -1502,7 +1502,7 @@ org.apache.spark.sql.catalyst.parser.ParseException
   "errorClass" : "PARSE_SYNTAX_ERROR",
   "sqlState" : "42601",
   "messageParameters" : {
-    "error" : "''my_table''",
+    "error" : "'('",
     "hint" : ""
   }
 }
@@ -1809,7 +1809,7 @@ org.apache.spark.sql.catalyst.parser.ParseException
   "errorClass" : "PARSE_SYNTAX_ERROR",
   "sqlState" : "42601",
   "messageParameters" : {
-    "error" : "''schema.table''",
+    "error" : "'('",
     "hint" : ""
   }
 }
@@ -2390,8 +2390,8 @@ org.apache.spark.sql.catalyst.parser.ParseException
   "errorClass" : "PARSE_SYNTAX_ERROR",
   "sqlState" : "42601",
   "messageParameters" : {
-    "error" : "':'",
-    "hint" : ": extra input ':'"
+    "error" : "'('",
+    "hint" : ""
   },
   "queryContext" : [ {
     "objectType" : "EXECUTE IMMEDIATE",
@@ -2574,8 +2574,8 @@ org.apache.spark.sql.catalyst.parser.ParseException
   "errorClass" : "PARSE_SYNTAX_ERROR",
   "sqlState" : "42601",
   "messageParameters" : {
-    "error" : "':'",
-    "hint" : ": extra input ':'"
+    "error" : "'('",
+    "hint" : ""
   },
   "queryContext" : [ {
     "objectType" : "EXECUTE IMMEDIATE",
diff --git 
a/sql/core/src/test/resources/sql-tests/results/postgreSQL/window_part3.sql.out 
b/sql/core/src/test/resources/sql-tests/results/postgreSQL/window_part3.sql.out
index 893528803096..95e8101801e5 100644
--- 
a/sql/core/src/test/resources/sql-tests/results/postgreSQL/window_part3.sql.out
+++ 
b/sql/core/src/test/resources/sql-tests/results/postgreSQL/window_part3.sql.out
@@ -383,7 +383,7 @@ org.apache.spark.sql.catalyst.parser.ParseException
   "errorClass" : "PARSE_SYNTAX_ERROR",
   "sqlState" : "42601",
   "messageParameters" : {
-    "error" : "'BY'",
+    "error" : "'('",
     "hint" : ""
   }
 }
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 ca7732772b58..91a999ad663f 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
@@ -23,7 +23,7 @@ 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.expressions.Literal
 import org.apache.spark.sql.catalyst.parser.ParseException
-import org.apache.spark.sql.catalyst.plans.logical.{CacheTableAsSelect, 
CTEInChildren, Limit, OverwriteByExpression, ReplaceTableAsSelect, WithCTE}
+import org.apache.spark.sql.catalyst.plans.logical.{CacheTableAsSelect, 
CTEInChildren, InsertIntoStatement, Limit, ReplaceTableAsSelect, 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
@@ -2533,11 +2533,10 @@ class ParametersSuite extends SharedSparkSession {
     }
   }
 
-  // SPARK-46625: INSERT INTO REPLACE WHERE goes through 
`OverwriteByExpression`, whose `table`
-  // slot is typed `NamedRelation`. `PlanWithUnresolvedIdentifier` extends 
`NamedRelation` so the
-  // placeholder sits in the slot directly. Verify on the parsed plan that the 
placeholder lives
-  // in `OverwriteByExpression.table` rather than wrapping the whole command 
-- running the
-  // analyzer fully would require a v2 catalog.
+  // 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.
   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`.
@@ -2545,13 +2544,13 @@ 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 overwrite = parsedPlan.collectFirst { case o: OverwriteByExpression => 
o }.getOrElse(
-      fail(s"Expected OverwriteByExpression in parsed plan:\n$parsedPlan"))
-    assert(overwrite.table.isInstanceOf[PlanWithUnresolvedIdentifier],
-      s"Expected OverwriteByExpression.table to be 
PlanWithUnresolvedIdentifier, " +
-        s"got ${overwrite.table.getClass.getSimpleName}:\n$parsedPlan")
+    val insert = parsedPlan.collectFirst { case i: InsertIntoStatement => i 
}.getOrElse(
+      fail(s"Expected InsertIntoStatement in parsed plan:\n$parsedPlan"))
+    assert(insert.table.isInstanceOf[PlanWithUnresolvedIdentifier],
+      s"Expected InsertIntoStatement.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
-    // OverwriteByExpression is a CTEInChildren) -- never as 
`WithCTE(OverwriteByExpression, _)`.
+    // InsertIntoStatement is a CTEInChildren) -- never as 
`WithCTE(InsertIntoStatement, _)`.
     val substituted = CTESubstitution.apply(parsedPlan)
     substituted.foreach {
       case WithCTE(_: CTEInChildren, _) =>
@@ -2561,29 +2560,51 @@ class ParametersSuite extends SharedSparkSession {
   }
 
   // SPARK-46625: Parameter inside `IDENTIFIER(:p)` on REPLACE WHERE lives in
-  // `OverwriteByExpression.table`, which is a non-child slot. Verify that
-  // `BindParameters.bind` reaches into the slot via the explicit 
`OverwriteByExpression`
+  // `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
-  // `OverwriteByExpression` exposes the PARAMETER bit for pruning. Done at 
the rule level
+  // `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 
OverwriteByExpression.table") {
+  test("SPARK-46625: BindParameters recurses into InsertIntoStatement.table") {
     val parsedPlan = spark.sessionState.sqlParser.parsePlan(
       """INSERT INTO IDENTIFIER(:tname) REPLACE WHERE a = 10
         |SELECT 1 AS a""".stripMargin)
-    val overwrite = parsedPlan.collectFirst { case o: OverwriteByExpression => 
o }.getOrElse(
-      fail(s"Expected OverwriteByExpression in parsed plan:\n$parsedPlan"))
-    // Pruning prerequisite: the PARAMETER bit must be visible at the 
OverwriteByExpression
+    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.
-    assert(overwrite.containsPattern(PARAMETER),
-      "OverwriteByExpression.getDefaultTreePatternBits must propagate 
`table`'s PARAMETER bit")
+    assert(insert.containsPattern(PARAMETER),
+      "InsertIntoStatement.getDefaultTreePatternBits must propagate `table`'s 
PARAMETER bit")
 
     val bound = BindParameters.apply(
       NameParameterizedQuery(parsedPlan, Seq("tname"), 
Seq(Literal("foo_table"))))
-    val boundOverwrite = bound.collectFirst { case o: OverwriteByExpression => 
o }.getOrElse(
-      fail(s"Expected OverwriteByExpression in bound plan:\n$bound"))
-    assert(!boundOverwrite.table.containsPattern(PARAMETER),
-      s"Expected :tname inside OverwriteByExpression.table to be bound, 
got:\n$boundOverwrite")
+    val boundInsert = bound.collectFirst { case i: InsertIntoStatement => i 
}.getOrElse(
+      fail(s"Expected InsertIntoStatement in bound plan:\n$bound"))
+    assert(!boundInsert.table.containsPattern(PARAMETER),
+      s"Expected :tname inside InsertIntoStatement.table to be bound, 
got:\n$boundInsert")
+  }
+
+  test("BindParameters binds IDENTIFIER(:p) in the table slot for REPLACE 
WHERE with a column " +
+      "list and preserves the column list") {
+    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"))
+    assert(insert.containsPattern(PARAMETER),
+      "InsertIntoStatement must expose the table slot'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"))
+    assert(!boundInsert.table.containsPattern(PARAMETER),
+      s"Expected :tname inside InsertIntoStatement.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
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
index 0896df430a42..78baca76aa75 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
@@ -4566,6 +4566,161 @@ class DataSourceV2SQLSuiteV1Filter
     }
   }
 
+  test("Selective Overwrite: REPLACE WHERE with column list - maps source 
columns by position") {
+    val df = spark.createDataFrame(Seq((1L, "a"), (2L, "b"), (3L, 
"c"))).toDF("id", "data")
+    df.createOrReplaceTempView("source")
+    val df2 = spark.createDataFrame(Seq(("d", 4L), ("e", 5L), ("f", 
6L))).toDF("data", "id")
+    df2.createOrReplaceTempView("source2")
+
+    val t = "testcat.tbl"
+    withTable(t) {
+      spark.sql(
+        s"CREATE TABLE $t (id bigint, data string) USING foo PARTITIONED BY 
(id)")
+      spark.sql(s"INSERT INTO TABLE $t SELECT * FROM source")
+
+      spark.sql(s"INSERT INTO $t (data, id) REPLACE WHERE id = 3 SELECT * FROM 
source2")
+      checkAnswer(
+        spark.table(s"$t"),
+        Seq(Row(1L, "a"), Row(2L, "b"), Row(4L, "d"), Row(5L, "e"), Row(6L, 
"f")))
+    }
+  }
+
+  test("Selective Overwrite: REPLACE WHERE with column list - omitted column 
becomes null") {
+    val df = spark.createDataFrame(Seq((1L, "a"), (2L, "b"), (3L, 
"c"))).toDF("id", "data")
+    df.createOrReplaceTempView("source")
+    val df2 = spark.createDataFrame(Seq(Tuple1(4L), Tuple1(5L))).toDF("id")
+    df2.createOrReplaceTempView("source2_ids")
+
+    val t = "testcat.tbl"
+    withTable(t) {
+      spark.sql(
+        s"CREATE TABLE $t (id bigint, data string) USING foo PARTITIONED BY 
(id)")
+      spark.sql(s"INSERT INTO TABLE $t SELECT * FROM source")
+
+      spark.sql(s"INSERT INTO $t (id) REPLACE WHERE id = 3 SELECT * FROM 
source2_ids")
+      checkAnswer(
+        spark.table(s"$t"),
+        Seq(Row(1L, "a"), Row(2L, "b"), Row(4L, null), Row(5L, null)))
+    }
+  }
+
+  test("Selective Overwrite: REPLACE WHERE with column list - too few data 
columns") {
+    val t = "testcat.tbl"
+    withTable(t) {
+      spark.sql(s"CREATE TABLE $t (id bigint, data string) USING foo 
PARTITIONED BY (id)")
+      checkError(
+        exception = analysisException(
+          s"INSERT INTO $t (id, data) REPLACE WHERE id = 1 SELECT 1"),
+        condition = "INSERT_COLUMN_ARITY_MISMATCH.NOT_ENOUGH_DATA_COLUMNS",
+        parameters = Map(
+          "tableName" -> "`testcat`.`tbl`",
+          "tableColumns" -> "`id`, `data`",
+          "dataColumns" -> "`1`"))
+    }
+  }
+
+  test("Selective Overwrite: REPLACE WHERE with column list - too many data 
columns") {
+    val t = "testcat.tbl"
+    withTable(t) {
+      spark.sql(s"CREATE TABLE $t (id bigint, data string) USING foo 
PARTITIONED BY (id)")
+      checkError(
+        exception = analysisException(
+          s"INSERT INTO $t (id) REPLACE WHERE id = 1 SELECT 1, 'a'"),
+        condition = "INSERT_COLUMN_ARITY_MISMATCH.TOO_MANY_DATA_COLUMNS",
+        parameters = Map(
+          "tableName" -> "`testcat`.`tbl`",
+          "tableColumns" -> "`id`",
+          "dataColumns" -> "`1`, `a`"))
+    }
+  }
+
+  test("Selective Overwrite: REPLACE WHERE with column list - unknown target 
column") {
+    val t = "testcat.tbl"
+    withTable(t) {
+      spark.sql(s"CREATE TABLE $t (id bigint, data string) USING foo 
PARTITIONED BY (id)")
+      checkError(
+        exception = analysisException(
+          s"INSERT INTO $t (id, nonexistent) REPLACE WHERE id = 1 SELECT 1, 
'a'"),
+        condition = "UNRESOLVED_COLUMN.WITH_SUGGESTION",
+        parameters = Map(
+          "objectName" -> "`nonexistent`",
+          "proposal" -> "`id`, `data`"),
+        context = ExpectedContext(
+          fragment = s"INSERT INTO $t (id, nonexistent) REPLACE WHERE id = 1",
+          start = 0,
+          stop = 50 + t.length))
+    }
+  }
+
+  test("Selective Overwrite: REPLACE WHERE with column list - duplicate 
columns") {
+    val t = "testcat.tbl"
+    withTable(t) {
+      spark.sql(s"CREATE TABLE $t (id bigint, data string) USING foo 
PARTITIONED BY (id)")
+      checkError(
+        exception = analysisException(
+          s"INSERT INTO $t (id, id) REPLACE WHERE id = 1 SELECT 1, 2"),
+        condition = "COLUMN_ALREADY_EXISTS",
+        parameters = Map("columnName" -> "`id`"))
+    }
+  }
+
+  test("Selective Overwrite: REPLACE WHERE with column list passes write 
options to the write") {
+    val t = "testcat.tbl"
+    withTable(t) {
+      spark.sql(s"CREATE TABLE $t (id bigint, data string) USING foo 
PARTITIONED BY (id)")
+      spark.sql(s"INSERT INTO $t VALUES (1, 'a'), (2, 'b'), (3, 'c')")
+
+      // The in-memory table fails the write only if the option reaches the 
write path.
+      intercept[IllegalStateException] {
+        spark.sql(
+          s"INSERT INTO $t WITH 
(`${InMemoryBaseTable.SIMULATE_FAILED_WRITE_OPTION}` = true) " +
+            "(data, id) REPLACE WHERE id = 3 SELECT 'z', 3")
+      }
+      checkAnswer(
+        spark.table(t),
+        Seq(Row(1L, "a"), Row(2L, "b"), Row(3L, "c")))
+    }
+  }
+
+  test("Selective Overwrite: REPLACE WHERE with column list maps struct fields 
by position") {
+    val t = "testcat.tbl"
+    withTable(t) {
+      spark.sql(
+        s"CREATE TABLE $t (id bigint, info struct<x: int, y: int>) USING foo 
PARTITIONED BY (id)")
+      spark.sql(
+        s"INSERT INTO $t VALUES (1, named_struct('x', 10, 'y', 20)), " +
+          "(2, named_struct('x', 30, 'y', 40))")
+
+      // Column list reorders top-level columns; struct fields still map by 
position, so the
+      // source's ('y', 'x') order lands in the target's (x, y).
+      spark.sql(
+        s"INSERT INTO $t (info, id) REPLACE WHERE id = 1 " +
+          "SELECT named_struct('y', 100, 'x', 200), 1")
+      checkAnswer(
+        spark.sql(s"SELECT id, info.x, info.y FROM $t ORDER BY id"),
+        Seq(Row(1L, 100, 200), Row(2L, 30, 40)))
+    }
+  }
+
+  test("Selective Overwrite: WITH SCHEMA EVOLUTION with column list widens the 
reordered column") {
+    val t = "testcat.tbl"
+    withTable(t) {
+      spark.sql(s"CREATE TABLE $t (id int, data string) USING foo PARTITIONED 
BY (id)")
+      spark.sql(s"INSERT INTO $t VALUES (1, 'a'), (2, 'b')")
+
+      // The column list reorders to (data, id) and supplies a BIGINT for id 
at position 1.
+      // Schema evolution must widen `id` (not `data`) to BIGINT.
+      spark.sql(
+        s"INSERT WITH SCHEMA EVOLUTION INTO $t (data, id) " +
+          "REPLACE WHERE id = 1 SELECT 'a', CAST(100 AS BIGINT)")
+
+      assert(spark.table(t).schema("id").dataType === LongType)
+      checkAnswer(
+        spark.table(t).orderBy("data"),
+        Seq(Row(100L, "a"), Row(2L, "b")))
+    }
+  }
+
   test("Overwrite: REPLACE WHERE without BY NAME - positional matching") {
     val df = spark.createDataFrame(Seq((1L, "a"), (2L, "b"), (3L, 
"c"))).toDF("id", "data")
     df.createOrReplaceTempView("source")
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/PlanResolutionSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/PlanResolutionSuite.scala
index cdba60d9960d..a924b637a79f 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/PlanResolutionSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/PlanResolutionSuite.scala
@@ -1397,6 +1397,23 @@ class PlanResolutionSuite extends SharedSparkSession 
with AnalysisTest {
     )
   }
 
+  test("INSERT INTO REPLACE WHERE with column list") {
+    val parsed = parseAndResolve(
+      """INSERT INTO testcat.tab (i, s)
+        |REPLACE WHERE i = 1
+        |SELECT * FROM v2Table""".stripMargin)
+    parsed match {
+      case overwriteByExpression: OverwriteByExpression =>
+        // ResolveInsertInto resolves the column list into a Project and 
converts the statement
+        // into a by-name OverwriteByExpression whose delete expression is the 
REPLACE WHERE
+        // condition, resolved against the target table.
+        assert(overwriteByExpression.isByName)
+        assert(overwriteByExpression.deleteExpr.resolved)
+      case other =>
+        fail(s"Expected OverwriteByExpression, but got: $other")
+    }
+  }
+
   test("INSERT INTO REPLACE ON is blocked when feature flag is disabled") {
     withSQLConf(SQLConf.INSERT_INTO_REPLACE_ON_ENABLED.key -> "false") {
       val ex = intercept[ParseException] {
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/sources/InsertSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/sources/InsertSuite.scala
index c790dc1d04c9..f2645e32e8a9 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/sources/InsertSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/sources/InsertSuite.scala
@@ -997,9 +997,10 @@ class InsertSuite extends DataSourceTest with 
SharedSparkSession {
     }
   }
 
-  for ((label, insertClause) <- Seq(
-      ("REPLACE ON", "AS t REPLACE ON t.i = 1"),
-      ("REPLACE USING", "AS t REPLACE USING (i)"))) {
+  for ((label, insertClause, operation) <- Seq(
+      ("REPLACE WHERE", "REPLACE WHERE i = 1", "INSERT INTO ... REPLACE 
WHERE"),
+      ("REPLACE ON", "AS t REPLACE ON t.i = 1", "INSERT INTO ... REPLACE 
ON/USING"),
+      ("REPLACE USING", "AS t REPLACE USING (i)", "INSERT INTO ... REPLACE 
ON/USING"))) {
     test(s"INSERT INTO ... $label is unsupported for V1 tables") {
       withTable("test_table") {
         val schema = new StructType().add("i", "int").add("j", "string")
@@ -1027,7 +1028,7 @@ class InsertSuite extends DataSourceTest with 
SharedSparkSession {
           sqlState = "0A000",
           parameters = Map(
             "tableName" -> "`spark_catalog`.`default`.`test_table`",
-            "operation" -> "INSERT INTO ... REPLACE ON/USING")
+            "operation" -> operation)
         )
       }
     }


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

Reply via email to