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 7d0a167933ac [MINOR][SQL] Make TypeOps extend TypeApiOps in the Types
Framework
7d0a167933ac is described below
commit 7d0a167933ac57207797af4517d7fd475cb97c77
Author: David Milicevic <[email protected]>
AuthorDate: Wed Jun 17 17:26:00 2026 +0200
[MINOR][SQL] Make TypeOps extend TypeApiOps in the Types Framework
### What changes were proposed in this pull request?
A small type-system refactor of the Types Framework, plus the zone-handling
fix it surfaces.
**Trait lift.** The server-side `TypeOps` trait now extends the api-side
`TypeApiOps` trait instead of `Serializable`:
```scala
-trait TypeOps extends Serializable {
- /** The DataType this Ops instance handles. */
- def dataType: DataType
+trait TypeOps extends TypeApiOps {
```
Concrete `*TypeOps` classes already extend their matching `*TypeApiOps`
(e.g. `TimeTypeOps extends TimeTypeApiOps(t) with TypeOps`), so this change
has no effect on any
existing implementation -- it only lifts that relationship to the trait
level. As a result the
duplicate `def dataType: DataType` declaration on `TypeOps` is removed (it
is inherited from
`TypeApiOps`), and the class doc / implementation guidance are updated to
match.
**Follow-on: TIMESTAMP_LTZ nanos render zone.** Advertising the client-side
operations (`format`,
`toSQLValue`, ...) as directly callable on a `TypeOps` value means the
server-side
`TimestampLTZNanosTypeOps` must carry a real rendering zone. It previously
hard-coded
`ZoneOffset.UTC`, which was sound only under the older "server-side ops are
never used for
rendering" invariant. The follow-up commit changes it to default to the
session-local time zone,
passed by-name so the parent builds its formatter lazily -- the config is
read only if a value is
actually rendered through the server-side ops, never on the physical-type /
literal / row-accessor
/ serde paths. This touches three files beyond the trait:
`TimestampNanosTypeApiOps` (by-name
`zoneId`), `TimestampNanosTypeOps` (session-zone default), and
`ToStringBase` (codegen
serialization hygiene for the threaded zone).
### Why are the changes needed?
The framework factory `TypeOps.apply(dt)` returns `Option[TypeOps]`, so
call sites manipulate
values whose static type is `TypeOps`. The client-side operations
(`format`, `toSQLValue`,
`getEncoder`, ...) are declared on `TypeApiOps`. Before this change,
invoking a client-side
operation on a value known only as `TypeOps` required a runtime
`isInstanceOf[TypeApiOps]`
narrowing, which is sound only by the convention that every concrete
`*TypeOps` happens to extend a
`*TypeApiOps` -- nothing in the type system enforces it.
Making `TypeOps` extend `TypeApiOps`:
- lets callers holding an `Option[TypeOps]` invoke client-side methods
directly via `.map`, without
an unsafe downcast;
- turns "every server-side `TypeOps` is also a client-side `TypeApiOps`"
into a compile-time
guarantee rather than a runtime invariant -- a new `*TypeOps` that does
not provide the
client-side operations will not compile;
- removes the now-redundant `dataType` declaration that both traits
previously carried.
This is a forward-looking modeling change: as more server-side integration
points need client-side
operations on a `TypeOps` value, they can call them directly and safely.
### Does this PR introduce _any_ user-facing change?
No observable change. The trait lift is compile-time-only. The follow-on
commit corrects the zone
the server-side `TypeOps` would use to render TIMESTAMP_LTZ nanos (UTC ->
session-local), but no
production path renders LTZ nanos through the server-side ops today, so
there is no behavioral
difference in practice. The correction makes the newly-documented "invoke
client-side methods on a
`TypeOps` value" contract accurate for the point at which such a caller is
added. Both
CAST-to-string paths (interpreted and codegen) already thread the cast's
own resolved zone, so cast
output is unchanged.
### How was this patch tested?
The trait lift is covered by compilation and the existing Types Framework
suites. A new test in
`TimestampNanosTypeOpsSuite` pins the zone correction: under a non-UTC
session zone
(`America/Los_Angeles`) it asserts the server-side
`TimestampLTZNanosTypeOps` renders TIMESTAMP_LTZ
in the session-local zone, so a regression back to a fixed UTC zone would
fail it.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code
This pull request and its description were written by Isaac.
Closes #56449 from davidm-db/typeops_extends_typeapiops.
Authored-by: David Milicevic <[email protected]>
Signed-off-by: Max Gekk <[email protected]>
(cherry picked from commit 64ceedcd43df02462fdc43f7809ad063f3b8b5dc)
Signed-off-by: Max Gekk <[email protected]>
---
.../spark/sql/types/ops/TimestampNanosTypeApiOps.scala | 10 ++++++----
.../spark/sql/catalyst/expressions/ToStringBase.scala | 5 ++++-
.../sql/catalyst/types/ops/TimestampNanosTypeOps.scala | 14 +++++++++-----
.../apache/spark/sql/catalyst/types/ops/TypeOps.scala | 15 ++++++++++-----
.../catalyst/types/ops/TimestampNanosTypeOpsSuite.scala | 16 ++++++++++++++++
5 files changed, 45 insertions(+), 15 deletions(-)
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 4e050b69471a..011e5504e96e 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
@@ -124,12 +124,14 @@ 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).
+ * The time zone LTZ values are rendered in (LTZ is zone-aware). Passed
by-name and forced only
+ * when the formatter is first built, so non-rendering callers never
evaluate it. The factories
+ * thread it in: `TypeApiOps.apply` passes the cast's resolved zone (CAST)
or the session-local
+ * time zone (zone-less callers such as Row JSON); the catalyst
`TimestampLTZNanosTypeOps`
+ * passes the session-local time zone for the server-side `TypeOps` path.
* @since 4.3.0
*/
-class TimestampLTZNanosTypeApiOps(val t: TimestampLTZNanosType, zoneId: ZoneId)
+class TimestampLTZNanosTypeApiOps(val t: TimestampLTZNanosType, zoneId: =>
ZoneId)
extends TimestampNanosTypeApiOps {
override def dataType: TimestampLTZNanosType = t
override protected def sqlTypeName: String = "TIMESTAMP_LTZ"
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 65e0b125c225..56e494ced8fe 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
@@ -242,7 +242,10 @@ trait ToStringBase { self: UnaryExpression with
TimeZoneAwareExpression =>
// 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
+ // Resolve the zone here so the reference object holds a ZoneId, not a
closure capturing
+ // this Cast; the held value is the cast's resolved zone, not a
session-config read.
+ val z = zoneId
+ val ops = TypeApiOps(from, z).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(
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 96fe32999b93..ca3976528b25 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, ZoneOffset}
+import java.time.{Instant, LocalDateTime}
import org.apache.spark.SparkIllegalArgumentException
import org.apache.spark.sql.catalyst.InternalRow
@@ -25,6 +25,7 @@ import org.apache.spark.sql.catalyst.expressions.{Expression,
Literal, MutableTi
import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke
import org.apache.spark.sql.catalyst.types.{PhysicalDataType,
PhysicalTimestampLTZNanosType, PhysicalTimestampNTZNanosType}
import org.apache.spark.sql.catalyst.util.DateTimeUtils
+import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types.{ObjectType, TimestampLTZNanosType,
TimestampNTZNanosType}
import org.apache.spark.sql.types.ops.{TimestampLTZNanosTypeApiOps,
TimestampNTZNanosTypeApiOps}
import org.apache.spark.unsafe.types.TimestampNanosVal
@@ -130,11 +131,14 @@ 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.
+// LTZ is zone-aware, so the inherited renderer needs a zone. The server-side
TypeOps has none of
+// its own (rendering normally flows through TypeApiOps.apply, which threads
the cast's resolved
+// zone), so default to the session-local time zone. It is passed by-name and
the parent builds
+// its formatter lazily, so the config is read only if a value is actually
rendered through this
+// server-side ops - never on the physical-type / literal / row-accessor /
serde paths.
case class TimestampLTZNanosTypeOps(override val t: TimestampLTZNanosType)
- extends TimestampLTZNanosTypeApiOps(t, ZoneOffset.UTC) with
TimestampNanosTypeOps {
+ extends TimestampLTZNanosTypeApiOps(t,
DateTimeUtils.getZoneId(SQLConf.get.sessionLocalTimeZone))
+ with TimestampNanosTypeOps {
override def getPhysicalType: PhysicalDataType =
PhysicalTimestampLTZNanosType
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TypeOps.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TypeOps.scala
index f11a3bb07156..b09feb0fb1cf 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TypeOps.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/types/ops/TypeOps.scala
@@ -27,6 +27,7 @@ import org.apache.spark.sql.catalyst.expressions.{Expression,
Literal, MutableVa
import org.apache.spark.sql.catalyst.types.PhysicalDataType
import org.apache.spark.sql.execution.arrow.ArrowFieldWriter
import org.apache.spark.sql.types.{DataType, TimestampLTZNanosType,
TimestampNTZNanosType, TimeType}
+import org.apache.spark.sql.types.ops.TypeApiOps
/**
* Server-side (catalyst) type operations for the Types Framework.
@@ -36,6 +37,12 @@ import org.apache.spark.sql.types.{DataType,
TimestampLTZNanosType, TimestampNTZ
* implemented by every type. Optional methods (serialization, Arrow writer)
return Option and
* default to None - types implement them as they expand their integration
coverage.
*
+ * Extends [[TypeApiOps]] (the api-side trait) so that callers holding an
`Option[TypeOps]` can
+ * invoke client-side methods (`format`, `toSQLValue`, `getEncoder`, ...)
directly via `.map`,
+ * instead of an `isInstanceOf[TypeApiOps]` runtime narrowing. Concrete
`*TypeOps` classes typically
+ * extend `*TypeApiOps` to inherit api-side method implementations -- this
trait makes that
+ * relationship part of the type system rather than a runtime invariant.
+ *
* USAGE - integration points use TypeOps(dt) which returns Option[TypeOps]:
* {{{
* def getPhysicalType(dt: DataType): PhysicalDataType =
@@ -48,7 +55,8 @@ import org.apache.spark.sql.types.{DataType,
TimestampLTZNanosType, TimestampNTZ
* }}}
*
* IMPLEMENTATION - to add a new type to the framework:
- * 1. Create a case class extending TypeOps (and optionally TypeApiOps for
client-side ops)
+ * 1. Create a case class extending the matching `*TypeApiOps` and mixing in
`TypeOps`
+ * (e.g., `case class FooTypeOps(...) extends FooTypeApiOps(...) with
TypeOps`)
* 2. Register it in TypeOps.apply() below - single registration point
* 3. No other file modifications needed - all integration points
automatically work
*
@@ -56,10 +64,7 @@ import org.apache.spark.sql.types.{DataType,
TimestampLTZNanosType, TimestampNTZ
* TimeTypeOps for a reference implementation
* @since 4.2.0
*/
-trait TypeOps extends Serializable {
-
- /** The DataType this Ops instance handles. */
- def dataType: DataType
+trait TypeOps extends TypeApiOps {
// ==================== Physical Type Representation ====================
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 6633416a0645..2cff609c870e 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
@@ -216,6 +216,22 @@ class TimestampNanosTypeOpsSuite extends SparkFunSuite
with SQLHelper {
}
}
+ test("SPARK-57285: catalyst LTZ ops renders in the session-local time zone
(not a fixed UTC)") {
+ // The server-side TimestampLTZNanosTypeOps defaults its render zone to
the session-local time
+ // zone (passed by-name, forced lazily at first render). Use a non-UTC
zone so a regression back
+ // to a fixed UTC zone would change the rendered wall clock and fail here.
+ withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> "America/Los_Angeles") {
+ precisions.foreach { p =>
+ val ops = TimestampLTZNanosTypeOps(TimestampLTZNanosType(p))
+ val v =
DateTimeUtils.instantToTimestampNanos(nanosLdt.toInstant(ZoneOffset.UTC), p)
+ val frac = expectedFraction(p)
+ // 2020-01-01 00:00 UTC is 2019-12-31 16:00 in America/Los_Angeles
(UTC-8 in January).
+ assert(ops.format(v) === s"2019-12-31 16:00:00.$frac", s"LTZ
session-zone format for p=$p")
+ assert(ops.toSQLValue(v) === s"TIMESTAMP_LTZ '2019-12-31
16:00:00.$frac'")
+ }
+ }
+ }
+
test("SPARK-57285: NTZ formatExternal renders LocalDateTime at the column
precision") {
precisions.foreach { p =>
val ops = TypeApiOps(TimestampNTZNanosType(p)).get
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]