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

pan3793 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new e28edaf0480e [SPARK-57627][CONNECT] Support primary key, foreign key, 
and SQL type metadata in DatabaseMetaData
e28edaf0480e is described below

commit e28edaf0480e19abca83cd2b215450b85fdaf438
Author: Jiwon Park <[email protected]>
AuthorDate: Tue Jun 30 11:34:05 2026 +0800

    [SPARK-57627][CONNECT] Support primary key, foreign key, and SQL type 
metadata in DatabaseMetaData
    
    ### What changes were proposed in this pull request?
    
    This PR implements five `DatabaseMetaData` methods in the Spark Connect 
JDBC driver (`SparkConnectDatabaseMetaData`) that previously threw 
`SQLFeatureNotSupportedException`:
    
    - `getPrimaryKeys` — returns an empty `ResultSet` with the JDBC-defined 
schema. Spark Connect does not expose primary keys over JDBC, so "no primary 
keys" is represented as an empty result rather than an error.
    - `getImportedKeys` / `getExportedKeys` / `getCrossReference` — return an 
empty `ResultSet` with the JDBC foreign-key schema, for the same reason. All 
three share a private `emptyForeignKeys` helper.
    - `getTypeInfo` — returns a static catalog of the Spark SQL atomic types 
(12 rows), ordered by `DATA_TYPE`, mirroring the type-code/precision mapping 
already used by `JdbcTypeUtils`. `TIMESTAMP_NTZ` is omitted because it maps to 
the same JDBC type code (`Types.TIMESTAMP`) as `TIMESTAMP`; `TIME` is omitted 
for now because its maximum `PRECISION`/scale representation in `getTypeInfo` 
is not yet settled.
    
    The result-set schemas (column names, order, and types) match the canonical 
definitions already used by Spark's Thrift server operations 
(`GetPrimaryKeysOperation`, `GetCrossReferenceOperation`, 
`GetTypeInfoOperation`), with one intentional correction: the `KEY_SEQ` column 
uses the JDBC-spec name `KEY_SEQ` rather than the `KEQ_SEQ` typo inherited from 
Hive in the Thrift operations.
    
    `getFunctions` is intentionally left throwing and is out of scope for this 
PR.
    
    ### Why are the changes needed?
    
    Returning an empty `ResultSet` (rather than throwing) for metadata that a 
driver does not support is the conventional JDBC behavior, and it is what other 
engines in this ecosystem do: Trino returns an empty result set for 
`getPrimaryKeys`/`getImportedKeys`, and Hive does so for `getImportedKeys`. 
Throwing `SQLFeatureNotSupportedException` breaks otherwise-recoverable client 
introspection — for example, BI tools that probe primary/foreign keys to infer 
table relationships abort the met [...]
    
    `getTypeInfo` is the one method here for which Spark can return real data: 
its atomic types are statically known. Hive, Trino, and the Databricks JDBC 
driver all implement `getTypeInfo`; Spark Connect was the outlier in throwing.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Previously these five methods threw `SQLFeatureNotSupportedException`. 
After this change:
    - `getPrimaryKeys`, `getImportedKeys`, `getExportedKeys`, and 
`getCrossReference` return an empty `ResultSet` with the JDBC-defined columns.
    - `getTypeInfo` returns the catalog of Spark SQL atomic types.
    
    This is a change within the unreleased branch only; the Spark Connect JDBC 
driver has not been released.
    
    ### How was this patch tested?
    
    Added in-process tests to `SparkConnectDatabaseMetaDataSuite`:
    - `getPrimaryKeys` and 
`getImportedKeys`/`getExportedKeys`/`getCrossReference` assert the result-set 
column schema and that the result is empty.
    - `getTypeInfo` asserts the column schema, the rows ordered by `DATA_TYPE`, 
that every type is nullable and searchable, that only `STRING` is 
case-sensitive, the per-type `LITERAL_PREFIX`/`LITERAL_SUFFIX` (including the 
`X'...'` hex syntax for `BINARY`), the `NUM_PREC_RADIX` (10 for numeric types, 
NULL otherwise), and that `DECIMAL` carries the expected precision and scale.
    
    ```
    build/sbt 'connect-client-jdbc/testOnly *SparkConnectDatabaseMetaDataSuite'
    ```
    All 10 tests pass.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8)
    
    Closes #56688 from j1wonpark/SPARK-57627.
    
    Authored-by: Jiwon Park <[email protected]>
    Signed-off-by: Cheng Pan <[email protected]>
---
 .../client/jdbc/SparkConnectDatabaseMetaData.scala | 167 +++++++++++++++++++--
 .../jdbc/SparkConnectDatabaseMetaDataSuite.scala   | 146 ++++++++++++++++++
 2 files changed, 303 insertions(+), 10 deletions(-)

diff --git 
a/sql/connect/client/jdbc/src/main/scala/org/apache/spark/sql/connect/client/jdbc/SparkConnectDatabaseMetaData.scala
 
b/sql/connect/client/jdbc/src/main/scala/org/apache/spark/sql/connect/client/jdbc/SparkConnectDatabaseMetaData.scala
index 9c7072b5013b..823984f666bc 100644
--- 
a/sql/connect/client/jdbc/src/main/scala/org/apache/spark/sql/connect/client/jdbc/SparkConnectDatabaseMetaData.scala
+++ 
b/sql/connect/client/jdbc/src/main/scala/org/apache/spark/sql/connect/client/jdbc/SparkConnectDatabaseMetaData.scala
@@ -598,14 +598,54 @@ class SparkConnectDatabaseMetaData(conn: 
SparkConnectConnection) extends Databas
       catalog: String, schema: String, table: String): ResultSet =
     throw new SQLFeatureNotSupportedException
 
-  override def getPrimaryKeys(catalog: String, schema: String, table: String): 
ResultSet =
-    throw new SQLFeatureNotSupportedException
+  // Spark supports informational PRIMARY KEY constraints on DSv2 tables 
(SPARK-51207), but the
+  // Spark Connect JDBC client cannot retrieve them in a structured way yet, 
so return an empty
+  // result set instead of throwing. JDBC clients call this during schema 
introspection.
+  override def getPrimaryKeys(catalog: String, schema: String, table: String): 
ResultSet = {
+    conn.checkOpen()
 
-  override def getImportedKeys(catalog: String, schema: String, table: 
String): ResultSet =
-    throw new SQLFeatureNotSupportedException
+    val df = conn.spark.emptyDataFrame
+      .withColumn("TABLE_CAT", lit(""))
+      .withColumn("TABLE_SCHEM", lit(""))
+      .withColumn("TABLE_NAME", lit(""))
+      .withColumn("COLUMN_NAME", lit(""))
+      .withColumn("KEY_SEQ", lit(0.toShort))
+      .withColumn("PK_NAME", lit(""))
+    new SparkConnectResultSet(df.collectResult())
+  }
 
-  override def getExportedKeys(catalog: String, schema: String, table: 
String): ResultSet =
-    throw new SQLFeatureNotSupportedException
+  // getImportedKeys, getExportedKeys and getCrossReference share the JDBC 
foreign-key result
+  // schema. Spark supports informational FOREIGN KEY constraints on DSv2 
tables (SPARK-51207),
+  // but the Spark Connect JDBC client cannot retrieve them in a structured 
way yet, so they all
+  // return an empty result set instead of throwing.
+  private def emptyForeignKeys: ResultSet = {
+    val df = conn.spark.emptyDataFrame
+      .withColumn("PKTABLE_CAT", lit(""))
+      .withColumn("PKTABLE_SCHEM", lit(""))
+      .withColumn("PKTABLE_NAME", lit(""))
+      .withColumn("PKCOLUMN_NAME", lit(""))
+      .withColumn("FKTABLE_CAT", lit(""))
+      .withColumn("FKTABLE_SCHEM", lit(""))
+      .withColumn("FKTABLE_NAME", lit(""))
+      .withColumn("FKCOLUMN_NAME", lit(""))
+      .withColumn("KEY_SEQ", lit(0.toShort))
+      .withColumn("UPDATE_RULE", lit(0.toShort))
+      .withColumn("DELETE_RULE", lit(0.toShort))
+      .withColumn("FK_NAME", lit(""))
+      .withColumn("PK_NAME", lit(""))
+      .withColumn("DEFERRABILITY", lit(0.toShort))
+    new SparkConnectResultSet(df.collectResult())
+  }
+
+  override def getImportedKeys(catalog: String, schema: String, table: 
String): ResultSet = {
+    conn.checkOpen()
+    emptyForeignKeys
+  }
+
+  override def getExportedKeys(catalog: String, schema: String, table: 
String): ResultSet = {
+    conn.checkOpen()
+    emptyForeignKeys
+  }
 
   override def getCrossReference(
       parentCatalog: String,
@@ -613,11 +653,39 @@ class SparkConnectDatabaseMetaData(conn: 
SparkConnectConnection) extends Databas
       parentTable: String,
       foreignCatalog: String,
       foreignSchema: String,
-      foreignTable: String): ResultSet =
-    throw new SQLFeatureNotSupportedException
+      foreignTable: String): ResultSet = {
+    conn.checkOpen()
+    emptyForeignKeys
+  }
 
-  override def getTypeInfo: ResultSet =
-    throw new SQLFeatureNotSupportedException
+  // Static catalog of the Spark SQL atomic types, so JDBC clients can resolve 
type
+  // information instead of failing on a thrown exception.
+  override def getTypeInfo: ResultSet = {
+    conn.checkOpen()
+
+    val df = TYPE_INFO
+      .toDF(
+        "TYPE_NAME",
+        "DATA_TYPE",
+        "PRECISION",
+        "LITERAL_PREFIX",
+        "LITERAL_SUFFIX",
+        "CREATE_PARAMS",
+        "NULLABLE",
+        "CASE_SENSITIVE",
+        "SEARCHABLE",
+        "UNSIGNED_ATTRIBUTE",
+        "FIXED_PREC_SCALE",
+        "AUTO_INCREMENT",
+        "LOCAL_TYPE_NAME",
+        "MINIMUM_SCALE",
+        "MAXIMUM_SCALE",
+        "SQL_DATA_TYPE",
+        "SQL_DATETIME_SUB",
+        "NUM_PREC_RADIX")
+      .orderBy("DATA_TYPE")
+    new SparkConnectResultSet(df.collectResult())
+  }
 
   override def getIndexInfo(
       catalog: String,
@@ -816,4 +884,83 @@ object SparkConnectDatabaseMetaData {
   )
 
   private[jdbc] val TABLE_TYPES = Seq("TABLE", "VIEW")
+
+  // One row of the java.sql.DatabaseMetaData.getTypeInfo result.
+  private[jdbc] type TypeInfoRow =
+    (
+        String,  // TYPE_NAME
+        Int,     // DATA_TYPE
+        Int,     // PRECISION
+        String,  // LITERAL_PREFIX
+        String,  // LITERAL_SUFFIX
+        String,  // CREATE_PARAMS
+        Short,   // NULLABLE
+        Boolean, // CASE_SENSITIVE
+        Short,   // SEARCHABLE
+        Boolean, // UNSIGNED_ATTRIBUTE
+        Boolean, // FIXED_PREC_SCALE
+        Boolean, // AUTO_INCREMENT
+        String,  // LOCAL_TYPE_NAME
+        Short,   // MINIMUM_SCALE
+        Short,   // MAXIMUM_SCALE
+        Int,     // SQL_DATA_TYPE
+        Int,     // SQL_DATETIME_SUB
+        Integer  // NUM_PREC_RADIX (null for non-numeric types)
+    )
+
+  // Fills the columns that are constant across all Spark atomic types: every 
type is
+  // nullable and searchable, and none are unsigned, fixed-prec-scale, or 
auto-increment.
+  // `literalPrefix` is also used as the literal suffix unless `literalSuffix` 
is given;
+  // BINARY is the exception, whose literals use the hex syntax X'...'.
+  private[jdbc] def typeRow(
+      typeName: String,
+      dataType: Int,
+      precision: Int,
+      literalPrefix: String,
+      createParams: String,
+      caseSensitive: Boolean,
+      minScale: Short,
+      maxScale: Short,
+      numPrecRadix: Integer,
+      literalSuffix: String = null): TypeInfoRow =
+    (
+      typeName,
+      dataType,
+      precision,
+      literalPrefix,
+      if (literalSuffix != null) literalSuffix else literalPrefix,
+      createParams,
+      typeNullable.toShort,
+      caseSensitive,
+      typeSearchable.toShort,
+      false,
+      false,
+      false,
+      null,
+      minScale,
+      maxScale,
+      0,
+      0,
+      numPrecRadix)
+
+  // Static JDBC type metadata for the Spark SQL atomic types, mirroring the
+  // JdbcTypeUtils type-code/precision mapping. Only STRING is case-sensitive.
+  // TIMESTAMP_NTZ is omitted because it maps to the same JDBC type code
+  // (Types.TIMESTAMP) as TIMESTAMP, so the TIMESTAMP row already covers it.
+  // TIME is omitted for now because its maximum PRECISION/scale representation
+  // in getTypeInfo is not yet settled.
+  private[jdbc] val TYPE_INFO: Seq[TypeInfoRow] = Seq(
+    typeRow("BOOLEAN", Types.BOOLEAN, 1, null, null, false, 0, 0, null),
+    typeRow("TINYINT", Types.TINYINT, 3, null, null, false, 0, 0, 10),
+    typeRow("SMALLINT", Types.SMALLINT, 5, null, null, false, 0, 0, 10),
+    typeRow("INT", Types.INTEGER, 10, null, null, false, 0, 0, 10),
+    typeRow("BIGINT", Types.BIGINT, 19, null, null, false, 0, 0, 10),
+    typeRow("FLOAT", Types.FLOAT, 7, null, null, false, 0, 0, 10),
+    typeRow("DOUBLE", Types.DOUBLE, 15, null, null, false, 0, 0, 10),
+    typeRow("DECIMAL", Types.DECIMAL, 38, null, "precision,scale", false, 0, 
38, 10),
+    typeRow("STRING", Types.VARCHAR, Int.MaxValue, "'", null, true, 0, 0, 
null),
+    typeRow("BINARY", Types.VARBINARY, Int.MaxValue, "X'", null, false, 0, 0, 
null,
+      literalSuffix = "'"),
+    typeRow("DATE", Types.DATE, 10, "'", null, false, 0, 0, null),
+    typeRow("TIMESTAMP", Types.TIMESTAMP, 29, "'", null, false, 0, 6, null))
 }
diff --git 
a/sql/connect/client/jdbc/src/test/scala/org/apache/spark/sql/connect/client/jdbc/SparkConnectDatabaseMetaDataSuite.scala
 
b/sql/connect/client/jdbc/src/test/scala/org/apache/spark/sql/connect/client/jdbc/SparkConnectDatabaseMetaDataSuite.scala
index 49c0dccc780c..4c07c4d1354c 100644
--- 
a/sql/connect/client/jdbc/src/test/scala/org/apache/spark/sql/connect/client/jdbc/SparkConnectDatabaseMetaDataSuite.scala
+++ 
b/sql/connect/client/jdbc/src/test/scala/org/apache/spark/sql/connect/client/jdbc/SparkConnectDatabaseMetaDataSuite.scala
@@ -808,4 +808,150 @@ class SparkConnectDatabaseMetaDataSuite extends 
ConnectFunSuite with RemoteSpark
       }
     }
   }
+
+  test("SparkConnectDatabaseMetaData getPrimaryKeys") {
+    withConnection { conn =>
+      val metadata = conn.getMetaData
+      // Spark has no primary keys, so an empty result set with the JDBC 
schema is returned.
+      Using.resource(metadata.getPrimaryKeys(null, null, null)) { rs =>
+        val rsmd = rs.getMetaData
+        assert((1 to rsmd.getColumnCount).map(rsmd.getColumnName) === Seq(
+          "TABLE_CAT", "TABLE_SCHEM", "TABLE_NAME", "COLUMN_NAME", "KEY_SEQ", 
"PK_NAME"))
+        assert(!rs.next())
+      }
+    }
+  }
+
+  test("SparkConnectDatabaseMetaData getImportedKeys, getExportedKeys and 
getCrossReference") {
+    withConnection { conn =>
+      val metadata = conn.getMetaData
+      val foreignKeySchema = Seq(
+        "PKTABLE_CAT", "PKTABLE_SCHEM", "PKTABLE_NAME", "PKCOLUMN_NAME",
+        "FKTABLE_CAT", "FKTABLE_SCHEM", "FKTABLE_NAME", "FKCOLUMN_NAME",
+        "KEY_SEQ", "UPDATE_RULE", "DELETE_RULE", "FK_NAME", "PK_NAME", 
"DEFERRABILITY")
+      // Spark has no foreign keys, so all three return an empty result set 
with the JDBC schema.
+      Seq(
+        () => metadata.getImportedKeys(null, null, null),
+        () => metadata.getExportedKeys(null, null, null),
+        () => metadata.getCrossReference(null, null, null, null, null, null))
+        .foreach { getForeignKeys =>
+        Using.resource(getForeignKeys()) { rs =>
+          val rsmd = rs.getMetaData
+          assert((1 to rsmd.getColumnCount).map(rsmd.getColumnName) === 
foreignKeySchema)
+          assert(!rs.next())
+        }
+      }
+    }
+  }
+
+  test("SparkConnectDatabaseMetaData getTypeInfo") {
+    withConnection { conn =>
+      val metadata = conn.getMetaData
+      Using.resource(metadata.getTypeInfo) { rs =>
+        val rsmd = rs.getMetaData
+        assert((1 to rsmd.getColumnCount).map(rsmd.getColumnName) === Seq(
+          "TYPE_NAME", "DATA_TYPE", "PRECISION", "LITERAL_PREFIX", 
"LITERAL_SUFFIX",
+          "CREATE_PARAMS", "NULLABLE", "CASE_SENSITIVE", "SEARCHABLE", 
"UNSIGNED_ATTRIBUTE",
+          "FIXED_PREC_SCALE", "AUTO_INCREMENT", "LOCAL_TYPE_NAME", 
"MINIMUM_SCALE",
+          "MAXIMUM_SCALE", "SQL_DATA_TYPE", "SQL_DATETIME_SUB", 
"NUM_PREC_RADIX"))
+
+        case class TypeInfo(
+            name: String,
+            dataType: Int,
+            precision: Int,
+            literalPrefix: String,
+            literalSuffix: String,
+            createParams: String,
+            caseSensitive: Boolean,
+            nullable: Short,
+            searchable: Short,
+            minScale: Short,
+            maxScale: Short,
+            numPrecRadix: Option[Int])
+        val types = new Iterator[TypeInfo] {
+          def hasNext: Boolean = rs.next()
+          def next(): TypeInfo = TypeInfo(
+            name = rs.getString("TYPE_NAME"),
+            dataType = rs.getInt("DATA_TYPE"),
+            precision = rs.getInt("PRECISION"),
+            literalPrefix = rs.getString("LITERAL_PREFIX"),
+            literalSuffix = rs.getString("LITERAL_SUFFIX"),
+            createParams = rs.getString("CREATE_PARAMS"),
+            caseSensitive = rs.getBoolean("CASE_SENSITIVE"),
+            nullable = rs.getShort("NULLABLE"),
+            searchable = rs.getShort("SEARCHABLE"),
+            minScale = rs.getShort("MINIMUM_SCALE"),
+            maxScale = rs.getShort("MAXIMUM_SCALE"),
+            numPrecRadix =
+              
Option(rs.getObject("NUM_PREC_RADIX")).map(_.asInstanceOf[Integer].toInt))
+        }.toSeq
+
+        // results are ordered by DATA_TYPE
+        assert(types.map(t => (t.name, t.dataType)) === Seq(
+          ("TINYINT", Types.TINYINT),
+          ("BIGINT", Types.BIGINT),
+          ("BINARY", Types.VARBINARY),
+          ("DECIMAL", Types.DECIMAL),
+          ("INT", Types.INTEGER),
+          ("SMALLINT", Types.SMALLINT),
+          ("FLOAT", Types.FLOAT),
+          ("DOUBLE", Types.DOUBLE),
+          ("STRING", Types.VARCHAR),
+          ("BOOLEAN", Types.BOOLEAN),
+          ("DATE", Types.DATE),
+          ("TIMESTAMP", Types.TIMESTAMP)))
+
+        // every type is nullable and searchable
+        assert(types.forall(_.nullable == DatabaseMetaData.typeNullable))
+        assert(types.forall(_.searchable == DatabaseMetaData.typeSearchable))
+        // only STRING is case-sensitive
+        assert(types.filter(_.caseSensitive).map(_.name) === Seq("STRING"))
+
+        // string-like types are quoted with a single quote on both sides, 
except BINARY,
+        // whose literals use the hex syntax X'...'. Numeric types carry no 
literal quote.
+        val quoted = Map(
+          "STRING" -> ("'", "'"),
+          "DATE" -> ("'", "'"),
+          "TIMESTAMP" -> ("'", "'"),
+          "BINARY" -> ("X'", "'"))
+        types.foreach { t =>
+          val (prefix, suffix) = quoted.getOrElse(t.name, (null, null))
+          assert(t.literalPrefix === prefix, s"unexpected LITERAL_PREFIX for 
${t.name}")
+          assert(t.literalSuffix === suffix, s"unexpected LITERAL_SUFFIX for 
${t.name}")
+        }
+
+        // PRECISION mirrors JdbcTypeUtils.getPrecision for every type
+        val precisions = Map(
+          "BOOLEAN" -> 1, "TINYINT" -> 3, "SMALLINT" -> 5, "INT" -> 10, 
"BIGINT" -> 19,
+          "FLOAT" -> 7, "DOUBLE" -> 15, "DECIMAL" -> 38, "STRING" -> 
Int.MaxValue,
+          "BINARY" -> Int.MaxValue, "DATE" -> 10, "TIMESTAMP" -> 29)
+        types.foreach { t =>
+          assert(t.precision === precisions(t.name), s"unexpected PRECISION 
for ${t.name}")
+        }
+
+        // CREATE_PARAMS is set only for the parameterized types
+        val createParams = Map("DECIMAL" -> "precision,scale")
+        types.foreach { t =>
+          assert(t.createParams === createParams.getOrElse(t.name, null),
+            s"unexpected CREATE_PARAMS for ${t.name}")
+        }
+
+        // (MINIMUM_SCALE, MAXIMUM_SCALE); types not listed carry no scale (0, 
0)
+        val scales = Map("DECIMAL" -> (0, 38), "TIMESTAMP" -> (0, 6))
+        types.foreach { t =>
+          val (minScale, maxScale) = scales.getOrElse(t.name, (0, 0))
+          assert(t.minScale === minScale.toShort, s"unexpected MINIMUM_SCALE 
for ${t.name}")
+          assert(t.maxScale === maxScale.toShort, s"unexpected MAXIMUM_SCALE 
for ${t.name}")
+        }
+
+        // NUM_PREC_RADIX is 10 for numeric types and NULL otherwise, 
mirroring JdbcTypeUtils
+        val numericTypes =
+          Set("TINYINT", "SMALLINT", "INT", "BIGINT", "FLOAT", "DOUBLE", 
"DECIMAL")
+        types.foreach { t =>
+          val expected = if (numericTypes.contains(t.name)) Some(10) else None
+          assert(t.numPrecRadix === expected, s"unexpected NUM_PREC_RADIX for 
${t.name}")
+        }
+      }
+    }
+  }
 }


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

Reply via email to