dongjoon-hyun commented on code in PR #59036:
URL: https://github.com/apache/spark/pull/59036#discussion_r4118738185


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowTablePropertiesExec.scala:
##########
@@ -34,7 +34,7 @@ case class ShowTablePropertiesExec(
     import scala.jdk.CollectionConverters._
 
     // The reserved properties are accessible through DESCRIBE
-    val properties = conf.redactOptions(catalogTable.properties.asScala.toMap)
+    val properties = 
conf.redactOptions(catalogTable.displayProperties.asScala.toMap)

Review Comment:
   This also changes the public API `spark.catalog.getTableProperties`, because 
`classic.Catalog.getTableProperties` is implemented by executing a 
`ShowTableProperties` plan. PySpark and Spark Connect 
(`SparkConnectPlanner.transformGetTableProperties`) go through the same path. 
The API was released in 4.2.0 and is documented as "Returns all table 
properties as a map (same as `SHOW TBLPROPERTIES`)".
   
   Take a connector whose `displayProperties()` returns `properties() + 
{"catalog-label" -> "catalog-value"}`:
   - `spark.catalog.getTableProperties("cat.ns.t")` contains `catalog-label`.
   - `spark.catalog.getCreateTableString("cat.ns.t")` doesn't.
   
   User code that copies table properties via `getTableProperties(src)` into 
`CREATE TABLE ... TBLPROPERTIES`, `ALTER TABLE ... SET TBLPROPERTIES` or 
`DataFrameWriterV2.tableProperty` would then persist the transient label on the 
target table. That is the leak this PR prevents for `SHOW CREATE TABLE`. The 
existing `getTableProperties` tests only use V1 tables, so they don't cover 
this.
   
   Could you either keep `getTableProperties` on `properties()`, or document 
this behavior change in the PR description and the API doc, with a test?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DescribeTableExec.scala:
##########
@@ -189,7 +189,8 @@ case class DescribeTableExec(
     rows += toCatalystRow("# Detailed Table Information", "", "")
     addIdentifierRows(rows, catalogName, identifier, entityLabel = "Table")
 
-    val tableType = if 
(table.properties().containsKey(TableCatalog.PROP_EXTERNAL)) {
+    val displayProperties = table.displayProperties()

Review Comment:
   This switches not only the free-form `Table Properties` list but also the 
`Type` row (EXTERNAL/MANAGED via `PROP_EXTERNAL`) and the reserved-property 
rows (Comment, Collation, Location, Provider, Owner, ...) to 
`displayProperties()`. `ShowTablesExtendedExec` does the same.
   
   However, the new Javadoc doesn't say that `displayProperties()` must be a 
superset of `properties()`. Suppose a connector takes "display-only properties" 
literally and returns only its labels, e.g. `Map.of("label", "x")`. For an 
external table:
   - `DESCRIBE TABLE EXTENDED` and `SHOW TABLE EXTENDED` show `Type MANAGED` 
and drop the Location/Provider/Owner rows.
   - `SHOW TBLPROPERTIES t ('k')` returns "does not have property: k" for a 
persisted key `k`.
   
   Meanwhile `SHOW CREATE TABLE` and 
`spark.catalog.getTable(...).tableType`/`description` still read 
`properties()`, so they disagree. Also, a display key named like a reserved key 
(`owner`, `location`, `external`, ...) silently overrides that row. Because the 
check is `containsKey(PROP_EXTERNAL)`, even `external=false` shows `EXTERNAL`.
   
   Could we keep `Type` and the reserved rows on `table.properties()` and use 
`displayProperties()` only for the `Table Properties` list? If not, please 
document the superset and reserved-key contract in the Javadoc.



##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/Table.java:
##########
@@ -95,6 +95,18 @@ default Map<String, String> properties() {
     return Collections.emptyMap();
   }
 
+  /**
+   * Returns the string map of properties exposed by commands that display 
table metadata.
+   *
+   * <p>The default implementation returns {@link #properties()}. 
Implementations may include
+   * transient metadata that should not be treated as persisted table 
properties.
+   *
+   * @since 4.4.0
+   */
+  default Map<String, String> displayProperties() {
+    return properties();

Review Comment:
   Minor: the display commands now call this new default method instead of 
`properties()`. Under Mockito's default answer (`RETURNS_DEFAULTS`), a mock 
doesn't run default methods, so a `Table` mock returns an empty `HashMap` here. 
Take a mock that only stubs `when(t.properties()).thenReturn(Map("external" -> 
"true", "k" -> "v").asJava)`:
   - `DescribeTableExec` now gives `Type MANAGED` and `Table Properties []`.
   - `SHOW TBLPROPERTIES` returns 0 rows.
   
   Spark's own tests don't do this. Downstream connector tests may change 
without warning, though, and would need `thenCallRealMethod()`. A note in the 
PR description may be worth it.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/DescribeTableSuite.scala:
##########
@@ -220,6 +222,37 @@ class DescribeTableSuite extends 
command.DescribeTableSuiteBase
     }
   }
 
+  test("display commands use display properties without exposing them to SHOW 
CREATE TABLE") {
+    withNamespaceAndTable("ns", "table") { tbl =>
+      sql(s"CREATE TABLE $tbl (id bigint) $defaultUsing " +
+        "TBLPROPERTIES ('persisted' = 'stored')")
+
+      val table = loadTable(catalog, "ns", "table")
+      val displayProperties = new util.HashMap[String, 
String](table.properties)
+      displayProperties.put("catalog-label", "catalog-value")
+      table.setDisplayProperties(displayProperties)

Review Comment:
   The display map here is a full copy of `table.properties` plus one 
non-reserved key, so this test can't tell whether the `Type` row and the 
reserved rows come from `displayProperties()` or from `properties()`. Reverting 
`DescribeTableExec` (L193, L202-203) or `ShowTablesExtendedExec` (L86, L96-97) 
back to `table.properties()` still passes. Could you add a display-only 
reserved key (e.g. `external` or `location`) and assert the `Type`/`Location` 
rows? Alternatively, pin whichever behavior we settle on in the 
`DescribeTableExec` comment.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala:
##########
@@ -75,6 +75,8 @@ abstract class InMemoryBaseTable(
   // Stores the table version validated during the last `ALTER TABLE ... ADD 
CONSTRAINT` operation.
   private var validatedTableVersion: String = null
 
+  private var tableDisplayProperties: util.Map[String, String] = null

Review Comment:
   This per-instance state doesn't survive when the in-memory test infra copies 
or rebuilds a table:
   - `InMemoryTable.copy()` and the other `copy()` implementations only call 
`setVersionAndValidatedVersionFrom`.
   - `InMemoryTableCatalog.alterTable` rebuilds the table via 
`newInMemoryTable`.
   - `pinTable` copies the table.
   
   `setDisplayProperties` is then silently lost and `displayProperties()` falls 
back to `properties`. That happens with:
   - `copyOnLoad=true` catalogs, e.g. `CachedTableSuite` and 
`DataSourceV2DataFrameSuite`.
   - `row_level_op_catalog`, which always returns `rlot.copy()` and is 
registered in the same v2 `CommandSuiteBase`.
   - Any `ALTER TABLE`.
   
   The new test passes only because `test_catalog` returns the live instance. 
Could you document this limitation, or store only the extra entries and 
propagate them on copy?



##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/Table.java:
##########
@@ -95,6 +95,18 @@ default Map<String, String> properties() {
     return Collections.emptyMap();
   }
 
+  /**
+   * Returns the string map of properties exposed by commands that display 
table metadata.
+   *
+   * <p>The default implementation returns {@link #properties()}. 
Implementations may include
+   * transient metadata that should not be treated as persisted table 
properties.
+   *
+   * @since 4.4.0
+   */
+  default Map<String, String> displayProperties() {

Review Comment:
   Question: `properties()` is declared on `Relation`, which `View` also 
implements, and v2 views have the same problem. `ShowCreateV2ViewExec` renders 
the non-reserved `View.properties()` into `TBLPROPERTIES`, and 
`DescribeV2ViewExec` and `ShowV2ViewPropertiesExec` read `viewInfo.properties` 
directly.
   
   The documented metadata-only path (`TableInfo.Builder` -> `DelegatingTable`) 
can't carry display-only metadata either. `DelegatingTable` doesn't override 
this method and `TableInfo` has no such field, so a catalog would have to 
subclass `DelegatingTable`.
   
   Is it intentional to scope this to `Table` only? Declaring it on `Relation`, 
with a setter on `RelationBuilder` (shared by `TableInfo.Builder` and 
`View.Builder`), would cover both paths. Adding it later would also be 
compatible, but it would help to state the intended scope.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala:
##########
@@ -87,6 +89,14 @@ abstract class InMemoryBaseTable(
 
   override def version(): String = tableVersion.toString
 
+  override def displayProperties(): util.Map[String, String] = {
+    Option(tableDisplayProperties).getOrElse(properties)
+  }
+
+  def setDisplayProperties(displayProperties: util.Map[String, String]): Unit 
= {
+    tableDisplayProperties = displayProperties
+  }

Review Comment:
   nit: these two members are inserted between `version()` and `setVersion()`, 
which splits the version-related members (`version`, `setVersion`, 
`setVersionAndValidatedVersionFrom`, ..., `setValidatedVersion`). `AGENTS.md` 
also asks us to follow the file's existing sectioning when adding members. 
Could we move them before `version()` (after `updateColumns`, following the 
`columns`/`properties`/`displayProperties` order in `Table`), or after 
`setValidatedVersion`?



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala:
##########
@@ -87,6 +89,14 @@ abstract class InMemoryBaseTable(
 
   override def version(): String = tableVersion.toString
 
+  override def displayProperties(): util.Map[String, String] = {
+    Option(tableDisplayProperties).getOrElse(properties)

Review Comment:
   nit: delegating to the interface default makes the intent clearer: behave 
like a regular connector when the display properties are unset.
   
   ```suggestion
       Option(tableDisplayProperties).getOrElse(super.displayProperties())
   ```



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowTablesExtendedExec.scala:
##########
@@ -82,7 +82,8 @@ case class ShowTablesExtendedExec(
     results.put("Catalog", catalogName)
     results.put("Namespace", identifier.namespace().quoted)
     results.put("Table", identifier.name())
-    val tableType = if 
(table.properties().containsKey(TableCatalog.PROP_EXTERNAL)) {
+    val displayProperties = table.displayProperties()

Review Comment:
   FYI, the session catalog never reaches this exec. For `spark_catalog` 
(builtin, or a `CatalogExtension` such as Delta/Iceberg-style session 
catalogs), `ResolveSessionCatalog` always rewrites 
`ShowTablesExtended(ResolvedV1Database(db), ...)` to the v1 
`ShowTablesCommand`; there is no `useV1Command` guard. That command prints 
`CatalogTable.simpleString` and never calls `loadTable()` or 
`displayProperties()`.
   
   `DESCRIBE TABLE EXTENDED` and `SHOW TBLPROPERTIES`, by contrast, fall back 
to v1 only for `V1Table`. When the extension returns its own `Table` 
implementation, those two use the v2 execs. So for the same session-catalog 
table, the display-only keys appear in `DESCRIBE TABLE EXTENDED` and `SHOW 
TBLPROPERTIES` but not in `SHOW TABLE EXTENDED`.
   
   The PR description says the metadata appears in `SHOW TABLE EXTENDED` with 
no conditions. Could you document the scope, or add a session-catalog test that 
pins the current behavior?



##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/Table.java:
##########
@@ -95,6 +95,18 @@ default Map<String, String> properties() {
     return Collections.emptyMap();
   }
 
+  /**
+   * Returns the string map of properties exposed by commands that display 
table metadata.
+   *
+   * <p>The default implementation returns {@link #properties()}. 
Implementations may include
+   * transient metadata that should not be treated as persisted table 
properties.

Review Comment:
   Design question: this models display-only metadata as a second full property 
map that defaults to `properties()`.
   - An implementation has to copy the persisted properties just to add one 
label, as the test does with `new util.HashMap[String, 
String](table.properties)`.
   - There are two sources of truth for the same keys. A cached display map can 
go stale after `ALTER TABLE ... SET TBLPROPERTIES` while `SHOW CREATE TABLE` 
shows the new value.
   
   Have you considered an additive form instead? For example, a map of 
catalog-owned extras that defaults to empty, which Spark merges as 
`properties() ++ extras` (persisted/reserved keys win) or renders as separate 
rows. That matches existing additive patterns such as `SupportsMetadataColumns` 
and the `Statistics`/`Metadata Columns`/`Constraints` sections of `DESCRIBE`, 
and it removes the superset-contract question. Since this is a new public API 
(`@since 4.4.0`), it would be good to settle the shape before it is released.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/DescribeTableSuite.scala:
##########
@@ -220,6 +222,37 @@ class DescribeTableSuite extends 
command.DescribeTableSuiteBase
     }
   }
 
+  test("display commands use display properties without exposing them to SHOW 
CREATE TABLE") {
+    withNamespaceAndTable("ns", "table") { tbl =>
+      sql(s"CREATE TABLE $tbl (id bigint) $defaultUsing " +
+        "TBLPROPERTIES ('persisted' = 'stored')")
+
+      val table = loadTable(catalog, "ns", "table")
+      val displayProperties = new util.HashMap[String, 
String](table.properties)
+      displayProperties.put("catalog-label", "catalog-value")
+      table.setDisplayProperties(displayProperties)
+
+      val describeRows = sql(s"DESCRIBE TABLE EXTENDED $tbl").collect()
+      val properties = describeRows.find(_.getString(0) == "Table Properties")
+      assert(properties.exists(_.getString(1) ==
+        "[catalog-label=catalog-value,persisted=stored]"))
+
+      val shownProperties = sql(s"SHOW TBLPROPERTIES $tbl").collect()
+        .map(row => row.getString(0) -> row.getString(1)).toMap
+      assert(shownProperties("catalog-label") == "catalog-value")
+      assert(shownProperties("persisted") == "stored")

Review Comment:
   nit: `checkAnswer` gives an exact comparison and a diff on failure. 
`assert(properties.exists(...))` only reports "... was false", and the `toMap` 
+ per-key asserts can't detect unexpected extra rows such as a leaked reserved 
key. For example:
   
   ```scala
   checkAnswer(
     sql(s"DESCRIBE TABLE EXTENDED $tbl").where("col_name = 'Table 
Properties'"),
     Row("Table Properties", "[catalog-label=catalog-value,persisted=stored]", 
""))
   checkAnswer(
     sql(s"SHOW TBLPROPERTIES $tbl"),
     Seq(Row("catalog-label", "catalog-value"), Row("persisted", "stored")))
   ```



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/DescribeTableSuite.scala:
##########
@@ -220,6 +222,37 @@ class DescribeTableSuite extends 
command.DescribeTableSuiteBase
     }
   }
 
+  test("display commands use display properties without exposing them to SHOW 
CREATE TABLE") {

Review Comment:
   nit: this test checks `SHOW TBLPROPERTIES`, `SHOW TABLE EXTENDED` and `SHOW 
CREATE TABLE` inside the `DESCRIBE TABLE` suite ("contains tests for the 
`DESCRIBE TABLE` command"). It runs as "DESCRIBE TABLE using V2 catalog V2 
command: ...", so people working on `ShowTblPropertiesSuite`, `ShowTablesSuite` 
or `ShowCreateTableSuite` can easily miss it. `loadTable` is available in all 
v2 command suites, so could we split this per command? The `SHOW CREATE TABLE` 
part could then reuse `getShowCreateDDL`.



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