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


##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRelationCatalog.scala:
##########
@@ -47,6 +47,14 @@ class InMemoryRelationCatalog extends RelationCatalog with 
SupportsNamespaces {
     Option(store.get(key)).getOrElse(throw new NoSuchTableException(ident))
   }
 
+  private var _lastLoadRelationOptions: Option[CaseInsensitiveStringMap] = None

Review Comment:
   **Finding 5.** This records only the last bag, while the sibling fixture 
#57582 touched records every call: `InMemoryTableCatalog.scala:58-63` keeps 
`_loadTableCalls: ArrayBuffer[(TableContext, CaseInsensitiveStringMap)]` and 
exposes `loadTableCalls` / `resetLoadTableCalls()` / `lastLoadTableOptions` on 
top of it. That accumulator is what lets `DataSourceV2OptionSuite` assert the 
things a last-call recorder cannot express -- "one load per distinct option 
bag" (`:568`), "identical bags load once" (`:610`), "analysis *and* refresh 
both forwarded" (`:542`) -- and the `RelationCatalog` equivalents of all three 
are missing here; finding 1 needs the third. Suggest mirroring the sibling 
(plus the `scala.collection.mutable` import):
   
   ```scala
     private val _loadRelationCalls = 
mutable.ArrayBuffer.empty[CaseInsensitiveStringMap]
     def loadRelationCalls: Seq[CaseInsensitiveStringMap] = 
_loadRelationCalls.toSeq
     def resetLoadRelationCalls(): Unit = _loadRelationCalls.clear()
     def lastLoadRelationOptions: Option[CaseInsensitiveStringMap] = 
_loadRelationCalls.lastOption
   ```
   
   and putting it in the "Test-only accessors" section at `:227` next to 
`getStoredInfo` / `getStoredView`, rather than between `loadRelation` and the 
`----- TableCatalog -----` banner.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2OptionSuite.scala:
##########
@@ -81,6 +81,23 @@ class DataSourceV2OptionSuite extends DatasourceV2SQLBase {
     }
   }
 
+  test("Propagate options to RelationCatalog.loadRelation on read") {

Review Comment:
   **Finding 8.** Nit: no JIRA prefix, and the test sits between the 
`SPARK-36680` and `SPARK-50286` cases rather than with the option-forwarding 
group at `:462`-`:540` / the `SPARK-58389:` block at `:542`+ that it is the 
sequel to. Something like `test("SPARK-58392: options are forwarded to 
loadRelation - DataFrame API")`, placed next to `"options are forwarded to 
loadTable - DataFrame API"`, would read as the pair it is.
   



##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/RelationCatalog.java:
##########
@@ -134,6 +135,26 @@ public interface RelationCatalog extends TableCatalog, 
ViewCatalog {
    */
   Relation loadRelation(Identifier ident) throws NoSuchTableException;
 
+  /**

Review Comment:
   **Finding 3.** The class javadoc's "Single-RPC perf entry points" list 
(`RelationCatalog.java:109`) still describes `loadRelation(Identifier)` as "the 
resolver's per-identifier read path", which this PR makes false -- the resolver 
now calls the overload, and the 1-arg one is left serving the DDL lookup in 
`Analyzer.lookupTableOrView` 
(`sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala:1275`)
 plus the derived `loadTable` / `loadView` / `tableExists` / `viewExists` 
defaults. The new overload is not in that list at all. Rough shape:
   
   ```
    *   <li>{@link #loadRelation(Identifier)} -- returns a {@link Table} for a 
table or a
    *       {@link View} for a view; callers discriminate via {@code 
instanceof}. Saves the
    *       {@code loadTable} -> {@code loadView} fallback on a cold cache. 
Used for lookups that
    *       carry no read options (DDL and misc commands), and as the base that 
the
    *       {@code loadTable} / {@code loadView} defaults derive from.</li>
    *   <li>{@link #loadRelation(Identifier, CaseInsensitiveStringMap)} -- the 
resolver's
    *       per-identifier read path: same contract, plus the user's read 
options.</li>
   ```
   



##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/RelationCatalog.java:
##########
@@ -134,6 +135,26 @@ public interface RelationCatalog extends TableCatalog, 
ViewCatalog {
    */
   Relation loadRelation(Identifier ident) throws NoSuchTableException;
 
+  /**
+   * Load the relation for an identifier that may resolve to either a table or 
a view, forwarding
+   * all user-specified options.
+   * <p>
+   * Behaves like {@link #loadRelation(Identifier)} but also receives the 
options passed to the
+   * read. The default implementation ignores {@code options} and delegates to
+   * {@link #loadRelation(Identifier)}; catalogs that want to receive the user 
options while
+   * reading a relation must override this method.
+   *
+   * @param ident the identifier
+   * @param options all options passed to the read

Review Comment:
   **Finding 4.** "all options passed to the read" is accurate about the bag's 
contents but not about when it arrives: the resolver routes here only when 
there is no time travel and no write privileges 
(`sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RelationResolution.scala:267`).
 A `versionAsOf` read or an `INSERT INTO ... WITH (...)` target never reaches 
this method -- both go to `TableCatalog.loadTable(Identifier, TableContext, 
CaseInsensitiveStringMap)`. #57582 was explicit about the analogous contract 
("An override replaces that dispatch and must honor `context` itself..."); 
without the same note here, an implementor who overrides only this method 
reasonably believes they have covered reads. Suggest adding:
   
   ```
      * Spark calls this for a plain read only -- no time travel and no write 
privileges, both of
      * which apply to tables only and route through
      * {@link TableCatalog#loadTable(Identifier, TableContext, 
CaseInsensitiveStringMap)} instead.
   ```
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2OptionSuite.scala:
##########
@@ -81,6 +81,23 @@ class DataSourceV2OptionSuite extends DatasourceV2SQLBase {
     }
   }
 
+  test("Propagate options to RelationCatalog.loadRelation on read") {
+    registerCatalog("testrelcat", classOf[InMemoryRelationCatalog])
+    val t1 = "testrelcat.ns1.ns2.table"
+    withTable(t1) {
+      sql(s"CREATE TABLE $t1 (id bigint, data string) USING parquet")
+
+      val relCatalog = 
catalog("testrelcat").asInstanceOf[InMemoryRelationCatalog]
+      assert(relCatalog.lastLoadRelationOptions.isEmpty)
+
+      spark.read.option("customOption", "customValue").table(t1)
+        .queryExecution.analyzed
+      val recorded = relCatalog.lastLoadRelationOptions
+      assert(recorded.isDefined)
+      assert(recorded.get.get("customOption") == "customValue")

Review Comment:
   **Finding 6.** `loadRelation` is the one load method that can return a 
`View`, and this new overload is what a combined table+view catalog will use 
for both kinds -- but only the table branch is covered. A view case is cheap: 
`CREATE VIEW testrelcat.ns1.ns2.v AS SELECT 1 AS x` seeds one through 
`InMemoryRelationCatalog.createView` (the same way 
`DataSourceV2MetadataViewSuite.seedV2View` does against its own 
`RelationCatalog`), then `spark.read.option("customOption", 
"customValue").table("testrelcat.ns1.ns2.v")` and the same recorded-bag 
assertion. Worth having: a connector may well want to pick a view definition 
variant from the read options, and `RelationResolution.createRelation` takes a 
visibly different branch for `View` 
(`createDataSourceV1Scan(V1Table.toCatalogTable(...))`) than for `Table`.
   



##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/RelationCatalog.java:
##########
@@ -134,6 +135,26 @@ public interface RelationCatalog extends TableCatalog, 
ViewCatalog {
    */
   Relation loadRelation(Identifier ident) throws NoSuchTableException;
 
+  /**
+   * Load the relation for an identifier that may resolve to either a table or 
a view, forwarding
+   * all user-specified options.
+   * <p>
+   * Behaves like {@link #loadRelation(Identifier)} but also receives the 
options passed to the
+   * read. The default implementation ignores {@code options} and delegates to
+   * {@link #loadRelation(Identifier)}; catalogs that want to receive the user 
options while
+   * reading a relation must override this method.
+   *
+   * @param ident the identifier
+   * @param options all options passed to the read
+   * @return a {@link Table} for tables, or a {@link View} for views
+   * @throws NoSuchTableException if neither a table nor a view exists at 
{@code ident}
+   * @since 4.3.0
+   */
+  default Relation loadRelation(Identifier ident, CaseInsensitiveStringMap 
options)

Review Comment:
   **Finding 1.** The options only reach a `RelationCatalog` from the 
resolver's plain-read branch. Every other load-with-options goes through 
`TableCatalog.loadTable(Identifier, TableContext, CaseInsensitiveStringMap)`, 
which `RelationCatalog` does not override -- so for an empty context its 
default dispatches to `loadTable(ident)` (`TableCatalog.java:238`), which 
`RelationCatalog` derives from `loadRelation(ident)` 
(`RelationCatalog.java:196`), and the options are gone. All three of these call 
it with a non-empty options bag and an empty context:
   
   - `V2TableRefreshUtil.refresh` -> `CatalogV2Util.getTable(catalog, ident, 
options = r.options)` 
(`sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2TableRefreshUtil.scala:99`)
   - `CacheManager.tryRefreshPlan` -> the same call 
(`sql/core/src/main/scala/org/apache/spark/sql/execution/CacheManager.scala:422`)
   - the `SupportsCatalogOptions` `spark.read.format(...).option(...).load()` 
path 
(`sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Utils.scala:144`)
   
   The refresh one is the one that bites: it reloads and then does 
`r.copy(table = currentTable)`, so a `Table` that analysis shaped from the 
options is silently replaced at execution time by the one loaded without them.
   
   Measured with a `RelationCatalog` fixture that returns a real v2 `Table` and 
records both overloads ("bare" = 1-arg). For a single 
`spark.read.option("split-size", "5").table(t).collect()`:
   
       relationLoads = [options:5, bare]     // analysis forwards, the refresh 
does not
   
   Adding this default turns it into `[options:5, options:5]`, with 
`DataSourceV2OptionSuite` (29 tests, including the whole `SPARK-58389:` block) 
still green:
   
   ```java
     /**
      * {@inheritDoc}
      * <p>
      * The default implementation derives from {@link #loadRelation(Identifier,
      * CaseInsensitiveStringMap)} for a plain read, so the user options reach 
the relation-level
      * entry point. Time-travel and write-privilege loads keep {@link 
TableCatalog}'s dispatch --
      * both apply to tables only.
      */
     @Override
     default Table loadTable(
         Identifier ident,
         TableContext context,
         CaseInsensitiveStringMap options) throws NoSuchTableException {
       if (context.timeTravel().isPresent() || 
!context.writePrivileges().isEmpty()) {
         return TableCatalog.super.loadTable(ident, context, options);
       }
       if (loadRelation(ident, options) instanceof Table t) {
         return t;
       }
       throw new NoSuchTableException(ident);
     }
   ```
   
   Worth noting why the new test cannot see this: `InMemoryRelationCatalog` 
stores tables as `DelegatingTable`, which `RelationResolution.createRelation` 
routes down the v1 scan path, so the read never becomes a 
`DataSourceV2Relation` and never reaches the refresh at all. A fixture 
returning a plain v2 `Table` (plus the call accumulator from finding 5) is what 
a regression test needs -- the `RelationCatalog` analogue of `"SPARK-58389: 
execution refresh forwards options on a plain table read"`.
   



-- 
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