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

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


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 369a1cff8c44 [SPARK-59778][SQL] Add display-only properties to the 
Table API
369a1cff8c44 is described below

commit 369a1cff8c44db838039b1112ff0a62f4aed3c75
Author: Szehon Ho <[email protected]>
AuthorDate: Mon Oct 26 13:29:56 2026 -0700

    [SPARK-59778][SQL] Add display-only properties to the Table API
    
    ### 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 t [...]
    
    `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 918645bce996329a1c840361f16a54d2c747eac1)
    Signed-off-by: Szehon Ho <[email protected]>
---
 docs/sql-ref-syntax-aux-show-tblproperties.md      |  8 ++++
 docs/sql-ref-syntax-ddl-alter-table.md             |  7 +++
 python/pyspark/sql/catalog.py                      |  6 +++
 .../org/apache/spark/sql/catalog/Catalog.scala     |  3 ++
 .../sql/connector/catalog/DelegatingTable.java     |  5 ++
 .../apache/spark/sql/connector/catalog/Table.java  | 32 +++++++++++++
 .../spark/sql/connector/catalog/TableInfo.java     | 35 +++++++++++++-
 .../sql/connector/catalog/CatalogV2Util.scala      | 10 ++++
 .../spark/sql/connector/catalog/CatalogSuite.scala | 53 ++++++++++++++++++++++
 .../sql/connector/catalog/CatalogV2UtilSuite.scala | 40 ++++++++++++++++
 .../sql/connector/catalog/InMemoryBaseTable.scala  | 16 ++++++-
 .../catalog/InMemoryRowLevelOperationTable.scala   |  2 +-
 .../InMemoryRowLevelOperationTableCatalog.scala    |  4 +-
 .../sql/connector/catalog/InMemoryTable.scala      |  2 +-
 .../connector/catalog/InMemoryTableCatalog.scala   |  3 +-
 .../catalog/MixedColumnIdTableCatalog.scala        |  4 +-
 .../catalog/NullColumnIdInMemoryTableCatalog.scala |  4 +-
 ...ableIdAndNullColumnIdInMemoryTableCatalog.scala |  4 +-
 .../TypeChangeResetsColIdTableCatalog.scala        |  2 +-
 .../org/apache/spark/sql/connect/Catalog.scala     |  3 ++
 .../datasources/v2/DescribeTableExec.scala         | 12 ++---
 .../datasources/v2/ShowTablePropertiesExec.scala   |  6 +--
 .../datasources/v2/ShowTablesExtendedExec.scala    | 16 ++++---
 .../DataSourceV2DataFrameSessionCatalogSuite.scala | 25 +++++++++-
 .../connector/DataSourceV2MetadataTableSuite.scala | 50 ++++++++++++++++++--
 .../execution/command/v2/CommandSuiteBase.scala    | 31 +++++++++++++
 .../execution/command/v2/DescribeTableSuite.scala  | 27 ++++++++++-
 .../command/v2/ShowCreateTableSuite.scala          | 15 ++++++
 .../sql/execution/command/v2/ShowTablesSuite.scala | 28 ++++++++++++
 .../command/v2/ShowTblPropertiesSuite.scala        | 39 ++++++++++++++++
 .../apache/spark/sql/internal/CatalogSuite.scala   | 28 +++++++++++-
 31 files changed, 481 insertions(+), 39 deletions(-)

diff --git a/docs/sql-ref-syntax-aux-show-tblproperties.md 
b/docs/sql-ref-syntax-aux-show-tblproperties.md
index 5b7ddcbcd953..cf2186c7998e 100644
--- a/docs/sql-ref-syntax-aux-show-tblproperties.md
+++ b/docs/sql-ref-syntax-aux-show-tblproperties.md
@@ -24,6 +24,14 @@ license: |
 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 tables in [Data Source V2](sql-v2-data-sources.html) catalogs, the output 
may also include
+display-only properties supplied by the catalog. Spark itself does not remove 
these entries on
+`ALTER TABLE ... UNSET TBLPROPERTIES`; whether they remain depends on the 
catalog.
+`SHOW CREATE TABLE` does not include them. Stored properties take precedence 
over display-only
+entries with the same key. After unsetting a stored property other than 
`comment`, Spark displays
+the display-only value with that key if the catalog still supplies it. 
Display-only `comment`
+entries are ignored, even after unsetting the stored `comment`.
+
 ### Syntax
 
 ```sql
diff --git a/docs/sql-ref-syntax-ddl-alter-table.md 
b/docs/sql-ref-syntax-ddl-alter-table.md
index 28ecc44a5bf7..156a279da909 100644
--- a/docs/sql-ref-syntax-ddl-alter-table.md
+++ b/docs/sql-ref-syntax-ddl-alter-table.md
@@ -278,6 +278,13 @@ ALTER TABLE table_identifier SET TBLPROPERTIES ( key1 = 
val1, key2 = val2, ... )
 
 `ALTER TABLE UNSET` command is used to drop the table property.
 
+For tables in [Data Source V2](sql-v2-data-sources.html) catalogs, catalogs 
may supply display-only
+properties in metadata commands such as `SHOW TBLPROPERTIES`. Spark itself 
does not remove these
+entries on `ALTER TABLE ... UNSET TBLPROPERTIES`; whether they remain depends 
on the catalog.
+After unsetting a stored property other than `comment`, Spark displays the 
display-only value
+with that key if the catalog still supplies it. Display-only `comment` entries 
are ignored,
+even after unsetting the stored `comment`.
+
 **Note:** If the specified property key does not exist, whether specify `IF 
EXISTS` or not, the command will ignore it and finally succeed.
 
 ##### Syntax
diff --git a/python/pyspark/sql/catalog.py b/python/pyspark/sql/catalog.py
index ed7d6ac7482b..6d0b3d6e5b7e 100644
--- a/python/pyspark/sql/catalog.py
+++ b/python/pyspark/sql/catalog.py
@@ -393,6 +393,12 @@ class Catalog:
         dict
             Map of property key to value.
 
+        Notes
+        -----
+        For V2 tables, the returned dict also includes display-only 
properties, which may be
+        transient. These entries are excluded from the table DDL returned by
+        :meth:`getCreateTableString`.
+
         Examples
         --------
         >>> _ = spark.sql("DROP TABLE IF EXISTS tbl_props_doc")
diff --git a/sql/api/src/main/scala/org/apache/spark/sql/catalog/Catalog.scala 
b/sql/api/src/main/scala/org/apache/spark/sql/catalog/Catalog.scala
index 1bb88be33d8e..836eebb0695b 100644
--- a/sql/api/src/main/scala/org/apache/spark/sql/catalog/Catalog.scala
+++ b/sql/api/src/main/scala/org/apache/spark/sql/catalog/Catalog.scala
@@ -846,6 +846,9 @@ abstract class Catalog {
   /**
    * Returns all table properties as a map (same as `SHOW TBLPROPERTIES`).
    *
+   * For V2 tables, the returned map also includes display-only properties, 
which may be
+   * transient. These entries are excluded from the table DDL returned by 
`getCreateTableString`.
+   *
    * @param tableName
    *   table or view name; may be qualified with catalog and database 
(namespace).
    * @since 4.2.0
diff --git 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/DelegatingTable.java
 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/DelegatingTable.java
index 2e8d63eb16d9..af215c350460 100644
--- 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/DelegatingTable.java
+++ 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/DelegatingTable.java
@@ -74,6 +74,11 @@ public class DelegatingTable implements Table {
     return Collections.unmodifiableMap(info.properties());
   }
 
+  @Override
+  public Map<String, String> displayProperties() {
+    return Collections.unmodifiableMap(info.displayProperties());
+  }
+
   @Override
   public Transform[] partitioning() {
     return info.partitions();
diff --git 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/Table.java 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/Table.java
index ec27bcf6c82e..2a9299d297c0 100644
--- 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/Table.java
+++ 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/Table.java
@@ -95,6 +95,38 @@ public interface Table extends Relation {
     return Collections.emptyMap();
   }
 
+  /**
+   * Returns additional, display-only table properties.
+   *
+   * <p>Spark adds these entries to the non-reserved properties displayed by 
V2 table metadata
+   * commands and {@code Catalog.getTableProperties}. Entries in {@link 
#properties()} take
+   * precedence, and reserved keys such as {@link TableCatalog#PROP_EXTERNAL},
+   * {@link TableCatalog#PROP_LOCATION}, and {@link TableCatalog#PROP_COMMENT} 
are ignored in
+   * this map. Table type and other reserved metadata are read from {@code 
properties()}.
+   * After a stored non-reserved property is removed, Spark displays the entry 
for that key if
+   * it remains in this map. Spark redacts these entries in the same way as 
stored properties.
+   *
+   * <p>Spark passes {@code ALTER TABLE ... SET/UNSET TBLPROPERTIES} to
+   * {@link TableCatalog#alterTable} without consulting this map. Catalogs 
should apply these
+   * changes to {@code properties()} and keep display-only entries unchanged.
+   *
+   * <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}.
+   * Implementations of {@link TableCatalog#createTableLike(Identifier, 
TableInfo, Table)} should
+   * not copy them from the source table. Spark reads these entries only to 
display table
+   * metadata, so implementations may compute them on demand.
+   *
+   * <p>The default implementation returns an empty map.
+   *
+   * @return a non-null map with non-null keys and values
+   * @since 4.4.0
+   */
+  default Map<String, String> displayProperties() {
+    return Collections.emptyMap();
+  }
+
   /**
    * Returns the set of capabilities for this table.
    */
diff --git 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableInfo.java
 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableInfo.java
index 1a3261e3085a..72f3586d2f6a 100644
--- 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableInfo.java
+++ 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableInfo.java
@@ -16,6 +16,7 @@
  */
 package org.apache.spark.sql.connector.catalog;
 
+import java.util.HashMap;
 import java.util.Map;
 import java.util.Objects;
 
@@ -24,7 +25,8 @@ import org.apache.spark.sql.connector.expressions.Transform;
 import org.apache.spark.sql.types.StructType;
 
 /**
- * Metadata describing a data-source table: its columns, properties, 
partitioning and constraints.
+ * Metadata describing a data-source table: its columns, stored and 
display-only properties,
+ * partitioning and constraints.
  * Spark realizes a {@code TableInfo} into a {@link Table} via {@link 
DelegatingTable}; a catalog
  * that has its own {@link Table} object returns that instead. Views are 
described by the sibling
  * {@link View}, which -- unlike a table -- is itself a {@link Relation} 
because Spark never builds
@@ -34,6 +36,7 @@ public class TableInfo {
 
   private final Column[] columns;
   private final Map<String, String> properties;
+  private final Map<String, String> displayProperties;
   private final Transform[] partitions;
   private final Constraint[] constraints;
 
@@ -43,6 +46,7 @@ public class TableInfo {
   protected TableInfo(Builder builder) {
     this.columns = builder.columns;
     this.properties = builder.properties;
+    this.displayProperties = builder.displayProperties;
     this.partitions = builder.partitions;
     this.constraints = builder.constraints;
   }
@@ -59,6 +63,22 @@ public class TableInfo {
     return properties;
   }
 
+  /**
+   * 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
+   * calling {@link TableCatalog#createTable(Identifier, TableInfo)},
+   * {@link TableCatalog#createTableLike(Identifier, TableInfo, Table)},
+   * {@link StagingTableCatalog#stageCreate(Identifier, TableInfo)},
+   * {@link StagingTableCatalog#stageReplace(Identifier, TableInfo)}, and
+   * {@link StagingTableCatalog#stageCreateOrReplace(Identifier, TableInfo)}.
+   *
+   * @since 4.4.0
+   */
+  public Map<String, String> displayProperties() {
+    return displayProperties;
+  }
+
   public Transform[] partitions() {
     return partitions;
   }
@@ -66,12 +86,25 @@ public class TableInfo {
   public Constraint[] constraints() { return constraints; }
 
   public static class Builder extends RelationBuilder<Builder> {
+    protected Map<String, String> displayProperties = new HashMap<>();
     protected Transform[] partitions = new Transform[0];
     protected Constraint[] constraints = new Constraint[0];
 
     @Override
     protected Builder self() { return this; }
 
+    /**
+     * Sets additional display-only properties.
+     *
+     * @param displayProperties a non-null map with non-null keys and values
+     * @see TableInfo#displayProperties()
+     * @since 4.4.0
+     */
+    public Builder withDisplayProperties(Map<String, String> 
displayProperties) {
+      this.displayProperties = new HashMap<>(displayProperties);
+      return this;
+    }
+
     public Builder withPartitions(Transform[] partitions) {
       this.partitions = partitions;
       return this;
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogV2Util.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogV2Util.scala
index de170375effb..68ddd2b34360 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogV2Util.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/connector/catalog/CatalogV2Util.scala
@@ -692,6 +692,16 @@ private[sql] object CatalogV2Util {
     builder
   }
 
+  /** Returns redacted, non-reserved table properties with additional 
display-only metadata. */
+  def tablePropertiesForDisplay(
+      properties: util.Map[String, String],
+      displayProperties: util.Map[String, String],
+      conf: SQLConf): Map[String, String] = {
+    val merged = (displayProperties.asScala.toMap ++ properties.asScala)
+      .filterNot { case (key, _) => TABLE_RESERVED_PROPERTIES.contains(key) }
+    conf.redactOptions(merged)
+  }
+
   def convertTableProperties(t: TableSpec): Map[String, String] = {
     val props = convertTableProperties(
       t.properties, t.options, t.serde, t.location, t.comment,
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogSuite.scala
index 8c59b2ef4598..9a784f3c383e 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogSuite.scala
@@ -77,6 +77,59 @@ class CatalogSuite extends SparkFunSuite {
     assert(loaded.getClass == catalog.getClass)
   }
 
+  test("TableInfo and DelegatingTable carry display-only properties 
separately") {
+    val displayProperties = new util.HashMap[String, String]()
+    displayProperties.put("catalog-label", "catalog-value")
+    val info = new TableInfo.Builder()
+      .withProperties(util.Map.of("persisted", "stored"))
+      .withDisplayProperties(displayProperties)
+      .build()
+    displayProperties.put("catalog-label", "changed")
+    val table = new DelegatingTable(info, "table")
+
+    assert(table.displayProperties() === util.Map.of("catalog-label", 
"catalog-value"))
+    assert(table.properties() === util.Map.of("persisted", "stored"))
+    intercept[UnsupportedOperationException] {
+      table.displayProperties().put("catalog-label", "changed")
+    }
+    val defaultTable = new DelegatingTable(new TableInfo.Builder().build(), 
"default")
+    assert(defaultTable.displayProperties().isEmpty)
+  }
+
+  gridTest("display properties survive copies, pinning and alterations")(
+      Seq("InMemoryTableCatalog", "InMemoryRowLevelOperationTableCatalog")) { 
catalogName =>
+    val catalog = if (catalogName == "InMemoryRowLevelOperationTableCatalog") {
+      new InMemoryRowLevelOperationTableCatalog
+    } else {
+      new InMemoryTableCatalog
+    }
+    // The row-level catalog always copies on load; enable this for 
InMemoryTableCatalog too.
+    catalog.initialize("test", new 
CaseInsensitiveStringMap(util.Map.of("copyOnLoad", "true")))
+    val table = catalog.createTable(testIdent, new TableInfo.Builder()
+      .withColumns(columns)
+      .withProperties(util.Map.of("persisted", "stored"))
+      .build()).asInstanceOf[InMemoryBaseTable]
+    table.setDisplayProperties(util.Map.of("catalog-label", "catalog-value"))
+    table.setVersion("3")
+    table.setValidatedVersion("2")
+    val loaded = catalog.loadTable(testIdent).asInstanceOf[InMemoryBaseTable]
+    assert(loaded ne table)
+    assert(loaded.displayProperties() === util.Map.of("catalog-label", 
"catalog-value"))
+    assert(loaded.version() === "3")
+    assert(loaded.validatedVersion() === "2")
+    catalog.pinTable(testIdent, "snapshot")
+
+    val altered = catalog.alterTable(testIdent, 
TableChange.setProperty("persisted", "updated"))
+      .asInstanceOf[InMemoryBaseTable]
+    assert(altered.validatedVersion() === "2")
+    val current = catalog.loadTable(testIdent)
+    assert(current.properties() === util.Map.of("persisted", "updated"))
+    assert(current.displayProperties() === util.Map.of("catalog-label", 
"catalog-value"))
+    val snapshot = catalog.loadTable(testIdent, "snapshot")
+    assert(snapshot.properties() === util.Map.of("persisted", "stored"))
+    assert(snapshot.displayProperties() === util.Map.of("catalog-label", 
"catalog-value"))
+  }
+
   test("listTables") {
     val catalog = newCatalog()
     val ident1 = Identifier.of(Array("ns"), "test_table_1")
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogV2UtilSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogV2UtilSuite.scala
index acbc2aefe212..d0891e86c38d 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogV2UtilSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogV2UtilSuite.scala
@@ -35,6 +35,46 @@ import org.apache.spark.sql.util.CaseInsensitiveStringMap
 
 class CatalogV2UtilSuite extends SparkFunSuite {
 
+  test("tablePropertiesForDisplay preserves stored properties with no display 
properties") {
+    val properties = java.util.Map.of(
+      "persisted", "stored", TableCatalog.PROP_EXTERNAL, "true")
+
+    assert(CatalogV2Util.tablePropertiesForDisplay(
+      properties, java.util.Map.of[String, String](), new SQLConf) ===
+      Map("persisted" -> "stored"))
+  }
+
+  test("tablePropertiesForDisplay ignores reserved display keys and preserves 
stored values") {
+    val displayProperties = new java.util.HashMap[String, String]()
+    CatalogV2Util.TABLE_RESERVED_PROPERTIES.foreach(displayProperties.put(_, 
"display-value"))
+    displayProperties.put("persisted", "display-value")
+    displayProperties.put("catalog-label", "catalog-value")
+    val properties = java.util.Map.of("persisted", "stored")
+
+    assert(CatalogV2Util.tablePropertiesForDisplay(
+      properties, displayProperties, new SQLConf) ===
+      Map("persisted" -> "stored", "catalog-label" -> "catalog-value"))
+  }
+
+  test("tablePropertiesForDisplay redacts stored and display-only values") {
+    val conf = new SQLConf
+    conf.setConf(SQLConf.SQL_OPTIONS_REDACTION_PATTERN, "(?i)catalog-label".r)
+    val properties = new java.util.HashMap[String, String](
+      java.util.Map.of("password", "stored-password", "persisted", "stored"))
+    val displayProperties = new java.util.HashMap[String, 
String](java.util.Map.of(
+      "catalog-label", "catalog-value", "secret", "display-secret", 
"persisted", "display-value"))
+
+    assert(CatalogV2Util.tablePropertiesForDisplay(properties, 
displayProperties, conf) === Map(
+      "password" -> "*********(redacted)",
+      "catalog-label" -> "*********(redacted)",
+      "secret" -> "*********(redacted)",
+      "persisted" -> "stored"))
+    assert(properties === java.util.Map.of(
+      "password", "stored-password", "persisted", "stored"))
+    assert(displayProperties === java.util.Map.of(
+      "catalog-label", "catalog-value", "secret", "display-secret", 
"persisted", "display-value"))
+  }
+
   private def catalogWithStateOptions(keys: java.util.Set[String]): 
TableCatalog = {
     val catalog = mock(classOf[TableCatalog])
     when(catalog.tableStateOptionKeys()).thenReturn(keys)
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala
index adf1e655ab10..08ce1090dae5 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryBaseTable.scala
@@ -76,6 +76,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
+
   // Assign column IDs to columns that do not have one, including nested 
struct fields within
   // arrays and maps. This simulates connectors that support column identity 
tracking.
   private var tableColumns: Array[Column] = 
InMemoryBaseTable.assignMissingIds(initialColumns)
@@ -86,6 +88,15 @@ abstract class InMemoryBaseTable(
     tableColumns = newColumns
   }
 
+  override def displayProperties(): util.Map[String, String] = {
+    Option(tableDisplayProperties).map(util.Collections.unmodifiableMap(_))
+      .getOrElse(super.displayProperties())
+  }
+
+  def setDisplayProperties(displayProperties: util.Map[String, String]): Unit 
= {
+    tableDisplayProperties = new util.HashMap[String, 
String](displayProperties)
+  }
+
   override def version(): String = tableVersion.toString
 
   def setVersion(version: String): Unit = {
@@ -93,7 +104,7 @@ abstract class InMemoryBaseTable(
   }
 
   /**
-   * Copies version and validated version from another table.
+   * Copies version, validated version and display-only metadata from another 
table.
    *
    * Some test catalogs (e.g. [[NullColumnIdInMemoryTableCatalog]],
    * [[NullTableIdAndNullColumnIdInMemoryTableCatalog]]) create a new table 
object
@@ -104,7 +115,8 @@ abstract class InMemoryBaseTable(
    * [[InMemoryTable]].copy, validated-version propagation, and the 
join-refresh
    * tests in [[DSv2IncrementallyConstructedQueryTests]]).
    */
-  def setVersionAndValidatedVersionFrom(sourceTable: InMemoryBaseTable): Unit 
= {
+  def copyTableStateFrom(sourceTable: InMemoryBaseTable): Unit = {
+    setDisplayProperties(sourceTable.displayProperties())
     setVersion(sourceTable.version())
     if (sourceTable.validatedVersion() != null) {
       setValidatedVersion(sourceTable.validatedVersion())
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala
index 3e04ab719b7b..7fb11ea8be26 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala
@@ -124,7 +124,7 @@ class InMemoryRowLevelOperationTable private (
       }
     }
     copied.commits ++= commits.map(_.copy())
-    copied.setVersionAndValidatedVersionFrom(this)
+    copied.copyTableStateFrom(this)
     copied.replacedPartitions = replacedPartitions
     copied.lastWriteInfo = lastWriteInfo
     copied.lastWriteLog = lastWriteLog
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTableCatalog.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTableCatalog.scala
index 5c9060fcca6c..15260d6e1b28 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTableCatalog.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTableCatalog.scala
@@ -100,7 +100,7 @@ class InMemoryRowLevelOperationTableCatalog
       tableId = table.id,
       schemaAlignmentConfig = table.schemaAlignmentConfig)
     newTable.alterTableWithData(table.data, schema)
-    newTable.setVersionAndValidatedVersionFrom(table)
+    newTable.copyTableStateFrom(table)
 
     tables.put(ident, newTable)
 
@@ -143,7 +143,7 @@ class PartialSchemaEvolutionCatalog extends 
InMemoryRowLevelOperationTableCatalo
       constraints = table.constraints,
       schemaAlignmentConfig = table.schemaAlignmentConfig)
     newTable.alterTableWithData(table.data, table.schema)
-    newTable.setVersionAndValidatedVersionFrom(table)
+    newTable.copyTableStateFrom(table)
     tables.put(ident, newTable)
     newTable
   }
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTable.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTable.scala
index 20ce2af03ac6..e5657f9d88bf 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTable.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTable.scala
@@ -161,7 +161,7 @@ class InMemoryTable(
 
     copiedTable.commits ++= commits.map(_.copy())
 
-    copiedTable.setVersionAndValidatedVersionFrom(this)
+    copiedTable.copyTableStateFrom(this)
 
     copiedTable
   }
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableCatalog.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableCatalog.scala
index dc9253e42540..50930031d2b3 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableCatalog.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryTableCatalog.scala
@@ -263,7 +263,6 @@ class BasicInMemoryTableCatalog extends TableCatalog {
     }
 
     table.increaseVersion()
-    val currentVersion = table.version()
     val columnsWithIds = InMemoryBaseTable.assignMissingIds(
       CatalogV2Util.structTypeToV2Columns(schema))
     val reconstructedId = 
Option(table.id()).getOrElse(util.UUID.randomUUID().toString)
@@ -278,7 +277,7 @@ class BasicInMemoryTableCatalog extends TableCatalog {
         throw new UnsupportedOperationException(
           s"Unsupported InMemoryBaseTable subclass: ${other.getClass.getName}")
     }
-    newTable.setVersion(currentVersion)
+    newTable.copyTableStateFrom(table)
     changes.foreach {
       case a: TableChange.AddConstraint =>
         newTable.setValidatedVersion(a.validatedTableVersion())
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/MixedColumnIdTableCatalog.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/MixedColumnIdTableCatalog.scala
index f77cad3c077d..4c38aa0dcf92 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/MixedColumnIdTableCatalog.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/MixedColumnIdTableCatalog.scala
@@ -50,7 +50,7 @@ class MixedColumnIdTableCatalog extends InMemoryTableCatalog {
       id = table.id,
       nullIdNames = snapshot)
     mixedTable.alterTableWithData(table.data, table.schema)
-    mixedTable.setVersionAndValidatedVersionFrom(table)
+    mixedTable.copyTableStateFrom(table)
     mixedTable
   }
 
@@ -121,7 +121,7 @@ class MixedColumnIdInMemoryTable(
     dataMap.synchronized {
       copiedTable.alterTableWithData(data, schema)
     }
-    copiedTable.setVersionAndValidatedVersionFrom(this)
+    copiedTable.copyTableStateFrom(this)
     copiedTable
   }
 }
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/NullColumnIdInMemoryTableCatalog.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/NullColumnIdInMemoryTableCatalog.scala
index fead2e6fba79..0185a6b0a893 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/NullColumnIdInMemoryTableCatalog.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/NullColumnIdInMemoryTableCatalog.scala
@@ -45,7 +45,7 @@ class NullColumnIdInMemoryTableCatalog extends 
InMemoryTableCatalog {
       constraints = table.constraints,
       id = table.id)
     nullColIdTable.alterTableWithData(table.data, table.schema)
-    nullColIdTable.setVersionAndValidatedVersionFrom(table)
+    nullColIdTable.copyTableStateFrom(table)
     nullColIdTable
   }
 
@@ -101,7 +101,7 @@ class NullColumnIdInMemoryTable(
     dataMap.synchronized {
       copiedTable.alterTableWithData(data, schema)
     }
-    copiedTable.setVersionAndValidatedVersionFrom(this)
+    copiedTable.copyTableStateFrom(this)
     copiedTable
   }
 }
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/NullTableIdAndNullColumnIdInMemoryTableCatalog.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/NullTableIdAndNullColumnIdInMemoryTableCatalog.scala
index c1d45e42fc46..09d7036956b6 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/NullTableIdAndNullColumnIdInMemoryTableCatalog.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/NullTableIdAndNullColumnIdInMemoryTableCatalog.scala
@@ -41,7 +41,7 @@ class NullTableIdAndNullColumnIdInMemoryTableCatalog extends 
InMemoryTableCatalo
       properties = table.properties,
       constraints = table.constraints)
     nullTable.alterTableWithData(table.data, table.schema)
-    nullTable.setVersionAndValidatedVersionFrom(table)
+    nullTable.copyTableStateFrom(table)
     nullTable
   }
 
@@ -94,7 +94,7 @@ class NullTableIdAndNullColumnIdInMemoryTable(
     dataMap.synchronized {
       copiedTable.alterTableWithData(data, schema)
     }
-    copiedTable.setVersionAndValidatedVersionFrom(this)
+    copiedTable.copyTableStateFrom(this)
     copiedTable
   }
 }
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/TypeChangeResetsColIdTableCatalog.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/TypeChangeResetsColIdTableCatalog.scala
index 83effcef2b26..09b057d0f7a0 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/TypeChangeResetsColIdTableCatalog.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/TypeChangeResetsColIdTableCatalog.scala
@@ -62,7 +62,7 @@ class TypeChangeResetsColIdTableCatalog extends 
InMemoryTableCatalog {
       alteredTable.constraints,
       id = alteredTable.id)
     tableWithResetIds.alterTableWithData(alteredTable.data, 
alteredTable.schema)
-    tableWithResetIds.setVersionAndValidatedVersionFrom(alteredTable)
+    tableWithResetIds.copyTableStateFrom(alteredTable)
     tables.put(ident, tableWithResetIds)
     tableWithResetIds
   }
diff --git 
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/Catalog.scala 
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/Catalog.scala
index ce7a10c4026c..807f9a1523f6 100644
--- 
a/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/Catalog.scala
+++ 
b/sql/connect/common/src/main/scala/org/apache/spark/sql/connect/Catalog.scala
@@ -853,6 +853,9 @@ class Catalog(sparkSession: SparkSession) extends 
catalog.Catalog {
   /**
    * Returns all table properties as a map (same as `SHOW TBLPROPERTIES`).
    *
+   * For V2 tables, the returned map also includes display-only properties, 
which may be
+   * transient. These entries are excluded from the table DDL returned by 
`getCreateTableString`.
+   *
    * @param tableName
    *   qualified or unqualified table or view name
    * @since 4.2.0
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DescribeTableExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DescribeTableExec.scala
index 68f4620eaf0d..d35aeb32cdbc 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DescribeTableExec.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DescribeTableExec.scala
@@ -18,7 +18,6 @@
 package org.apache.spark.sql.execution.datasources.v2
 
 import scala.collection.mutable.ArrayBuffer
-import scala.jdk.CollectionConverters._
 
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.catalog.{CatalogTableType, ClusterBySpec}
@@ -189,7 +188,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 tableProperties = table.properties()
+    val tableType = if 
(tableProperties.containsKey(TableCatalog.PROP_EXTERNAL)) {
       CatalogTableType.EXTERNAL.name
     } else {
       CatalogTableType.MANAGED.name
@@ -198,13 +198,13 @@ case class DescribeTableExec(
     CatalogV2Util.TABLE_RESERVED_PROPERTIES
       .filterNot(_ == TableCatalog.PROP_EXTERNAL)
       .foreach(propKey => {
-        if (table.properties.containsKey(propKey)) {
-          rows += toCatalystRow(propKey.capitalize, 
table.properties.get(propKey), "")
+        if (tableProperties.containsKey(propKey)) {
+          rows += toCatalystRow(propKey.capitalize, 
tableProperties.get(propKey), "")
         }
       })
     val properties =
-      conf.redactOptions(table.properties.asScala.toMap).toList
-        .filter(kv => !CatalogV2Util.TABLE_RESERVED_PROPERTIES.contains(kv._1))
+      CatalogV2Util.tablePropertiesForDisplay(
+        tableProperties, table.displayProperties(), conf).toList
         .sortBy(_._1).map {
         case (key, value) => key + "=" + value
       }.mkString("[", ",", "]")
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowTablePropertiesExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowTablePropertiesExec.scala
index 3a957e043393..b561f98825b1 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowTablePropertiesExec.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowTablePropertiesExec.scala
@@ -31,11 +31,9 @@ case class ShowTablePropertiesExec(
     propertyKey: Option[String]) extends LeafV2CommandExec {
 
   override protected def run(): Seq[InternalRow] = {
-    import scala.jdk.CollectionConverters._
-
     // The reserved properties are accessible through DESCRIBE
-    val properties = conf.redactOptions(catalogTable.properties.asScala.toMap)
-      .filter { case (k, _) => 
!CatalogV2Util.TABLE_RESERVED_PROPERTIES.contains(k) }
+    val properties = CatalogV2Util.tablePropertiesForDisplay(
+      catalogTable.properties(), catalogTable.displayProperties(), conf)
     propertyKey match {
       case Some(p) =>
         val propValue = properties
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowTablesExtendedExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowTablesExtendedExec.scala
index f9ca033310c7..3dbe3644dd90 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowTablesExtendedExec.scala
+++ 
b/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 tableProperties = table.properties()
+    val tableType = if 
(tableProperties.containsKey(TableCatalog.PROP_EXTERNAL)) {
       CatalogTableType.EXTERNAL
     } else {
       CatalogTableType.MANAGED
@@ -92,15 +93,16 @@ case class ShowTablesExtendedExec(
     CatalogV2Util.TABLE_RESERVED_PROPERTIES
       .filterNot(_ == TableCatalog.PROP_EXTERNAL)
       .foreach(propKey => {
-        if (table.properties.containsKey(propKey)) {
-          results.put(propKey.capitalize, table.properties.get(propKey))
+        if (tableProperties.containsKey(propKey)) {
+          results.put(propKey.capitalize, tableProperties.get(propKey))
         }
       })
 
-    val displayedTableProperties = 
conf.redactOptions(table.properties.asScala.toMap).toList
-      .filterNot { case (key, _) => 
CatalogV2Util.TABLE_RESERVED_PROPERTIES.contains(key) }
-      .sortBy(_._1)
-      .map { case (key, value) => key + "=" + value }
+    val displayedTableProperties =
+      CatalogV2Util.tablePropertiesForDisplay(
+        tableProperties, table.displayProperties(), conf).toList
+        .sortBy(_._1)
+        .map { case (key, value) => key + "=" + value }
     if (displayedTableProperties.nonEmpty) {
       results.put("Table Properties", displayedTableProperties.mkString("[", 
", ", "]"))
     }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2DataFrameSessionCatalogSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2DataFrameSessionCatalogSuite.scala
index d10460181329..881baf2fb5b3 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2DataFrameSessionCatalogSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2DataFrameSessionCatalogSuite.scala
@@ -21,7 +21,7 @@ import java.util.Locale
 
 import org.scalatest.BeforeAndAfter
 
-import org.apache.spark.sql.{AnalysisException, DataFrame, SaveMode}
+import org.apache.spark.sql.{AnalysisException, DataFrame, Row, SaveMode}
 import org.apache.spark.sql.catalyst.TableIdentifier
 import org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException
 import org.apache.spark.sql.connector.catalog._
@@ -53,6 +53,28 @@ class DataSourceV2DataFrameSessionCatalogSuite
 
   override protected val catalogAndNamespace: String = ""
 
+  test("display properties survive ALTER TABLE in the session catalog") {
+    val t = "display_properties"
+    withTable(t) {
+      sql(s"CREATE TABLE $t (id bigint) USING $v2Format")
+      val table = sessionCatalog.loadTable(Identifier.of(Array("default"), t))
+        .asInstanceOf[InMemoryTable]
+      table.setDisplayProperties(java.util.Map.of("catalog-label", 
"catalog-value"))
+
+      sql(s"ALTER TABLE $t SET TBLPROPERTIES ('k' = 'v')")
+      checkAnswer(sql(s"SHOW TBLPROPERTIES $t"),
+        Seq(Row("catalog-label", "catalog-value"), Row("k", "v")))
+      checkAnswer(sql(s"DESCRIBE TABLE EXTENDED $t").where("col_name = 'Table 
Properties'"),
+        Row("Table Properties", "[catalog-label=catalog-value,k=v]", ""))
+
+      assert(!sql(s"SHOW TABLE EXTENDED LIKE '$t'")
+        .head().getString(3).contains("catalog-label"))
+
+      sql(s"ALTER TABLE $t UNSET TBLPROPERTIES ('k')")
+      checkAnswer(sql(s"SHOW TBLPROPERTIES $t"), Row("catalog-label", 
"catalog-value"))
+    }
+  }
+
   test("saveAsTable: Append mode should not fail if the table already exists " 
+
     "and a same-name temp view exist") {
     withTable("same_name") {
@@ -177,6 +199,7 @@ class InMemoryTableSessionCatalog extends 
TestV2SessionCatalogBase[InMemoryTable
 
         val newTable = new InMemoryTable(table.name, schema, 
table.partitioning, properties)
           .alterTableWithData(table.data, schema)
+        newTable.setDisplayProperties(table.displayProperties())
 
         tables.put(ident, newTable)
 
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2MetadataTableSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2MetadataTableSuite.scala
index 3a7e5322d620..b3b2f2d1efea 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2MetadataTableSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2MetadataTableSuite.scala
@@ -17,6 +17,8 @@
 
 package org.apache.spark.sql.connector
 
+import java.util
+
 import org.apache.spark.SparkConf
 import org.apache.spark.sql.Row
 import org.apache.spark.sql.catalyst.analysis.NoSuchTableException
@@ -39,6 +41,34 @@ class DataSourceV2MetadataTableSuite extends 
SharedSparkSession {
       "spark.sql.catalog.table_catalog",
       classOf[TestingDataSourceTableCatalog].getName)
 
+  test("metadata commands include display properties from DelegatingTable") {
+    val tableName = "table_catalog.display_properties.test_display_properties"
+    checkAnswer(sql(s"SHOW TBLPROPERTIES $tableName"),
+      Seq(Row("catalog-label", "catalog-value"), Row("persisted", "stored")))
+    checkAnswer(
+      sql(s"DESCRIBE TABLE EXTENDED $tableName").where("col_name = 'Table 
Properties'"),
+      Row("Table Properties", 
"[catalog-label=catalog-value,persisted=stored]", ""))
+    checkAnswer(
+      sql("SHOW TABLE EXTENDED IN table_catalog.display_properties " +
+        "LIKE 'test_display_properties'"),
+      Row("display_properties", "test_display_properties", false,
+        """Catalog: table_catalog
+          |Namespace: display_properties
+          |Table: test_display_properties
+          |Type: MANAGED
+          |Provider: parquet
+          |Table Properties: [catalog-label=catalog-value, persisted=stored]
+          |Schema: root
+          | |-- id: long (nullable = true)
+          |
+          |""".stripMargin))
+    assert(spark.catalog.getTableProperties(tableName) ===
+      util.Map.of("catalog-label", "catalog-value", "persisted", "stored"))
+    val ddl = spark.catalog.getCreateTableString(tableName)
+    assert(ddl.contains("'persisted' = 'stored'"))
+    assert(!ddl.contains("catalog-label"))
+  }
+
   test("file source table") {
     withTempPath { path =>
       val loc = path.getCanonicalPath
@@ -113,10 +143,21 @@ class DataSourceV2MetadataTableSuite extends 
SharedSparkSession {
 /**
  * A read-only [[TableCatalog]] that returns [[DelegatingTable]] for a small 
set of canned
  * table fixtures. Used to drive the data-source-table read path (file source 
+ v2 provider)
- * through Spark's V1 data-source machinery.
+ * through Spark's V1 data-source machinery. The table
+ * `display_properties.test_display_properties` backs the V2 metadata-command 
test and is the
+ * only table returned by `listTables`.
  */
 class TestingDataSourceTableCatalog extends TableCatalog {
   override def loadTable(ident: Identifier): Table = ident.name() match {
+    case "test_display_properties" =>
+      val info = new TableInfo.Builder()
+        .withSchema(new StructType().add("id", "long"))
+        .withProperties(util.Map.of("persisted", "stored"))
+        .withProvider("parquet")
+        .withDisplayProperties(util.Map.of(
+          "catalog-label", "catalog-value", "persisted", "display-value"))
+        .build()
+      new DelegatingTable(info, ident.toString)
     case "test_json" =>
       val info = new TableInfo.Builder()
         .withSchema(new StructType().add("col", "string"))
@@ -152,8 +193,11 @@ class TestingDataSourceTableCatalog extends TableCatalog {
     throw new RuntimeException("shouldn't be called")
   override def renameTable(oldIdent: Identifier, newIdent: Identifier): Unit =
     throw new RuntimeException("shouldn't be called")
-  override def listTables(namespace: Array[String]): Array[Identifier] =
-    throw new RuntimeException("shouldn't be called")
+  override def listTables(namespace: Array[String]): Array[Identifier] = 
namespace match {
+    case Array("display_properties") =>
+      Array(Identifier.of(namespace, "test_display_properties"))
+    case _ => throw new RuntimeException("shouldn't be called")
+  }
 
   private var catalogName = ""
   override def initialize(name: String, options: CaseInsensitiveStringMap): 
Unit = {
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CommandSuiteBase.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CommandSuiteBase.scala
index 548e15f9e783..123575eb2dd7 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CommandSuiteBase.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CommandSuiteBase.scala
@@ -17,6 +17,8 @@
 
 package org.apache.spark.sql.execution.command.v2
 
+import scala.jdk.CollectionConverters._
+
 import org.apache.spark.SparkConf
 import org.apache.spark.sql.catalyst.analysis.ResolvePartitionSpec
 import org.apache.spark.sql.catalyst.catalog.CatalogTypes.TablePartitionSpec
@@ -77,4 +79,33 @@ trait CommandSuiteBase extends SharedSparkSession {
     catalogPlugin.asTableCatalog.loadTable(Identifier.of(Array(schema), table))
       .asInstanceOf[InMemoryTable]
   }
+
+  def createTableWithDisplayProperties(
+      namespace: String,
+      table: String,
+      external: Boolean = false,
+      withLocation: Boolean = false): Unit = {
+    val location = if (withLocation) "LOCATION 'file:/tmp/display-properties' 
" else ""
+    val tableType = if (external) "EXTERNAL " else ""
+    sql(s"CREATE ${tableType}TABLE $catalog.$namespace.$table (id bigint) 
$defaultUsing " +
+      s"COMMENT 'table comment' $location" +
+      "TBLPROPERTIES ('persisted' = 'stored')")
+    val tableCatalog = spark.sessionState.catalogManager.catalog(catalog)
+      .asInstanceOf[BasicInMemoryTableCatalog]
+    val liveTable = tableCatalog.liveTable(Identifier.of(Array(namespace), 
table))
+      .asInstanceOf[InMemoryTable]
+    setDisplayProperties(liveTable, external)
+  }
+
+  /**
+   * Sets display-only test metadata.
+   */
+  def setDisplayProperties(table: InMemoryTable, external: Boolean = false): 
Unit = {
+    val reserved = CatalogV2Util.TABLE_RESERVED_PROPERTIES.map(key => key -> 
s"display-$key")
+    table.setDisplayProperties((reserved.toMap ++ Map(
+      "catalog-label" -> "catalog-value",
+      "password" -> "display-password",
+      "persisted" -> "display-value",
+      TableCatalog.PROP_EXTERNAL -> (!external).toString)).asJava)
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/DescribeTableSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/DescribeTableSuite.scala
index 2e8fe7a4c023..b2385236e9b2 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/DescribeTableSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/DescribeTableSuite.scala
@@ -18,7 +18,7 @@
 package org.apache.spark.sql.execution.command.v2
 
 import org.apache.spark.sql.{AnalysisException, QueryTest, Row}
-import org.apache.spark.sql.connector.catalog.TableCatalog
+import org.apache.spark.sql.connector.catalog.{CatalogV2Util, TableCatalog}
 import org.apache.spark.sql.execution.command
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types.StringType
@@ -220,6 +220,31 @@ class DescribeTableSuite extends 
command.DescribeTableSuiteBase
     }
   }
 
+  gridTest("display properties preserve table type and reserved metadata")(
+      Seq((false, false), (true, true))) {
+    case (external, withLocation) =>
+      withNamespaceAndTable("ns", "table") { tbl =>
+        createTableWithDisplayProperties("ns", "table", external, withLocation)
+
+        val reservedRows = 
CatalogV2Util.TABLE_RESERVED_PROPERTIES.map(_.capitalize)
+        checkAnswer(
+          sql(s"DESCRIBE TABLE EXTENDED $tbl").where(
+            $"col_name".isin((reservedRows ++ Seq("Type", "Table 
Properties")): _*)),
+          Seq(
+            Row("Type", if (external) "EXTERNAL" else "MANAGED", ""),
+            Row("Comment", "table comment", ""),
+            Row("Provider", "_", ""),
+            Row("Owner", Utils.getCurrentUserName(), ""),
+            Row("Table Properties",
+              
"[catalog-label=catalog-value,password=*********(redacted),persisted=stored]", 
"")) ++
+            (if (withLocation) {
+              Seq(Row("Location", "file:/tmp/display-properties", ""))
+            } else {
+              Nil
+            }))
+      }
+  }
+
   test("DESCRIBE TABLE EXTENDED emits structured Catalog/Namespace/Table 
rows") {
     // Pin that DescribeTableExec emits the resolved-identifier components as 
separate
     // rows under the `# Detailed Table Information` block, so consumers can 
read each
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/ShowCreateTableSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/ShowCreateTableSuite.scala
index 961ea612e3ae..425b359036d4 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/ShowCreateTableSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/ShowCreateTableSuite.scala
@@ -33,6 +33,21 @@ class ShowCreateTableSuite extends 
command.ShowCreateTableSuiteBase with Command
 
   override def fullName: String = s"$catalog.$ns.$table"
 
+  test("show create table excludes display-only properties") {
+    withNamespaceAndTable(ns, table) { t =>
+      createTableWithDisplayProperties(ns, table, withLocation = true)
+
+      assert(getShowCreateDDL(t, false) === Array(
+        s"CREATE TABLE $t (",
+        "id BIGINT)",
+        defaultUsing,
+        "COMMENT 'table comment'",
+        "LOCATION 'file:/tmp/display-properties'",
+        "TBLPROPERTIES (",
+        "'persisted' = 'stored')"))
+    }
+  }
+
   test("SPARK-33898: show create table as serde") {
     withNamespaceAndTable(ns, table) { t =>
       spark.sql(s"CREATE TABLE $t (id bigint, data string) $defaultUsing")
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/ShowTablesSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/ShowTablesSuite.scala
index e0888fee6ce2..91f71b75160e 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/ShowTablesSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/ShowTablesSuite.scala
@@ -69,6 +69,34 @@ class ShowTablesSuite extends command.ShowTablesSuiteBase 
with CommandSuiteBase
     }
   }
 
+  gridTest("show table extended preserves reserved metadata with display 
properties")(
+      Seq((false, false), (true, true))) {
+    case (external, withLocation) =>
+      withNamespaceAndTable("ns", "tbl") { _ =>
+        createTableWithDisplayProperties("ns", "tbl", external, withLocation)
+
+        val information = sql(s"SHOW TABLE EXTENDED IN $catalog.ns LIKE 'tbl'")
+          .head().getString(3)
+        val tableType = if (external) "EXTERNAL" else "MANAGED"
+        val locationRow = if (withLocation) "Location: 
file:/tmp/display-properties\n" else ""
+        val properties =
+          "[catalog-label=catalog-value, password=*********(redacted), 
persisted=stored]"
+        assert(information ===
+          s"""Catalog: $catalog
+             |Namespace: ns
+             |Table: tbl
+             |Type: $tableType
+             |Comment: table comment
+             |${locationRow}Provider: _
+             |Owner: ${Utils.getCurrentUserName()}
+             |Table Properties: $properties
+             |Schema: root
+             | |-- id: long (nullable = true)
+             |
+             |""".stripMargin)
+      }
+  }
+
   test("show table extended omits empty table properties") {
     withNamespaceAndTable("ns", "tbl") { t =>
       sql(s"CREATE TABLE $t (id bigint) $defaultUsing")
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/ShowTblPropertiesSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/ShowTblPropertiesSuite.scala
index ace1e30a26b2..b96dc8415a5e 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/ShowTblPropertiesSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/ShowTblPropertiesSuite.scala
@@ -17,10 +17,49 @@
 
 package org.apache.spark.sql.execution.command.v2
 
+import org.apache.spark.sql.Row
 import org.apache.spark.sql.execution.command
 
 /**
  * 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") {
+    withNamespaceAndTable("ns", "table") { tbl =>
+      createTableWithDisplayProperties("ns", "table")
+
+      checkAnswer(sql(s"SHOW TBLPROPERTIES $tbl"),
+        Seq(Row("catalog-label", "catalog-value"), Row("password", 
"*********(redacted)"),
+          Row("persisted", "stored")))
+      checkAnswer(sql(s"SHOW TBLPROPERTIES $tbl ('persisted')"), 
Row("persisted", "stored"))
+      checkAnswer(sql(s"SHOW TBLPROPERTIES $tbl ('catalog-label')"),
+        Row("catalog-label", "catalog-value"))
+      checkAnswer(sql(s"SHOW TBLPROPERTIES $tbl ('password')"),
+        Row("password", "*********(redacted)"))
+
+      checkAnswer(sql(s"SHOW TBLPROPERTIES $tbl ('location')"),
+        Row("location", s"Table $tbl does not have property: location"))
+
+      sql(s"ALTER TABLE $tbl SET TBLPROPERTIES ('persisted' = 'updated')")
+      checkAnswer(sql(s"SHOW TBLPROPERTIES $tbl"),
+        Seq(Row("catalog-label", "catalog-value"), Row("password", 
"*********(redacted)"),
+          Row("persisted", "updated")))
+
+      sql(s"ALTER TABLE $tbl UNSET TBLPROPERTIES ('persisted', 
'catalog-label')")
+      checkAnswer(sql(s"SHOW TBLPROPERTIES $tbl"),
+        Seq(Row("catalog-label", "catalog-value"), Row("password", 
"*********(redacted)"),
+          Row("persisted", "display-value")))
+      checkAnswer(sql(s"SHOW TBLPROPERTIES $tbl ('persisted')"),
+        Row("persisted", "display-value"))
+      assert(spark.catalog.getTableProperties(tbl).get("persisted") === 
"display-value")
+      assert(!spark.catalog.getCreateTableString(tbl).contains("persisted"))
+
+      sql(s"ALTER TABLE $tbl SET TBLPROPERTIES ('catalog-label' = 'updated')")
+      checkAnswer(sql(s"SHOW TBLPROPERTIES $tbl ('catalog-label')"),
+        Row("catalog-label", "updated"))
+      sql(s"ALTER TABLE $tbl UNSET TBLPROPERTIES ('catalog-label')")
+      checkAnswer(sql(s"SHOW TBLPROPERTIES $tbl ('catalog-label')"),
+        Row("catalog-label", "catalog-value"))
+    }
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/internal/CatalogSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/internal/CatalogSuite.scala
index 40184a16a658..762fd0cd7a65 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/internal/CatalogSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/internal/CatalogSuite.scala
@@ -34,7 +34,7 @@ import 
org.apache.spark.sql.catalyst.expressions.GenericInternalRow
 import org.apache.spark.sql.catalyst.plans.logical.Range
 import org.apache.spark.sql.classic.Catalog
 import org.apache.spark.sql.connector.{FakeV2Provider, 
InMemoryTableSessionCatalog}
-import org.apache.spark.sql.connector.catalog.{CatalogManager, CatalogV2Util, 
Identifier, InMemoryCatalog}
+import org.apache.spark.sql.connector.catalog.{CatalogManager, CatalogV2Util, 
Identifier, InMemoryBaseTable, InMemoryCatalog, TableCatalog}
 import org.apache.spark.sql.connector.catalog.CatalogV2Implicits.CatalogHelper
 import org.apache.spark.sql.connector.catalog.functions._
 import org.apache.spark.sql.test.SharedSparkSession
@@ -1201,6 +1201,32 @@ class CatalogSuite extends SharedSparkSession with 
AnalysisTest with BeforeAndAf
     spark.catalog.dropTable(t)
   }
 
+  test("catalog API: getTableProperties includes V2 display-only properties") {
+    val t = "testcat.ns.table"
+    withTable(t) {
+      sql(s"CREATE TABLE $t (id INT) USING _ TBLPROPERTIES ('persisted' = 
'stored')")
+      val catalog = 
spark.sessionState.catalogManager.catalog("testcat").asTableCatalog
+      val table = catalog.loadTable(Identifier.of(Array("ns"), "table"))
+        .asInstanceOf[InMemoryBaseTable]
+      table.setDisplayProperties(Map(
+        "catalog-label" -> "catalog-value",
+        "password" -> "display-password",
+        "persisted" -> "display-value",
+        TableCatalog.PROP_EXTERNAL -> "true",
+        TableCatalog.PROP_COMMENT -> "display comment").asJava)
+
+      assert(spark.catalog.getTableProperties(t).asScala.toMap ===
+        Map("catalog-label" -> "catalog-value", "password" -> 
"*********(redacted)",
+          "persisted" -> "stored"))
+      assert(spark.catalog.getTable(t).tableType === "MANAGED")
+      assert(spark.catalog.getTable(t).description == null)
+      val ddl = spark.catalog.getCreateTableString(t)
+      assert(ddl.contains("'persisted' = 'stored'"))
+      assert(!ddl.contains("catalog-label"))
+      assert(!ddl.contains("password"))
+    }
+  }
+
   test("catalog API: getCreateTableString") {
     val t = "catalog_api_ext_ddl_t"
     spark.sql(s"DROP TABLE IF EXISTS $t")


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

Reply via email to