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

MaxGekk 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 e7d95e0ca20b [SPARK-55444][SQL] Route nanosecond timestamp Parquet 
support through the Types Framework
e7d95e0ca20b is described below

commit e7d95e0ca20b6f0306e1ed7737124ee077912234
Author: Stevo Mitric <[email protected]>
AuthorDate: Fri Jun 26 10:24:40 2026 +0200

    [SPARK-55444][SQL] Route nanosecond timestamp Parquet support through the 
Types Framework
    
    ### What changes were proposed in this pull request?
    Migrate Parquet read/write for 
`TimestampLTZNanosType`/`TimestampNTZNanosType` from inline `dt match` arms in 
the `*Default` methods to the Phase-3a `ParquetTypeOps` framework, mirroring 
how `TimeType` was integrated.
    
    - Add `TimestampNanosParquetOps` (shared trait + LTZ/NTZ impls) covering 
schema conversion, value write (INT64 epoch-nanos packing with overflow check), 
and the row-based read converter.
    - Register both nanos types in `ParquetTypeOps.apply`.
    - Remove the now-dead nanos branches and private helpers from 
`ParquetSchemaConverter`,
      `ParquetWriteSupport`, `ParquetRowConverter`, and `ParquetUtils` 
(framework dispatch runs first).
    
    The Parquet→Spark read-schema inference stays inline since it keys on the 
Parquet annotation, not a Spark type (same as `TimeType`).
    
    ### Why are the changes needed?
    Two parallel Parquet code paths existed (the `ParquetTypeOps` registry for 
`TimeType` and inline matches for nanos). This consolidates nanos onto the 
single registration point, following the TimeType dead-branch cleanup in 
SPARK-55444.
    
    ### Does this PR introduce _any_ user-facing change?
      No. Behavior is unchanged; this is an internal refactor.
    
    ### How was this patch tested?
    New tests in this PR.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Generated-by: Claude Code (Claude Opus 4.8)
    
    Closes #56735 from stevomitric/stevomitric/route-parquet-ts-nanos.
    
    Authored-by: Stevo Mitric <[email protected]>
    Signed-off-by: Max Gekk <[email protected]>
    (cherry picked from commit e27a6046fe211f7a1cfbb263eb6efca5c462e41c)
    Signed-off-by: Max Gekk <[email protected]>
---
 .../datasources/parquet/ParquetRowConverter.scala  |  49 +-----
 .../parquet/ParquetSchemaConverter.scala           |   8 -
 .../datasources/parquet/ParquetUtils.scala         |   4 +-
 .../datasources/parquet/ParquetWriteSupport.scala  |  24 ---
 .../parquet/types/ops/ParquetTypeOps.scala         |   4 +-
 .../types/ops/TimestampNanosParquetOps.scala       | 189 +++++++++++++++++++++
 .../types/ops/TimestampNanosParquetOpsSuite.scala  | 167 ++++++++++++++++++
 7 files changed, 362 insertions(+), 83 deletions(-)

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 5c41f30255a4..1b633d63dc22 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, DateTimeConstants, DateTimeUtils, GenericArrayData, 
ResolveDefaultColumns, STUtils}
+import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, 
CaseInsensitiveMap, 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
@@ -44,7 +44,7 @@ import 
org.apache.spark.sql.execution.datasources.{DataSourceUtils, VariantMetad
 import 
org.apache.spark.sql.execution.datasources.parquet.types.ops.ParquetTypeOps
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types._
-import org.apache.spark.unsafe.types.{BinaryView, TimestampNanosVal, 
UTF8String, VariantVal}
+import org.apache.spark.unsafe.types.{BinaryView, UTF8String, VariantVal}
 import org.apache.spark.util.collection.Utils
 
 /**
@@ -499,17 +499,6 @@ 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] =>
@@ -602,40 +591,6 @@ 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 df9db863f85f..3e33e33d6544 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
@@ -753,14 +753,6 @@ class SparkToParquetSchemaConverter(
         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 f87e155d41ab..a884b4c5fe23 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
@@ -46,7 +46,7 @@ import 
org.apache.spark.sql.execution.datasources.parquet.types.ops.ParquetTypeO
 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, TimestampLTZNanosType, 
TimestampNTZNanosType, UserDefinedType, VariantType}
+import org.apache.spark.sql.types.{ArrayType, AtomicType, DataType, MapType, 
NullType, StructField, StructType, UserDefinedType, VariantType}
 import org.apache.spark.util.ArrayImplicits._
 
 object ParquetUtils extends Logging {
@@ -216,8 +216,6 @@ object ParquetUtils extends Logging {
       .getOrElse(isBatchReadSupportedDefault(sqlConf, dt))
 
   private def isBatchReadSupportedDefault(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 641a563cd7c1..5498611961d2 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
@@ -35,13 +35,11 @@ import 
org.apache.spark.sql.{SPARK_LEGACY_DATETIME_METADATA_KEY, SPARK_LEGACY_IN
 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.errors.QueryExecutionErrors
 import org.apache.spark.sql.execution.datasources.DataSourceUtils
 import 
org.apache.spark.sql.execution.datasources.parquet.types.ops.ParquetTypeOps
 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
@@ -191,16 +189,6 @@ class ParquetWriteSupport extends 
WriteSupport[InternalRow] with Logging {
     }
   }
 
-  private def timestampNanosToEpochNanos(value: TimestampNanosVal, isNtz: 
Boolean): Long = {
-    try {
-      DateTimeUtils.timestampNanosToEpochNanos(value)
-    } catch {
-      case _: ArithmeticException =>
-        throw QueryExecutionErrors.timestampNanosEpochNanosOverflowError(
-          value, isNtz, sink = "Parquet INT64")
-    }
-  }
-
   // `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 
= {
@@ -294,18 +282,6 @@ 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 BinaryType =>
         (row: SpecializedGetters, ordinal: Int) =>
           
recordConsumer.addBinary(Binary.fromReusedByteArray(row.getBinary(ordinal)))
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/ParquetTypeOps.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/ParquetTypeOps.scala
index 76296500a792..598ec75fb45f 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/ParquetTypeOps.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/ParquetTypeOps.scala
@@ -27,7 +27,7 @@ import 
org.apache.spark.sql.catalyst.expressions.SpecializedGetters
 import org.apache.spark.sql.catalyst.util.RebaseDateTime.RebaseSpec
 import 
org.apache.spark.sql.execution.datasources.parquet.{HasParentContainerUpdater, 
ParentContainerUpdater, ParquetToSparkSchemaConverter}
 import org.apache.spark.sql.internal.SQLConf
-import org.apache.spark.sql.types.{DataType, StructType, TimeType}
+import org.apache.spark.sql.types.{DataType, StructType, 
TimestampLTZNanosType, TimestampNTZNanosType, TimeType}
 
 /**
  * Optional trait for Parquet storage format integration in the Types 
Framework.
@@ -220,6 +220,8 @@ private[parquet] object ParquetTypeOps {
   def apply(dt: DataType): Option[ParquetTypeOps] = {
     dt match {
       case tt: TimeType => Some(TimeTypeParquetOps(tt))
+      case t: TimestampLTZNanosType => Some(TimestampLTZNanosParquetOps(t))
+      case t: TimestampNTZNanosType => Some(TimestampNTZNanosParquetOps(t))
       // Add new types here - single registration point
       case _ => None
     }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimestampNanosParquetOps.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimestampNanosParquetOps.scala
new file mode 100644
index 000000000000..3359ba390809
--- /dev/null
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimestampNanosParquetOps.scala
@@ -0,0 +1,189 @@
+/*
+ * 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.types.ops
+
+import org.apache.parquet.io.api.{Converter, RecordConsumer}
+import org.apache.parquet.schema.{LogicalTypeAnnotation, Type, Types}
+import 
org.apache.parquet.schema.LogicalTypeAnnotation.{TimestampLogicalTypeAnnotation,
 TimeUnit}
+import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT64
+import org.apache.parquet.schema.Type.Repetition
+
+import org.apache.spark.sql.catalyst.expressions.SpecializedGetters
+import org.apache.spark.sql.catalyst.util.{DateTimeConstants, DateTimeUtils}
+import org.apache.spark.sql.errors.QueryExecutionErrors
+import 
org.apache.spark.sql.execution.datasources.parquet.{HasParentContainerUpdater, 
ParentContainerUpdater, ParquetPrimitiveConverter}
+import org.apache.spark.sql.types.{DataType, TimestampLTZNanosType, 
TimestampNTZNanosType}
+import org.apache.spark.unsafe.types.TimestampNanosVal
+
+/**
+ * Parquet operations shared by the nanosecond-precision timestamp types
+ * ([[TimestampLTZNanosType]] / [[TimestampNTZNanosType]]).
+ *
+ * Both are primitive types stored in Parquet as INT64 with a TIMESTAMP(NANOS) 
annotation. The two
+ * differ only in the `isAdjustedToUTC` flag (LTZ = true, NTZ = false) and in 
which row accessor /
+ * overflow-error flavor the write path uses; the schema annotation unit and 
the entire read path
+ * are identical, so they share this trait and supply the differences via the 
abstract members.
+ *
+ * IMPORTANT - internal vs Parquet representation:
+ *   - Spark internal: [[TimestampNanosVal]] = (epochMicros: Long, 
nanosWithinMicro: Short in
+ *     [0, 999])
+ *   - Parquet storage: INT64 epoch-nanoseconds (signed), so the on-disk range 
is bounded to
+ *     ~1677-09-21 .. 2262-04-11
+ *   - Write path: (epochMicros, nanosWithinMicro) -> epochMicros * 1000 + 
nanosWithinMicro, via
+ *     `DateTimeUtils.timestampNanosToEpochNanos` (exact arithmetic); 
out-of-range values throw
+ *     `timestampNanosEpochNanosOverflowError`
+ *   - Read path: epoch-nanos -> floorDiv / floorMod 1000 -> (epochMicros, 
nanosWithinMicro) (floor
+ *     semantics keep `nanosWithinMicro` in [0, 999] for pre-epoch values), 
then the
+ *     sub-microsecond digits are truncated to the requested precision
+ *
+ * TIMESTAMP(NANOS) postdates Spark's switch to the proleptic Gregorian 
calendar, so the values are
+ * exempt from datetime rebasing (the rebase modes only cover DATE, 
TIMESTAMP_MILLIS and
+ * TIMESTAMP_MICROS). Vectorized read is not supported: the value is a 16-byte 
composite rather
+ * than a single long slot, so `isBatchReadSupported` stays false (the trait 
default) and reads go
+ * through the row-based converter.
+ *
+ * @see ParquetTypeOps for the dispatch contract
+ * @since 4.3.0
+ */
+private[parquet] trait TimestampNanosParquetOps extends ParquetTypeOps {
+
+  /** The Spark type this ops handles, used for error messages. */
+  protected def sparkType: DataType
+
+  /** The requested fractional-second precision; sub-microsecond digits are 
truncated to it. */
+  protected def precision: Int
+
+  /** True for [[TimestampNTZNanosType]] (no time zone), false for 
[[TimestampLTZNanosType]]. */
+  protected def isNtz: Boolean
+
+  /** Reads the nanos value from the row using the type-specific accessor. */
+  protected def getNanos(row: SpecializedGetters, ordinal: Int): 
TimestampNanosVal
+
+  // The Parquet TIMESTAMP `isAdjustedToUTC` flag: LTZ is UTC-adjusted, NTZ is 
not.
+  private def isAdjustedToUTC: Boolean = !isNtz
+
+  // ==================== Schema Conversion ====================
+
+  override def convertToParquetType(
+      fieldName: String, repetition: Repetition, inShredded: Boolean): Type =
+    Types.primitive(INT64, repetition)
+      .as(LogicalTypeAnnotation.timestampType(isAdjustedToUTC, TimeUnit.NANOS))
+      .named(fieldName)
+
+  // ==================== Value Write ====================
+
+  override def makeWriter(
+      recordConsumer: () => RecordConsumer,
+      makeFieldWriter: DataType => (SpecializedGetters, Int) => Unit
+  ): (SpecializedGetters, Int) => Unit =
+    // TIMESTAMP(NANOS) values are always proleptic Gregorian and are exempt 
from datetime
+    // rebasing. The supplier is evaluated at write time (not creation time) 
because the
+    // RecordConsumer is null during init() and set later in prepareForWrite().
+    (row: SpecializedGetters, ordinal: Int) =>
+      recordConsumer().addLong(
+        TimestampNanosParquetOps.timestampNanosToEpochNanos(getNanos(row, 
ordinal), isNtz))
+
+  // ==================== Row-Based Read ====================
+
+  override def newConverter(
+      parquetType: Type,
+      updater: ParentContainerUpdater): Converter with 
HasParentContainerUpdater = {
+    // Framework-first dispatch in ParquetRowConverter routes here for any 
nanos catalyst type,
+    // regardless of the actual Parquet encoding. Only an INT64 
TIMESTAMP(NANOS) column can be
+    // decoded as a nanos timestamp; anything else (a non-NANOS timestamp, a 
foreign annotation,
+    // etc.) must fail loudly, matching the legacy ParquetRowConverter 
behavior where the guarded
+    // nanos arms fell through to the cannot-create-converter error.
+    if (!TimestampNanosParquetOps.isNanosTimestamp(parquetType)) {
+      throw QueryExecutionErrors.cannotCreateParquetConverterForDataTypeError(
+        sparkType, parquetType.toString)
+    }
+    val p = precision
+    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, p)
+        this.updater.set(TimestampNanosVal.fromParts(epochMicros, 
nanosWithinMicro.toShort))
+      }
+    }
+  }
+}
+
+/**
+ * Parquet operations for [[TimestampLTZNanosType]] (nanosecond precision, 
with time zone).
+ * Stored as INT64 TIMESTAMP(NANOS, isAdjustedToUTC=true).
+ *
+ * @since 4.3.0
+ */
+case class TimestampLTZNanosParquetOps(t: TimestampLTZNanosType) extends 
TimestampNanosParquetOps {
+  override protected def sparkType: DataType = t
+  override protected def precision: Int = t.precision
+  override protected def isNtz: Boolean = false
+  override protected def getNanos(row: SpecializedGetters, ordinal: Int): 
TimestampNanosVal =
+    row.getTimestampLTZNanos(ordinal)
+}
+
+/**
+ * Parquet operations for [[TimestampNTZNanosType]] (nanosecond precision, 
without time zone).
+ * Stored as INT64 TIMESTAMP(NANOS, isAdjustedToUTC=false).
+ *
+ * @since 4.3.0
+ */
+case class TimestampNTZNanosParquetOps(t: TimestampNTZNanosType) extends 
TimestampNanosParquetOps {
+  override protected def sparkType: DataType = t
+  override protected def precision: Int = t.precision
+  override protected def isNtz: Boolean = true
+  override protected def getNanos(row: SpecializedGetters, ordinal: Int): 
TimestampNanosVal =
+    row.getTimestampNTZNanos(ordinal)
+}
+
+private[ops] object TimestampNanosParquetOps {
+
+  /**
+   * Whether the Parquet field is an INT64 TIMESTAMP(NANOS) column. The 
physical type is checked
+   * (isPrimitive && INT64) in addition to the logical annotation so a 
malformed file that carries
+   * a TIMESTAMP(NANOS) annotation on a non-INT64 physical type is rejected by 
the read guard with
+   * the clean cannotCreateParquetConverterForDataTypeError rather than 
failing later in the
+   * primitive converter. Mirrors 
TimeTypeParquetOps.requireCompatibleParquetType.
+   */
+  private[ops] def isNanosTimestamp(parquetType: Type): Boolean =
+    parquetType.isPrimitive &&
+      parquetType.asPrimitiveType.getPrimitiveTypeName == INT64 &&
+      (parquetType.getLogicalTypeAnnotation match {
+        case ts: TimestampLogicalTypeAnnotation => ts.getUnit == TimeUnit.NANOS
+        case _ => false
+      })
+
+  /**
+   * Combines the `(epochMicros, nanosWithinMicro)` pair into a single INT64 
epoch-nanoseconds
+   * value for Parquet storage. Delegates the exact-arithmetic packing to
+   * [[DateTimeUtils.timestampNanosToEpochNanos]]; values outside the 
signed-int64 epoch-nanos
+   * range (~1677-09-21 .. 2262-04-11) throw 
`timestampNanosEpochNanosOverflowError`.
+   */
+  private[ops] def timestampNanosToEpochNanos(value: TimestampNanosVal, isNtz: 
Boolean): Long = {
+    try {
+      DateTimeUtils.timestampNanosToEpochNanos(value)
+    } catch {
+      case _: ArithmeticException =>
+        throw QueryExecutionErrors.timestampNanosEpochNanosOverflowError(
+          value, isNtz, sink = "Parquet INT64")
+    }
+  }
+}
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimestampNanosParquetOpsSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimestampNanosParquetOpsSuite.scala
new file mode 100644
index 000000000000..4d08efb77c93
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimestampNanosParquetOpsSuite.scala
@@ -0,0 +1,167 @@
+/*
+ * 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.types.ops
+
+import org.apache.parquet.io.api.PrimitiveConverter
+import org.apache.parquet.schema.{LogicalTypeAnnotation, Type, Types}
+import 
org.apache.parquet.schema.LogicalTypeAnnotation.{TimestampLogicalTypeAnnotation,
 TimeUnit}
+import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT64
+import org.apache.parquet.schema.Type.Repetition.REQUIRED
+
+import org.apache.spark.{SparkArithmeticException, SparkFunSuite, 
SparkRuntimeException}
+import org.apache.spark.sql.catalyst.util.DateTimeConstants
+import 
org.apache.spark.sql.execution.datasources.parquet.ParentContainerUpdater
+import org.apache.spark.sql.types.{TimestampLTZNanosType, 
TimestampNTZNanosType}
+import org.apache.spark.unsafe.types.TimestampNanosVal
+
+/**
+ * Unit tests for the nanosecond-timestamp Parquet ops
+ * ([[TimestampLTZNanosParquetOps]] / [[TimestampNTZNanosParquetOps]]), the 
Types Framework
+ * integration for [[TimestampLTZNanosType]] / [[TimestampNTZNanosType]] in 
Parquet.
+ *
+ * End-to-end read/write/round-trip behavior is covered by 
`ParquetTimestampNanosSuite`; this
+ * suite pins the ops-level contracts the framework dispatch relies on:
+ *   - the write schema annotation (INT64 TIMESTAMP(NANOS), isAdjustedToUTC = 
true for LTZ /
+ *     false for NTZ);
+ *   - the read-path guard that fails loudly when a nanos type is requested 
over a non-NANOS
+ *     Parquet column (so framework-first dispatch never silently mis-decodes);
+ *   - the (epochMicros, nanosWithinMicro) -> INT64 epoch-nanos packing and 
its overflow error.
+ */
+class TimestampNanosParquetOpsSuite extends SparkFunSuite {
+
+  private val ltz = TimestampLTZNanosParquetOps(
+    TimestampLTZNanosType(TimestampLTZNanosType.NANOS_PRECISION))
+  private val ntz = TimestampNTZNanosParquetOps(
+    TimestampNTZNanosType(TimestampNTZNanosType.NANOS_PRECISION))
+
+  // ---------- schema conversion (write path) ----------
+
+  test("LTZ converts to INT64 TIMESTAMP(NANOS, isAdjustedToUTC=true)") {
+    assertNanosTimestampSchema(ltz.convertToParquetType("c", REQUIRED, 
inShredded = false),
+      expectedAdjustedToUTC = true)
+  }
+
+  test("NTZ converts to INT64 TIMESTAMP(NANOS, isAdjustedToUTC=false)") {
+    assertNanosTimestampSchema(ntz.convertToParquetType("c", REQUIRED, 
inShredded = false),
+      expectedAdjustedToUTC = false)
+  }
+
+  // ---------- read-path guard ----------
+
+  test("newConverter rejects a non-NANOS Parquet column (TIMESTAMP(MICROS))") {
+    val microsField = Types.primitive(INT64, REQUIRED)
+      .as(LogicalTypeAnnotation.timestampType(true, TimeUnit.MICROS))
+      .named("c")
+    val ex = intercept[SparkRuntimeException] {
+      ltz.newConverter(microsField, new ParentContainerUpdater {})
+    }
+    assert(ex.getCondition === "PARQUET_CONVERSION_FAILURE.UNSUPPORTED")
+  }
+
+  test("newConverter rejects a raw INT64 column with no annotation") {
+    val rawField = Types.primitive(INT64, REQUIRED).named("c")
+    val ex = intercept[SparkRuntimeException] {
+      ntz.newConverter(rawField, new ParentContainerUpdater {})
+    }
+    assert(ex.getCondition === "PARQUET_CONVERSION_FAILURE.UNSUPPORTED")
+  }
+
+  test("newConverter decodes INT64 epoch-nanos into the (epochMicros, 
nanosWithinMicro) pair") {
+    // 1_000_000_500 ns = 1_000_000 micros + 500 ns; precision 9 keeps all 
sub-micro digits.
+    assert(decode(ltz, nanosField(isAdjustedToUTC = true), 1000000500L) ===
+      TimestampNanosVal.fromParts(1000000L, 500.toShort))
+    // Floor semantics keep nanosWithinMicro in [0, 999] for pre-epoch values.
+    assert(decode(ltz, nanosField(isAdjustedToUTC = true), -1L) ===
+      TimestampNanosVal.fromParts(-1L, 999.toShort))
+  }
+
+  test("newConverter truncates sub-precision nanos to an explicit lower read 
precision") {
+    val ntz7 = TimestampNTZNanosParquetOps(TimestampNTZNanosType(7))
+    // nanosWithinMicro 123 -> truncated to 100 at precision 7.
+    assert(decode(ntz7, nanosField(isAdjustedToUTC = false), 2000000123L) ===
+      TimestampNanosVal.fromParts(2000000L, 100.toShort))
+  }
+
+  // ---------- isNanosTimestamp helper ----------
+
+  test("isNanosTimestamp recognizes only INT64 TIMESTAMP(NANOS)") {
+    val nanos = Types.primitive(INT64, REQUIRED)
+      .as(LogicalTypeAnnotation.timestampType(false, 
TimeUnit.NANOS)).named("c")
+    val micros = Types.primitive(INT64, REQUIRED)
+      .as(LogicalTypeAnnotation.timestampType(false, 
TimeUnit.MICROS)).named("c")
+    val timeNanos = Types.primitive(INT64, REQUIRED)
+      .as(LogicalTypeAnnotation.timeType(false, TimeUnit.NANOS)).named("c")
+    val raw = Types.primitive(INT64, REQUIRED).named("c")
+
+    assert(TimestampNanosParquetOps.isNanosTimestamp(nanos))
+    assert(!TimestampNanosParquetOps.isNanosTimestamp(micros))
+    assert(!TimestampNanosParquetOps.isNanosTimestamp(timeNanos))
+    assert(!TimestampNanosParquetOps.isNanosTimestamp(raw))
+  }
+
+  // ---------- (epochMicros, nanosWithinMicro) -> INT64 epoch-nanos packing 
----------
+
+  test("timestampNanosToEpochNanos combines micros and sub-micro nanos") {
+    val value = TimestampNanosVal.fromParts(1000000L, 500.toShort)
+    assert(TimestampNanosParquetOps.timestampNanosToEpochNanos(value, isNtz = 
false) ===
+      1000000L * DateTimeConstants.NANOS_PER_MICROS + 500L)
+  }
+
+  test("timestampNanosToEpochNanos throws DATETIME_OVERFLOW outside the INT64 
epoch-nanos range") {
+    // Year ~5138; well past the int64 epoch-nanos cutoff (2262) but still 
renderable, so the
+    // multiply overflows and the overflow error - not a rendering error - is 
what surfaces.
+    val tooLarge = TimestampNanosVal.fromParts(100000000000000000L, 0.toShort)
+    Seq(true, false).foreach { isNtz =>
+      val ex = intercept[SparkArithmeticException] {
+        TimestampNanosParquetOps.timestampNanosToEpochNanos(tooLarge, isNtz)
+      }
+      assert(ex.getCondition === "DATETIME_OVERFLOW")
+    }
+  }
+
+  // ---------- helpers ----------
+
+  private def nanosField(isAdjustedToUTC: Boolean): Type =
+    Types.primitive(INT64, REQUIRED)
+      .as(LogicalTypeAnnotation.timestampType(isAdjustedToUTC, TimeUnit.NANOS))
+      .named("c")
+
+  // Builds the converter, feeds one INT64 epoch-nanos value through addLong, 
and returns the
+  // value the converter set into its updater (the decoded TimestampNanosVal).
+  private def decode(ops: TimestampNanosParquetOps, field: Type, epochNanos: 
Long): Any = {
+    var captured: Any = null
+    val updater = new ParentContainerUpdater {
+      override def set(value: Any): Unit = captured = value
+    }
+    ops.newConverter(field, 
updater).asInstanceOf[PrimitiveConverter].addLong(epochNanos)
+    captured
+  }
+
+  private def assertNanosTimestampSchema(
+      parquetType: Type, expectedAdjustedToUTC: Boolean): Unit = {
+    assert(parquetType.isPrimitive, s"expected a primitive type, got 
$parquetType")
+    assert(parquetType.asPrimitiveType.getPrimitiveTypeName === INT64)
+    parquetType.getLogicalTypeAnnotation match {
+      case ts: TimestampLogicalTypeAnnotation =>
+        assert(ts.getUnit === TimeUnit.NANOS)
+        assert(ts.isAdjustedToUTC === expectedAdjustedToUTC)
+      case other =>
+        fail(s"expected a TIMESTAMP logical type annotation, got $other")
+    }
+  }
+}


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

Reply via email to