Skip to content

[SPARK-59778][SQL] Add display-only properties to the Table API - #59036

Closed
szehon-ho wants to merge 9 commits into
apache:masterfrom
szehon-ho:codex/table-display-properties
Closed

szehon-ho wants to merge 9 commits into
apache:masterfrom
szehon-ho:codex/table-display-properties

Conversation

@szehon-ho

@szehon-ho szehon-ho commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

What changes were proposed in this pull request?

Add Table.displayProperties() for additional display-only table properties, defaulting to an empty map. The map and its keys and values must be non-null. Spark merges these entries with non-reserved stored properties for V2 DESCRIBE TABLE EXTENDED, SHOW TBLPROPERTIES, and SHOW TABLE EXTENDED. Stored values take precedence, and reserved keys in the display map are ignored. Table type, location, provider, owner, and other reserved metadata continue to come from properties().

Support metadata-only tables through TableInfo.Builder.withDisplayProperties() and DelegatingTable. Keep SHOW CREATE TABLE on properties(). The API documentation also guides createTableLike implementations to exclude display-only entries when copying source metadata, notes that implementations may compute them on demand, and explains that Spark redacts them like stored properties.

Why are the changes needed?

Catalogs may expose transient metadata such as labels that should be visible when inspecting a table without appearing in the DDL used to recreate it. The additive API lets connectors supply these entries separately from stored properties.

JIRA: https://issues.apache.org/jira/browse/SPARK-59778

Does this PR introduce any user-facing change?

Yes. Table implementations can opt in to display-only properties. Existing connectors retain their behavior because the new method defaults to an empty map. Mockito mocks that only stub properties() also retain their existing results.

spark.catalog.getTableProperties() remains equivalent to SHOW TBLPROPERTIES, including in PySpark and Spark Connect, so it also returns display-only entries. The Scala, Connect Scala, and Python API documentation explains that these entries may be transient and are excluded from the DDL returned by getCreateTableString().

ALTER TABLE ... SET/UNSET TBLPROPERTIES forwards changes to the catalog without consulting display properties. The API documentation guides catalogs to apply these changes to stored properties and keep display-only entries unchanged. After a colliding stored non-reserved property is unset, the display value is shown if it remains in the display map. Reserved display keys, including comment, remain ignored. The shared display helper applies the existing redaction rules and reuses the stored property map fetched for reserved metadata.

TableInfo carries display properties as returned metadata. Spark leaves them empty when calling the table creation and staging methods. V2 views retain their existing behavior. The SQL references for SHOW TBLPROPERTIES and ALTER TABLE ... UNSET TBLPROPERTIES explain display-only entries and stored-property precedence.

For now, SHOW TABLE EXTENDED omits display-only entries for the built-in session catalog and its CatalogExtensions. DESCRIBE TABLE EXTENDED and SHOW TBLPROPERTIES include them when an extension returns a V2 table.

How was this patch tested?

For this revision, all 34 selected tests passed using SPARK_LOCAL_IP=localhost build/sbt:

  • Catalyst CatalogV2UtilSuite: 24 tests.
  • Catalyst CatalogSuite -- -z "display properties": 2 tests.
  • V2 DescribeTableSuite, ShowTablesSuite, ShowTblPropertiesSuite, DataSourceV2MetadataTableSuite, and DataSourceV2DataFrameSessionCatalogSuite, filtered by "display properties": 7 tests.
  • V2 ShowCreateTableSuite -- -z "display-only properties": 1 test.

The added assertions cover reserved-key lookup and the session-catalog SHOW TABLE EXTENDED limitation. The helper redaction test checks that mutable input maps remain unchanged. External-table fixtures now use CREATE EXTERNAL TABLE, and display fixture setup uses the underlying live table.

Catalyst Java Checkstyle, Catalyst/SQL test Scalastyle, and git diff --check also passed for this revision.

The previous revision passed all 214 selected tests; one pre-existing test was ignored:

  • Catalyst CatalogSuite and CatalogV2UtilSuite: 103 tests.
  • V2 DescribeTableSuite, ShowTblPropertiesSuite, ShowTablesSuite, and ShowCreateTableSuite, plus DataSourceV2MetadataTableSuite: 100 tests.
  • DataSourceV2DataFrameSessionCatalogSuite -- -z "display properties": 1 test.
  • org.apache.spark.sql.internal.CatalogSuite -- -z "catalog API": 10 tests.

Ran with SPARK_LOCAL_IP=localhost build/sbt, using catalyst/testOnly and sql/testOnly for the suites above. Coverage includes stored/display collisions, SET and UNSET, every reserved display key with and without a stored location, redaction, metadata-only tables, exclusion from generated DDL, and preservation across copies, pinning, and ALTER TABLE in both V2 and session-catalog fixtures.

The previous revision also passed Catalyst Java Checkstyle, Catalyst/SQL main and test Scalastyle, and git diff --check. SQL API/Connect Common scalafmt validation and Python syntax checks passed in an earlier revision; those files are unchanged in this update.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Codex (GPT-6)

@dongjoon-hyun dongjoon-hyun left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the PR, @szehon-ho . The motivation of keeping catalog-owned transient metadata out of SHOW CREATE TABLE makes sense. I left inline comments; here is a summary.

API behavior and contract

  1. spark.catalog.getTableProperties (Scala/Java, PySpark, Spark Connect; released in 4.2.0) is implemented via ShowTableProperties. It now returns display-only keys as table properties, while getCreateTableString doesn't. The PR description doesn't mention this and no test covers it.
  2. The Type (EXTERNAL/MANAGED) row and the reserved rows (Location, Provider, Owner, Comment, ...) are also derived from displayProperties(), but the Javadoc doesn't require it to be a superset of properties(). A connector returning only its labels would get wrong or missing metadata in DESCRIBE TABLE EXTENDED and SHOW TABLE EXTENDED.
  3. For spark_catalog (builtin or CatalogExtension), SHOW TABLE EXTENDED is always rewritten to the v1 ShowTablesCommand. For the same table, the display-only keys appear in DESCRIBE TABLE EXTENDED and SHOW TBLPROPERTIES but not in SHOW TABLE EXTENDED.
  4. (Minor) Mockito mocks that stub only properties() now get an empty map from the new default method.

API design

  1. Could this be an additive map of extras (default empty) that Spark merges, instead of a second full map that defaults to properties()?
  2. Is it intentional to add this only to Table and not to Relation? v2 views and the TableInfo/DelegatingTable path currently have no way to use it.

Tests (mostly nits)

  1. The test fixture can't detect whether the Type/reserved rows come from displayProperties().
  2. The in-memory test catalogs lose tableDisplayProperties in copy(), alterTable and pinTable.
  3. The SHOW assertions live in the DESCRIBE TABLE suite.
  4. checkAnswer would be more precise than the hand-rolled assertions.
  5. The new InMemoryBaseTable members split the version section.
  6. The fallback could use super.displayProperties().


// The reserved properties are accessible through DESCRIBE
val properties = conf.redactOptions(catalogTable.properties.asScala.toMap)
val properties = conf.redactOptions(catalogTable.displayProperties.asScala.toMap)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept getTableProperties() consistent with SHOW TBLPROPERTIES, including the additional display-only entries. The Scala and Python API docs and PR description now explain that these entries may be transient and unsuitable for copying into DDL, and point to getCreateTableString(). Added a V2 test in org.apache.spark.sql.internal.CatalogSuite covering the returned properties, unchanged table type/description, and exclusion of the display-only entry from generated DDL. Existing connectors retain their behavior because the new method defaults to an empty map.

addIdentifierRows(rows, catalogName, identifier, entityLabel = "Table")

val tableType = if (table.properties().containsKey(TableCatalog.PROP_EXTERNAL)) {
val displayProperties = table.displayProperties()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed both commands so Type and all reserved metadata rows continue to read properties(). The shared CatalogV2Util.tablePropertiesForDisplay() helper merges the additional entries with properties() (existing values win), then removes reserved keys from the free-form property list. The Javadoc now defines that contract, and the command tests cover managed/external tables and conflicting reserved display keys.

results.put("Namespace", identifier.namespace().quoted)
results.put("Table", identifier.name())
val tableType = if (table.properties().containsKey(TableCatalog.PROP_EXTERNAL)) {
val displayProperties = table.displayProperties()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented this scope in the PR description. The new properties are used only by the V2 table execution paths. SHOW TABLE EXTENDED for spark_catalog, including session-catalog extensions, still uses the V1 command. The description also calls out that DESCRIBE TABLE EXTENDED and SHOW TBLPROPERTIES can use the new API when an extension returns a V2 table.

* @since 4.4.0
*/
default Map<String, String> displayProperties() {
return properties();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The additive API addresses this: an empty display map now contributes no extra entries, while Spark still reads properties(). Added a CatalogV2UtilSuite regression test with a Mockito mock that stubs only properties() and leaves displayProperties() unstubbed. No thenCallRealMethod() is needed for the existing properties to remain visible; the PR description also notes this.

Comment on lines +101 to +102
* <p>The default implementation returns {@link #properties()}. Implementations may include
* transient metadata that should not be treated as persisted table properties.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted the additive form. displayProperties() now returns only extra entries and defaults to an empty map. Spark merges them with the current properties(), gives existing values precedence, and filters reserved keys in one shared helper. The SHOW TBLPROPERTIES test includes a colliding key and verifies that an ALTER TABLE ... SET TBLPROPERTIES update is reflected while the additional entry is preserved.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixture now stores only the additional entries and copies them defensively. Renamed the shared state-copy helper to copyTableStateFrom() and updated its callers to propagate display metadata alongside version state; table reconstruction during ALTER TABLE also preserves it. Added tests for copyOnLoad, the row-level operation catalog, pinning, and alteration, including checks that pinned properties retain their original values while the current table reflects the update.

}
}

test("display commands use display properties without exposing them to SHOW CREATE TABLE") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split the coverage across DescribeTableSuite, ShowTblPropertiesSuite, ShowTablesSuite, and ShowCreateTableSuite. The SHOW CREATE TABLE test now uses getShowCreateDDL() and compares the full expected DDL.

Comment on lines +235 to +243
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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the hand-rolled checks with checkAnswer for the selected DESCRIBE rows and the full SHOW TBLPROPERTIES result, plus keyed-property checks. SHOW TABLE EXTENDED and SHOW CREATE TABLE now compare the complete expected output. The fixtures also include reserved keys and a collision with an existing property.

Comment on lines +92 to +98
override def displayProperties(): util.Map[String, String] = {
Option(tableDisplayProperties).getOrElse(properties)
}

def setDisplayProperties(displayProperties: util.Map[String, String]): Unit = {
tableDisplayProperties = displayProperties
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved displayProperties() and setDisplayProperties() immediately after updateColumns(), before version(), so the version-related methods stay together.

override def version(): String = tableVersion.toString

override def displayProperties(): util.Map[String, String] = {
Option(tableDisplayProperties).getOrElse(properties)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: delegating to the interface default makes the intent clearer: behave like a regular connector when the display properties are unset.

Suggested change
Option(tableDisplayProperties).getOrElse(properties)
Option(tableDisplayProperties).getOrElse(super.displayProperties())

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied: the fixture now falls back to super.displayProperties(). With the additive contract, that default is an empty map.

@szehon-ho

Copy link
Copy Markdown
Member Author

Thanks @dongjoon-hyun for review! can you take another look?

@szehon-ho

Copy link
Copy Markdown
Member Author

Btw i also chatted with @aokolnychyi offline, he is also ok in concept on the new API

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for addressing the previous comments, @szehon-ho. I left 13 inline comments on the latest commit. Since displayProperties() defaults to an empty map, none of them changes the behavior of existing connectors. Here is a summary, ordered by priority.

API behavior and contract

  1. CatalogV2Util L682: display-only keys share the key namespace of ALTER TABLE ... SET/UNSET TBLPROPERTIES, which only change properties(). UNSET cannot remove a listed display key, and unsetting a colliding stored key makes the display value show up under that key.
  2. TableInfo L70: TableInfo is also the input of createTable, createTableLike and stage*, where displayProperties has no documented meaning. Spark never sets it, the defaults drop it, and some test catalogs persist it.

Test coverage

  1. DescribeTableSuite L235: apart from external, every reserved key in the display maps is also stored, so no test covers a display-only location, collation, table_type or is_managed_location.
  2. DelegatingTable L78: the TableInfo.Builder.withDisplayProperties + DelegatingTable path has no SQL-level DESCRIBE/SHOW test.
  3. ShowTblPropertiesSuite L30: no test checks that display-only entries are redacted.

Documentation

  1. Catalog (sql/api) L849: the Spark Connect Scala client's getTableProperties keeps its own Scaladoc without this note.
  2. Table L101: the Javadoc doesn't say that SHOW TABLE EXTENDED on the session catalog omits these entries.

Test infrastructure

  1. InMemoryTableCatalog L174: only two in-memory catalogs honor TableInfo.displayProperties() at create time, and NullTableIdInMemoryTableCatalog drops it.
  2. ShowTblPropertiesSuite L46: InMemoryTableSessionCatalog.alterTable still drops display state on ALTER TABLE.
  3. InMemoryBaseTable L105: the rename dropped the rationale for copying the version.
  4. InMemoryTableCatalog L282: alterTable copies the table state by hand instead of calling copyTableStateFrom.

Nits

  1. catalog.py L386: the new Notes section comes before Parameters.
  2. CatalogSuite L1205: the new test registers display_catalog instead of reusing testcat.

Items 1 and 2 are the most important ones, since they settle the contract of the new public API before it is released.


/** Returns non-reserved table properties with additional display-only metadata. */
def tablePropertiesForDisplay(table: Table): Map[String, String] = {
(table.displayProperties().asScala.toMap ++ table.properties().asScala)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Display-only keys share the key namespace that ALTER TABLE ... SET/UNSET TBLPROPERTIES manage, but those commands only change properties(). With the fixture in ShowTblPropertiesSuite (stored persisted=stored; display persisted=display-value and catalog-label=catalog-value):

  • ALTER TABLE t UNSET TBLPROPERTIES ('persisted') removes the stored key, and then SHOW TBLPROPERTIES t ('persisted') returns display-value instead of "Table ... does not have property: persisted". getTableProperties still contains persisted, while SHOW CREATE TABLE no longer does.
  • ALTER TABLE t UNSET TBLPROPERTIES ('catalog-label') succeeds, because V2 UnsetTableProperties only emits TableChange.removeProperty (a no-op for a missing key, per TableChange.java L63), but the key is still listed afterwards. No SQL can remove it, so a tool that syncs properties by diffing getTableProperties against a desired set never converges.
  • SET TBLPROPERTIES ('catalog-label' = 'x') followed by UNSET brings catalog-value back.

Neither the Javadoc nor the tests cover this; ShowTblPropertiesSuite only exercises SET. Could the Table#displayProperties() Javadoc state that ALTER TABLE ... SET/UNSET TBLPROPERTIES only affect properties(), and that a display entry shows through once a colliding stored key is unset? An UNSET step in that test would pin it. Alternatively, rendering the display entries separately (the other option in #59036 (comment)) would avoid the shared namespace.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented that SET/UNSET only changes properties() and cannot remove catalog-controlled display entries. Extended ShowTblPropertiesSuite to check unsetting a colliding stored key, unsetting a display-only key, and SET followed by UNSET. It also checks that getTableProperties returns the display value after UNSET while the key is absent from getCreateTableString.

*
* @since 4.4.0
*/
public Map<String, String> displayProperties() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TableInfo is also the input of TableCatalog.createTable(Identifier, TableInfo), createTableLike and StagingTableCatalog.stage*, but this new field has no documented meaning on that path.

  • Spark never sets it: CreateTableExec, ReplaceTableExec, WriteToDataSourceV2Exec (CTAS/RTAS), CreateTableLikeExec and the pipelines DatasetManager build TableInfo without it.
  • The default TableCatalog.createTable(ident, tableInfo) (TableCatalog.java L359-362) and the StagingTableCatalog.stage* defaults flatten TableInfo to (columns, partitions, properties) and drop it. V2SessionCatalog and JDBCTableCatalog use that default, and DelegatingCatalogExtension doesn't override it, so it never reaches the delegate.
  • The test catalogs in this PR persist it at create time (BasicInMemoryTableCatalog.createTable and InMemoryRowLevelOperationTableCatalog.createTable), and InMemoryRelationCatalog stores the whole TableInfo.

So a connector modeled on the test catalogs persists "display-only" entries at create time. Code that copies a metadata-only table with catB.createTable(ident, delegatingTable.getTableInfo()) freezes the source catalog's transient labels into catB on some catalogs and silently drops them on others. Could the Javadoc here and on withDisplayProperties say that Spark doesn't set it for create, replace, stage or createTableLike, and that catalogs may ignore it there?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified both Javadocs: display properties describe metadata returned by a catalog, and Spark leaves them empty when calling createTable, createTableLike, stageCreate, stageReplace, or stageCreateOrReplace. Removed the create-time display-property setters from the two in-memory catalogs; the preservation test now sets the entries on the live table returned by createTable.

if (external) {
table.properties.put(TableCatalog.PROP_EXTERNAL, "true")
}
table.setDisplayProperties(Map(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apart from external, every reserved key in this display map is also stored on the table, so the test only checks which value wins. The table is created with COMMENT, LOCATION, USING _ and the default owner, so the display comment, location, provider and owner all collide with stored values. A display-only location, collation, table_type or is_managed_location is never exercised, which was the ask in #59036 (comment).

For example, suppose the reserved-row loop (DescribeTableExec L198-204, ShowTablesExtendedExec L93-99) were changed to read displayProperties ++ properties while Type stays on properties(). A table without a stored location would then show the display Location (or a Collation or Table_type row), but all the new tests would still pass because every table they use has a stored location. The col_name IN ('Type', 'Comment', 'Location', 'Provider', 'Owner', 'Table Properties') filter at L246 would also hide such a leaked row.

Could the display map be built from CatalogV2Util.TABLE_RESERVED_PROPERTIES, as CatalogV2UtilSuite does, with one table created without LOCATION, and could the filter cover all the reserved rows? The same 15-line fixture is repeated in ShowTablesSuite L77-91, so a shared helper next to loadTable in the v2 CommandSuiteBase would keep the suites in sync.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a shared fixture in CommandSuiteBase that supplies every key in TABLE_RESERVED_PROPERTIES. Both DESCRIBE and SHOW TABLE EXTENDED now test managed/external tables with and without a stored LOCATION. The DESCRIBE assertion includes all reserved rows, and SHOW TABLE EXTENDED checks the complete output.

}

@Override
public Map<String, String> displayProperties() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metadata-only path this enables (TableInfo.Builder.withDisplayProperties + DelegatingTable) is only covered by unit tests. CatalogSuite and CatalogV2UtilSuite call displayProperties() and tablePropertiesForDisplay directly, and every SQL-level test uses InMemoryBaseTable.setDisplayProperties. No test runs DESCRIBE TABLE EXTENDED, SHOW TBLPROPERTIES or SHOW TABLE EXTENDED against a catalog that returns a DelegatingTable.

That path is special-cased elsewhere: RelationResolution L464-468 treats DelegatingTable as an "interpret via v1" sentinel for reads, and V1Table.toCatalogTable(catalog, ident, DelegatingTable) reads only info.properties. If the metadata commands were ever routed the same way, display entries would disappear for metadata-only catalogs without any failing test. Could we add one end-to-end test? InMemoryRelationCatalog is already registered in v2 ShowTablesSuite, and a catalog that builds the TableInfo in loadTable, like TestingDataSourceTableCatalog in DataSourceV2MetadataTableSuite, would also work.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an end-to-end test in DataSourceV2MetadataTableSuite. Its catalog builds TableInfo with display properties in loadTable and returns a DelegatingTable. The test exercises DESCRIBE TABLE EXTENDED, SHOW TBLPROPERTIES, SHOW TABLE EXTENDED, and getTableProperties, and verifies that getCreateTableString excludes the display-only entry.

* The class contains tests for the `SHOW TBLPROPERTIES` command to check V2 table catalogs.
*/
class ShowTblPropertiesSuite extends command.ShowTblPropertiesSuiteBase with CommandSuiteBase {
test("display properties supplement current properties and exclude reserved keys") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Display-only entries are a new input to three redacted outputs, but no test checks that they are redacted. Today DescribeTableExec L206, ShowTablePropertiesExec L35 and ShowTablesExtendedExec L102 redact the merged map, which is right. However, the new fixtures only use catalog-label, persisted and reserved keys, none of which matches spark.redaction.regex or spark.sql.redaction.options.regex. The existing redaction test (SHOW TBLPROPERTIES BASIC in ShowTblPropertiesSuiteBase) only covers a stored password property.

If a later change redacted properties() before merging, a connector's display entry such as password or jdbc_url would be printed in clear by DESCRIBE TABLE EXTENDED, SHOW TBLPROPERTIES, SHOW TABLE EXTENDED and getTableProperties, and no test would fail. Could this test add a display-only password entry and expect *********(redacted)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a display-only password to the shared fixture and asserted *********(redacted) in DESCRIBE TABLE EXTENDED, SHOW TBLPROPERTIES (including single-key lookup), and SHOW TABLE EXTENDED. The catalog API test also verifies redaction in getTableProperties.

checkAnswer(sql(s"SHOW TBLPROPERTIES $tbl ('catalog-label')"),
Row("catalog-label", "catalog-value"))

sql(s"ALTER TABLE $tbl SET TBLPROPERTIES ('persisted' = 'updated')")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This holds for InMemoryTableCatalog, but the session-catalog test catalog still drops display state on ALTER TABLE. InMemoryTableSessionCatalog.alterTable (DataSourceV2DataFrameSessionCatalogSuite.scala L160-181) rebuilds the table as new InMemoryTable(table.name, schema, table.partitioning, properties) without copying the display properties. That catalog is how the tests model a session-catalog extension that returns V2 tables (DatasourceV2SQLBase registers it as spark_catalog), which is the case the PR description says DESCRIBE TABLE EXTENDED and SHOW TBLPROPERTIES support. There, after setDisplayProperties on the live table and ALTER TABLE t SET TBLPROPERTIES ('k' = 'v'), SHOW TBLPROPERTIES t no longer lists the display entry.

No current test hits this, and that rebuild already reset the version before this PR. But since #59036 (comment) says table reconstruction during ALTER TABLE preserves the entries, could it also call setDisplayProperties(table.displayProperties())?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added setDisplayProperties(table.displayProperties()) when the session-catalog fixture reconstructs a table. A new regression test checks SHOW TBLPROPERTIES and DESCRIBE TABLE EXTENDED after SET, and that the display entry survives a subsequent UNSET.

* monotonic-version assumption that downstream consumers rely on (e.g.
* [[InMemoryTable]].copy, validated-version propagation, and the join-refresh
* tests in [[DSv2IncrementallyConstructedQueryTests]]).
* Copies version, validated version and display-only metadata into a replacement table.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rename replaced the explanation of why the version must be copied with a one-line summary. The previous Scaladoc said that replacement tables start with a version counter of 0, and that without this call the counter resets and breaks the monotonic-version assumption that InMemoryTable.copy, validated-version propagation and the join-refresh tests in DSv2IncrementallyConstructedQueryTests rely on. That is still true (the version-aware equals/hashCode below uses it), and it isn't documented anywhere else in the code, so the next person who adds a catalog that rebuilds tables may skip this call. Also, 5 of the 11 callers are copy() snapshots rather than replacement tables. Could we keep the old paragraph and just add the display-only metadata to the first sentence?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored the paragraph explaining why the version must be copied, and changed the summary to cover version, validated version, and display-only metadata copied from another table.

s"Unsupported InMemoryBaseTable subclass: ${other.getClass.getName}")
}
newTable.setVersion(currentVersion)
newTable.setDisplayProperties(table.displayProperties())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this copies the table state by hand instead of calling copyTableStateFrom. Now that the helper is the generic "copy table state" hook, the most common ALTER TABLE path (test_catalog, InMemoryTableCatalog, InMemoryCatalog) still keeps its own list: setVersion + setDisplayProperties, and validatedVersion only for AddConstraint. InMemoryRowLevelOperationTableCatalog.alterTable carries validatedVersion over through the helper instead. If a field is later added only to copyTableStateFrom, this path resets it, and the catalogs that wrap super.alterTable (NullColumnIdInMemoryTableCatalog, MixedColumnIdTableCatalog, TypeChangeResetsColIdTableCatalog, ...) copy from the already-reset table. Could this call newTable.copyTableStateFrom(table) and keep the AddConstraint loop, or at least carry a comment to keep the two in sync?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed this path to call copyTableStateFrom and kept the AddConstraint loop. The preservation test also checks version copying and that validatedVersion survives a property alteration.

Comment thread python/pyspark/sql/catalog.py Outdated

.. versionadded:: 4.2.0

Notes

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this Notes section comes before Parameters. The other Notes sections in this file (e.g. listViews, functionExists and listColumns) come after Returns and before Examples, which is also the numpydoc order. The rendered API docs reorder it, but help(spark.catalog.getTableProperties) and IDEs show the raw order. Could it move below Returns?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved Notes below Returns and before Examples.

}

test("catalog API: getTableProperties includes V2 display-only properties") {
val catalogName = "display_catalog"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the suite's before block already registers testcat as the same InMemoryCatalog class for every test, and the other V2 tests in this suite use it. Could this test use testcat and drop the withSQLConf wrapper?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched the test to the existing testcat and removed the withSQLConf wrapper.

@szehon-ho

Copy link
Copy Markdown
Member Author

Thank you, @dongjoon-hyun when you have a chance can you take another look?

@dongjoon-hyun

Copy link
Copy Markdown
Member

Sure, @szehon-ho .

@dongjoon-hyun

Copy link
Copy Markdown
Member

@szehon-ho

Copy link
Copy Markdown
Member Author

Ah seems @HyukjinKwon already approved

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for addressing round 2, @szehon-ho. I reviewed the latest commit, 7b9429e, and left 10 inline comments. All 13 round-2 comments are addressed except the second halves of R2-1 and R2-2 (items 2 and 3 below). Since displayProperties() still defaults to an empty map, none of these changes the behavior of existing connectors. Here is a summary, ordered by priority.

API contract

  1. CatalogV2Util L682: displayProperties() has no null contract. A null value makes SHOW TBLPROPERTIES and getTableProperties fail while DESCRIBE TABLE EXTENDED prints k=null, and a null map fails all three commands with an NPE.
  2. Table L107: the SET/UNSET sentences describe the catalog's alterTable behavior as Spark's, and the R2-1 note that a display value shows through after unsetting a colliding stored key is still missing.
  3. TableInfo L68: the "catalogs may ignore it there" half of R2-2 is missing, and the five-method list is repeated in the setter.

Pre-existing

  1. DescribeTableExec L192: Type ignores table_type, so a DelegatingTable built with withTableType(EXTERNAL) shows both Type MANAGED and Table_type EXTERNAL. A follow-up JIRA seems better than changing it here.

Performance and cleanup

  1. DescribeTableExec L206: properties() is still read twice per table, because the helper reads it again.
  2. CatalogV2Util L681: the display helper returns unredacted values, and all three callers redact them the same way.

Tests

  1. ShowCreateTableSuite L43: the shared display fixture in CommandSuiteBase isn't reused here.
  2. DescribeTableSuite L223: the 4-way grid and the table setup are copied in ShowTablesSuite, and two of the four cases add no coverage.
  3. CatalogSuite (catalyst) L99: the gridTest mode names don't describe what differs between the two cases.

Nits

  1. InMemoryTableCatalog L171: a whitespace-only re-wrap left over from R2-8.

Items 1 and 2 are the most important ones, since they define the contract of the new public API before it is released.


/** Returns non-reserved table properties with additional display-only metadata. */
def tablePropertiesForDisplay(table: Table): Map[String, String] = {
(table.displayProperties().asScala.toMap ++ table.properties().asScala)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new API has no null contract, and this merge passes null values through. TableInfo.Builder.withDisplayProperties copies the map with new HashMap<>(...), which keeps null values, and Utils.redact passes them through. Take a connector that returns a DelegatingTable whose display map has "last_refresh" -> null for a value it doesn't know yet:

  • SHOW TBLPROPERTIES t, SHOW TBLPROPERTIES t ('last_refresh') and spark.catalog.getTableProperties fail, because ShowTableProperties declares key and value as non-nullable (v2Commands.scala L1583-1584) and toCatalystRow rejects the null.
  • DESCRIBE TABLE EXTENDED and SHOW TABLE EXTENDED print last_refresh=null.

If displayProperties() itself returns null, asScala returns null and .toMap throws an NPE in all three commands. ShowTablesExtendedExec has no per-table error handling, so one such table fails SHOW TABLE EXTENDED IN cat.ns LIKE '*' for the whole namespace.

properties() has the same exposure, but the SQL path rejects null property values, while these entries are computed by the catalog and may be transient. Could the Table#displayProperties() Javadoc say that the map and its keys and values must not be null, as TableCatalog#tableStateOptionKeys() does with "a non-null set"? Alternatively, this helper could drop null entries.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an explicit non-null map/keys/values contract to Table.displayProperties() and the TableInfo builder parameter. This uses the documented-contract approach rather than silently dropping invalid entries.

* {@link TableCatalog#PROP_LOCATION} are ignored in this map. Table type and other reserved
* metadata are read from {@code properties()}.
*
* <p>{@code ALTER TABLE ... SET/UNSET TBLPROPERTIES} only changes {@code properties()}.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two sentences describe the catalog's behavior as Spark's. On this path Spark never reads the display entries: SetTableProperties and UnsetTableProperties only produce TableChange.setProperty and TableChange.removeProperty, and AlterTableExec.scala L41 passes them to TableCatalog.alterTable. Whether a display entry survives SET, or disappears on UNSET, is up to the catalog:

  • InMemoryTableSessionCatalog.alterTable dropped the display state on every ALTER TABLE until R2-9 was fixed.
  • A catalog that handles removeProperty("label") by deleting its label contradicts "cannot be removed with UNSET TBLPROPERTIES".

Also, the second half of R2-1, that a display value shows through once a colliding stored key is unset, is only in the PR description. Could this paragraph be phrased as guidance for implementors? For example:

<p>Spark passes {@code ALTER TABLE ... SET/UNSET TBLPROPERTIES} to
{@link TableCatalog#alterTable} as {@link TableChange#setProperty} and
{@link TableChange#removeProperty} without consulting this map. Implementations should apply
them to {@code properties()} only and keep display-only entries unchanged. Then
{@code UNSET TBLPROPERTIES} of a display-only key succeeds but the key is still listed, and
unsetting a stored key that collides with a display-only key exposes the display-only value.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded this as guidance for catalog implementations: Spark forwards SET/UNSET to alterTable without consulting the display map. The precedence paragraph now also states that, after a stored property is removed, Spark displays the entry for that key if it remains in the display map.

/**
* Returns the additional display-only properties described by {@link Table#displayProperties()}.
*
* <p>These properties describe metadata returned by a catalog. Spark leaves them empty when

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: R2-2 also asked to say that catalogs may ignore this field on the create path, and that half is missing here and in the setter (L96-99). The default TableCatalog.createTable(Identifier, TableInfo) and the StagingTableCatalog.stage* defaults drop it, but a catalog that stores the whole TableInfo, such as InMemoryRelationCatalog, persists whatever a non-Spark caller put there, e.g. catB.createTable(ident, delegatingTable.getTableInfo()). Without that sentence, implementors can't tell whether to ignore it or store it. Could we add something like "Catalogs may ignore them in these methods."? The setter could then just refer to {@link TableInfo#displayProperties()}, so the five-method list lives in one place.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the duplicated creation-method list from the builder documentation; it now lives only on the getter. I kept the scope to the current Spark behavior: these are returned metadata, and Spark leaves them empty on creation/staging calls. Since TableInfo is internal for now, I would prefer not to add a contract for non-Spark callers supplying this field on those paths.


val tableType = if (table.properties().containsKey(TableCatalog.PROP_EXTERNAL)) {
val tableProperties = table.properties()
val tableType = if (tableProperties.containsKey(TableCatalog.PROP_EXTERNAL)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, pre-existing: this still decides Type by containsKey(PROP_EXTERNAL) alone and ignores PROP_TABLE_TYPE, although withTableType is the only type setter on TableInfo.Builder. For a DelegatingTable built with .withTableType(TableSummary.EXTERNAL_TABLE_TYPE), like the existing test_json fixture in DataSourceV2MetadataTableSuite:

  • DESCRIBE TABLE EXTENDED prints both Type | MANAGED and Table_type | EXTERNAL.
  • SHOW TABLE EXTENDED prints Type: MANAGED (ShowTablesExtendedExec.scala L86).
  • SHOW TABLE EXTENDED ... AS JSON (through listTableSummaries) and V1Table.toCatalogTable treat it as EXTERNAL.

This is the external=false case from R1-2 seen from another side. It isn't introduced by this PR, and fixing it changes pinned outputs, so could we file a follow-up JIRA to derive Type from table_type first and fall back to external?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is pre-existing and should be fixed separately by honoring table_type before the external fallback. Leaving that behavior unchanged in this PR. I have prepared the follow-up JIRA draft, but have not filed it yet.

val properties =
conf.redactOptions(table.properties.asScala.toMap).toList
.filter(kv => !CatalogV2Util.TABLE_RESERVED_PROPERTIES.contains(kv._1))
conf.redactOptions(CatalogV2Util.tablePropertiesForDisplay(table)).toList

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: L191 hoists table.properties(), but tablePropertiesForDisplay(table) reads table.properties() again, so this command still reads it twice per table, and so does ShowTablesExtendedExec.scala (L85 and L102). Could the helper take the map the caller already has, e.g. tablePropertiesForDisplay(tableProperties, table.displayProperties())? Then the Type and reserved rows also come from the same snapshot as the Table Properties row.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed the helper to accept the stored and display property maps. DESCRIBE TABLE EXTENDED and SHOW TABLE EXTENDED now pass the stored map already fetched for the reserved metadata rows, so each reads properties() only once.

}

/** Returns non-reserved table properties with additional display-only metadata. */
def tablePropertiesForDisplay(table: Table): Map[String, String] = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: all three callers wrap this helper in the same conf.redactOptions(...): DescribeTableExec.scala L206, ShowTablePropertiesExec.scala L35 and ShowTablesExtendedExec.scala L102. Since the name says "for display", a future caller could print the result as is, and these entries can hold secrets, as the display-only password in the new fixture shows. Could the helper take a conf: SQLConf, like lookupCachedRelationWithStateOptions in this object, and redact inside? If you prefer to keep redaction at the call sites, could the Scaladoc say that the values are not redacted?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved redaction into tablePropertiesForDisplay with an explicit SQLConf parameter and removed the three caller-side wrappers. Added a helper-level test for stored and display-only secrets, the SQL redaction pattern, stored-value precedence, and unchanged input maps.

sql(s"CREATE TABLE $t (id bigint) $defaultUsing " +
"COMMENT 'table comment' LOCATION 'file:/tmp/display-properties' " +
"TBLPROPERTIES ('persisted' = 'stored')")
loadTable(catalog, ns, table).setDisplayProperties(Map(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the other v2 suites use the shared setDisplayProperties helper from CommandSuiteBase, which this suite also mixes in. ShowCreateTableExec reads only table.properties, so setDisplayProperties(loadTable(catalog, ns, table)) keeps the expected DDL, also covers display-only provider, collation, owner and password, and lets us drop scala.jdk.CollectionConverters._ (L20) and TableCatalog (L25). Separately, could the helper's Scaladoc mention that external = true writes external into the stored properties? It shares its name with InMemoryBaseTable.setDisplayProperties, which only sets display entries.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SHOW CREATE TABLE now uses the shared fixture, including all of its reserved display keys and password entry, and the unused imports are removed. The setter documentation also states that external = true updates the stored properties.

}
}

gridTest("display properties preserve table type and reserved metadata")(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this gridTest and the one at ShowTablesSuite.scala L72-80 differ only in the table name. The grid, the LOCATION literal, the CREATE TABLE statement and the setDisplayProperties(loadTable(...), external) call are copies, so the shared helper from R2-3 keeps only the display map in sync. Also, the two axes are independent (Type depends only on the stored external, and Location only on the stored location), so (false, true) and (true, false) add no coverage, while (true, true) still checks that a display external=false and a display location don't override the stored values. Could the table creation move into the CommandSuiteBase helper, and the grid become Seq((false, false), (true, true))?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved table creation into createTableWithDisplayProperties in CommandSuiteBase and reused it across DESCRIBE, SHOW TABLE EXTENDED, and SHOW CREATE TABLE. Reduced both grids to (false, false) and (true, true).

assert(defaultTable.displayProperties().isEmpty)
}

gridTest("display properties survive copies, pinning and alterations")(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the two modes are named after different things, an option and a catalog class. L106 passes copyOnLoad=true to both, but InMemoryRowLevelOperationTableCatalog.loadTable always returns rlot.copy(), so the option has no effect in the rowLevel case, and the names don't say what actually differs. Could the modes be named after the catalogs, e.g. Seq("InMemoryTableCatalog", "InMemoryRowLevelOperationTableCatalog"), with a short comment on the option?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed the grid cases after the two catalog classes and added a comment explaining that the row-level catalog always copies on load, while the option enables this for InMemoryTableCatalog.


override def createTable(ident: Identifier, tableInfo: TableInfo): Table = {
createTable(ident, tableInfo.columns(), tableInfo.partitions(), tableInfo.properties(),
createTable(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: after the create-time setDisplayProperties was removed for R2-8, this re-wrap is the only change left in this method, and the arguments are unchanged. Could we restore the original single line to keep the diff and git blame clean?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored the original argument wrapping in this method.

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for addressing round 3, @szehon-ho. All 10 round-3 comments are answered, and you kept the "catalogs may ignore" part of R3-3 and the table_type change of R3-4 out of this PR. I reviewed the same head, f25203b, again on top of the current master (the merge is clean) and left 15 inline comments. I found no correctness issue in the main code, and since displayProperties() still defaults to an empty map, none of these changes the behavior of existing connectors. Here is a summary, ordered by priority.

API documentation

  1. Table.java L106: the show-through sentence doesn't hold for comment, the one ignored key that SET/UNSET TBLPROPERTIES accept.
  2. Table.java L116: CREATE TABLE ... LIKE passes the source table, with these entries, to createTableLike, whose Javadoc invites connectors to clone its custom state.
  3. Table.java L110: the SQL reference pages for ALTER TABLE ... UNSET TBLPROPERTIES and SHOW TBLPROPERTIES don't mention display-only entries.
  4. TableInfo.java L100: nothing says that Spark reads these entries only to display metadata, so the builder path computes them on every load.
  5. Table.java L101: the Javadoc doesn't say that these entries are redacted like stored properties.
  6. TableInfo.java L97: the setter's @see doesn't lead to the create/stage note, and the five method references are unchecked {@code} spans.

Tests

  1. DataSourceV2DataFrameSessionCatalogSuite.scala L69: no test pins the documented session-catalog SHOW TABLE EXTENDED limitation, and one assertion here would.
  2. CatalogV2UtilSuite.scala L77: some assertions in the new helper tests can't fail, including the two "unchanged input maps" checks.
  3. ShowTblPropertiesSuite.scala L36: the single-key path of SHOW TBLPROPERTIES is never queried with a reserved key.
  4. DataSourceV2MetadataTableSuite.scala L195: Predef.assert replaced the "shouldn't be called" guard, so an unexpected listing now fails as INTERNAL_ERROR.

Test fixtures

  1. CommandSuiteBase.scala L92: the fixture mutates whatever loadTable returns, which works only because test_catalog returns the live table.
  2. CommandSuiteBase.scala L100: CREATE EXTERNAL TABLE should work on this catalog, so the stored external doesn't need to be written by hand.
  3. ShowTblPropertiesSuite.scala L29: this is the only display-property test that doesn't use createTableWithDisplayProperties.
  4. InMemoryBaseTable.scala L91: displayProperties() changes from a read-only map to the internal mutable map after the first copy or ALTER TABLE.

Nits

  1. CatalogV2UtilSuite.scala L20: import java.util mixes two styles in this file and in DataSourceV2DataFrameSessionCatalogSuite.

Item 1 is the most important one, since it corrects a sentence of the new public API contract, and my R3-2 wording had the same gap. Items 2 to 6 are documentation only, and all the test items are non-blocking.

* precedence, and reserved keys such as {@link TableCatalog#PROP_EXTERNAL} and
* {@link TableCatalog#PROP_LOCATION} are ignored in this map. Table type and other reserved
* metadata are read from {@code properties()}.
* After a stored property is removed, Spark displays the entry for that key if it remains in

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence doesn't hold for comment, the one ignored key that SET/UNSET TBLPROPERTIES accept. AstBuilder.cleanTableProperties keeps comment (AstBuilder.scala L6058-6059: "we don't make it a reserved table property"), so ALTER TABLE t UNSET TBLPROPERTIES ('comment') reaches alterTable as TableChange.removeProperty("comment"). However, tablePropertiesForDisplay and the reserved-row loops still treat comment as one of TABLE_RESERVED_PROPERTIES. Take a table created with COMMENT 'c' whose displayProperties() contains comment=d. After that UNSET:

  • DESCRIBE TABLE EXTENDED has no Comment row and no comment entry in Table Properties.
  • SHOW TBLPROPERTIES t ('comment') returns "does not have property: comment".

So the stored property was removed and comment remains in this map, but nothing is displayed. The two examples on L103-104 (external, location) are keys that the parser rejects in TBLPROPERTIES, so they don't hint at this exception, and the wording I suggested in R3-2 had the same gap. Could we name {@link TableCatalog#PROP_COMMENT} among the ignored keys and scope this sentence to non-reserved keys, e.g. "After a stored non-reserved property is removed, ..."?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scoped the show-through sentence to stored non-reserved properties and named PROP_COMMENT alongside the ignored reserved keys.

* <p>For now, {@code SHOW TABLE EXTENDED} omits these entries for the built-in session catalog
* and its {@link CatalogExtension}s.
*
* <p>These entries may be transient and are excluded from {@code SHOW CREATE TABLE}. The

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CREATE TABLE ... LIKE hands the source table, including these entries, to the target catalog. DataSourceV2Strategy L294 passes the resolved source Table as is, and CreateTableLikeExec L71 calls createTableLike(targetIdent, tableInfo, sourceTable). The createTableLike Javadoc says that connectors "may read format-specific properties or other custom state from this object to clone additional metadata" (TableCatalog.java L382-383, see also L372-373). With this PR, that object also exposes displayProperties(), and a DelegatingTable also exposes getTableInfo().displayProperties(). Nothing tells the connector not to clone them, so a catalog that does would persist the source catalog's transient labels on the new table, which is what this PR keeps out of SHOW CREATE TABLE. Unlike R2-2 and R3-3, which were about non-Spark callers filling TableInfo, here Spark itself makes the call. Could this sentence also cover it, e.g. "These entries may be transient. Spark excludes them from SHOW CREATE TABLE, and TableCatalog#createTableLike implementations should not copy them from the source table."? InMemoryTableCatalog.createTableLike (L324) already copies only sourceTable.properties().

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added guidance that TableCatalog.createTableLike implementations should not copy display-only entries from the source table, with a link to the full method signature.

* this map.
*
* <p>Spark passes {@code ALTER TABLE ... SET/UNSET TBLPROPERTIES} to
* {@link TableCatalog#alterTable} without consulting this map. Catalogs should apply these

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SQL reference pages don't mention these entries, so SQL users only see the effect. This paragraph explains the SET/UNSET behavior to connector authors, but docs/sql-ref-syntax-ddl-alter-table.md L279 still says that ALTER TABLE UNSET "is used to drop the table property", and docs/sql-ref-syntax-aux-show-tblproperties.md L24-25 says that all the properties are returned. For a V2 table with display-only entries:

  • ALTER TABLE t UNSET TBLPROPERTIES ('catalog-label') succeeds, but catalog-label is still listed.
  • Unsetting a stored key that collides with a display-only key switches the listed value to the display-only value instead of removing the key, as the new ShowTblPropertiesSuite test pins (L47-61).

Neither page becomes wrong, but the explanation now exists only in this Javadoc and in the PR description. Could we add a short note to the SHOW TBLPROPERTIES page, and optionally to the UNSET section of the ALTER TABLE page, e.g. "For V2 tables, the output may also include display-only properties supplied by the catalog. ALTER TABLE ... UNSET TBLPROPERTIES does not remove them, and SHOW CREATE TABLE does not include them."?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added notes to both the SHOW TBLPROPERTIES reference and the ALTER TABLE ... UNSET TBLPROPERTIES section. They explain that UNSET leaves display-only entries intact and can expose a display value after removing a colliding stored non-reserved property. The SHOW page also notes their exclusion from SHOW CREATE TABLE.

* @see Table#displayProperties()
* @since 4.4.0
*/
public Builder withDisplayProperties(Map<String, String> displayProperties) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spark reads these entries only in display commands, but this builder makes a catalog compute them on every load. The only readers are DescribeTableExec L207, ShowTablePropertiesExec L36 and ShowTablesExtendedExec L103. This setter copies the map when the TableInfo is built (L101), and loadTable/loadRelation don't know which command is running. So a catalog that follows the new TestingDataSourceTableCatalog fixture computes the entries for every query that resolves the table, although RelationResolution L467 turns a DelegatingTable into a CatalogTable through V1Table.toCatalogTable, which reads only info.properties (V1Table.scala L123). That costs nothing when the labels come with the table metadata. A catalog that fetches them from a separate service, such as a tagging API, pays one extra call per query, and nothing says that it can defer them by overriding displayProperties(), e.g. in a DelegatingTable subclass. Could the Table#displayProperties() Javadoc, or this setter, say something like "Spark reads these entries only to display table metadata, so implementations may compute them on demand."?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to Table.displayProperties() that Spark reads these entries only to display table metadata, so implementations may compute them on demand.

/**
* Returns additional, display-only table properties.
*
* <p>Spark adds these entries to the non-reserved properties displayed by V2 table metadata

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this paragraph also say that these entries are redacted like stored properties? tablePropertiesForDisplay applies conf.redactOptions to the merged map (CatalogV2Util L687), which uses both spark.sql.redaction.options.regex (default (?i)url) and spark.redaction.regex, and Utils.redact replaces a value when either the key or the value matches. With the default configs, a catalog that returns docs_url -> https://wiki/t and team -> credentials-platform gets *********(redacted) for both entries in every output, including getTableProperties. Redacting them is the right behavior, but only the Scaladoc of the private[sql] helper mentions it, so a connector author can't tell from the API doc why an ordinary label is masked. A sentence such as "Spark redacts these entries in the same way as stored properties." would be enough.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the sentence that Spark redacts these entries in the same way as stored properties.

sql(s"CREATE TABLE $catalog.$namespace.$table (id bigint) $defaultUsing " +
s"COMMENT 'table comment' $location" +
"TBLPROPERTIES ('persisted' = 'stored')")
setDisplayProperties(loadTable(catalog, namespace, table), external)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, latent: the fixture writes into whatever loadTable returns. On test_catalog that is the live table, because copyOnLoad defaults to false, so the current tests are fine. However, row_level_op_catalog, registered in the same sparkConf (L50-51), returns rlot.copy() on every load, and the copy shares properties with the live table (InMemoryRowLevelOperationTable.copy) but gets its own display map (InMemoryBaseTable.setDisplayProperties). With that catalog, external = true would reach the live table while every display entry is lost on the next load, so the "excludes display-only properties" checks would pass without checking anything. Could the fixture take the table from BasicInMemoryTableCatalog.liveTable, as RowLevelOperationSuiteBase.table does for the same reason?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shared fixture now obtains the underlying table through BasicInMemoryTableCatalog.liveTable before setting display properties, so the setup also works for catalogs that copy on load.

*/
def setDisplayProperties(table: InMemoryTable, external: Boolean = false): Unit = {
if (external) {
table.properties.put(TableCatalog.PROP_EXTERNAL, "true")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: CREATE EXTERNAL TABLE ... LOCATION should work on this V2 catalog, so the fixture doesn't need to write external into the stored map by hand. For a non-session catalog, the parser keeps the EXTERNAL flag, the LOCATION requirement exists only in the V1 SessionCatalog.createTable, and CreateTableExec stores external=true through CatalogV2Util.convertTableProperties. That is the same path that already stores external=true for spark.catalog.createTable with a path on testcat, and the reserved-key error points users to it ("please use CREATE EXTERNAL TABLE"). Could createTableWithDisplayProperties use CREATE ${if (external) "EXTERNAL " else ""}TABLE ... and drop L99-101? Then setDisplayProperties only sets display entries, like InMemoryBaseTable.setDisplayProperties, and the caveat in its Scaladoc (L96) is no longer needed. The conflicting display external value can still come from the argument.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixture now uses CREATE EXTERNAL TABLE when requested. Removed the direct write to stored external; setDisplayProperties now sets only display entries, and its Scaladoc reflects that.

class ShowTblPropertiesSuite extends command.ShowTblPropertiesSuiteBase with CommandSuiteBase {
test("display properties supplement current properties and exclude reserved keys") {
withNamespaceAndTable("ns", "table") { tbl =>
sql(s"CREATE TABLE $tbl (id bigint) $defaultUsing " +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this is the only one of the four V2 display-property tests that still creates its table by hand. createTableWithDisplayProperties("ns", "table") creates the same table with persisted = stored and the same display fixture. Its extra COMMENT 'table comment' doesn't change any assertion here, because comment is a reserved key that tablePropertiesForDisplay drops, and getCreateTableString still contains no persisted. Could L29-31 call the helper, so that the shared table definition lives in one place?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reused createTableWithDisplayProperties("ns", "table") in the SHOW TBLPROPERTIES test, consolidating its setup with the other display-property tests.

}

override def displayProperties(): util.Map[String, String] = {
Option(tableDisplayProperties).getOrElse(super.displayProperties())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: until display properties are set, this returns the interface default Collections.emptyMap(), which rejects put. After setDisplayProperties or any copyTableStateFrom call (every copy(), alterTable and pinTable path), it returns the internal HashMap itself, so t.displayProperties().put(...) silently changes the table's state. No current test mutates it, so this is latent, but DelegatingTable returns a read-only view, which the new catalyst CatalogSuite test pins. Returning Collections.unmodifiableMap(tableDisplayProperties) here would make both states read-only and leave setDisplayProperties as the only way to change them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapped the configured display map in Collections.unmodifiableMap, keeping the accessor read-only after setting or copying display properties as well as in its default state.


package org.apache.spark.sql.connector.catalog

import java.util

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the rest of this file writes JDK types fully qualified (e.g. L81 java.util.Set[String] and L161 java.util.Map.of), but the new tests add import java.util for util.Map.of and util.HashMap (L42-69). Could they use java.util.Map.of and new java.util.HashMap and drop the import? The same applies to DataSourceV2DataFrameSessionCatalogSuite L20 and L63, where the only util.Map.of sits next to the existing java.util.Map[String, String] at L151.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed import java.util from both files and qualified the new Map and HashMap usages as java.util.Map and java.util.HashMap to match the surrounding code.

@szehon-ho

Copy link
Copy Markdown
Member Author

thanks for the review @dongjoon-hyun, can you take another look when you get a chance?

@dongjoon-hyun

Copy link
Copy Markdown
Member

Sure, @szehon-ho .

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, LGTM (Pending CIs).

a property key. If no key is specified then all the properties are returned.

For V2 tables, the output may also include display-only properties supplied by the catalog.
`ALTER TABLE ... UNSET TBLPROPERTIES` does not remove these entries, and `SHOW CREATE TABLE`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this sentence and "unsetting a stored non-reserved property exposes the display-only value" on L30 read as guarantees from Spark. However, the API contract in Table.displayProperties() only says that catalogs should apply SET/UNSET TBLPROPERTIES to properties() and keep display-only entries unchanged, and it now also allows computing them on demand. For example, a catalog that derives a display label from a stored property, or one that removes the key from both maps in alterTable, would no longer list the entry after UNSET. This is the same distinction that R3-2 made for the Javadoc. Could we say what Spark does and leave the rest to the catalog, e.g. "Spark itself does not remove these entries on ALTER TABLE ... UNSET TBLPROPERTIES; whether they remain depends on the catalog."? The same applies to sql-ref-syntax-ddl-alter-table.md L282-283.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated both SQL reference pages in 7a57149 to distinguish Spark's behavior from the catalog's: Spark itself does not remove display-only entries on UNSET, whether they remain depends on the catalog, and a display value appears after unsetting a stored key only if the catalog still supplies it.

For V2 tables, the output may also include display-only properties supplied by the catalog.
`ALTER TABLE ... UNSET TBLPROPERTIES` does not remove these entries, and `SHOW CREATE TABLE`
does not include them. Stored properties take precedence over display-only entries with the
same key; unsetting a stored non-reserved property exposes the display-only value if present.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "non-reserved" isn't defined anywhere in the SQL reference, and the Note at the end of this page lists the V1 exclusions (spark.sql*, EXTERNAL, comment, Hive statistics), not the V2 reserved keys. In practice, comment is the only reserved key that UNSET TBLPROPERTIES accepts, because AstBuilder.cleanTableProperties keeps it and rejects or drops the other reserved keys. So SQL users can't tell that UNSET TBLPROPERTIES ('comment') doesn't expose a display-only comment. Could we name the key instead, e.g. "unsetting a stored property other than comment exposes ..."? The same applies to sql-ref-syntax-ddl-alter-table.md L283.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated both pages in 7a57149 to say "other than comment" instead of "non-reserved", and explicitly state that display-only comment entries are ignored even after unsetting the stored comment.

This statement returns the value of a table property given an optional value for
a property key. If no key is specified then all the properties are returned.

For V2 tables, the output may also include display-only properties supplied by the catalog.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: within docs/sql-ref*.md, "V2 tables" appears only here and in sql-ref-syntax-ddl-alter-table.md L281, so SQL users can't tell which tables it covers. For example, parquet or Hive tables in spark_catalog are not covered. Could we link the existing Data Source V2 page, e.g. For tables in [Data Source V2](sql-v2-data-sources.html) catalogs, ...?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated both pages in 7a57149 to use "For tables in Data Source V2 catalogs" with a link to the existing Data Source V2 page.

@szehon-ho szehon-ho closed this Oct 22, 2026
@szehon-ho szehon-ho reopened this Oct 22, 2026
@szehon-ho
szehon-ho force-pushed the codex/table-display-properties branch from 7a57149 to 63f73a7 Compare October 22, 2026 22:16
@szehon-ho

Copy link
Copy Markdown
Member Author

re-trigger CI

@szehon-ho

Copy link
Copy Markdown
Member Author

Build pass but build status not updated due to #58579, merging

@szehon-ho szehon-ho closed this in 918645b Oct 26, 2026
szehon-ho added a commit that referenced this pull request Oct 26, 2026
### What changes were proposed in this pull request?

Add `Table.displayProperties()` for additional display-only table properties, defaulting to an empty map. The map and its keys and values must be non-null. Spark merges these entries with non-reserved stored properties for V2 `DESCRIBE TABLE EXTENDED`, `SHOW TBLPROPERTIES`, and `SHOW TABLE EXTENDED`. Stored values take precedence, and reserved keys in the display map are ignored. Table type, location, provider, owner, and other reserved metadata continue to come from `properties()`.

Support metadata-only tables through `TableInfo.Builder.withDisplayProperties()` and `DelegatingTable`. Keep `SHOW CREATE TABLE` on `properties()`. The API documentation also guides `createTableLike` implementations to exclude display-only entries when copying source metadata, notes that implementations may compute them on demand, and explains that Spark redacts them like stored properties.

### Why are the changes needed?

Catalogs may expose transient metadata such as labels that should be visible when inspecting a table without appearing in the DDL used to recreate it. The additive API lets connectors supply these entries separately from stored properties.

JIRA: https://issues.apache.org/jira/browse/SPARK-59778

### Does this PR introduce _any_ user-facing change?

Yes. Table implementations can opt in to display-only properties. Existing connectors retain their behavior because the new method defaults to an empty map. Mockito mocks that only stub `properties()` also retain their existing results.

`spark.catalog.getTableProperties()` remains equivalent to `SHOW TBLPROPERTIES`, including in PySpark and Spark Connect, so it also returns display-only entries. The Scala, Connect Scala, and Python API documentation explains that these entries may be transient and are excluded from the DDL returned by `getCreateTableString()`.

`ALTER TABLE ... SET/UNSET TBLPROPERTIES` forwards changes to the catalog without consulting display properties. The API documentation guides catalogs to apply these changes to stored properties and keep display-only entries unchanged. After a colliding stored non-reserved property is unset, the display value is shown if it remains in the display map. Reserved display keys, including `comment`, remain ignored. The shared display helper applies the existing redaction rules and reuses the stored property map fetched for reserved metadata.

`TableInfo` carries display properties as returned metadata. Spark leaves them empty when calling the table creation and staging methods. V2 views retain their existing behavior. The SQL references for `SHOW TBLPROPERTIES` and `ALTER TABLE ... UNSET TBLPROPERTIES` explain display-only entries and stored-property precedence.

For now, `SHOW TABLE EXTENDED` omits display-only entries for the built-in session catalog and its `CatalogExtension`s. `DESCRIBE TABLE EXTENDED` and `SHOW TBLPROPERTIES` include them when an extension returns a V2 table.

### How was this patch tested?

For this revision, all 34 selected tests passed using `SPARK_LOCAL_IP=localhost build/sbt`:

- Catalyst `CatalogV2UtilSuite`: 24 tests.
- Catalyst `CatalogSuite -- -z "display properties"`: 2 tests.
- V2 `DescribeTableSuite`, `ShowTablesSuite`, `ShowTblPropertiesSuite`, `DataSourceV2MetadataTableSuite`, and `DataSourceV2DataFrameSessionCatalogSuite`, filtered by `"display properties"`: 7 tests.
- V2 `ShowCreateTableSuite -- -z "display-only properties"`: 1 test.

The added assertions cover reserved-key lookup and the session-catalog `SHOW TABLE EXTENDED` limitation. The helper redaction test checks that mutable input maps remain unchanged. External-table fixtures now use `CREATE EXTERNAL TABLE`, and display fixture setup uses the underlying live table.

Catalyst Java Checkstyle, Catalyst/SQL test Scalastyle, and `git diff --check` also passed for this revision.

The previous revision passed all 214 selected tests; one pre-existing test was ignored:

- Catalyst `CatalogSuite` and `CatalogV2UtilSuite`: 103 tests.
- V2 `DescribeTableSuite`, `ShowTblPropertiesSuite`, `ShowTablesSuite`, and `ShowCreateTableSuite`, plus `DataSourceV2MetadataTableSuite`: 100 tests.
- `DataSourceV2DataFrameSessionCatalogSuite -- -z "display properties"`: 1 test.
- `org.apache.spark.sql.internal.CatalogSuite -- -z "catalog API"`: 10 tests.

Ran with `SPARK_LOCAL_IP=localhost build/sbt`, using `catalyst/testOnly` and `sql/testOnly` for the suites above. Coverage includes stored/display collisions, SET and UNSET, every reserved display key with and without a stored location, redaction, metadata-only tables, exclusion from generated DDL, and preservation across copies, pinning, and ALTER TABLE in both V2 and session-catalog fixtures.

The previous revision also passed Catalyst Java Checkstyle, Catalyst/SQL main and test Scalastyle, and `git diff --check`. SQL API/Connect Common scalafmt validation and Python syntax checks passed in an earlier revision; those files are unchanged in this update.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Codex (GPT-6)

Closes #59036 from szehon-ho/codex/table-display-properties.

Authored-by: Szehon Ho <[email protected]>
Signed-off-by: Szehon Ho <[email protected]>
(cherry picked from commit 918645b)
Signed-off-by: Szehon Ho <[email protected]>
@szehon-ho

Copy link
Copy Markdown
Member Author

Merge Summary:

Posted by merge_spark_pr.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants