MaxGekk commented on code in PR #57198:
URL: https://github.com/apache/spark/pull/57198#discussion_r3576998999


##########
sql/core/src/main/scala/org/apache/spark/sql/jdbc/MySQLDialect.scala:
##########
@@ -282,6 +325,12 @@ private case class MySQLDialect() extends JdbcDialect with 
SQLConfHelper with No
     // scalastyle:on line.size.limit
     case TimestampNTZType if !conf.legacyMySqlTimestampNTZMappingEnabled =>
       Option(JdbcType("DATETIME", java.sql.Types.TIMESTAMP))
+    case t: TimeType =>
+      // MySQL supports TIME(p) for p in [0,6]. Use the declared precision 
when valid;
+      // fall back to bare TIME (= TIME(6)) for out-of-range precisions (e.g. 
nanosecond).
+      val p = t.precision
+      if (p >= 0 && p <= 6) Option(JdbcType(s"TIME($p)", java.sql.Types.TIME))
+      else Option(JdbcType("TIME", java.sql.Types.TIME))

Review Comment:
   This out-of-range fallback loses **all** fractional seconds, not just the 
sub-microsecond part. `TimeType.MAX_PRECISION` is 9, so `TimeType(7|8|9)` 
reaches the `else` and emits bare `TIME` — which in MySQL is `TIME(0)` (whole 
seconds), as this PR's own read test confirms (inserting `13:31:24.123` into a 
bare `TIME` column reads back `13:31:24`). So `12:00:00.123456789` is stored as 
`12:00:00`, not truncated to microseconds. MySQL's max fractional precision is 
6, so the fallback should emit `TIME(6)`:
   
   ```suggestion
         // MySQL supports TIME(p) for p in [0,6]. Use the declared precision 
when valid;
         // fall back to TIME(6) (MySQL's max fractional precision) for 
out-of-range
         // precisions (e.g. nanosecond), which truncates to microseconds 
rather than
         // to whole seconds (bare TIME = TIME(0) in MySQL).
         val p = t.precision
         if (p >= 0 && p <= 6) Option(JdbcType(s"TIME($p)", 
java.sql.Types.TIME))
         else Option(JdbcType("TIME(6)", java.sql.Types.TIME))
   ```



##########
connector/docker-integration-tests/src/test/scala/org/apache/spark/sql/jdbc/MySQLIntegrationSuite.scala:
##########
@@ -364,6 +364,91 @@ class MySQLIntegrationSuite extends 
SharedJDBCIntegrationSuite {
       .load()
     checkAnswer(df, Row("brown     ", "fox"))
   }
+
+  test("SPARK-57555: MySQL TIME type read as TimeType") {
+    withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+      val df = spark.read.jdbc(jdbcUrl, "dates", new Properties)
+      val timeCol = df.schema("t")
+      // MySQL bare TIME (no precision) reports scale=0 via JDBC metadata
+      assert(timeCol.dataType === TimeType(0),
+        s"Expected TimeType(0) for bare TIME but got ${timeCol.dataType}")
+      val time3Col = df.schema("t1")
+      assert(time3Col.dataType === TimeType(3),
+        s"Expected TimeType(3) but got ${time3Col.dataType}")
+      // Bare TIME(0) truncates fractional seconds; TIME(3) preserves 
milliseconds
+      checkAnswer(df.select("t", "t1"), Row(
+        LocalTime.of(13, 31, 24),
+        LocalTime.of(13, 31, 24, 123000000)))
+    }
+  }
+
+  test("SPARK-57555: MySQL TIME type write round-trip") {
+    withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+      val data = Seq(
+        Row(LocalTime.of(8, 30, 0), LocalTime.of(17, 22, 31, 123456000))
+      )
+      val schema = new org.apache.spark.sql.types.StructType()
+        .add("t", TimeType(0))
+        .add("t_micro", TimeType(6))
+      val df = spark.createDataFrame(spark.sparkContext.parallelize(data), 
schema)
+      df.write.mode("overwrite")
+        .option("createTableColumnTypes", "t TIME(0), t_micro TIME(6)")
+        .jdbc(jdbcUrl, "time_write_test", new Properties)
+
+      val result = spark.read.jdbc(jdbcUrl, "time_write_test", new Properties)
+      assert(result.schema("t").dataType === TimeType(0))
+      assert(result.schema("t_micro").dataType === TimeType(6))
+      checkAnswer(result, Row(
+        LocalTime.of(8, 30, 0),
+        LocalTime.of(17, 22, 31, 123456000)))
+    }
+  }
+
+  test("SPARK-57555: MySQL TIME precision preservation round-trip") {
+    // Verifies TimeType(3) -> TIME(3) -> read back as TimeType(3)
+    withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+      val data = Seq(Row(LocalTime.of(10, 15, 30, 500000000)))
+      val schema = new org.apache.spark.sql.types.StructType()
+        .add("t3", TimeType(3))
+      val df = spark.createDataFrame(spark.sparkContext.parallelize(data), 
schema)
+      df.write.mode("overwrite").jdbc(jdbcUrl, "time_precision_test", new 
Properties)
+
+      val result = spark.read.jdbc(jdbcUrl, "time_precision_test", new 
Properties)
+      assert(result.schema("t3").dataType === TimeType(3),
+        "Precision should be preserved on round-trip")
+      checkAnswer(result, Row(LocalTime.of(10, 15, 30, 500000000)))
+    }
+  }
+
+  test("SPARK-57555: MySQL TIME nanosecond write truncates to microseconds") {
+    // MySQL TIME max precision is 6 (microseconds). TimeType(9) writes as 
bare TIME (= TIME(6)),
+    // and the nanosecond portion is truncated on read-back.

Review Comment:
   The premise here is wrong in the same way as the production comment: bare 
MySQL `TIME` is `TIME(0)`, not `TIME(6)`. With the `getJDBCType` fix (emit 
`TIME(6)` for out-of-range precision), the accurate statement is:
   
   ```suggestion
       // MySQL TIME max precision is 6 (microseconds). TimeType(9) writes as 
TIME(6),
       // and the sub-microsecond portion is truncated on read-back.
   ```



##########
connector/docker-integration-tests/src/test/scala/org/apache/spark/sql/jdbc/MySQLIntegrationSuite.scala:
##########
@@ -364,6 +364,91 @@ class MySQLIntegrationSuite extends 
SharedJDBCIntegrationSuite {
       .load()
     checkAnswer(df, Row("brown     ", "fox"))
   }
+
+  test("SPARK-57555: MySQL TIME type read as TimeType") {
+    withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+      val df = spark.read.jdbc(jdbcUrl, "dates", new Properties)
+      val timeCol = df.schema("t")
+      // MySQL bare TIME (no precision) reports scale=0 via JDBC metadata
+      assert(timeCol.dataType === TimeType(0),
+        s"Expected TimeType(0) for bare TIME but got ${timeCol.dataType}")
+      val time3Col = df.schema("t1")
+      assert(time3Col.dataType === TimeType(3),
+        s"Expected TimeType(3) but got ${time3Col.dataType}")
+      // Bare TIME(0) truncates fractional seconds; TIME(3) preserves 
milliseconds
+      checkAnswer(df.select("t", "t1"), Row(
+        LocalTime.of(13, 31, 24),
+        LocalTime.of(13, 31, 24, 123000000)))
+    }
+  }
+
+  test("SPARK-57555: MySQL TIME type write round-trip") {
+    withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+      val data = Seq(
+        Row(LocalTime.of(8, 30, 0), LocalTime.of(17, 22, 31, 123456000))
+      )
+      val schema = new org.apache.spark.sql.types.StructType()
+        .add("t", TimeType(0))
+        .add("t_micro", TimeType(6))
+      val df = spark.createDataFrame(spark.sparkContext.parallelize(data), 
schema)
+      df.write.mode("overwrite")
+        .option("createTableColumnTypes", "t TIME(0), t_micro TIME(6)")
+        .jdbc(jdbcUrl, "time_write_test", new Properties)
+
+      val result = spark.read.jdbc(jdbcUrl, "time_write_test", new Properties)
+      assert(result.schema("t").dataType === TimeType(0))
+      assert(result.schema("t_micro").dataType === TimeType(6))
+      checkAnswer(result, Row(
+        LocalTime.of(8, 30, 0),
+        LocalTime.of(17, 22, 31, 123456000)))
+    }
+  }
+
+  test("SPARK-57555: MySQL TIME precision preservation round-trip") {
+    // Verifies TimeType(3) -> TIME(3) -> read back as TimeType(3)
+    withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+      val data = Seq(Row(LocalTime.of(10, 15, 30, 500000000)))
+      val schema = new org.apache.spark.sql.types.StructType()
+        .add("t3", TimeType(3))
+      val df = spark.createDataFrame(spark.sparkContext.parallelize(data), 
schema)
+      df.write.mode("overwrite").jdbc(jdbcUrl, "time_precision_test", new 
Properties)
+
+      val result = spark.read.jdbc(jdbcUrl, "time_precision_test", new 
Properties)
+      assert(result.schema("t3").dataType === TimeType(3),
+        "Precision should be preserved on round-trip")
+      checkAnswer(result, Row(LocalTime.of(10, 15, 30, 500000000)))
+    }
+  }
+
+  test("SPARK-57555: MySQL TIME nanosecond write truncates to microseconds") {
+    // MySQL TIME max precision is 6 (microseconds). TimeType(9) writes as 
bare TIME (= TIME(6)),
+    // and the nanosecond portion is truncated on read-back.
+    withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+      val data = Seq(Row(LocalTime.of(12, 0, 0, 123456789)))
+      val schema = new org.apache.spark.sql.types.StructType()
+        .add("t_nanos", TimeType(9))
+      val df = spark.createDataFrame(spark.sparkContext.parallelize(data), 
schema)
+      df.write.mode("overwrite").jdbc(jdbcUrl, "time_nanos_test", new 
Properties)
+
+      val result = spark.read.jdbc(jdbcUrl, "time_nanos_test", new Properties)
+      // Read back as TimeType(6) since MySQL stores at most microseconds
+      assert(result.schema("t_nanos").dataType === TimeType(6))
+      // Nanoseconds truncated to microseconds: 123456789 -> 123456000
+      checkAnswer(result, Row(LocalTime.of(12, 0, 0, 123456000)))

Review Comment:
   These assertions are correct — `TimeType(6)` with microseconds preserved is 
what a nanosecond write *should* round-trip to. But they don't hold against the 
current code: `getJDBCType` emits bare `TIME` (= MySQL `TIME(0)`) for 
`TimeType(9)`, so the column stores whole seconds and this reads back as 
`TimeType(0)` / `12:00:00`. On a real MySQL server both assertions would fail. 
This wasn't caught because the suite is `@DockerTest` and isn't part of 
standard PR CI (the description only reports the JDBCSuite unit run). Once the 
`getJDBCType` fix above lands, these assertions become correct as written — 
just fix the comment premise below.



##########
sql/core/src/main/scala/org/apache/spark/sql/jdbc/MySQLDialect.scala:
##########
@@ -184,6 +221,12 @@ private case class MySQLDialect() extends JdbcDialect with 
SQLConfHelper with No
         // scalastyle:on line.size.limit
         Some(getTimestampType(md.build()))
       case Types.TIMESTAMP if !conf.legacyMySqlTimestampNTZMappingEnabled => 
Some(TimestampType)
+      case Types.TIME if conf.isTimeTypeEnabled && 
!conf.legacyJdbcTimeMappingEnabled =>
+        // MySQL Connector/J Bug #84308: COLUMN_SIZE=8 and DECIMAL_DIGITS=0 
for all TIME columns
+        // regardless of declared precision. The actual precision is injected 
by
+        // updateExtraColumnMeta via INFORMATION_SCHEMA query.
+        val precision = md.build().getLong("datetimePrecision").toInt

Review Comment:
   `MetadataBuilder.getLong` throws `NoSuchElementException` if 
`datetimePrecision` is absent. It's safe today — `JdbcUtils.getSchema` always 
calls `updateExtraColumnMeta` before `getCatalystType`, and every branch there 
sets the key for a TIME column — but coupling this read to an unrelated method 
having run first is fragile. Consider guarding it:
   
   ```suggestion
           val md0 = md.build()
           val precision = if (md0.contains("datetimePrecision")) {
             md0.getLong("datetimePrecision").toInt
           } else {
             TimeType.DEFAULT_PRECISION
           }
   ```
   
   (Non-blocking.)



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to