peter-toth commented on code in PR #57865:
URL: https://github.com/apache/spark/pull/57865#discussion_r3780813492


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RelationResolution.scala:
##########
@@ -548,7 +548,15 @@ class RelationResolution(
    */
   private def loadRelation(ref: V2TableReference): LogicalPlan = {
     val resolvedCatalog = 
catalogManager.catalog(ref.catalog.name).asTableCatalog
-    val table = resolvedCatalog.loadTable(ref.identifier)
+    val table = ref.context match {
+      case V2TableReference.WriteTargetContext =>
+        // WriteTargetContext is currently used by transactional streaming 
writes and does not
+        // retain required write privileges. Keep the legacy load unchanged; 
options and
+        // privileges must be handled together in a follow-up.
+        resolvedCatalog.loadTable(ref.identifier)
+      case _ =>
+        CatalogV2Util.getTable(resolvedCatalog, ref.identifier, options = 
ref.options)

Review Comment:
   **Finding 1.** This is the same site as my round-1 comment, but the rebase 
onto #57799 turned it around: the branch can't be reached, and the description 
now claims a change the PR doesn't make.
   
   `resolveReference:499` picks `loadRelation` only when 
`!ref.context.cacheable`, and `Context` is sealed with three implementations 
(`V2TableReference.scala:88-112`) — `cacheable = false` only for 
`WriteTargetContext`. `TemporaryViewContext` and `TransactionContext` go to 
`getOrLoadRelation:508`, which on master already calls 
`CatalogV2Util.getTable(catalog, ref.identifier, options = ref.options)` at 
`:520`. So `case _` is dead.
   
   Measured: with the branch body replaced by a throw, 
`DataSourceV2OptionSuite`, `AppendDataTransactionSuite`, 
`StreamingTransactionSuite` and `DataSourceV2SQLSuiteV1Filter` are all green 
(453 tests, 1 ignored).
   
   Two consequences. The `match` reads as if `loadRelation` served several 
contexts when only one reaches it, which is exactly the kind of thing the next 
reader will keep alive. And these two description lines are now false and 
should go:
   
   * "DataFrame temporary-view and transaction-source re-resolution now invoke 
the context/options-aware `loadTable` overload, forwarding only 
catalog-declared table-state options"
   * the matching user-facing sentence, "DataFrame temporary-view and 
transaction-source re-resolution now invoke the context/options-aware 
`loadTable` overload."
   
   Back to the base shape, with the comment kept and the reachability spelled 
out:
   
   ```scala
     private def loadRelation(ref: V2TableReference): LogicalPlan = {
       val resolvedCatalog = 
catalogManager.catalog(ref.catalog.name).asTableCatalog
       // Only WriteTargetContext gets here (the sole non-cacheable context); 
it is currently used by
       // transactional streaming writes and does not retain required write 
privileges. Keep the
       // legacy load unchanged; options and privileges must be handled 
together in a follow-up.
       val table = resolvedCatalog.loadTable(ref.identifier)
       createRelation(ref, resolvedCatalog, table)
     }
   ```
   



##########
sql/core/src/main/scala/org/apache/spark/sql/classic/DataFrameWriter.scala:
##########
@@ -177,7 +177,9 @@ final class DataFrameWriter[T] private[sql](ds: Dataset[T]) 
extends sql.DataFram
               val catalog = CatalogV2Util.getTableProviderCatalog(
                 supportsExtract, catalogManager, dsOptions)
 
-              (catalog.loadTable(ident), Some(catalog), Some(ident))
+              val table = CatalogV2Util.getTableForWrite(
+                catalog, ident, getWritePrivileges, dsOptions)

Review Comment:
   **Finding 8.** Thanks for putting this in the description — that closes my 
round-1 finding 2. What's still missing is the release-notes surface: 
`docs/sql-migration-guide.md` has no entry, and this PR changes two behaviours 
that have already shipped.
   
   1. This line asks the catalog to authorize `INSERT` / `INSERT, DELETE` on 
the `SupportsCatalogOptions` `save()` path, which passed no privileges before, 
so a connector that authorizes can reject a `save()` that used to work.
   2. `getTableForWrite` now rejects `versionAsOf` / `timestampAsOf` (or their 
configured equivalents) on `insertInto`, `saveAsTable`, `save()` and, via the 
`UnresolvedRelation`, `DataFrameWriterV2.append` / `overwrite` / 
`overwritePartitions`. Measured on this head: `df.write.option("versionAsOf", 
"1").insertInto(t)` throws `UNSUPPORTED_FEATURE.TIME_TRAVEL`. Master has no 
check on that path at all — `insertIntoCommand` and `saveAsTableCommand` never 
build an `UnresolvedRelation`, so `RelationResolution`'s check never ran and 
the option was passed to the connector as an ordinary write option.
   
   The precedent is in the same section: "Upgrading from Spark SQL 4.2 to 4.3" 
already carries #57508's bullet ("each reference now uses its own options 
instead of the second reference silently inheriting the first reference's 
options via the analyzer's relation cache"), which is the read-side twin of 
this work.
   
   Rough bullets:
   
   ```markdown
   - Since Spark 4.3, `DataFrameWriter.save()` on a `SupportsCatalogOptions` 
source loads its write
     target with the required `TableWritePrivilege`s (`INSERT`, or `INSERT` and 
`DELETE` for
     `SaveMode.Overwrite`), matching `insertInto` and `saveAsTable`. A catalog 
that authorizes writes
     in `TableCatalog.loadTable` may now reject a `save()` that previously 
succeeded.
   - Since Spark 4.3, the Spark-recognized time-travel options (`versionAsOf` 
and `timestampAsOf`, or
     the keys configured by `spark.sql.timeTravelVersionKey` / 
`spark.sql.timeTravelTimestampKey`) are
     rejected with `UNSUPPORTED_FEATURE.TIME_TRAVEL` when set on a DataSource 
V2 write target.
     Previously they were passed to the connector as ordinary write options.
   ```
   



##########
sql/core/src/main/scala/org/apache/spark/sql/classic/DataFrameWriter.scala:
##########
@@ -486,7 +490,10 @@ final class DataFrameWriter[T] private[sql](ds: 
Dataset[T]) extends sql.DataFram
       v2ProviderOpt: Option[TableProvider],
       ident: Identifier,
       nameParts: Seq[String]): LogicalPlan = {
-    val tableOpt = try Option(catalog.loadTable(ident, 
getWritePrivileges.toSet.asJava)) catch {
+    val tableOptions = new CaseInsensitiveStringMap(extraOptions.toMap.asJava)
+    val tableOpt = try {
+      Option(CatalogV2Util.getTableForWrite(catalog, ident, 
getWritePrivileges, tableOptions))

Review Comment:
   **Finding 9.** `rejectTimeTravelOptionsForWrite` runs before the load, so it 
throws here before the `NoSuchTableException` catch can tell us whether there 
is a write target at all. Two measurements on this head:
   
   ```
   df.write.option("versionAsOf", 
"1").mode("append").saveAsTable("testcat.ns1.new_tbl")
     -> [UNSUPPORTED_FEATURE.TIME_TRAVEL] ... testcat.ns1.new_tbl     (table 
does not exist)
   
   df.writeTo("testcat.ns1.new_tbl2").option("versionAsOf", "1").create()
     -> succeeds, option passed straight through as a write option
   ```
   
   So "rejected consistently on existing write targets across the DataFrame 
writer APIs" doesn't hold either way round: it fires where the command is a 
`CreateTableAsSelect` and no target state is read, and it never fires on 
`DataFrameWriterV2`'s `create` / `replace` / `createOrReplace`. 
`SaveMode.Overwrite` here is the same shape — it goes to `ReplaceTableAsSelect` 
and discards the loaded table apart from the `V1Table` test at `:501`.
   
   Either narrow it to loads of an existing target, which is what the 
description promises:
   
   ```scala
       val tableOpt = try {
         Option(catalog.loadTable(ident, new TableContext(null, 
getWritePrivileges.asJava),
           CatalogV2Util.extractTableStateOptions(catalog, tableOptions)))
       } catch {
         case _: NoSuchTableException => None
       }
       tableOpt.foreach(_ => 
CatalogV2Util.rejectTimeTravelOptionsForWrite(catalog, ident, tableOptions))
   ```
   
   or apply it to the `CreateTableAsSelect` / `ReplaceTableAsSelect` write 
options too and reword the description to "on all catalog-backed V2 write 
paths". I'd take the second — the option is meaningless on a create as well — 
as long as the wording matches.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogV2Util.scala:
##########
@@ -519,6 +519,36 @@ private[sql] object CatalogV2Util {
     catalog.asTableCatalog.loadTable(ident, context, stateOptions)
   }
 
+  /**
+   * Loads a table for a write, forwarding the required privileges and only 
the write options that
+   * the catalog declares may affect table state. The complete option map 
remains on the write
+   * relation for write planning.
+   */
+  def getTableForWrite(
+      catalog: CatalogPlugin,
+      ident: Identifier,
+      writePrivileges: Set[TableWritePrivilege],
+      options: CaseInsensitiveStringMap): Table = {
+    rejectTimeTravelOptionsForWrite(catalog, ident, options)
+    val context = new TableContext(null, writePrivileges.asJava)
+    val stateOptions = extractTableStateOptions(catalog, options)
+    catalog.asTableCatalog.loadTable(ident, context, stateOptions)
+  }
+
+  private def rejectTimeTravelOptionsForWrite(
+      catalog: CatalogPlugin,
+      ident: Identifier,
+      options: CaseInsensitiveStringMap): Unit = {
+    val conf = SQLConf.get
+    val containsTimeTravelOption = Seq(
+      conf.getConf(SQLConf.TIME_TRAVEL_TIMESTAMP_KEY),
+      
conf.getConf(SQLConf.TIME_TRAVEL_VERSION_KEY)).exists(options.containsKey)
+    if (containsTimeTravelOption) {
+      throw QueryCompilationErrors.timeTravelUnsupportedError(

Review Comment:
   **Finding 10.** Same user mistake, different error depending on which API 
you used.
   
   `.quoted` backticks only when a part needs it, while the pre-existing check 
at `RelationResolution.scala:242` uses `toSQLId`, which always backticks. The 
new test encodes both forms side by side — `` `testcat`.`ns1`.`ns2`.`table` `` 
for the SQL and `writeTo` cases, `testcat.ns1.ns2.table` for `insertInto` and 
`saveAsTable`. Measured: `Time travel on the relation: 
testcat.ns1.probe_new_tbl`.
   
   The error *class* diverges too, because on the `UnresolvedRelation` paths 
`TimeTravelSpec.fromOptions:98` throws before `tryResolvePersistent` gets to 
its check:
   
   ```
   
df.write.option("versionAsOf","1").option("timestampAsOf","2021-01-01").insertInto(t)
     -> [UNSUPPORTED_FEATURE.TIME_TRAVEL]
   same options via writeTo(t).append()
     -> [INVALID_TIME_TRAVEL_SPEC] Cannot specify both version and timestamp ...
   ```
   
   `toSQLId` is already in scope in this file (`:407`), so the format half is 
one token:
   
   ```scala
         throw QueryCompilationErrors.timeTravelUnsupportedError(
           toSQLId(ident.toQualifiedNameParts(catalog)))
   ```
   
   For the class half, move the presence check ahead of the parse in 
`resolveRelation:163` when the write-privilege marker is set, so every path 
answers the same way for the same input:
   
   ```scala
       val timeTravelSpecFromOptions =
         if 
(u.options.containsKey(UnresolvedRelation.REQUIRED_WRITE_PRIVILEGES)) {
           // Time travel applies to reads only; reject it before parsing so a 
malformed or duplicated
           // spec on a write target reports TIME_TRAVEL rather than a 
parse-level error.
           rejectTimeTravelOptionsForWrite(...)
           None
         } else {
           TimeTravelSpec.fromOptions(u.options, ...)
         }
   ```
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2OptionSuite.scala:
##########
@@ -463,28 +639,131 @@ class DataSourceV2OptionSuite extends 
DatasourceV2SQLBase {
     withTable(t1) {
       sql(s"CREATE TABLE $t1 (id bigint, data string)")
       sql(s"INSERT INTO $t1 VALUES (1, 'a'), (2, 'b')")
+      inMemoryCatalog.resetLoadTableCalls()
 
       val captured = withQueryExecutionsCaptured(spark) {
         Seq(3 -> "c", 4 -> "d").toDF("id", "data")
           .writeTo(t1)
-          .option("write.split-size", "10")
+          .option(loadOption, loadOptionValue)
+          .option(writeOption, writeOptionValue)
           .overwrite(lit(true))
       }
       assert(captured.size === 1)
       val qe = captured.head
 
       var collected = qe.optimizedPlan.collect {
-        case OverwriteByExpression(_: DataSourceV2Relation, _, _, 
writeOptions, _, _, _, _) =>
-          assert(writeOptions("write.split-size") === "10")
+        case OverwriteByExpression(
+            relation: DataSourceV2Relation, _, _, writeOptions, _, _, _, _) =>
+          assert(relation.table.isInstanceOf[InMemoryBaseTable])
+          assert(writeOptions(loadOption) === loadOptionValue)
+          assert(writeOptions(writeOption) === writeOptionValue)
       }
       assert (collected.size == 1)
 
       collected = qe.executedPlan.collect {
         case OverwriteByExpressionExec(_, _, write, _, _) =>
           val append = 
write.toBatch.asInstanceOf[InMemoryBaseTable#TruncateAndAppend]
-          assert(append.info.options.get("write.split-size") === "10")
+          assertTargetOptions(append.info.options)
       }
       assert (collected.size == 1)
+      assertWriteLoad(Set(TableWritePrivilege.INSERT, 
TableWritePrivilege.DELETE))
+    }
+  }
+
+  test("SPARK-58389: DataFrameWriter saveAsTable separates load and write 
options") {
+    val t1 = s"${catalogAndNamespace}table"
+    withTable(t1) {
+      sql(s"CREATE TABLE $t1 (id bigint, data string)")
+      Seq(
+        ("append", Set(TableWritePrivilege.INSERT)),
+        ("overwrite", Set(TableWritePrivilege.INSERT, 
TableWritePrivilege.DELETE))
+      ).foreach { case (mode, expectedPrivileges) =>
+        inMemoryCatalog.resetLoadTableCalls()
+
+        val captured = withQueryExecutionsCaptured(spark) {
+          Seq(1 -> "a").toDF("id", "data")
+            .write
+            .option(loadOption, loadOptionValue)
+            .option(writeOption, writeOptionValue)
+            .mode(mode)
+            .saveAsTable(t1)
+        }
+
+        assertWriteLoad(expectedPrivileges)

Review Comment:
   **Finding 11.** Round-1 finding 5 is closed for the `UnresolvedRelation` 
paths, thanks. The three writer paths that don't go through it are still 
unpinned: `insertInto` (`DataFrameWriter.scala:361`), `saveAsTable` (`:493`) 
and `save()` (`:152`) each build `new 
CaseInsensitiveStringMap(extraOptions.toMap.asJava)`, so casing survives only 
because `CaseInsensitiveMap.toMap` is overridden to return `originalMap` — 
`iterator` is lowercased, so passing `extraOptions` itself would silently 
lowercase every key on all three paths and no test here would fail.
   
   `loadOption` / `writeOption` are lowercase, and `assertTargetOptions` reads 
through the case-insensitive `get`, so nothing catches it. One mixed-case key 
plus one assert in `assertWriteLoad` covers all three:
   
   ```scala
     private val loadOption = "load-Option"
   ```
   
   and in `assertWriteLoad`, next to the existing `options.get(loadOption)` 
check:
   
   ```scala
         assert(options.asCaseSensitiveMap().containsKey(loadOption))
   ```
   



##########
sql/api/src/main/scala/org/apache/spark/sql/DataFrameWriter.scala:
##########
@@ -267,7 +267,9 @@ abstract class DataFrameWriter[T] {
    *    +---+---+
    * }}}
    *
-   * Because it inserts data to an existing table, format or options will be 
ignored.
+   * Because it inserts data to an existing table, the format is ignored. For 
data source V2

Review Comment:
   **Finding 12.** The sentence you replaced ("format or options will be 
ignored") covered V1 as well, and that half is still true: for a V1 target 
`insertInto` builds `InsertIntoStatement` with no options at all 
(`DataFrameWriter.scala:392`), so the options are dropped. The new text only 
says what happens for V2, which leaves a V1 user with no statement either way.
   
   ```scala
      * Because it inserts data to an existing table, the format is ignored. 
For data source V2
      * tables, catalog-declared table-state options are forwarded to the table 
load and all options
      * are forwarded to the write; for V1 tables the options are ignored.
   ```
   



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

To unsubscribe, e-mail: [email protected]

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


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

Reply via email to