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

MaxGekk 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 59fdb3e3ecff [SPARK-57456][SQL] Support nanosecond-precision timestamp 
types in the JSON datasource (v1 and v2)
59fdb3e3ecff is described below

commit 59fdb3e3ecff20ac8df368a5c1181327332c3495
Author: Maxim Gekk <[email protected]>
AuthorDate: Tue Jun 30 16:44:16 2026 +0200

    [SPARK-57456][SQL] Support nanosecond-precision timestamp types in the JSON 
datasource (v1 and v2)
    
    ### What changes were proposed in this pull request?
    Umbrella: [SPARK-56822](https://issues.apache.org/jira/browse/SPARK-56822) 
(Timestamps with nanosecond precision).
    
    This PR adds read and write support for the nanosecond-capable timestamp 
types `TIMESTAMP_NTZ(p)` and `TIMESTAMP_LTZ(p)` (`p` in 7-9) to the JSON 
datasource, for both the v1 (`JsonFileFormat`) and v2 (`JsonTable`) paths, 
reaching parity with the microsecond `TimestampType` / `TimestampNTZType`, and 
removes the [SPARK-57166](https://issues.apache.org/jira/browse/SPARK-57166) 
rejection guardrail.
    
    Specifically:
    - `JacksonParser`: adds `TimestampLTZNanosType` / `TimestampNTZNanosType` 
read cases that delegate to the existing `parseNanos` / 
`parseWithoutTimeZoneNanos` formatter methods with the column precision.
    - `JacksonGenerator`: adds the corresponding write cases that delegate to 
`formatNanos` / `formatWithoutTimeZoneNanos`.
    - `JsonFileFormat` (v1) and `JsonTable` (v2): drop the 
`AnyTimestampNanoType` rejection in `supportDataType` / `supportsDataType`.
    
    Notes:
    - Schema inference (`JsonInferSchema`) keeps inferring microsecond 
`TimestampType` / `TimestampNTZType` by default; nanosecond types are reached 
only via an explicit user schema.
    - No new options: the existing `timestampFormat` / `timestampNTZFormat` 
options drive the nanos path. The column type carries the precision, and the 
count of `S` letters in the pattern controls how many fractional-second digits 
are emitted on write (text output needs up to 9 `S` for full precision; reads 
with the default formatter parse the full fraction and truncate to the declared 
precision).
    - The legacy time parser policy rejects nanos: the legacy LTZ formatter 
cannot represent sub-microsecond digits, so it raises 
`UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_WITH_LEGACY_TIME_PARSER` (the NTZ 
formatter always uses the ISO-8601 path).
    
    ### Why are the changes needed?
    JSON rejected nanos timestamp types in its datasource capability checks and 
lacked the conversions to round-trip them, so these columns could not be 
written or read through JSON. This extends nanosecond-precision timestamp 
support (umbrella SPARK-56822) to the JSON datasource, matching the existing 
microsecond timestamp behavior and the Parquet/ORC/Avro/CSV nanosecond support.
    
    ### Does this PR introduce _any_ user-facing change?
    Yes. With `spark.sql.timestampNanosTypes.enabled=true`, columns of type 
`TIMESTAMP_NTZ(7-9)` / `TIMESTAMP_LTZ(7-9)` can now be written to and read from 
JSON files, and parsed/generated by `from_json` / `to_json`. Previously such 
columns were rejected with `UNSUPPORTED_DATA_TYPE_FOR_DATASOURCE`. This is a 
change within the unreleased master/branch only.
    
    ### How was this patch tested?
    - `JsonExpressionsSuite`: `JsonToStructs` nanosecond parsing at the 
catalyst expression level.
    - `JsonFunctionsSuite`: flipped the existing `from_json` nanosecond test to 
assert successful parsing and the truncated value (instead of an 
unsupported-type error); added `to_json` and `to_json` / `from_json` round-trip 
tests.
    - `FileBasedDataSourceSuite`: removed JSON from the SPARK-57166 rejection 
list; added end-to-end round-trip (precisions 7-9, NTZ and LTZ, v1 and v2), a 
nested struct/array/map round-trip, and a LEGACY time-parser-policy rejection 
test (write and read).
    - `JsonSuite`: `DataFrameReader.json(Dataset[String])` read, a 
custom-schema file round-trip, and a mixed microsecond/nanosecond schema 
round-trip; these run under the `JsonV1Suite`, `JsonV2Suite`, 
`JsonLegacyTimeParserSuite`, and `JsonUnsafeRowSuite` variants.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Generated-by: Cursor 2.1, Claude Opus 4.8
    
    Closes #56865 from MaxGekk/nanos-json-ds.
    
    Authored-by: Maxim Gekk <[email protected]>
    Signed-off-by: Max Gekk <[email protected]>
---
 .../spark/sql/catalyst/json/JacksonGenerator.scala |  13 ++
 .../spark/sql/catalyst/json/JacksonParser.scala    |  17 +-
 .../expressions/JsonExpressionsSuite.scala         |  29 +++
 .../datasources/json/JsonFileFormat.scala          |   3 -
 .../execution/datasources/v2/json/JsonTable.scala  |   3 -
 .../spark/sql/FileBasedDataSourceSuite.scala       | 202 +++++++++++++++------
 .../org/apache/spark/sql/JsonFunctionsSuite.scala  |  85 ++++++++-
 .../sql/execution/datasources/json/JsonSuite.scala |  97 +++++++++-
 8 files changed, 371 insertions(+), 78 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JacksonGenerator.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JacksonGenerator.scala
index f34fab37d1de..7abf86850fc4 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JacksonGenerator.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JacksonGenerator.scala
@@ -159,6 +159,19 @@ class JacksonGenerator(
         
timestampNTZFormatter.format(DateTimeUtils.microsToLocalDateTime(row.getLong(ordinal)))
       gen.writeString(timestampString)
 
+    case t: TimestampLTZNanosType =>
+      (row: SpecializedGetters, ordinal: Int) =>
+        val timestampString =
+          timestampFormatter.formatNanos(row.getTimestampLTZNanos(ordinal), 
t.precision)
+        gen.writeString(timestampString)
+
+    case t: TimestampNTZNanosType =>
+      (row: SpecializedGetters, ordinal: Int) =>
+        val timestampString =
+          timestampNTZFormatter.formatWithoutTimeZoneNanos(
+            row.getTimestampNTZNanos(ordinal), t.precision)
+        gen.writeString(timestampString)
+
     case DateType =>
       (row: SpecializedGetters, ordinal: Int) =>
         val dateString = dateFormatter.format(row.getInt(ordinal))
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JacksonParser.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JacksonParser.scala
index fa03a9aea833..f5d90c5a09bf 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JacksonParser.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JacksonParser.scala
@@ -39,7 +39,7 @@ import org.apache.spark.sql.internal.{LegacyBehaviorPolicy, 
SQLConf}
 import org.apache.spark.sql.sources.Filter
 import org.apache.spark.sql.types._
 import org.apache.spark.types.variant._
-import org.apache.spark.unsafe.types.{CalendarInterval, UTF8String, VariantVal}
+import org.apache.spark.unsafe.types.{CalendarInterval, TimestampNanosVal, 
UTF8String, VariantVal}
 import org.apache.spark.util.Utils
 
 /**
@@ -380,6 +380,21 @@ class JacksonParser(
           timestampNTZFormatter.parseWithoutTimeZone(parser.getText, false)
       }
 
+    case t: TimestampLTZNanosType =>
+      (parser: JsonParser) => parseJsonToken[TimestampNanosVal](parser, 
dataType) {
+        // Unlike the microsecond TimestampType, the nanosecond types accept 
only string input.
+        // The numeric-epoch shorthand (a JSON integer read as epoch seconds) 
is legacy
+        // TimestampType behavior and is intentionally not carried over to the 
nanos types.
+        case VALUE_STRING if parser.getTextLength >= 1 =>
+          timestampFormatter.parseNanos(parser.getText, t.precision)
+      }
+
+    case t: TimestampNTZNanosType =>
+      (parser: JsonParser) => parseJsonToken[TimestampNanosVal](parser, 
dataType) {
+        case VALUE_STRING if parser.getTextLength >= 1 =>
+          timestampNTZFormatter.parseWithoutTimeZoneNanos(parser.getText, 
t.precision, false)
+      }
+
     case DateType =>
       (parser: JsonParser) => parseJsonToken[java.lang.Integer](parser, 
dataType) {
         case VALUE_STRING if parser.getTextLength >= 1 =>
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/JsonExpressionsSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/JsonExpressionsSuite.scala
index 7f6ae46be3a8..37916f5a93be 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/JsonExpressionsSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/JsonExpressionsSuite.scala
@@ -18,6 +18,7 @@
 package org.apache.spark.sql.catalyst.expressions
 
 import java.text.{DecimalFormat, DecimalFormatSymbols, SimpleDateFormat}
+import java.time.{LocalDateTime, ZoneOffset}
 import java.util.{Calendar, Locale, TimeZone}
 
 import org.scalatest.exceptions.TestFailedException
@@ -30,6 +31,7 @@ import org.apache.spark.sql.catalyst.expressions.Cast._
 import 
org.apache.spark.sql.catalyst.expressions.codegen.GenerateUnsafeProjection
 import org.apache.spark.sql.catalyst.util._
 import org.apache.spark.sql.catalyst.util.DateTimeTestUtils.{PST, UTC, UTC_OPT}
+import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types._
 import org.apache.spark.unsafe.types.{CalendarInterval, UTF8String}
 
@@ -570,6 +572,33 @@ class JsonExpressionsSuite extends SparkFunSuite with 
ExpressionEvalHelper {
     }
   }
 
+  test("SPARK-57456: from_json with nanos timestamp") {
+    val jsonData = """{"t": "2016-01-01T00:00:00.123456789"}"""
+    // No timestamp format option: the default formatter parses the full 
sub-second fraction and
+    // truncates the sub-precision digits toward zero to the declared 
precision.
+    withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
+      TimestampNanosTestUtils.foreachNanosPrecision { p =>
+        val nano = TimestampNanosTestUtils.nanoOfSecTruncator(p)(123456789)
+        val ldt = LocalDateTime.of(2016, 1, 1, 0, 0, 0, nano)
+        checkEvaluation(
+          JsonToStructs(
+            StructType(StructField("t", TimestampNTZNanosType(p)) :: Nil),
+            Map.empty[String, String],
+            Literal(jsonData),
+            UTC_OPT),
+          InternalRow(TimestampNanosTestUtils.localDateTimeToNanosVal(ldt)))
+        // LTZ: the string has no zone, so it is interpreted in the given time 
zone (UTC here).
+        checkEvaluation(
+          JsonToStructs(
+            StructType(StructField("t", TimestampLTZNanosType(p)) :: Nil),
+            Map.empty[String, String],
+            Literal(jsonData),
+            UTC_OPT),
+          
InternalRow(TimestampNanosTestUtils.instantToNanosVal(ldt.toInstant(ZoneOffset.UTC))))
+      }
+    }
+  }
+
   test("SPARK-19543: from_json empty input column") {
     val schema = StructType(StructField("a", IntegerType) :: Nil)
     checkEvaluation(
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonFileFormat.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonFileFormat.scala
index 09883c0786fa..ee01d67d1f7b 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonFileFormat.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/json/JsonFileFormat.scala
@@ -127,9 +127,6 @@ case class JsonFileFormat() extends TextBasedFileFormat 
with DataSourceRegister
 
     case _: GeometryType | _: GeographyType => false
 
-    // Nanosecond-capable timestamps are not yet supported by this datasource.
-    case _: AnyTimestampNanoType => false
-
     case _: AtomicType => true
 
     case st: StructType => st.forall { f => supportDataType(f.dataType) }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/json/JsonTable.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/json/JsonTable.scala
index 0612041c1a72..74095e85a0f6 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/json/JsonTable.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/json/JsonTable.scala
@@ -61,9 +61,6 @@ case class JsonTable(
   override def supportsDataType(dataType: DataType): Boolean = dataType match {
     case _: GeometryType | _: GeographyType => false
 
-    // Nanosecond-capable timestamps are not yet supported by this datasource.
-    case _: AnyTimestampNanoType => false
-
     case _: AtomicType => true
 
     case st: StructType => st.forall { f => supportsDataType(f.dataType) }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
index 16d2bc0d3082..1251d78818b2 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
@@ -46,7 +46,6 @@ import org.apache.spark.sql.functions._
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
 import org.apache.spark.sql.types._
-import org.apache.spark.unsafe.types.TimestampNanosVal
 import org.apache.spark.util.HadoopFSUtils
 
 class FileBasedDataSourceSuite extends SharedSparkSession
@@ -1339,65 +1338,6 @@ class FileBasedDataSourceSuite extends SharedSparkSession
     }
   }
 
-  test("SPARK-57166: nanosecond timestamp types are not supported in selected 
file data sources") {
-    // Parquet and ORC support nanosecond-capable timestamps, while these 
formats still reject them.
-    val unsupportedDataSources = Seq("json")
-    val nanosTypes = Seq(TimestampNTZNanosType(9), TimestampLTZNanosType(9))
-    withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
-      // Test both v1 and v2 data sources.
-      Seq(true, false).foreach { useV1 =>
-        val useV1List = if (useV1) {
-          unsupportedDataSources.mkString(",")
-        } else {
-          ""
-        }
-        withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> useV1List) {
-          unsupportedDataSources.foreach { format =>
-            nanosTypes.foreach { nanosType =>
-              val expectedType = s""""${nanosType.sql}""""
-              withTempDir { dir =>
-                // Write path: a nanos-typed column cannot be written. The 
nanos literal is built
-                // directly from its internal value to avoid relying on 
cast/parser support.
-                val nanosLiteral =
-                  Literal.create(new TimestampNanosVal(0L, 0.toShort), 
nanosType)
-                val df = spark.range(1).select(Column(nanosLiteral).as("ts"))
-                val writeDir = new File(dir, "write").getCanonicalPath
-                checkError(
-                  exception = intercept[AnalysisException] {
-                    df.write.format(format).mode("overwrite").save(writeDir)
-                  },
-                  condition = "UNSUPPORTED_DATA_TYPE_FOR_DATASOURCE",
-                  parameters = Map(
-                    "columnName" -> "`ts`",
-                    "columnType" -> expectedType,
-                    "format" -> formatMapping(format)))
-
-                // Read path: a user-specified nanos schema is rejected. Write 
a benign file first
-                // so schema validation (not file listing) is what fails.
-                val readDir = new File(dir, "read").getCanonicalPath
-                // XML requires a `rowTag` option on both the read and write 
paths.
-                val extraOptions =
-                  if (format == "xml") Map("rowTag" -> "row") else 
Map.empty[String, String]
-                Seq("a").toDF("ts").write.format(format).options(extraOptions)
-                  .mode("overwrite").save(readDir)
-                checkError(
-                  exception = intercept[AnalysisException] {
-                    spark.read.schema(new StructType().add("ts", nanosType))
-                      
.format(format).options(extraOptions).load(readDir).collect()
-                  },
-                  condition = "UNSUPPORTED_DATA_TYPE_FOR_DATASOURCE",
-                  parameters = Map(
-                    "columnName" -> "`ts`",
-                    "columnType" -> expectedType,
-                    "format" -> formatMapping(format)))
-              }
-            }
-          }
-        }
-      }
-    }
-  }
-
   test("SPARK-57166: ORC supports nanosecond timestamp types in v1 and v2") {
     withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
       // Validate both v1 and v2 ORC paths.
@@ -1473,6 +1413,148 @@ class FileBasedDataSourceSuite extends 
SharedSparkSession
     }
   }
 
+  test("SPARK-57456: JSON supports nanosecond timestamp types in v1 and v2") {
+    withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
+      Seq(true, false).foreach { useV1 =>
+        val useV1List = if (useV1) "json" else ""
+        withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> useV1List) {
+          foreachNanosPrecision { precision =>
+            // JSON is text-based: the format string must carry enough 
fractional-second digits to
+            // represent the full precision. Use exactly `precision` 
S-characters.
+            val fracPat = "S" * precision
+            Seq(TimestampNTZNanosType(precision), 
TimestampLTZNanosType(precision)).foreach {
+              nanosType =>
+                withTempDir { dir =>
+                  val wallClock = LocalDateTime.of(1970, 1, 1, 0, 20, 34, 
567890123)
+                  val value: Any = nanosType match {
+                    case _: TimestampNTZNanosType => wallClock
+                    case _: TimestampLTZNanosType => 
wallClock.toInstant(ZoneOffset.UTC)
+                  }
+                  val df = spark.createDataFrame(
+                    spark.sparkContext.parallelize(Seq(Row(value))),
+                    new StructType().add("ts", nanosType))
+                  val path = new File(dir, 
s"json_nanos_${nanosType.typeName}").getCanonicalPath
+                  val (fmtKey, fmtVal) = nanosType match {
+                    case _: TimestampNTZNanosType =>
+                      ("timestampNTZFormat", s"yyyy-MM-dd'T'HH:mm:ss.$fracPat")
+                    case _: TimestampLTZNanosType =>
+                      ("timestampFormat", 
s"yyyy-MM-dd'T'HH:mm:ss.${fracPat}XXX")
+                  }
+                  df.write.format("json").option(fmtKey, 
fmtVal).mode("overwrite").save(path)
+                  val readBack = spark.read
+                    .schema(new StructType().add("ts", nanosType))
+                    .option(fmtKey, fmtVal)
+                    .format("json").load(path)
+                  checkAnswer(readBack, df)
+                }
+            }
+          }
+        }
+      }
+    }
+  }
+
+  test("SPARK-57456: JSON supports nested nanosecond timestamp types in v1 and 
v2") {
+    withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
+      Seq(true, false).foreach { useV1 =>
+        val useV1List = if (useV1) "json" else ""
+        withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> useV1List) {
+          foreachNanosPrecision { precision =>
+            val fracPat = "S" * precision
+            Seq(TimestampNTZNanosType(precision), 
TimestampLTZNanosType(precision)).foreach {
+              nanosType =>
+                withTempDir { dir =>
+                  val wallClock = LocalDateTime.of(1970, 1, 1, 0, 20, 34, 
567890123)
+                  val leaf: Any = nanosType match {
+                    case _: TimestampNTZNanosType => wallClock
+                    case _: TimestampLTZNanosType => 
wallClock.toInstant(ZoneOffset.UTC)
+                  }
+                  // Embed the nanos leaf inside a struct, an array, and a map 
value. The guardrails
+                  // and Jackson read/write paths recurse into all three.
+                  val schema = new StructType()
+                    .add("s", new StructType().add("ts", nanosType))
+                    .add("a", ArrayType(nanosType))
+                    .add("m", MapType(StringType, nanosType))
+                  val row = Row(Row(leaf), Seq(leaf), Map("k" -> leaf))
+                  val df = spark.createDataFrame(
+                    spark.sparkContext.parallelize(Seq(row)), schema)
+                  val (fmtKey, fmtVal) = nanosType match {
+                    case _: TimestampNTZNanosType =>
+                      ("timestampNTZFormat", s"yyyy-MM-dd'T'HH:mm:ss.$fracPat")
+                    case _: TimestampLTZNanosType =>
+                      ("timestampFormat", 
s"yyyy-MM-dd'T'HH:mm:ss.${fracPat}XXX")
+                  }
+                  val path =
+                    new File(dir, 
s"json_nested_${nanosType.typeName}").getCanonicalPath
+                  df.write.format("json").option(fmtKey, 
fmtVal).mode("overwrite").save(path)
+                  val readBack = spark.read.schema(schema).option(fmtKey, 
fmtVal)
+                    .format("json").load(path)
+                  checkAnswer(readBack, df)
+                }
+            }
+          }
+        }
+      }
+    }
+  }
+
+  test("SPARK-57456: JSON rejects nanosecond timestamps under the LEGACY time 
parser policy") {
+    // The legacy timestamp formatter cannot represent sub-microsecond digits, 
so the nanos
+    // formatter methods raise TIMESTAMP_NANOS_WITH_LEGACY_TIME_PARSER. Only 
the LTZ formatter is
+    // legacy under this policy (the NTZ formatter always uses the ISO-8601 
path), so this covers
+    // TimestampLTZNanosType.
+    def rootNanosError(e: Throwable): SparkUnsupportedOperationException = {
+      var cause: Throwable = e
+      while (cause != null && 
!cause.isInstanceOf[SparkUnsupportedOperationException]) {
+        cause = cause.getCause
+      }
+      assert(cause != null,
+        s"Expected TIMESTAMP_NANOS_WITH_LEGACY_TIME_PARSER, but got: 
${e.getMessage}")
+      cause.asInstanceOf[SparkUnsupportedOperationException]
+    }
+
+    withSQLConf(
+        SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+        SQLConf.LEGACY_TIME_PARSER_POLICY.key -> "LEGACY") {
+      val nanosType = TimestampLTZNanosType(9)
+      val schema = new StructType().add("ts", nanosType)
+      val expectedParameters =
+        Map("config" -> ("\"" + SQLConf.LEGACY_TIME_PARSER_POLICY.key + "\""))
+      Seq(true, false).foreach { useV1 =>
+        val useV1List = if (useV1) "json" else ""
+        withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> useV1List) {
+          withTempDir { dir =>
+            // Write path.
+            val df = spark.createDataFrame(
+              spark.sparkContext.parallelize(
+                Seq(Row(LocalDateTime.of(2020, 1, 1, 0, 0, 0, 
1).toInstant(ZoneOffset.UTC)))),
+              schema)
+            val writeDir = new File(dir, "write").getCanonicalPath
+            checkError(
+              exception = rootNanosError(intercept[SparkException] {
+                df.write.format("json").mode("overwrite").save(writeDir)
+              }),
+              condition = 
"UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_WITH_LEGACY_TIME_PARSER",
+              parameters = expectedParameters)
+
+            // Read path: write a benign file first so schema-driven parsing 
is what fails. Use
+            // FAILFAST so the unsupported-feature error surfaces instead of 
being turned into a
+            // null record by the permissive bad-record handling.
+            val readDir = new File(dir, "read").getCanonicalPath
+            
Seq("a").toDF("ts").write.format("json").mode("overwrite").save(readDir)
+            checkError(
+              exception = rootNanosError(intercept[SparkException] {
+                spark.read.schema(schema).option("mode", "FAILFAST")
+                  .format("json").load(readDir).collect()
+              }),
+              condition = 
"UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_WITH_LEGACY_TIME_PARSER",
+              parameters = expectedParameters)
+          }
+        }
+      }
+    }
+  }
+
   // Asserts the ignoredPathSegmentRegex contract for `format`: the default 
regex hides the
   // '_'-prefixed file; a never-matching per-read option or session conf each 
surface it; the
   // option overrides the conf.
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/JsonFunctionsSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/JsonFunctionsSuite.scala
index 20deafb8b386..396b86144f4d 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/JsonFunctionsSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/JsonFunctionsSuite.scala
@@ -18,19 +18,19 @@
 package org.apache.spark.sql
 
 import java.text.SimpleDateFormat
-import java.time.{Duration, LocalDateTime, Period}
+import java.time.{Duration, LocalDateTime, Period, ZoneOffset}
 import java.util.Locale
 
 import scala.jdk.CollectionConverters._
 
 import com.fasterxml.jackson.core.StreamReadConstraints
 
-import org.apache.spark.{SparkException, SparkRuntimeException, 
SparkUnsupportedOperationException}
+import org.apache.spark.{SparkException, SparkRuntimeException}
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.{JsonToStructs, Literal, 
MultiGetJsonObject}
 import org.apache.spark.sql.catalyst.expressions.Cast._
+import org.apache.spark.sql.catalyst.util.TimestampNanosTestUtils
 import 
org.apache.spark.sql.catalyst.util.TimestampNanosTestUtils.foreachNanosPrecision
-import org.apache.spark.sql.errors.DataTypeErrors.toSQLType
 import org.apache.spark.sql.execution.{InputAdapter, SparkPlan, 
WholeStageCodegenExec}
 import org.apache.spark.sql.functions._
 import org.apache.spark.sql.internal.SQLConf
@@ -609,8 +609,12 @@ class JsonFunctionsSuite extends SharedSparkSession {
 
   test("SPARK-57164: from_json with a nanos timestamp DDL schema string") {
     val df = Seq("""{"c": "2020-01-01T00:00:00.123456789"}""").toDF("value")
-    withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
+    // Fix the session timezone so the TIMESTAMP_LTZ expected value is 
deterministic.
+    withSQLConf(
+        SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
       foreachNanosPrecision { p =>
+        val nano = TimestampNanosTestUtils.nanoOfSecTruncator(p)(123456789)
         Seq(
           s"TIMESTAMP_NTZ($p)" -> TimestampNTZNanosType(p),
           s"TIMESTAMP_LTZ($p)" -> TimestampLTZNanosType(p),
@@ -621,12 +625,73 @@ class JsonFunctionsSuite extends SharedSparkSession {
               from_json($"value", s"c $spelling", Map.empty[String, 
String]).as("v"))
             // The schema string resolves to the nanos type ...
             
assert(parsed.schema("v").dataType.asInstanceOf[StructType]("c").dataType === 
expected)
-            // ... but the JSON datasource does not support nanosecond 
timestamps yet, so the
-            // value converter rejects it at execution.
-            checkError(
-              exception = 
intercept[SparkUnsupportedOperationException](parsed.collect()),
-              condition = "UNSUPPORTED_DATATYPE",
-              parameters = Map("typeName" -> toSQLType(expected)))
+            // ... and the JSON datasource correctly parses the nanosecond 
timestamp, truncating
+            // sub-precision digits toward zero.
+            val expectedValue = expected match {
+              case _: TimestampNTZNanosType => LocalDateTime.of(2020, 1, 1, 0, 
0, 0, nano)
+              case _: TimestampLTZNanosType =>
+                LocalDateTime.of(2020, 1, 1, 0, 0, 0, 
nano).toInstant(ZoneOffset.UTC)
+            }
+            checkAnswer(parsed, Row(Row(expectedValue)))
+        }
+      }
+    }
+  }
+
+  test("SPARK-57456: to_json with nanos timestamp types") {
+    withSQLConf(
+        SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+      foreachNanosPrecision { p =>
+        // The pattern must carry `p` fractional digits to emit the full 
declared precision; the
+        // floored value has exactly `p` significant digits, so the rendered 
fraction is the first
+        // `p` digits of 123456789.
+        val fracPat = "S" * p
+        val frac = "123456789".take(p)
+        val ldt = LocalDateTime.of(2020, 1, 1, 0, 0, 0, 123456789)
+        Seq(
+          (TimestampNTZNanosType(p): DataType, "timestampNTZFormat",
+            s"yyyy-MM-dd'T'HH:mm:ss.$fracPat", ldt: Any,
+            s"""{"ts":"2020-01-01T00:00:00.$frac"}"""),
+          (TimestampLTZNanosType(p): DataType, "timestampFormat",
+            s"yyyy-MM-dd'T'HH:mm:ss.${fracPat}XXX", 
ldt.toInstant(ZoneOffset.UTC): Any,
+            s"""{"ts":"2020-01-01T00:00:00.${frac}Z"}""")).foreach {
+          case (nanosType, optKey, fmt, value, expectedJson) =>
+            val schema = new StructType().add("ts", nanosType)
+            val df = spark.createDataFrame(
+              spark.sparkContext.parallelize(Seq(Row(value))), schema)
+            checkAnswer(
+              df.select(to_json(struct($"ts"), Map(optKey -> fmt))),
+              Row(expectedJson))
+        }
+      }
+    }
+  }
+
+  test("SPARK-57456: roundtrip in to_json and from_json - nanos timestamps") {
+    withSQLConf(
+        SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+      foreachNanosPrecision { p =>
+        val fracPat = "S" * p
+        val ldt = LocalDateTime.of(2020, 1, 1, 0, 0, 0, 123456789)
+        Seq(
+          (TimestampNTZNanosType(p): DataType, "timestampNTZFormat",
+            s"yyyy-MM-dd'T'HH:mm:ss.$fracPat", ldt: Any),
+          (TimestampLTZNanosType(p): DataType, "timestampFormat",
+            s"yyyy-MM-dd'T'HH:mm:ss.${fracPat}XXX", 
ldt.toInstant(ZoneOffset.UTC): Any)).foreach {
+          case (nanosType, optKey, fmt, value) =>
+            val schema = new StructType().add("ts", nanosType)
+            val df = spark.createDataFrame(
+              spark.sparkContext.parallelize(Seq(Row(value))), schema)
+            val options = Map(optKey -> fmt)
+            // The input column already carries precision `p`, so the to_json 
-> from_json
+            // round-trip with a `p`-digit pattern is loss-free.
+            val readBack = df
+              .select(to_json(struct($"ts"), options).as("json"))
+              .select(from_json($"json", schema, options).as("data"))
+              .select($"data.ts".as("ts"))
+            checkAnswer(readBack, df.select($"ts"))
         }
       }
     }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/json/JsonSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/json/JsonSuite.scala
index 8cdae049723a..99be72b08a94 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/json/JsonSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/json/JsonSuite.scala
@@ -21,7 +21,7 @@ import java.io._
 import java.nio.charset.{Charset, StandardCharsets}
 import java.nio.file.Files
 import java.sql.{Date, Timestamp}
-import java.time.{Duration, Instant, LocalDate, LocalDateTime, Period, ZoneId}
+import java.time.{Duration, Instant, LocalDate, LocalDateTime, Period, ZoneId, 
ZoneOffset}
 import java.util.Locale
 import java.util.concurrent.atomic.AtomicLong
 
@@ -39,6 +39,8 @@ import org.apache.spark.sql.{functions => F, _}
 import org.apache.spark.sql.catalyst.json._
 import org.apache.spark.sql.catalyst.util.{CharsetProvider, DateTimeTestUtils, 
DateTimeUtils, HadoopCompressionCodec}
 import org.apache.spark.sql.catalyst.util.HadoopCompressionCodec.GZIP
+import org.apache.spark.sql.catalyst.util.TimestampNanosTestUtils
+import 
org.apache.spark.sql.catalyst.util.TimestampNanosTestUtils.foreachNanosPrecision
 import org.apache.spark.sql.catalyst.util.TypeUtils.toSQLType
 import org.apache.spark.sql.errors.QueryExecutionErrors.toSQLId
 import org.apache.spark.sql.execution.ExternalRDD
@@ -2979,6 +2981,99 @@ abstract class JsonSuite
     }
   }
 
+  test("SPARK-57456: read nanosecond timestamps via 
DataFrameReader.json(Dataset[String])") {
+    val ds = Seq("""{"ts": "2020-01-01T00:00:00.123456789"}""").toDS()
+    // CORRECTED overrides the LEGACY policy set by the inherited 
JsonLegacyTimeParserSuite (which
+    // would reject nanos). UTC fixes the LTZ expected value.
+    withSQLConf(
+        SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+        SQLConf.LEGACY_TIME_PARSER_POLICY.key -> "CORRECTED",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+      foreachNanosPrecision { p =>
+        val nano = TimestampNanosTestUtils.nanoOfSecTruncator(p)(123456789)
+        Seq(TimestampNTZNanosType(p), TimestampLTZNanosType(p)).foreach { 
nanosType =>
+          val readBack = spark.read.schema(new StructType().add("ts", 
nanosType)).json(ds)
+          val expected = nanosType match {
+            case _: TimestampNTZNanosType =>
+              Row(LocalDateTime.of(2020, 1, 1, 0, 0, 0, nano))
+            case _: TimestampLTZNanosType =>
+              Row(LocalDateTime.of(2020, 1, 1, 0, 0, 0, 
nano).toInstant(ZoneOffset.UTC))
+          }
+          checkAnswer(readBack, expected)
+        }
+      }
+    }
+  }
+
+  test("SPARK-57456: Roundtrip in reading and writing nanosecond timestamps 
with custom schema") {
+    withSQLConf(
+        SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+        SQLConf.LEGACY_TIME_PARSER_POLICY.key -> "CORRECTED") {
+      foreachNanosPrecision { precision =>
+        // Text output needs a pattern carrying `precision` fractional-second 
digits.
+        val fracPat = "S" * precision
+        Seq(TimestampNTZNanosType(precision), 
TimestampLTZNanosType(precision)).foreach {
+          nanosType =>
+            withTempPath { path =>
+              val wallClock = LocalDateTime.of(1970, 1, 1, 0, 20, 34, 
567890123)
+              val value: Any = nanosType match {
+                case _: TimestampNTZNanosType => wallClock
+                case _: TimestampLTZNanosType => 
wallClock.toInstant(ZoneOffset.UTC)
+              }
+              val df = spark.createDataFrame(
+                spark.sparkContext.parallelize(Seq(Row(value))),
+                new StructType().add("ts", nanosType))
+              val (fmtKey, fmtVal) = nanosType match {
+                case _: TimestampNTZNanosType =>
+                  ("timestampNTZFormat", s"yyyy-MM-dd'T'HH:mm:ss.$fracPat")
+                case _: TimestampLTZNanosType =>
+                  ("timestampFormat", s"yyyy-MM-dd'T'HH:mm:ss.${fracPat}XXX")
+              }
+              df.write.option(fmtKey, fmtVal).json(path.getAbsolutePath)
+              val readBack = spark.read.schema(new StructType().add("ts", 
nanosType))
+                .option(fmtKey, fmtVal).json(path.getAbsolutePath)
+              checkAnswer(readBack, df)
+            }
+        }
+      }
+    }
+  }
+
+  test("SPARK-57456: mixed microsecond and nanosecond timestamps in one 
schema") {
+    withSQLConf(
+        SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+        SQLConf.LEGACY_TIME_PARSER_POLICY.key -> "CORRECTED",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+      withTempPath { path =>
+        // One LTZ pair (micros + nanos) shares `timestampFormat`; one NTZ 
pair shares
+        // `timestampNTZFormat`. A single 9-S pattern per kind serves both 
precisions: the micros
+        // columns just get trailing-zero fractions.
+        val schema = new StructType()
+          .add("ltz_micros", TimestampType)
+          .add("ltz_nanos", TimestampLTZNanosType(9))
+          .add("ntz_micros", TimestampNTZType)
+          .add("ntz_nanos", TimestampNTZNanosType(9))
+        val row = Row(
+          LocalDateTime.of(2020, 1, 1, 0, 0, 0, 
123456000).toInstant(ZoneOffset.UTC),
+          LocalDateTime.of(2020, 1, 1, 0, 0, 0, 
123456789).toInstant(ZoneOffset.UTC),
+          LocalDateTime.of(2020, 1, 1, 0, 0, 0, 123456000),
+          LocalDateTime.of(2020, 1, 1, 0, 0, 0, 123456789))
+        val df = 
spark.createDataFrame(spark.sparkContext.parallelize(Seq(row)), schema)
+        val ltzFmt = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSXXX"
+        val ntzFmt = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS"
+        df.write
+          .option("timestampFormat", ltzFmt)
+          .option("timestampNTZFormat", ntzFmt)
+          .json(path.getAbsolutePath)
+        val readBack = spark.read.schema(schema)
+          .option("timestampFormat", ltzFmt)
+          .option("timestampNTZFormat", ntzFmt)
+          .json(path.getAbsolutePath)
+        checkAnswer(readBack, df)
+      }
+    }
+  }
+
   test("SPARK-37360: Timestamp type inference for a column with TIMESTAMP_NTZ 
values") {
     withTempPath { path =>
       val exp = spark.sql("""


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


Reply via email to