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 1175d40c3b30 [SPARK-57285][SQL] Route nanosecond timestamp 
cast-to-string through the Types Framework
1175d40c3b30 is described below

commit 1175d40c3b306d3626c0dfa8d25a8aef035113a7
Author: Maxim Gekk <[email protected]>
AuthorDate: Thu Jun 11 00:02:40 2026 +0200

    [SPARK-57285][SQL] Route nanosecond timestamp cast-to-string through the 
Types Framework
    
    ### What changes were proposed in this pull request?
    
    This PR makes the Types Framework (`TypeApiOps`) the single integration 
point for nanosecond timestamp `CAST(... AS STRING)`, for both the interpreted 
and codegen paths.
    
    Specifically:
    
    - `TypeApiOps.apply` gains a by-name `zoneId` parameter that defaults to 
the session-local time zone config (`SqlApiConf.get.sessionLocalTimeZone`) and 
is threaded into the `TIMESTAMP_LTZ` nanos ops. It is by-name so the zone is 
forced only when the LTZ ops is actually constructed: zone-independent 
(`TimeType`, `TIMESTAMP_NTZ` nanos) and unsupported types never evaluate it, 
which matters because a `CAST`'s zone is unresolved (`None.get`) until a time 
zone is assigned.
    - `TimestampLTZNanosTypeApiOps` now carries its `ZoneId` as a required 
constructor parameter and holds the fraction formatter in a `transient private 
lazy val`, so the formatter is built once per ops instance (once per cast, per 
task) rather than per row. `TimestampNTZNanosTypeApiOps` stays zone-independent 
(UTC).
    - `ToStringBase` no longer bypasses or special-cases the framework: both 
the interpreted and codegen paths dispatch uniformly through `TypeApiOps(from, 
zoneId)`. `CAST` passes its resolved zone; zone-less callers accept the 
session-zone default.
    - The nanosecond ops implement `formatExternal` to render the external Row 
value (`Instant` for LTZ, `LocalDateTime` for NTZ) at the column precision, so 
`Row.json` / `Row.prettyJson` render nanos columns through the `formatExternal` 
routing introduced in #56392 (SPARK-57338). The two-arg `formatExternal(value, 
nested)` returns `None`, leaving the `HiveResult` output path unchanged.
    - The now-unused `UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_TO_STRING` error 
condition and its `DataTypeErrors` helper are removed.
    
    The microsecond timestamp types (`TIMESTAMP` / `TIMESTAMP_NTZ`) remain 
handled inline in `ToStringBase` and are out of scope.
    
    ### Why are the changes needed?
    
    SPARK-57256 implemented nanosecond cast-to-string inline in `ToStringBase`, 
deliberately bypassing the framework because the zone-less 
`TypeApiOps.format(v)` cannot render LTZ in the session time zone. That left 
nanos cast-to-string as a one-off integration outside the framework, 
inconsistent with the SPIP direction (SPARK-56822) of wiring the new types 
through the centralized `TypeOps` / `TypeApiOps`. This PR closes that gap.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, two narrow deltas:
    
    1. `CAST(... AS STRING)` on nanosecond `TIMESTAMP_LTZ` / `TIMESTAMP_NTZ` 
now dispatches through the Types Framework. The rendered string is unchanged 
(zone-aware LTZ, zone-independent NTZ, precision flooring, trailing-zero 
trimming); the only behavioral change is that the zone-less type-level 
`format()` no longer throws `UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_TO_STRING` 
(that error condition is removed).
    2. `Row.json` / `Row.prettyJson` on a nanosecond `TIMESTAMP_LTZ` / 
`TIMESTAMP_NTZ` column now renders the value (NTZ zone-independent, LTZ in 
`spark.sql.session.timeZone`) via the `formatExternal` routing added in #56392 
(SPARK-57338), instead of raising 
`UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_TO_STRING`.
    
    `EXPLAIN` and SQL-literal `toSQLValue` are unchanged: they don't route 
through the framework (`Literal.toString` / `Literal.sql` render via 
`value.toString`, and `toSQLValue` has no production caller).
    
    ### How was this patch tested?
    
    - Updated `TimestampNanosTypeOpsSuite` to cover the new behavior: NTZ 
renders zone-independently, LTZ renders in the zone carried by the ops 
instance, and zone-less LTZ now renders in the session-local time zone (instead 
of raising), exercising precision flooring.
    - Added a `RowJsonSuite` test that `Row.json` renders a nanosecond 
`TIMESTAMP_NTZ` / `TIMESTAMP_LTZ` column at the column precision (this PR is 
rebased on the merged #56392, which routes `Row.jsonValue` through 
`formatExternal`).
    - `CastWithAnsiOnSuite`, `CastWithAnsiOffSuite`, `ToPrettyStringSuite`, and 
`TimestampNanosRowSuite` stay green (281 tests pass), and `SparkThrowableSuite` 
(33 tests) confirms the removed error condition leaves the error framework 
consistent.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Cursor (Claude Opus 4.8)
    
    Closes #56355 from MaxGekk/nanos-typeframework-formatter.
    
    Authored-by: Maxim Gekk <[email protected]>
    Signed-off-by: Max Gekk <[email protected]>
---
 .../src/main/resources/error/error-conditions.json |  5 --
 .../apache/spark/sql/errors/DataTypeErrors.scala   |  6 --
 .../sql/types/ops/TimestampNanosTypeApiOps.scala   | 85 +++++++++++++------
 .../apache/spark/sql/types/ops/TypeApiOps.scala    | 26 ++++--
 .../sql/catalyst/expressions/ToStringBase.scala    | 49 +++++------
 .../catalyst/types/ops/TimestampNanosTypeOps.scala |  7 +-
 .../scala/org/apache/spark/sql/RowJsonSuite.scala  | 31 ++++---
 .../types/ops/TimestampNanosTypeOpsSuite.scala     | 96 +++++++++++++++++++---
 8 files changed, 202 insertions(+), 103 deletions(-)

diff --git a/common/utils/src/main/resources/error/error-conditions.json 
b/common/utils/src/main/resources/error/error-conditions.json
index bee2acec18f9..344b9ac613e4 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -8383,11 +8383,6 @@
           "Temporary views cannot be created with the WITH SCHEMA clause. 
Recreate the temporary view when the underlying schema changes, or use a 
persisted view."
         ]
       },
-      "TIMESTAMP_NANOS_TO_STRING" : {
-        "message" : [
-          "Converting values of the nanosecond timestamp type <dataType> to a 
string."
-        ]
-      },
       "TIMESTAMP_NANOS_WITH_LEGACY_TIME_PARSER" : {
         "message" : [
           "Parsing or formatting nanosecond-precision timestamps 
(TIMESTAMP_LTZ/TIMESTAMP_NTZ with precision in [7, 9]) under the LEGACY time 
parser policy. Set <config> to CORRECTED."
diff --git 
a/sql/api/src/main/scala/org/apache/spark/sql/errors/DataTypeErrors.scala 
b/sql/api/src/main/scala/org/apache/spark/sql/errors/DataTypeErrors.scala
index 7a4b2a56d25e..955e242d4ab5 100644
--- a/sql/api/src/main/scala/org/apache/spark/sql/errors/DataTypeErrors.scala
+++ b/sql/api/src/main/scala/org/apache/spark/sql/errors/DataTypeErrors.scala
@@ -299,10 +299,4 @@ private[sql] object DataTypeErrors extends 
DataTypeErrorsBase {
         "configValue" -> "true"),
       cause = null)
   }
-
-  def cannotConvertNanosTimestampToStringError(dataType: DataType): Throwable 
= {
-    new SparkUnsupportedOperationException(
-      errorClass = "UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_TO_STRING",
-      messageParameters = Map("dataType" -> toSQLType(dataType)))
-  }
 }
diff --git 
a/sql/api/src/main/scala/org/apache/spark/sql/types/ops/TimestampNanosTypeApiOps.scala
 
b/sql/api/src/main/scala/org/apache/spark/sql/types/ops/TimestampNanosTypeApiOps.scala
index fc3bb947ee8f..d9b3a431f1ec 100644
--- 
a/sql/api/src/main/scala/org/apache/spark/sql/types/ops/TimestampNanosTypeApiOps.scala
+++ 
b/sql/api/src/main/scala/org/apache/spark/sql/types/ops/TimestampNanosTypeApiOps.scala
@@ -17,24 +17,30 @@
 
 package org.apache.spark.sql.types.ops
 
+import java.time.{Instant, LocalDateTime, ZoneId, ZoneOffset}
+
 import org.apache.spark.sql.catalyst.encoders.AgnosticEncoder
 import 
org.apache.spark.sql.catalyst.encoders.AgnosticEncoders.{InstantNanosEncoder, 
LocalDateTimeNanosEncoder}
+import org.apache.spark.sql.catalyst.util.TimestampFormatter
 import org.apache.spark.sql.errors.{DataTypeErrors, DataTypeErrorsBase}
 import org.apache.spark.sql.types.{TimestampLTZNanosType, 
TimestampNTZNanosType}
+import org.apache.spark.unsafe.types.TimestampNanosVal
 
 /**
  * Client-side (spark-api) operations shared by the nanosecond timestamp types
  * (TimestampNTZNanosType and TimestampLTZNanosType).
  *
  * Internal values are [[org.apache.spark.unsafe.types.TimestampNanosVal]] 
(epoch micros + nanos
- * within the micro). The two concrete subclasses differ only in their 
DataType and SQL-literal
- * prefix; storage and formatting are identical.
+ * within the micro). The two concrete subclasses differ only in their 
DataType, SQL-literal
+ * prefix, and time-zone handling; storage is identical.
  *
- * SCOPE (SPARK-57207): this issue wires physical representation, literals, 
row accessors, and
- * codegen class selection. CAST to STRING is implemented separately, 
zone-aware, in ToStringBase
- * (SPARK-57256). The zone-less, type-level format() here (and the 
toSQLValue() that delegates to
- * it) still raises the user-facing 
UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_TO_STRING error, since LTZ
- * rendering needs the session time zone that this op does not have.
+ * CAST to STRING (SPARK-57285): the Types Framework is the single integration 
point for
+ * nanosecond timestamp cast-to-string, via the zone-less [[format]] / 
[[formatUTF8]]. NTZ
+ * rendering is zone-independent (the value is the UTC-grid wall clock). LTZ 
rendering depends on
+ * the session time zone, so the LTZ ops carries a ZoneId and builds its 
formatter once per
+ * instance. The zone is threaded in via TypeApiOps.apply(dt, zoneId): CAST 
passes the cast's
+ * resolved session zone, while zone-less callers (Row JSON via 
formatExternal) accept the
+ * default, the session-local time zone config.
  *
  * Dataset encoders are wired here to the precision-aware leaves added by 
SPARK-57033
  * (LocalDateTimeNanosEncoder / InstantNanosEncoder), so that turning on the 
Types Framework
@@ -47,27 +53,20 @@ abstract class TimestampNanosTypeApiOps extends TypeApiOps 
with DataTypeErrorsBa
   /** SQL literal prefix for this type, e.g. "TIMESTAMP_NTZ" or 
"TIMESTAMP_LTZ". */
   protected def sqlTypeName: String
 
-  // ==================== String Formatting ====================
+  /** The fractional-second precision in [7, 9] of this type. */
+  protected def precision: Int
 
-  // CAST to STRING for the nanosecond timestamp types is handled zone-aware 
by ToStringBase
-  // (SPARK-57256), alongside the microsecond timestamp types, because LTZ 
rendering depends on the
-  // session time zone that this zone-less, type-level formatter does not 
have. The remaining
-  // zone-less callers (EXPLAIN plan output and SQL-literal rendering via 
toSQLValue) still raise a
-  // user-facing unsupported-feature error here rather than silently 
truncating to microseconds.
-  override def format(v: Any): String =
-    throw DataTypeErrors.cannotConvertNanosTimestampToStringError(dataType)
+  // ==================== String Formatting ====================
 
   // Row JSON (Row.json / Row.prettyJson) holds the external Row value 
(java.time.Instant for LTZ,
-  // java.time.LocalDateTime for NTZ), but rendering nanosecond timestamps 
from this zone-less path
-  // is not supported yet (same reason as format above), so raise the 
user-facing
-  // UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_TO_STRING error here rather than 
silently truncating.
-  override def formatExternal(value: Any): Option[String] =
-    throw DataTypeErrors.cannotConvertNanosTimestampToStringError(dataType)
-
-  // The Hive result path (HiveResult.toHiveString) is zone-aware and renders 
nanosecond timestamps
-  // through its own default formatter, so return None here to fall through to 
it rather than raise
-  // the unsupported-rendering error that the zone-less single-arg 
formatExternal above throws. This
-  // is a temporary override until nanos external rendering is unified across 
the two paths.
+  // java.time.LocalDateTime for NTZ); each subclass overrides the single-arg 
formatExternal to
+  // render it through the same formatter as its zone-aware cast-to-string, so 
Row JSON shows the
+  // nanosecond value rather than silently truncating to microseconds via the 
legacy path.
+
+  // The Hive result path (HiveResult.toHiveString) renders nanosecond 
timestamps through its own
+  // zone-aware default formatter, so return None here to fall through to it 
rather than to the
+  // subclass single-arg rendering. This is a temporary split until nanos 
external rendering is
+  // unified across the zone-less (Row JSON) and zone-aware (Hive) paths.
   override def formatExternal(value: Any, nested: Boolean): Option[String] = 
None
 
   override def toSQLValue(v: Any): String = s"$sqlTypeName '${format(v)}'"
@@ -96,6 +95,20 @@ abstract class TimestampNanosTypeApiOps extends TypeApiOps 
with DataTypeErrorsBa
 class TimestampNTZNanosTypeApiOps(val t: TimestampNTZNanosType) extends 
TimestampNanosTypeApiOps {
   override def dataType: TimestampNTZNanosType = t
   override protected def sqlTypeName: String = "TIMESTAMP_NTZ"
+  override protected def precision: Int = t.precision
+
+  // NTZ rendering is zone-independent: the value is the UTC-grid wall clock, 
so a UTC formatter
+  // produces the same string regardless of the session zone.
+  @transient private lazy val formatter = 
TimestampFormatter.getFractionFormatter(ZoneOffset.UTC)
+
+  override def format(v: Any): String =
+    formatter.formatWithoutTimeZoneNanos(v.asInstanceOf[TimestampNanosVal], 
precision)
+
+  // Row JSON holds the external java.time.LocalDateTime 
(LocalDateTimeNanosEncoder, SPARK-57033)
+  // already at the column precision; render it zone-independently through the 
same UTC formatter
+  // as the internal-value format above.
+  override def formatExternal(value: Any): Option[String] =
+    Some(formatter.format(value.asInstanceOf[LocalDateTime]))
 
   // Mirrors RowEncoder.encoderForDataTypeDefault for TimestampNTZNanosType 
(SPARK-57033):
   // maps to java.time.LocalDateTime with the column precision.
@@ -107,11 +120,31 @@ class TimestampNTZNanosTypeApiOps(val t: 
TimestampNTZNanosType) extends Timestam
  *
  * @param t
  *   The TimestampLTZNanosType with precision information
+ * @param zoneId
+ *   The time zone LTZ values are rendered in (LTZ is zone-aware). 
`TypeApiOps.apply` threads in
+ *   the session zone: the cast's resolved zone for CAST, or the session-local 
time zone config
+ *   for zone-less render callers (Row JSON via formatExternal).
  * @since 4.3.0
  */
-class TimestampLTZNanosTypeApiOps(val t: TimestampLTZNanosType) extends 
TimestampNanosTypeApiOps {
+class TimestampLTZNanosTypeApiOps(val t: TimestampLTZNanosType, zoneId: ZoneId)
+    extends TimestampNanosTypeApiOps {
   override def dataType: TimestampLTZNanosType = t
   override protected def sqlTypeName: String = "TIMESTAMP_LTZ"
+  override protected def precision: Int = t.precision
+
+  // LTZ rendering depends on the session time zone, carried by this ops 
instance (the cast's
+  // resolved zone, or the session-local time zone config for the zone-less 
framework lookup). The
+  // fraction formatter is built once here (per cast / per ops) rather than 
per row.
+  @transient private lazy val formatter = 
TimestampFormatter.getFractionFormatter(zoneId)
+
+  override def format(v: Any): String =
+    formatter.formatNanos(v.asInstanceOf[TimestampNanosVal], precision)
+
+  // Row JSON holds the external java.time.Instant (InstantNanosEncoder, 
SPARK-57033) already at the
+  // column precision; render it in the ops' session zone through the same 
formatter as the
+  // internal-value format above.
+  override def formatExternal(value: Any): Option[String] =
+    Some(formatter.format(value.asInstanceOf[Instant]))
 
   // Mirrors RowEncoder.encoderForDataTypeDefault for TimestampLTZNanosType 
(SPARK-57033):
   // maps to java.time.Instant with the column precision.
diff --git 
a/sql/api/src/main/scala/org/apache/spark/sql/types/ops/TypeApiOps.scala 
b/sql/api/src/main/scala/org/apache/spark/sql/types/ops/TypeApiOps.scala
index 40a158d70af3..d7575976e1f5 100644
--- a/sql/api/src/main/scala/org/apache/spark/sql/types/ops/TypeApiOps.scala
+++ b/sql/api/src/main/scala/org/apache/spark/sql/types/ops/TypeApiOps.scala
@@ -17,9 +17,12 @@
 
 package org.apache.spark.sql.types.ops
 
+import java.time.ZoneId
+
 import org.apache.arrow.vector.types.pojo.ArrowType
 
 import org.apache.spark.sql.catalyst.encoders.AgnosticEncoder
+import org.apache.spark.sql.catalyst.util.SparkDateTimeUtils
 import org.apache.spark.sql.internal.SqlApiConf
 import org.apache.spark.sql.types.{DataType, TimestampLTZNanosType, 
TimestampNTZNanosType, TimeType}
 import org.apache.spark.unsafe.types.UTF8String
@@ -127,11 +130,11 @@ trait TypeApiOps extends Serializable {
    * for TimeType).
    *
    * Consumer: Row JSON (Row.json / Row.prettyJson). Semantics:
-   *   - Some(s): s is used as the rendered JSON string.
+   *   - Some(s): s is used as the rendered JSON string (e.g. the nanosecond 
timestamp types
+   *     render the external Instant/LocalDateTime at the column precision).
    *   - None: Row JSON falls back to its legacy toJsonDefault rendering.
    *   - throw: an implementation may raise instead, to signal that rendering 
this type on this
-   *     zone-less path is unsupported (e.g. the nanosecond timestamp types 
raise
-   *     UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_TO_STRING).
+   *     zone-less path is unsupported.
    *
    * Returning None silently routes the type into the legacy path, so a type 
that must be rendered
    * by the framework should override this (every currently registered type 
does).
@@ -144,8 +147,8 @@ trait TypeApiOps extends Serializable {
    * Semantics mirror the single-arg overload: Some(s) is used directly, None 
falls back to
    * HiveResult's zone-aware legacy rendering. The default delegates to the 
single-arg overload;
    * override it separately when the two consumers need different behavior 
(e.g. the nanosecond
-   * timestamp types throw on the zone-less Row JSON path but return None here 
so the zone-aware
-   * Hive path renders them).
+   * timestamp types render the external value on the zone-less Row JSON path 
but return None here
+   * so the zone-aware Hive path renders them through its own formatter).
    */
   def formatExternal(value: Any, nested: Boolean): Option[String] = 
formatExternal(value)
 
@@ -172,15 +175,24 @@ object TypeApiOps {
    *
    * @param dt
    *   the DataType to get operations for
+   * @param zoneId
+   *   the session time zone for zone-aware rendering (TIMESTAMP_LTZ nanos). 
CAST passes the
+   *   cast's resolved zone; zone-less callers (Row JSON via formatExternal) 
accept the default,
+   *   the session-local time zone config. By-name so it is forced only when 
LTZ is constructed:
+   *   zone-independent and unsupported types never evaluate it, which matters 
because a CAST's
+   *   zone is unresolved (`None.get`) until the time zone is assigned.
    * @return
    *   Some(TypeApiOps) if supported, None otherwise
    */
-  def apply(dt: DataType): Option[TypeApiOps] = {
+  def apply(
+      dt: DataType,
+      zoneId: => ZoneId = 
SparkDateTimeUtils.getZoneId(SqlApiConf.get.sessionLocalTimeZone))
+      : Option[TypeApiOps] = {
     if (!SqlApiConf.get.typesFrameworkEnabled) return None
     dt match {
       case tt: TimeType => Some(new TimeTypeApiOps(tt))
       case t: TimestampNTZNanosType => Some(new TimestampNTZNanosTypeApiOps(t))
-      case t: TimestampLTZNanosType => Some(new TimestampLTZNanosTypeApiOps(t))
+      case t: TimestampLTZNanosType => Some(new TimestampLTZNanosTypeApiOps(t, 
zoneId))
       // Add new types here - single registration point
       case _ => None
     }
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/ToStringBase.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/ToStringBase.scala
index 561fd33b0a18..3a6ae2c2045f 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/ToStringBase.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/ToStringBase.scala
@@ -29,7 +29,7 @@ import org.apache.spark.sql.internal.SQLConf.BinaryOutputStyle
 import org.apache.spark.sql.types._
 import org.apache.spark.sql.types.ops.TypeApiOps
 import org.apache.spark.unsafe.UTF8StringBuilder
-import org.apache.spark.unsafe.types.{CalendarInterval, TimestampNanosVal, 
UTF8String}
+import org.apache.spark.unsafe.types.{CalendarInterval, UTF8String}
 import org.apache.spark.util.ArrayImplicits._
 import org.apache.spark.util.SparkStringUtils
 
@@ -66,16 +66,14 @@ trait ToStringBase { self: UnaryExpression with 
TimeZoneAwareExpression =>
       case NoConstraint => castToString(from)
     }
 
-  private def castToString(from: DataType): Any => UTF8String = from match {
-    // Nanosecond timestamp string formatting is zone-aware (LTZ renders in 
the session time zone),
-    // so it lives in castToStringDefault alongside the microsecond timestamp 
types rather than the
-    // zone-less Types Framework formatter (SPARK-57256).
-    case _: TimestampNTZNanosType | _: TimestampLTZNanosType => 
castToStringDefault(from)
-    case _ =>
-      TypeApiOps(from)
-        .map(ops => acceptAny[Any](v => ops.formatUTF8(v)))
-        .getOrElse(castToStringDefault(from))
-  }
+  // The Types Framework is the single integration point for framework types' 
cast-to-string, via
+  // the zone-less formatUTF8. The cast's session zone is threaded into the 
lookup so TIMESTAMP_LTZ
+  // nanos renders in it; zone-independent types (TimeType, TIMESTAMP_NTZ 
nanos) ignore it
+  // (SPARK-57285).
+  private def castToString(from: DataType): Any => UTF8String =
+    TypeApiOps(from, zoneId)
+      .map(ops => acceptAny[Any](ops.formatUTF8))
+      .getOrElse(castToStringDefault(from))
 
   private def castToStringDefault(from: DataType): Any => UTF8String = from 
match {
     case CalendarIntervalType =>
@@ -87,12 +85,6 @@ trait ToStringBase { self: UnaryExpression with 
TimeZoneAwareExpression =>
       acceptAny[Long](t => UTF8String.fromString(timestampFormatter.format(t)))
     case TimestampNTZType =>
       acceptAny[Long](t => 
UTF8String.fromString(timestampNTZFormatter.format(t)))
-    case t: TimestampLTZNanosType =>
-      acceptAny[TimestampNanosVal](v =>
-        UTF8String.fromString(timestampFormatter.formatNanos(v, t.precision)))
-    case t: TimestampNTZNanosType =>
-      acceptAny[TimestampNanosVal](v =>
-        
UTF8String.fromString(timestampNTZFormatter.formatWithoutTimeZoneNanos(v, 
t.precision)))
     case _: TimeType =>
       acceptAny[Long](t => UTF8String.fromString(timeFormatter.format(t)))
     case ArrayType(et, _) =>
@@ -246,18 +238,17 @@ trait ToStringBase { self: UnaryExpression with 
TimeZoneAwareExpression =>
           ctx.addReferenceObj("timestampNTZFormatter", timestampNTZFormatter),
           timestampNTZFormatter.getClass)
         (c, evPrim) => code"$evPrim = UTF8String.fromString($tf.format($c));"
-      case t: TimestampLTZNanosType =>
-        val tf = JavaCode.global(
-          ctx.addReferenceObj("timestampFormatter", timestampFormatter),
-          timestampFormatter.getClass)
-        (c, evPrim) =>
-          code"$evPrim = UTF8String.fromString($tf.formatNanos($c, 
${t.precision}));"
-      case t: TimestampNTZNanosType =>
-        val tf = JavaCode.global(
-          ctx.addReferenceObj("timestampNTZFormatter", timestampNTZFormatter),
-          timestampNTZFormatter.getClass)
-        (c, evPrim) =>
-          code"$evPrim = 
UTF8String.fromString($tf.formatWithoutTimeZoneNanos($c, ${t.precision}));"
+      case _: TimestampNTZNanosType | _: TimestampLTZNanosType =>
+        // Route nanosecond timestamp cast-to-string through the Types 
Framework: emit a runtime
+        // call into the ops reference object. The cast's session zone is 
threaded into the lookup
+        // so LTZ carries it; NTZ is zone-independent (SPARK-57285).
+        val ops = TypeApiOps(from, zoneId).get
+        // Pin the reference-object cast type to the public TypeApiOps class; 
the runtime ops class
+        // lives in sql/api, so the inferred concrete-class cast would be 
unnecessarily specific.
+        val opsRef = JavaCode.global(
+          ctx.addReferenceObj("typeApiOps", ops, classOf[TypeApiOps].getName),
+          classOf[TypeApiOps])
+        (c, evPrim) => code"$evPrim = $opsRef.formatUTF8($c);"
       case _: TimeType =>
         val tf = JavaCode.global(
           ctx.addReferenceObj("timeFormatter", timeFormatter),
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeOps.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeOps.scala
index 9957367ed039..96fe32999b93 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeOps.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeOps.scala
@@ -17,7 +17,7 @@
 
 package org.apache.spark.sql.catalyst.types.ops
 
-import java.time.{Instant, LocalDateTime}
+import java.time.{Instant, LocalDateTime, ZoneOffset}
 
 import org.apache.spark.SparkIllegalArgumentException
 import org.apache.spark.sql.catalyst.InternalRow
@@ -130,8 +130,11 @@ case class TimestampNTZNanosTypeOps(override val t: 
TimestampNTZNanosType)
  *   The TimestampLTZNanosType with precision information
  * @since 4.3.0
  */
+// Server-side TypeOps is used only for physical type, literals, row 
accessors, and serde - never
+// for rendering (cast-to-string flows through TypeApiOps.apply). So the 
rendering zone is unused
+// here; pass UTC rather than reading the session config on every construction.
 case class TimestampLTZNanosTypeOps(override val t: TimestampLTZNanosType)
-  extends TimestampLTZNanosTypeApiOps(t) with TimestampNanosTypeOps {
+  extends TimestampLTZNanosTypeApiOps(t, ZoneOffset.UTC) with 
TimestampNanosTypeOps {
 
   override def getPhysicalType: PhysicalDataType = 
PhysicalTimestampLTZNanosType
 
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/RowJsonSuite.scala 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/RowJsonSuite.scala
index edc7b9512d38..183eefaa8d75 100644
--- a/sql/catalyst/src/test/scala/org/apache/spark/sql/RowJsonSuite.scala
+++ b/sql/catalyst/src/test/scala/org/apache/spark/sql/RowJsonSuite.scala
@@ -21,7 +21,7 @@ import java.time.{LocalDate, LocalDateTime, LocalTime, 
ZoneOffset}
 
 import org.json4s.JsonAST.{JArray, JBool, JDecimal, JDouble, JLong, JNull, 
JObject, JString, JValue}
 
-import org.apache.spark.{SparkFunSuite, SparkIllegalArgumentException, 
SparkUnsupportedOperationException}
+import org.apache.spark.{SparkFunSuite, SparkIllegalArgumentException}
 import org.apache.spark.sql.catalyst.encoders.{ExamplePoint, ExamplePointUDT}
 import org.apache.spark.sql.catalyst.expressions.GenericRowWithSchema
 import org.apache.spark.sql.catalyst.plans.SQLHelper
@@ -174,24 +174,23 @@ class RowJsonSuite extends SparkFunSuite with SQLHelper {
     }
   }
 
-  // Routing the external value through formatExternal must not silently 
change the nanosecond
-  // timestamp behavior: those ops raise the clean unsupported-rendering error 
directly from
-  // formatExternal instead of mis-rendering the external value as a 
microsecond timestamp.
-  test("SPARK-57338: nanosecond timestamp column raises the 
unsupported-rendering error in JSON") {
+  // With nanosecond cast-to-string now supported through the framework 
(SPARK-57285), Row JSON
+  // routes the external Row value (LocalDateTime for NTZ, Instant for LTZ) 
through formatExternal
+  // and renders it at the column precision, rather than raising an 
unsupported-rendering error.
+  test("SPARK-57338: nanosecond timestamp column renders the external value in 
JSON") {
+    def nanosRowJson(value: Any, dt: DataType): JValue =
+      new GenericRowWithSchema(Array(value), new StructType().add("a", 
dt)).jsonValue
     withSQLConf(
         SQLConf.TYPES_FRAMEWORK_ENABLED.key -> "true",
-        SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
+        SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
       val ldt = LocalDateTime.of(2020, 1, 1, 12, 0, 0, 123456789)
-      Seq[(Any, DataType)](
-        ldt -> TimestampNTZNanosType(9),
-        ldt.toInstant(ZoneOffset.UTC) -> TimestampLTZNanosType(9)).foreach { 
case (value, dt) =>
-        checkError(
-          exception = intercept[SparkUnsupportedOperationException] {
-            new GenericRowWithSchema(Array(value), new StructType().add("a", 
dt)).jsonValue
-          },
-          condition = "UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_TO_STRING",
-          parameters = Map("dataType" -> toSQLType(dt)))
-      }
+      // NTZ renders zone-independently (the value is the UTC-grid wall clock).
+      assert(nanosRowJson(ldt, TimestampNTZNanosType(9)) ===
+        JObject("a" -> JString("2020-01-01 12:00:00.123456789")))
+      // LTZ renders in the session zone; with UTC it matches the NTZ wall 
clock.
+      assert(nanosRowJson(ldt.toInstant(ZoneOffset.UTC), 
TimestampLTZNanosType(9)) ===
+        JObject("a" -> JString("2020-01-01 12:00:00.123456789")))
     }
   }
 
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeOpsSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeOpsSuite.scala
index 2d69729540cd..b31fe310755f 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeOpsSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/types/ops/TimestampNanosTypeOpsSuite.scala
@@ -17,9 +17,9 @@
 
 package org.apache.spark.sql.catalyst.types.ops
 
-import java.time.{Instant, LocalDateTime}
+import java.time.{Instant, LocalDateTime, ZoneOffset}
 
-import org.apache.spark.{SparkFunSuite, SparkUnsupportedOperationException}
+import org.apache.spark.SparkFunSuite
 import org.apache.spark.sql.catalyst.{CatalystTypeConverters, InternalRow}
 import org.apache.spark.sql.catalyst.encoders.{AgnosticEncoder, 
ExpressionEncoder}
 import 
org.apache.spark.sql.catalyst.encoders.AgnosticEncoders.{InstantNanosEncoder, 
LocalDateTimeNanosEncoder, OptionEncoder}
@@ -30,8 +30,8 @@ import org.apache.spark.sql.catalyst.types.{PhysicalDataType, 
PhysicalTimestampL
 import org.apache.spark.sql.catalyst.util.DateTimeUtils
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types.{DataType, TimestampLTZNanosType, 
TimestampNTZNanosType}
-import org.apache.spark.sql.types.ops.TypeApiOps
-import org.apache.spark.unsafe.types.TimestampNanosVal
+import org.apache.spark.sql.types.ops.{TimestampLTZNanosTypeApiOps, TypeApiOps}
+import org.apache.spark.unsafe.types.{TimestampNanosVal, UTF8String}
 
 /**
  * Tests for the Types Framework wiring of the nanosecond timestamp types 
(SPARK-57207).
@@ -162,18 +162,90 @@ class TimestampNanosTypeOpsSuite extends SparkFunSuite 
with SQLHelper {
     }
   }
 
-  test("format and toSQLValue raise 
UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_TO_STRING") {
-    allCases.foreach { case (dt, _, value) =>
-      val ops = TypeApiOps(dt).get
-      Seq[() => Any](() => ops.format(value), () => 
ops.toSQLValue(value)).foreach { call =>
-        checkError(
-          exception = intercept[SparkUnsupportedOperationException](call()),
-          condition = "UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_TO_STRING",
-          parameters = Map("dataType" -> ("\"" + dt.sql + "\"")))
+  // SPARK-57285: a value with sub-precision (sub-nano) digits so flooring is 
exercised; built from
+  // a UTC wall clock so the rendered NTZ/LTZ strings are predictable.
+  private val nanosLdt = LocalDateTime.of(2020, 1, 1, 0, 0, 0, 123456789)
+
+  // Expected fractional second after flooring to the given nanos precision 
(trailing zeros are
+  // trimmed by the formatter, but 123456789 has none to trim at p in {7,8,9}).
+  private def expectedFraction(precision: Int): String = precision match {
+    case 7 => "1234567"
+    case 8 => "12345678"
+    case 9 => "123456789"
+  }
+
+  test("SPARK-57285: NTZ format renders zone-independently (no session zone 
needed)") {
+    precisions.foreach { p =>
+      val ops = TypeApiOps(TimestampNTZNanosType(p)).get
+      val v = DateTimeUtils.localDateTimeToTimestampNanos(nanosLdt, p)
+      val expected = s"2020-01-01 00:00:00.${expectedFraction(p)}"
+      // NTZ is zone-independent: it renders the same regardless of the 
session zone.
+      assert(ops.format(v) === expected, s"NTZ format for precision $p")
+      assert(ops.formatUTF8(v) === UTF8String.fromString(expected))
+      assert(ops.toSQLValue(v) === s"TIMESTAMP_NTZ '$expected'")
+    }
+  }
+
+  test("SPARK-57285: LTZ format renders in the zone carried by the ops 
instance") {
+    precisions.foreach { p =>
+      val t = TimestampLTZNanosType(p)
+      // Physical value is the epoch instant of the UTC wall clock above.
+      val v = 
DateTimeUtils.instantToTimestampNanos(nanosLdt.toInstant(ZoneOffset.UTC), p)
+      val frac = expectedFraction(p)
+      // UTC zone: the rendered wall clock matches the UTC instant.
+      val utcOps = new TimestampLTZNanosTypeApiOps(t, ZoneOffset.UTC)
+      assert(utcOps.format(v) === s"2020-01-01 00:00:00.$frac")
+      assert(utcOps.formatUTF8(v) === UTF8String.fromString(s"2020-01-01 
00:00:00.$frac"))
+      assert(utcOps.toSQLValue(v) === s"TIMESTAMP_LTZ '2020-01-01 
00:00:00.$frac'")
+      // A +05:00 zone shifts the rendered wall clock; the fraction is 
unaffected.
+      val offsetOps = new TimestampLTZNanosTypeApiOps(t, ZoneOffset.ofHours(5))
+      assert(offsetOps.format(v) === s"2020-01-01 05:00:00.$frac")
+    }
+  }
+
+  test("SPARK-57285: LTZ zone-less lookup renders in the session-local time 
zone config") {
+    withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+      precisions.foreach { p =>
+        val dt = TimestampLTZNanosType(p)
+        // TypeApiOps.apply has no cast zone, so it defaults to the 
session-local time zone config.
+        val ops = TypeApiOps(dt).get
+        val v = 
DateTimeUtils.instantToTimestampNanos(nanosLdt.toInstant(ZoneOffset.UTC), p)
+        val frac = expectedFraction(p)
+        assert(ops.format(v) === s"2020-01-01 00:00:00.$frac")
+        assert(ops.toSQLValue(v) === s"TIMESTAMP_LTZ '2020-01-01 
00:00:00.$frac'")
       }
     }
   }
 
+  test("SPARK-57285: NTZ formatExternal renders LocalDateTime at the column 
precision") {
+    precisions.foreach { p =>
+      val ops = TypeApiOps(TimestampNTZNanosType(p)).get
+      // The encoder floors to column precision before handing the value to 
Row JSON;
+      // round-trip through TimestampNanosVal to reproduce that flooring, then 
format.
+      val v = DateTimeUtils.localDateTimeToTimestampNanos(nanosLdt, p)
+      val ldt = DateTimeUtils.timestampNanosToLocalDateTime(v)
+      val expected = s"2020-01-01 00:00:00.${expectedFraction(p)}"
+      assert(ops.formatExternal(ldt) === Some(expected), s"NTZ formatExternal 
for precision $p")
+    }
+  }
+
+  test("SPARK-57285: LTZ formatExternal renders Instant at the column 
precision in the ops zone") {
+    precisions.foreach { p =>
+      val t = TimestampLTZNanosType(p)
+      val v = 
DateTimeUtils.instantToTimestampNanos(nanosLdt.toInstant(ZoneOffset.UTC), p)
+      val instant = DateTimeUtils.timestampNanosToInstant(v)
+      val frac = expectedFraction(p)
+      // UTC zone: rendered wall clock matches the UTC instant.
+      val utcOps = new TimestampLTZNanosTypeApiOps(t, ZoneOffset.UTC)
+      assert(utcOps.formatExternal(instant) === Some(s"2020-01-01 
00:00:00.$frac"),
+        s"LTZ formatExternal for precision $p")
+      // A +05:00 zone shifts the rendered wall clock; the fraction is 
unaffected.
+      val offsetOps = new TimestampLTZNanosTypeApiOps(t, ZoneOffset.ofHours(5))
+      assert(offsetOps.formatExternal(instant) === Some(s"2020-01-01 
05:00:00.$frac"),
+        s"LTZ formatExternal for precision $p with +05:00")
+    }
+  }
+
   private def checkOptionRoundtrip[T](
       enc: AgnosticEncoder[Option[T]],
       values: Seq[Option[T]]): Unit = {


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


Reply via email to