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

uros-b 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 bb62cf4121a2 [SPARK-57102][SQL] Support nanosecond-precision 
timestamps in the Parquet data source
bb62cf4121a2 is described below

commit bb62cf4121a2dace3d5d3baec304c21180d2d164
Author: Stevo Mitric <[email protected]>
AuthorDate: Wed Jun 17 23:10:19 2026 +0200

    [SPARK-57102][SQL] Support nanosecond-precision timestamps in the Parquet 
data source
    
    ### What changes were proposed in this pull request?
    
    This PR adds read and write support for the nanosecond-capable timestamp 
types `TimestampNTZNanosType(p)` / `TimestampLTZNanosType(p)` (precision `p` in 
`[7, 9]`, from the SPIP [SPARK-56822]) in the built-in Parquet data source, 
gated behind the existing preview flag `spark.sql.timestampNanosTypes.enabled`.
    
    - Schema conversion (`ParquetSchemaConverter`, both directions):
      - Write: `TimestampLTZNanosType` / `TimestampNTZNanosType` -> `INT64` 
annotated `TIMESTAMP(NANOS, isAdjustedToUTC)` (`isAdjustedToUTC = true` for 
LTZ, `false` for NTZ).
      - Read: `INT64` + `TIMESTAMP(NANOS, ...)` -> `TimestampLTZNanosType(9)` / 
`TimestampNTZNanosType(9)`. Parquet's `NANOS` unit carries no precision 
parameter, so reads mint the canonical precision 9. The legacy 
`spark.sql.legacy.parquet.nanosAsLong` path keeps precedence and is unchanged.
    - Read values (non-vectorized / row-based reader, `ParquetRowConverter`): 
an `INT64` epoch-nanoseconds value is split into `epochMicros = floorDiv(v, 
1000)` and `nanosWithinMicro = floorMod(v, 1000)` and stored as 
`TimestampNanosVal`. `TIMESTAMP(NANOS)` values are exempt from datetime 
rebasing on both read and write: the `NANOS` unit postdates the legacy 
hybrid-calendar writers, so such files are always proleptic Gregorian (the 
`spark.sql.parquet.datetimeRebaseModeIn{Read,Write}` conf [...]
    - Write values (`ParquetWriteSupport`): a `TimestampNanosVal` is written as 
`INT64` epoch-nanoseconds using exact arithmetic 
(`Math.addExact(Math.multiplyExact(epochMicros, 1000), nanosWithinMicro)`); 
values outside the representable `INT64` epoch-nanosecond range (~1677-09-21 .. 
2262-04-11) fail instead of silently wrapping.
    - The Parquet `supportDataType` guards (V1 `ParquetFileFormat` and V2 
`ParquetTable`) are relaxed to accept the nanos types, and the feature flag is 
propagated to the read Hadoop configuration in both the V1 and V2 paths.
    - The nanos types are excluded from `ParquetUtils.isBatchReadSupported`, so 
columnar reads transparently fall back to the row-based reader. 
Vectorized-reader support is a follow-up.
    
    Spark-written files round-trip the exact type (including precision) via the 
Spark schema stored in the Parquet key-value metadata; "foreign" files with no 
Spark metadata (e.g. produced by Trino/DuckDB/pandas) derive the nanos type 
from the Parquet annotation.
    
    ### Why are the changes needed?
    
    Nanosecond-precision timestamps are common in data produced by 
pandas/PyArrow, Trino, ClickHouse, DuckDB, and similar systems. Spark currently 
rejects Parquet `INT64 TIMESTAMP(NANOS)` (`PARQUET_TYPE_ILLEGAL`), or, with 
`spark.sql.legacy.parquet.nanosAsLong=true`, reads it as a raw `LongType` that 
drops all timestamp and time-zone semantics. This PR lets Spark read and write 
such data as first-class nanosecond timestamp types, as part of the SPIP 
[SPARK-56822] "Timestamps with nanoseco [...]
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, behind the preview flag `spark.sql.timestampNanosTypes.enabled` 
(default off in production). When the flag is enabled:
    - Parquet files with `INT64 TIMESTAMP(NANOS, isAdjustedToUTC=true/false)` 
are read as `TimestampLTZNanosType(9)` / `TimestampNTZNanosType(9)` instead of 
being rejected.
    - Columns of these types can be written to Parquet (as `INT64 
TIMESTAMP(NANOS)`).
    
    When the flag is off, behavior is unchanged, including the legacy 
`spark.sql.legacy.parquet.nanosAsLong` escape hatch.
    
    ### How was this patch tested?
    
    New `ParquetTimestampNanosSuite` covering: Spark write/read round-trip 
preserving value and precision at `p` = 7, 8, 9 (vectorized reader on and off); 
reading "foreign" `TIMESTAMP(NANOS)` files written directly via parquet-mr for 
both NTZ and LTZ, including a pre-epoch (negative) instant that exercises floor 
semantics and nulls; `nanosAsLong` precedence; the disabled-feature error 
pinned via `checkError` (`PARQUET_TYPE_ILLEGAL`, both `isAdjustedToUTC` 
values); an out-of-`INT64`-range  [...]
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Anthropic Claude Opus 4.8)
    
    Closes #56407 from stevomitric/stevomitric/SPARK-57102-parquet-nanos.
    
    Authored-by: Stevo Mitric <[email protected]>
    Signed-off-by: Uros Bojanic <[email protected]>
    (cherry picked from commit 59b6031202e315585181f1c379465cd7eab32440)
    Signed-off-by: Uros Bojanic <[email protected]>
---
 .../sql/catalyst/util/SparkDateTimeUtils.scala     |   4 +-
 .../spark/sql/errors/QueryExecutionErrors.scala    |  19 +-
 .../datasources/parquet/ParquetFileFormat.scala    |   9 +-
 .../datasources/parquet/ParquetRowConverter.scala  |  49 ++-
 .../parquet/ParquetSchemaConverter.scala           |  20 ++
 .../datasources/parquet/ParquetUtils.scala         |   4 +-
 .../datasources/parquet/ParquetWriteSupport.scala  |  27 +-
 .../datasources/v2/parquet/ParquetScan.scala       |   3 +
 .../datasources/v2/parquet/ParquetTable.scala      |   3 -
 .../spark/sql/FileBasedDataSourceSuite.scala       |   7 +-
 .../datasources/parquet/ParquetSchemaSuite.scala   |  77 ++++-
 .../parquet/ParquetTimestampNanosSuite.scala       | 330 +++++++++++++++++++++
 12 files changed, 526 insertions(+), 26 deletions(-)

diff --git 
a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/SparkDateTimeUtils.scala
 
b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/SparkDateTimeUtils.scala
index d85aa65e3454..ea20057278ce 100644
--- 
a/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/SparkDateTimeUtils.scala
+++ 
b/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/SparkDateTimeUtils.scala
@@ -220,7 +220,9 @@ trait SparkDateTimeUtils {
    * user-reachable input here. An out-of-range value therefore indicates an 
internal caller bug
    * and raises an internal error rather than silently retaining all 
sub-microsecond digits.
    */
-  private def truncateNanosWithinMicroToPrecision(nanosWithinMicro: Int, 
precision: Int): Int = {
+  private[sql] def truncateNanosWithinMicroToPrecision(
+      nanosWithinMicro: Int,
+      precision: Int): Int = {
     precision match {
       case 7 => (nanosWithinMicro / 100) * 100
       case 8 => (nanosWithinMicro / 10) * 10
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
index d9441d6d3206..08c9e0462b8b 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
@@ -54,7 +54,7 @@ import 
org.apache.spark.sql.internal.StaticSQLConf.GLOBAL_TEMP_DATABASE
 import org.apache.spark.sql.streaming.OutputMode
 import org.apache.spark.sql.types._
 import org.apache.spark.unsafe.array.ByteArrayMethods
-import org.apache.spark.unsafe.types.UTF8String
+import org.apache.spark.unsafe.types.{TimestampNanosVal, UTF8String}
 import org.apache.spark.util.{CircularBuffer, Utils}
 
 /**
@@ -2609,6 +2609,23 @@ private[sql] object QueryExecutionErrors extends 
QueryErrorsBase with ExecutionE
       summary = "")
   }
 
+  def parquetTimestampNanosOverflowError(
+      value: TimestampNanosVal, isNtz: Boolean): SparkArithmeticException = {
+    // Render TIMESTAMP_NTZ values without a zone (LocalDateTime, no trailing 
`Z`); TIMESTAMP_LTZ
+    // values are absolute instants and render as UTC with a trailing `Z`.
+    val rendered =
+      if (isNtz) DateTimeUtils.timestampNanosToLocalDateTime(value).toString
+      else DateTimeUtils.timestampNanosToInstant(value).toString
+    new SparkArithmeticException(
+      errorClass = "DATETIME_OVERFLOW",
+      messageParameters = Map(
+        "operation" -> (s"write the timestamp value $rendered as Parquet INT64 
" +
+          "epoch-nanoseconds " +
+          "(supported range: 1677-09-21T00:12:43.145224192Z to 
2262-04-11T23:47:16.854775807Z)")),
+      context = Array.empty,
+      summary = "")
+  }
+
   def timeAddIntervalOverflowError(
       time: Long,
       timePrecision: Int,
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFileFormat.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFileFormat.scala
index 2b32c4e0d056..cf2626c2d63e 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFileFormat.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFileFormat.scala
@@ -149,6 +149,9 @@ class ParquetFileFormat
     hadoopConf.setBoolean(
       SQLConf.LEGACY_PARQUET_NANOS_AS_LONG.key,
       sqlConf.legacyParquetNanosAsLong)
+    hadoopConf.setBoolean(
+      SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key,
+      sqlConf.timestampNanosTypesEnabled)
     hadoopConf.setBoolean(
       SQLConf.PARQUET_READER_RESPECT_UNKNOWN_TYPE_ANNOTATION.key,
       sqlConf.parquetReaderRespectUnknownTypeAnnotation)
@@ -416,9 +419,6 @@ class ParquetFileFormat
     case g: GeometryType => GeometryType.isSridSupported(g.srid)
     case g: GeographyType => GeographyType.isSridSupported(g.srid)
 
-    // Nanosecond-capable timestamps are not yet supported by this datasource.
-    case _: AnyTimestampNanoType => false
-
     case _: AtomicType | _: NullType => true
 
     case st: StructType => st.forall { f => supportDataType(f.dataType) }
@@ -458,6 +458,7 @@ object ParquetFileFormat extends Logging {
       sqlConf.isParquetINT96AsTimestamp,
       inferTimestampNTZ = sqlConf.parquetInferTimestampNTZEnabled,
       nanosAsLong = sqlConf.legacyParquetNanosAsLong,
+      timestampNanosTypesEnabled = sqlConf.timestampNanosTypesEnabled,
       respectUnknownTypeAnnotation =
         sqlConf.parquetReaderRespectUnknownTypeAnnotation)
 
@@ -565,6 +566,7 @@ object ParquetFileFormat extends Logging {
     val assumeInt96IsTimestamp = sqlConf.isParquetINT96AsTimestamp
     val inferTimestampNTZ = sqlConf.parquetInferTimestampNTZEnabled
     val nanosAsLong = sqlConf.legacyParquetNanosAsLong
+    val timestampNanosTypesEnabled = sqlConf.timestampNanosTypesEnabled
     val respectUnknownTypeAnnotation =
       sqlConf.parquetReaderRespectUnknownTypeAnnotation
 
@@ -576,6 +578,7 @@ object ParquetFileFormat extends Logging {
         assumeInt96IsTimestamp = assumeInt96IsTimestamp,
         inferTimestampNTZ = inferTimestampNTZ,
         nanosAsLong = nanosAsLong,
+        timestampNanosTypesEnabled = timestampNanosTypesEnabled,
         respectUnknownTypeAnnotation = respectUnknownTypeAnnotation)
 
       readParquetFootersInParallel(conf, files, ignoreCorruptFiles, 
ignoreMissingFiles)
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetRowConverter.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetRowConverter.scala
index 85cc50457323..6c9485dc6fc8 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetRowConverter.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetRowConverter.scala
@@ -35,7 +35,7 @@ import org.apache.spark.internal.Logging
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions._
 import org.apache.spark.sql.catalyst.types.{PhysicalByteType, 
PhysicalShortType}
-import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, 
CaseInsensitiveMap, DateTimeUtils, GenericArrayData, ResolveDefaultColumns, 
STUtils}
+import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, 
CaseInsensitiveMap, DateTimeConstants, DateTimeUtils, GenericArrayData, 
ResolveDefaultColumns, STUtils}
 import org.apache.spark.sql.catalyst.util.RebaseDateTime.RebaseSpec
 import org.apache.spark.sql.catalyst.util.ResolveDefaultColumns._
 import org.apache.spark.sql.errors.QueryCompilationErrors
@@ -43,7 +43,7 @@ import org.apache.spark.sql.errors.QueryExecutionErrors
 import org.apache.spark.sql.execution.datasources.{DataSourceUtils, 
VariantMetadata}
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types._
-import org.apache.spark.unsafe.types.{BinaryView, UTF8String, VariantVal}
+import org.apache.spark.unsafe.types.{BinaryView, TimestampNanosVal, 
UTF8String, VariantVal}
 import org.apache.spark.util.collection.Utils
 
 /**
@@ -484,6 +484,17 @@ private[parquet] class ParquetRowConverter(
           }
         }
 
+      // The TIMESTAMP(NANOS) parquet type postdates Spark's switch to the 
proleptic Gregorian
+      // calendar, so no legacy hybrid-calendar writer could have produced it. 
Nanos values are
+      // always proleptic Gregorian and are exempt from datetime rebasing
+      // (`spark.sql.parquet.datetimeRebaseModeInRead` only covers DATE, 
TIMESTAMP_MILLIS and
+      // TIMESTAMP_MICROS).
+      case t: TimestampLTZNanosType if isNanosTimestamp(parquetType) =>
+        makeNanosTimestampConverter(updater, t.precision)
+
+      case t: TimestampNTZNanosType if isNanosTimestamp(parquetType) =>
+        makeNanosTimestampConverter(updater, t.precision)
+
       // Allow upcasting INT32 date to timestampNTZ.
       case TimestampNTZType if 
parquetType.asPrimitiveType().getPrimitiveTypeName == INT32 &&
           
parquetType.getLogicalTypeAnnotation.isInstanceOf[DateLogicalTypeAnnotation] =>
@@ -587,6 +598,40 @@ private[parquet] class ParquetRowConverter(
   private def canReadAsTimestampNTZ(parquetType: Type): Boolean =
     
parquetType.getLogicalTypeAnnotation.isInstanceOf[TimestampLogicalTypeAnnotation]
 
+  // A Parquet INT64 column annotated as TIMESTAMP(NANOS), read into one of the
+  // nanosecond-precision Spark timestamp types.
+  private def isNanosTimestamp(parquetType: Type): Boolean =
+    parquetType.getLogicalTypeAnnotation match {
+      case ts: TimestampLogicalTypeAnnotation => ts.getUnit == TimeUnit.NANOS
+      case _ => false
+    }
+
+  /**
+   * Builds a converter for a Parquet INT64 `TIMESTAMP(NANOS)` column read 
into a
+   * nanosecond-precision Spark type ([[TimestampNTZNanosType]] / 
[[TimestampLTZNanosType]]). The
+   * int64 epoch-nanoseconds value is split into the `(epochMicros, 
nanosWithinMicro)` pair with
+   * floor semantics (so pre-epoch values keep `nanosWithinMicro` in `[0, 
999]`), then the
+   * sub-microsecond digits are truncated to `precision`. The truncation 
mirrors
+   * [[DateTimeUtils.instantToTimestampNanos]] / 
[[DateTimeUtils.localDateTimeToTimestampNanos]];
+   * it matters when an explicit read schema (e.g. `TIMESTAMP_NTZ(7)`) is 
applied to a foreign
+   * full-precision file - otherwise the stored value would carry digits below 
`precision`,
+   * violating the invariant the rest of the stack maintains. NANOS is exempt 
from datetime
+   * rebasing (see the call site).
+   */
+  private def makeNanosTimestampConverter(
+      updater: ParentContainerUpdater,
+      precision: Int): ParquetPrimitiveConverter =
+    new ParquetPrimitiveConverter(updater) {
+      override def addLong(value: Long): Unit = {
+        val epochMicros = Math.floorDiv(value, 
DateTimeConstants.NANOS_PER_MICROS)
+        val rawNanosWithinMicro =
+          Math.floorMod(value, DateTimeConstants.NANOS_PER_MICROS).toInt
+        val nanosWithinMicro =
+          
DateTimeUtils.truncateNanosWithinMicroToPrecision(rawNanosWithinMicro, 
precision)
+        this.updater.set(TimestampNanosVal.fromParts(epochMicros, 
nanosWithinMicro.toShort))
+      }
+    }
+
   /**
    * Parquet converter for strings. A dictionary is used to minimize string 
decoding cost.
    */
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaConverter.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaConverter.scala
index b8ff84325635..c479c37b89fd 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaConverter.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaConverter.scala
@@ -60,6 +60,7 @@ class ParquetToSparkSchemaConverter(
     caseSensitive: Boolean = SQLConf.CASE_SENSITIVE.defaultValue.get,
     inferTimestampNTZ: Boolean = 
SQLConf.PARQUET_INFER_TIMESTAMP_NTZ_ENABLED.defaultValue.get,
     nanosAsLong: Boolean = 
SQLConf.LEGACY_PARQUET_NANOS_AS_LONG.defaultValue.get,
+    timestampNanosTypesEnabled: Boolean = false,
     useFieldId: Boolean = 
SQLConf.PARQUET_FIELD_ID_READ_ENABLED.defaultValue.get,
     val ignoreVariantAnnotation: Boolean =
       SQLConf.PARQUET_IGNORE_VARIANT_ANNOTATION.defaultValue.get,
@@ -72,6 +73,7 @@ class ParquetToSparkSchemaConverter(
     caseSensitive = conf.caseSensitiveAnalysis,
     inferTimestampNTZ = conf.parquetInferTimestampNTZEnabled,
     nanosAsLong = conf.legacyParquetNanosAsLong,
+    timestampNanosTypesEnabled = conf.timestampNanosTypesEnabled,
     useFieldId = conf.parquetFieldIdReadEnabled,
     ignoreVariantAnnotation = conf.parquetIgnoreVariantAnnotation,
     respectUnknownTypeAnnotation =
@@ -83,6 +85,8 @@ class ParquetToSparkSchemaConverter(
     caseSensitive = conf.get(SQLConf.CASE_SENSITIVE.key).toBoolean,
     inferTimestampNTZ = 
conf.get(SQLConf.PARQUET_INFER_TIMESTAMP_NTZ_ENABLED.key).toBoolean,
     nanosAsLong = conf.get(SQLConf.LEGACY_PARQUET_NANOS_AS_LONG.key).toBoolean,
+    timestampNanosTypesEnabled =
+      conf.getBoolean(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key, false),
     useFieldId = conf.getBoolean(SQLConf.PARQUET_FIELD_ID_READ_ENABLED.key,
       SQLConf.PARQUET_FIELD_ID_READ_ENABLED.defaultValue.get),
     ignoreVariantAnnotation = 
conf.getBoolean(SQLConf.PARQUET_IGNORE_VARIANT_ANNOTATION.key,
@@ -327,6 +331,13 @@ class ParquetToSparkSchemaConverter(
           case timestamp: TimestampLogicalTypeAnnotation
             if timestamp.getUnit == TimeUnit.NANOS && nanosAsLong =>
             LongType
+          case timestamp: TimestampLogicalTypeAnnotation
+            if timestamp.getUnit == TimeUnit.NANOS && 
timestampNanosTypesEnabled =>
+            if (timestamp.isAdjustedToUTC) {
+              TimestampLTZNanosType(TimestampLTZNanosType.NANOS_PRECISION)
+            } else {
+              TimestampNTZNanosType(TimestampNTZNanosType.NANOS_PRECISION)
+            }
           case time: TimeLogicalTypeAnnotation
             if time.getUnit == TimeUnit.MICROS && !time.isAdjustedToUTC =>
             TimeType(TimeType.MICROS_PRECISION)
@@ -735,6 +746,15 @@ class SparkToParquetSchemaConverter(
       case TimestampNTZType =>
         Types.primitive(INT64, repetition)
           .as(LogicalTypeAnnotation.timestampType(false, 
TimeUnit.MICROS)).named(field.name)
+
+      case _: TimestampLTZNanosType =>
+        Types.primitive(INT64, repetition)
+          .as(LogicalTypeAnnotation.timestampType(true, 
TimeUnit.NANOS)).named(field.name)
+
+      case _: TimestampNTZNanosType =>
+        Types.primitive(INT64, repetition)
+          .as(LogicalTypeAnnotation.timestampType(false, 
TimeUnit.NANOS)).named(field.name)
+
       case BinaryType =>
         Types.primitive(BINARY, repetition).named(field.name)
 
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetUtils.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetUtils.scala
index b4e0fe5fddb8..c60754813994 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetUtils.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetUtils.scala
@@ -45,7 +45,7 @@ import 
org.apache.spark.sql.execution.datasources.{AggregatePushDownUtils, DataS
 import org.apache.spark.sql.execution.datasources.v2.V2ColumnUtils
 import org.apache.spark.sql.internal.{LegacyBehaviorPolicy, SQLConf}
 import org.apache.spark.sql.internal.SQLConf.PARQUET_AGGREGATE_PUSHDOWN_ENABLED
-import org.apache.spark.sql.types.{ArrayType, AtomicType, DataType, MapType, 
NullType, StructField, StructType, UserDefinedType, VariantType}
+import org.apache.spark.sql.types.{ArrayType, AtomicType, DataType, MapType, 
NullType, StructField, StructType, TimestampLTZNanosType, 
TimestampNTZNanosType, UserDefinedType, VariantType}
 import org.apache.spark.util.ArrayImplicits._
 
 object ParquetUtils extends Logging {
@@ -207,6 +207,8 @@ object ParquetUtils extends Logging {
       schema.forall(f => isBatchReadSupported(sqlConf, f.dataType))
 
   def isBatchReadSupported(sqlConf: SQLConf, dt: DataType): Boolean = dt match 
{
+    case _: TimestampNTZNanosType | _: TimestampLTZNanosType =>
+      false
     case _: AtomicType =>
       true
     case _: NullType =>
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetWriteSupport.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetWriteSupport.scala
index d7c9a24245e5..d7fd5991c75f 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetWriteSupport.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetWriteSupport.scala
@@ -34,11 +34,13 @@ import org.apache.spark.internal.Logging
 import org.apache.spark.sql.{SPARK_LEGACY_DATETIME_METADATA_KEY, 
SPARK_LEGACY_INT96_METADATA_KEY, SPARK_TIMEZONE_METADATA_KEY, 
SPARK_VERSION_METADATA_KEY}
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.SpecializedGetters
-import org.apache.spark.sql.catalyst.util.{DateTimeUtils, STUtils}
+import org.apache.spark.sql.catalyst.util.{DateTimeConstants, DateTimeUtils, 
STUtils}
+import org.apache.spark.sql.errors.QueryExecutionErrors
 import org.apache.spark.sql.execution.datasources.DataSourceUtils
 import org.apache.spark.sql.internal.{LegacyBehaviorPolicy, SQLConf}
 import org.apache.spark.sql.types._
 import org.apache.spark.types.variant.Variant
+import org.apache.spark.unsafe.types.TimestampNanosVal
 
 /**
  * A Parquet [[WriteSupport]] implementation that writes Catalyst 
[[InternalRow]]s as Parquet
@@ -188,6 +190,17 @@ class ParquetWriteSupport extends 
WriteSupport[InternalRow] with Logging {
     }
   }
 
+  private def timestampNanosToEpochNanos(value: TimestampNanosVal, isNtz: 
Boolean): Long = {
+    try {
+      Math.addExact(
+        Math.multiplyExact(value.epochMicros, 
DateTimeConstants.NANOS_PER_MICROS),
+        value.nanosWithinMicro.toLong)
+    } catch {
+      case _: ArithmeticException =>
+        throw QueryExecutionErrors.parquetTimestampNanosOverflowError(value, 
isNtz)
+    }
+  }
+
   // `inShredded` indicates whether the current traversal is nested within a 
shredded Variant
   // schema. This affects how timestamp values are written.
   private def makeWriter(dataType: DataType, inShredded: Boolean): ValueWriter 
= {
@@ -268,6 +281,18 @@ class ParquetWriteSupport extends 
WriteSupport[InternalRow] with Logging {
         // MICROS time unit.
         (row: SpecializedGetters, ordinal: Int) => 
recordConsumer.addLong(row.getLong(ordinal))
 
+      // TIMESTAMP(NANOS) values are always proleptic Gregorian and are exempt 
from datetime
+      // rebasing; see the TIMESTAMP(NANOS) converters in 
`ParquetRowConverter` for details.
+      case _: TimestampLTZNanosType =>
+        (row: SpecializedGetters, ordinal: Int) =>
+          recordConsumer.addLong(
+            timestampNanosToEpochNanos(row.getTimestampLTZNanos(ordinal), 
isNtz = false))
+
+      case _: TimestampNTZNanosType =>
+        (row: SpecializedGetters, ordinal: Int) =>
+          recordConsumer.addLong(
+            timestampNanosToEpochNanos(row.getTimestampNTZNanos(ordinal), 
isNtz = true))
+
       case _: TimeType =>
         (row: SpecializedGetters, ordinal: Int) =>
           
recordConsumer.addLong(DateTimeUtils.nanosToMicros(row.getLong(ordinal)))
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/parquet/ParquetScan.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/parquet/ParquetScan.scala
index d0c7859964e0..c029ac4d0d98 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/parquet/ParquetScan.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/parquet/ParquetScan.scala
@@ -161,6 +161,9 @@ case class ParquetScan(
     hadoopConf.setBoolean(
       SQLConf.LEGACY_PARQUET_NANOS_AS_LONG.key,
       conf.legacyParquetNanosAsLong)
+    hadoopConf.setBoolean(
+      SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key,
+      conf.timestampNanosTypesEnabled)
     hadoopConf.setBoolean(
       SQLConf.PARQUET_READER_RESPECT_UNKNOWN_TYPE_ANNOTATION.key,
       conf.parquetReaderRespectUnknownTypeAnnotation)
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/parquet/ParquetTable.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/parquet/ParquetTable.scala
index 19974f613cc0..67052c201a9d 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/parquet/ParquetTable.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/parquet/ParquetTable.scala
@@ -55,9 +55,6 @@ case class ParquetTable(
     case g: GeometryType => GeometryType.isSridSupported(g.srid)
     case g: GeographyType => GeographyType.isSridSupported(g.srid)
 
-    // 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 e5f64545986f..dbf5dee4146c 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
@@ -1336,9 +1336,10 @@ class FileBasedDataSourceSuite extends SharedSparkSession
     }
   }
 
-  test("SPARK-57166: nanosecond timestamp types are not supported in file data 
sources") {
-    // None of these built-in file formats support nanosecond-capable 
timestamps yet.
-    val unsupportedDataSources = Seq("parquet", "orc", "json", "csv", "xml")
+  test("SPARK-57166: nanosecond timestamp types are not supported in 
non-Parquet file sources") {
+    // These built-in file formats do not support nanosecond-capable 
timestamps. Parquet support is
+    // covered separately in ParquetTimestampNanosSuite.
+    val unsupportedDataSources = Seq("orc", "json", "csv", "xml")
     val nanosTypes = Seq(TimestampNTZNanosType(9), TimestampLTZNanosType(9))
     withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
       // Test both v1 and v2 data sources.
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaSuite.scala
index c8d866d8b2d0..e468a1449783 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaSuite.scala
@@ -60,6 +60,7 @@ abstract class ParquetSchemaTest extends ParquetTest with 
SharedSparkSession {
       nanosAsLong = nanosAsLong)
   }
 
+  // scalastyle:off argcount
   protected def testParquetToCatalyst(
       testName: String,
       sqlSchema: StructType,
@@ -70,13 +71,16 @@ abstract class ParquetSchemaTest extends ParquetTest with 
SharedSparkSession {
       inferTimestampNTZ: Boolean = true,
       sparkReadSchema: Option[StructType] = None,
       expectedParquetColumn: Option[ParquetColumn] = None,
-      nanosAsLong: Boolean = false): Unit = {
+      nanosAsLong: Boolean = false,
+      timestampNanosTypesEnabled: Boolean = false): Unit = {
+    // scalastyle:on argcount
     val converter = new ParquetToSparkSchemaConverter(
       assumeBinaryIsString = binaryAsString,
       assumeInt96IsTimestamp = int96AsTimestamp,
       caseSensitive = caseSensitive,
       inferTimestampNTZ = inferTimestampNTZ,
-      nanosAsLong = nanosAsLong)
+      nanosAsLong = nanosAsLong,
+      timestampNanosTypesEnabled = timestampNanosTypesEnabled)
 
     test(s"sql <= parquet: $testName") {
       val actualParquetColumn = converter.convertParquetColumn(
@@ -126,7 +130,8 @@ abstract class ParquetSchemaTest extends ParquetTest with 
SharedSparkSession {
       outputTimestampType: SQLConf.ParquetOutputTimestampType.Value =
         SQLConf.ParquetOutputTimestampType.INT96,
       expectedParquetColumn: Option[ParquetColumn] = None,
-      nanosAsLong: Boolean = false): Unit = {
+      nanosAsLong: Boolean = false,
+      timestampNanosTypesEnabled: Boolean = false): Unit = {
 
     testCatalystToParquet(
       testName,
@@ -142,7 +147,8 @@ abstract class ParquetSchemaTest extends ParquetTest with 
SharedSparkSession {
       binaryAsString,
       int96AsTimestamp,
       expectedParquetColumn = expectedParquetColumn,
-      nanosAsLong = nanosAsLong)
+      nanosAsLong = nanosAsLong,
+      timestampNanosTypesEnabled = timestampNanosTypesEnabled)
   }
 
   protected def compareParquetColumn(actual: ParquetColumn, expected: 
ParquetColumn): Unit = {
@@ -1111,13 +1117,15 @@ class ParquetSchemaSuite extends ParquetSchemaTest {
 
   test("SPARK-40819: parquet file with TIMESTAMP(NANOS, true) (with default 
nanosAsLong=false)") {
     val testDataPath = testFile("test-data/timestamp-nanos.parquet")
-    checkError(
-      exception = intercept[AnalysisException] {
-        spark.read.parquet(testDataPath).collect()
-      },
-      condition = "PARQUET_TYPE_ILLEGAL",
-      parameters = Map("parquetType" -> "INT64 (TIMESTAMP(NANOS,true))")
-    )
+    withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "false") {
+      checkError(
+        exception = intercept[AnalysisException] {
+          spark.read.parquet(testDataPath).collect()
+        },
+        condition = "PARQUET_TYPE_ILLEGAL",
+        parameters = Map("parquetType" -> "INT64 (TIMESTAMP(NANOS,true))")
+      )
+    }
   }
 
   test("SPARK-47261: parquet file with unsupported type") {
@@ -2630,6 +2638,53 @@ class ParquetSchemaSuite extends ParquetSchemaTest {
       inferTimestampNTZ = true)
   }
 
+  // The nanosecond timestamp types are written as INT64 with the 
TIMESTAMP(NANOS) annotation
+  // and, with `spark.sql.timestampNanosTypes.enabled`, read back at the 
canonical precision 9.
+  testSchema(
+    "SPARK-57102: TimestampNTZNanos written and read as INT64 with 
TIMESTAMP(NANOS,false)",
+    StructType(Seq(StructField("f1", TimestampNTZNanosType(9)))),
+    """message root {
+      |  optional INT64 f1 (TIMESTAMP(NANOS,false));
+      |}
+    """.stripMargin,
+    binaryAsString = true,
+    int96AsTimestamp = true,
+    writeLegacyParquetFormat = false,
+    timestampNanosTypesEnabled = true)
+
+  testSchema(
+    "SPARK-57102: TimestampLTZNanos written and read as INT64 with 
TIMESTAMP(NANOS,true)",
+    StructType(Seq(StructField("f1", TimestampLTZNanosType(9)))),
+    """message root {
+      |  optional INT64 f1 (TIMESTAMP(NANOS,true));
+      |}
+    """.stripMargin,
+    binaryAsString = true,
+    int96AsTimestamp = true,
+    writeLegacyParquetFormat = false,
+    timestampNanosTypesEnabled = true)
+
+  testCatalystToParquet(
+    "SPARK-57102: TimestampNTZNanos(7) is written as INT64 with 
TIMESTAMP(NANOS,false)",
+    StructType(Seq(StructField("f1", TimestampNTZNanosType(7)))),
+    """message root {
+      |  optional INT64 f1 (TIMESTAMP(NANOS,false));
+      |}
+    """.stripMargin,
+    writeLegacyParquetFormat = false)
+
+  testParquetToCatalyst(
+    "SPARK-57102: legacy nanosAsLong takes precedence over the nanos timestamp 
types",
+    StructType(Seq(StructField("f1", LongType))),
+    """message root {
+      |  optional INT64 f1 (TIMESTAMP(NANOS,true));
+      |}
+    """.stripMargin,
+    binaryAsString = true,
+    int96AsTimestamp = true,
+    nanosAsLong = true,
+    timestampNanosTypesEnabled = true)
+
   testCatalystToParquet(
     "TimestampNTZ Spark to Parquet conversion for complex types",
     StructType(
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetTimestampNanosSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetTimestampNanosSuite.scala
new file mode 100644
index 000000000000..b3e8ecdd5868
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetTimestampNanosSuite.scala
@@ -0,0 +1,330 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources.parquet
+
+import java.io.File
+
+import org.apache.hadoop.fs.Path
+import org.apache.parquet.example.data.simple.SimpleGroupFactory
+import org.apache.parquet.hadoop.ParquetFileWriter.Mode
+import org.apache.parquet.hadoop.example.ExampleParquetWriter
+import org.apache.parquet.schema.{LogicalTypeAnnotation, MessageType, Types}
+import org.apache.parquet.schema.LogicalTypeAnnotation.TimeUnit
+import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT64
+
+import org.apache.spark.{SparkArithmeticException, SparkException}
+import org.apache.spark.sql.{AnalysisException, QueryTest, Row}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types._
+
+class ParquetTimestampNanosSuite extends QueryTest with ParquetTest with 
SharedSparkSession {
+
+  private def withNanosEnabled(f: => Unit): Unit = {
+    withSQLConf(
+      SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true")(f)
+  }
+
+  private def writeForeignNanosParquet(
+      file: File,
+      isAdjustedToUTC: Boolean,
+      values: Seq[Option[Long]]): Unit = {
+    val schema: MessageType = Types.buildMessage()
+      .optional(INT64)
+      .as(LogicalTypeAnnotation.timestampType(isAdjustedToUTC, TimeUnit.NANOS))
+      .named("ts")
+      .named("spark_schema")
+    val conf = spark.sessionState.newHadoopConf()
+    val writer = ExampleParquetWriter.builder(new Path(file.toURI))
+      .withType(schema)
+      .withConf(conf)
+      .withWriteMode(Mode.OVERWRITE)
+      .build()
+    try {
+      val factory = new SimpleGroupFactory(schema)
+      values.foreach { v =>
+        val group = factory.newGroup()
+        v.foreach(x => group.add("ts", x))
+        writer.write(group)
+      }
+    } finally {
+      writer.close()
+    }
+  }
+
+  test("SPARK-57102: Spark write/read round-trips nanos value and precision") {
+    withNanosEnabled {
+      Seq("true", "false").foreach { vectorized =>
+        withSQLConf(SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> 
vectorized) {
+          Seq(7, 8, 9).foreach { p =>
+            withTempPath { dir =>
+              val frac = "123456789".take(p)
+              val df = spark.sql(
+                s"""SELECT * FROM VALUES
+                   |  (TIMESTAMP_NTZ '2020-01-01 12:34:56.$frac',
+                   |   TIMESTAMP_LTZ '2020-01-01 12:34:56.$frac'),
+                   |  (TIMESTAMP_NTZ '1969-12-31 23:59:59.$frac',
+                   |   TIMESTAMP_LTZ '1969-12-31 23:59:59.$frac'),
+                   |  (CAST(NULL AS TIMESTAMP_NTZ($p)), CAST(NULL AS 
TIMESTAMP_LTZ($p)))
+                   |  AS t(ntz, ltz)""".stripMargin)
+              assert(df.schema("ntz").dataType === TimestampNTZNanosType(p))
+              assert(df.schema("ltz").dataType === TimestampLTZNanosType(p))
+
+              df.write.parquet(dir.getCanonicalPath)
+              val read = spark.read.parquet(dir.getCanonicalPath)
+
+              assert(read.schema("ntz").dataType === TimestampNTZNanosType(p))
+              assert(read.schema("ltz").dataType === TimestampLTZNanosType(p))
+              checkAnswer(read, df.collect().toSeq)
+            }
+          }
+        }
+      }
+    }
+  }
+
+  test("SPARK-57102: read a foreign TIMESTAMP(NANOS) file as nanosecond 
timestamp types") {
+    withNanosEnabled {
+      withSQLConf(
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC",
+        SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "false") {
+        val values = Seq(Some(123L), Some(1000000123L), Some(-1L), None)
+
+        withTempPath { dir =>
+          val file = new File(dir, "ntz.parquet")
+          writeForeignNanosParquet(file, isAdjustedToUTC = false, values)
+          val read = spark.read.parquet(file.getCanonicalPath)
+          assert(read.schema("ts").dataType === TimestampNTZNanosType(9))
+          checkAnswer(read, spark.sql(
+            """SELECT * FROM VALUES
+              |  (TIMESTAMP_NTZ '1970-01-01 00:00:00.000000123'),
+              |  (TIMESTAMP_NTZ '1970-01-01 00:00:01.000000123'),
+              |  (TIMESTAMP_NTZ '1969-12-31 23:59:59.999999999'),
+              |  (CAST(NULL AS TIMESTAMP_NTZ(9)))
+              |  AS t(ts)""".stripMargin).collect().toSeq)
+        }
+
+        withTempPath { dir =>
+          val file = new File(dir, "ltz.parquet")
+          writeForeignNanosParquet(file, isAdjustedToUTC = true, values)
+          val read = spark.read.parquet(file.getCanonicalPath)
+          assert(read.schema("ts").dataType === TimestampLTZNanosType(9))
+          checkAnswer(read, spark.sql(
+            """SELECT * FROM VALUES
+              |  (TIMESTAMP_LTZ '1970-01-01 00:00:00.000000123'),
+              |  (TIMESTAMP_LTZ '1970-01-01 00:00:01.000000123'),
+              |  (TIMESTAMP_LTZ '1969-12-31 23:59:59.999999999'),
+              |  (CAST(NULL AS TIMESTAMP_LTZ(9)))
+              |  AS t(ts)""".stripMargin).collect().toSeq)
+        }
+      }
+    }
+  }
+
+  test("SPARK-57102: explicit lower-precision read schema truncates 
sub-precision nanos") {
+    withNanosEnabled {
+      withSQLConf(
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC",
+        SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "false") {
+        // Foreign file with full 9-digit precision: .123456789 after the 
epoch, and -1ns (which
+        // floors to epochMicros = -1, nanosWithinMicro = 999).
+        val values = Seq(Some(123456789L), Some(-1L))
+
+        withTempPath { dir =>
+          val file = new File(dir, "ntz.parquet")
+          writeForeignNanosParquet(file, isAdjustedToUTC = false, values)
+          // Reading with an explicit TIMESTAMP_NTZ(7) schema must floor the 
sub-microsecond
+          // digits to precision 7, matching 
DateTimeUtils.localDateTimeToTimestampNanos.
+          val read = spark.read.schema("ts 
TIMESTAMP_NTZ(7)").parquet(file.getCanonicalPath)
+          assert(read.schema("ts").dataType === TimestampNTZNanosType(7))
+          checkAnswer(read, spark.sql(
+            """SELECT * FROM VALUES
+              |  (TIMESTAMP_NTZ '1970-01-01 00:00:00.123456700'),
+              |  (TIMESTAMP_NTZ '1969-12-31 23:59:59.999999900')
+              |  AS t(ts)""".stripMargin).collect().toSeq)
+        }
+
+        withTempPath { dir =>
+          val file = new File(dir, "ltz.parquet")
+          writeForeignNanosParquet(file, isAdjustedToUTC = true, values)
+          // precision 8 drops only the last digit.
+          val read = spark.read.schema("ts 
TIMESTAMP_LTZ(8)").parquet(file.getCanonicalPath)
+          assert(read.schema("ts").dataType === TimestampLTZNanosType(8))
+          checkAnswer(read, spark.sql(
+            """SELECT * FROM VALUES
+              |  (TIMESTAMP_LTZ '1970-01-01 00:00:00.123456780'),
+              |  (TIMESTAMP_LTZ '1969-12-31 23:59:59.999999990')
+              |  AS t(ts)""".stripMargin).collect().toSeq)
+        }
+      }
+    }
+  }
+
+  test("SPARK-57102: requesting a nanos type over a non-NANOS Parquet column 
fails clearly") {
+    withNanosEnabled {
+      withSQLConf(SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "false") {
+        withTempPath { dir =>
+          // Write a microsecond column: the Parquet annotation is 
TIMESTAMP(MICROS), not NANOS.
+          spark.sql("SELECT TIMESTAMP_NTZ '2020-01-01 12:34:56.123456' AS ts")
+            .write.parquet(dir.getCanonicalPath)
+          // Forcing a nanosecond read schema leaves no matching converter 
case (the guard requires
+          // a NANOS annotation), so it falls through to the generic 
PARQUET_CONVERSION_FAILURE
+          // error - the same path every other type uses, not a confusing one.
+          val e = intercept[SparkException] {
+            spark.read.schema("ts 
TIMESTAMP_NTZ(7)").parquet(dir.getCanonicalPath).collect()
+          }
+          var cause: Throwable = e
+          while (cause != null && (cause.getMessage == null ||
+              !cause.getMessage.contains("PARQUET_CONVERSION_FAILURE"))) {
+            cause = cause.getCause
+          }
+          assert(cause != null,
+            s"Expected a PARQUET_CONVERSION_FAILURE error, but got: 
${e.getMessage}")
+        }
+      }
+    }
+  }
+
+  test("SPARK-57102: legacy nanosAsLong reads a foreign TIMESTAMP(NANOS) file 
as LongType") {
+    withTempPath { dir =>
+      val file = new File(dir, "foreign.parquet")
+      writeForeignNanosParquet(file, isAdjustedToUTC = false, Seq(Some(123L), 
Some(-1L)))
+      withSQLConf(
+        SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+        SQLConf.LEGACY_PARQUET_NANOS_AS_LONG.key -> "true",
+        SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "false") {
+        val read = spark.read.parquet(file.getCanonicalPath)
+        assert(read.schema("ts").dataType === LongType)
+        checkAnswer(read, Seq(Row(123L), Row(-1L)))
+      }
+    }
+  }
+
+  test("SPARK-57102: reading a foreign TIMESTAMP(NANOS) file fails when the 
feature is disabled") {
+    Seq(true, false).foreach { isAdjustedToUTC =>
+      withTempPath { dir =>
+        val file = new File(dir, "foreign.parquet")
+        writeForeignNanosParquet(file, isAdjustedToUTC, Seq(Some(123L)))
+        withSQLConf(
+          SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "false",
+          SQLConf.LEGACY_PARQUET_NANOS_AS_LONG.key -> "false") {
+          checkError(
+            exception = intercept[AnalysisException] {
+              spark.read.parquet(file.getCanonicalPath).schema
+            },
+            condition = "PARQUET_TYPE_ILLEGAL",
+            parameters = Map("parquetType" -> s"INT64 
(TIMESTAMP(NANOS,$isAdjustedToUTC))"))
+        }
+      }
+    }
+  }
+
+  test("SPARK-57102: writing a timestamp outside the INT64 epoch-nanos range 
fails loudly") {
+    withNanosEnabled {
+      withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+        Seq("TIMESTAMP_NTZ", "TIMESTAMP_LTZ").foreach { typeName =>
+          withTempPath { dir =>
+            val df = spark.sql(s"SELECT $typeName '9999-12-31 
23:59:59.999999999' AS ts")
+            val e = intercept[SparkException] {
+              df.write.parquet(dir.getCanonicalPath)
+            }
+            var cause: Throwable = e
+            while (cause != null && 
!cause.isInstanceOf[SparkArithmeticException]) {
+              cause = cause.getCause
+            }
+            assert(
+              cause != null,
+              s"Expected a DATETIME_OVERFLOW error for $typeName, but got: 
${e.getMessage}")
+            // NTZ renders without a zone; LTZ renders as a UTC instant with a 
trailing `Z`.
+            val renderedValue =
+              if (typeName == "TIMESTAMP_NTZ") "9999-12-31T23:59:59.999999999"
+              else "9999-12-31T23:59:59.999999999Z"
+            checkError(
+              exception = cause.asInstanceOf[SparkArithmeticException],
+              condition = "DATETIME_OVERFLOW",
+              parameters = Map("operation" ->
+                (s"write the timestamp value $renderedValue as Parquet INT64 " 
+
+                  "epoch-nanoseconds (supported range: 
1677-09-21T00:12:43.145224192Z to " +
+                  "2262-04-11T23:47:16.854775807Z)")))
+          }
+        }
+      }
+    }
+  }
+
+  test("SPARK-57102: datetime rebase configs do not affect TIMESTAMP(NANOS) 
reads") {
+    withNanosEnabled {
+      withSQLConf(
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC",
+        SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "false") {
+        withTempPath { dir =>
+          val file = new File(dir, "ltz.parquet")
+          // 1800-01-01 00:00:00 UTC predates the last Julian-to-Gregorian 
switch instant of the
+          // rebase logic, so applying a timestamp rebase (or the 
EXCEPTION-mode guard) on this
+          // value would change the result or fail the read.
+          writeForeignNanosParquet(
+            file, isAdjustedToUTC = true, Seq(Some(-5364662400000000000L)))
+          Seq("EXCEPTION", "CORRECTED", "LEGACY").foreach { mode =>
+            withSQLConf(SQLConf.PARQUET_REBASE_MODE_IN_READ.key -> mode) {
+              checkAnswer(
+                spark.read.parquet(file.getCanonicalPath),
+                spark.sql(
+                  "SELECT TIMESTAMP_LTZ '1800-01-01 
00:00:00.000000000'").collect().toSeq)
+            }
+          }
+        }
+      }
+    }
+  }
+
+  test("SPARK-57102: nanos timestamps round-trip inside a nested (array) 
column") {
+    withNanosEnabled {
+      withSQLConf(SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "false") {
+        withTempPath { dir =>
+          val df = spark.sql(
+            "SELECT array(TIMESTAMP_NTZ '2020-01-01 00:00:00.123456789', " +
+            "TIMESTAMP_NTZ '1969-12-31 23:59:59.000000001') AS arr")
+          df.write.parquet(dir.getCanonicalPath)
+          val read = spark.read.parquet(dir.getCanonicalPath)
+          
assert(read.schema("arr").dataType.asInstanceOf[ArrayType].elementType ===
+            TimestampNTZNanosType(9))
+          checkAnswer(read, df.collect().toSeq)
+        }
+      }
+    }
+  }
+
+  test("SPARK-57102: nanos timestamps round-trip via the V2 file source") {
+    withNanosEnabled {
+      withSQLConf(
+        SQLConf.USE_V1_SOURCE_LIST.key -> "",
+        SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "false") {
+        withTempPath { dir =>
+          val df = spark.sql(
+            "SELECT TIMESTAMP_NTZ '2020-01-01 12:34:56.123456789' AS ntz, " +
+            "TIMESTAMP_LTZ '2020-01-01 12:34:56.123456789' AS ltz")
+          df.write.parquet(dir.getCanonicalPath)
+          val read = spark.read.parquet(dir.getCanonicalPath)
+          assert(read.schema("ntz").dataType === TimestampNTZNanosType(9))
+          assert(read.schema("ltz").dataType === TimestampLTZNanosType(9))
+          checkAnswer(read, df.collect().toSeq)
+        }
+      }
+    }
+  }
+}


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


Reply via email to