From 3233b6745c0cc7216fe672424c75a09b5db96c9f Mon Sep 17 00:00:00 2001 From: Stevo Mitric Date: Wed, 16 Sep 2026 10:22:24 +0000 Subject: [PATCH 1/5] [SPARK-57833][SQL] Support nanosecond-precision timestamps in timestampadd ### What changes were proposed in this pull request? Extend `timestampadd(unit, quantity, timestamp)` to accept nanosecond-precision timestamp endpoints (`TIMESTAMP_NTZ(p)` / `TIMESTAMP_LTZ(p)`, p in [7, 9]). - Widen `TimestampAdd.inputTypes` to `TypeCollection(AnyTimestampType, AnyTimestampNanoType)` so a nanosecond argument is no longer implicitly cast to microseconds (which silently dropped the sub-microsecond fraction). The result keeps the input's nanosecond type. - For units of MICROSECOND or coarser, the addition runs on the microsecond grid (reusing the existing calendar/DST/overflow handling) and the sub-microsecond fraction is carried through unchanged. - Add a `NANOSECOND` unit (the token already exists for interval literals; it is added to the shared `datetimeUnit` grammar rule): the fraction absorbs the quantity and whole microseconds carry into `epochMicros`. Overflow surfaces as `DATETIME_OVERFLOW`. `NANOSECOND` is meaningful only for a nanosecond-precision timestamp; on a microsecond timestamp it is rejected as an invalid unit. The new logic lives in `TimestampAdd` and a new `DateTimeUtils.timestampAddNanos` helper. `timestampdiff` is out of scope here: because `datetimeUnit` is shared, `timestampdiff(NANOSECOND, ...)` now parses but returns a clean `INVALID_PARAMETER_VALUE.DATETIME_UNIT` error at runtime (previously an `UNRESOLVED_ROUTINE`); nanosecond support for `timestampdiff` is left to the rest of SPARK-57833. ### Why are the changes needed? Part of the nanosecond-precision timestamp umbrella (SPARK-56822). Without this, `timestampadd` rejected nanosecond timestamp arguments by casting them to microseconds, silently losing the sub-microsecond digits. ### Does this PR introduce any user-facing change? Yes. `timestampadd` now accepts nanosecond-precision timestamps and preserves their sub-microsecond fraction, and supports a new `NANOSECOND` unit for nanosecond-precision inputs. ### How was this patch tested? New unit test in `DateExpressionsSuite` (NTZ/LTZ, precisions 7/8/9, fraction preservation for units >= MICROSECOND, the NANOSECOND unit with positive/negative and multi-microsecond carry, case-insensitivity, null propagation, the invalid-unit rejection on a microsecond timestamp, and carry overflow; interpreted and codegen via checkEvaluation). Added `timestampadd` cases to the `timestamp-ntz-nanos.sql` / `timestamp-ltz-nanos.sql` golden files and regenerated the analyzer and result goldens. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Isaac Co-authored-by: Isaac --- .../sql/catalyst/parser/SqlBaseParser.g4 | 2 +- .../expressions/datetimeExpressions.scala | 31 ++++++++-- .../sql/catalyst/util/DateTimeUtils.scala | 39 +++++++++++++ .../expressions/DateExpressionsSuite.scala | 50 ++++++++++++++++ .../timestamp-ltz-nanos.sql.out | 28 +++++++++ .../timestamp-ntz-nanos.sql.out | 42 ++++++++++++++ .../sql-tests/inputs/timestamp-ltz-nanos.sql | 8 +++ .../sql-tests/inputs/timestamp-ntz-nanos.sql | 11 ++++ .../results/timestamp-ltz-nanos.sql.out | 32 +++++++++++ .../results/timestamp-ntz-nanos.sql.out | 57 +++++++++++++++++++ 10 files changed, 293 insertions(+), 7 deletions(-) diff --git a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4 b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4 index 7865f08423254..45fb97828178d 100644 --- a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4 +++ b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4 @@ -1445,7 +1445,7 @@ shiftOperator datetimeUnit : YEAR | QUARTER | MONTH | WEEK | DAY | DAYOFYEAR - | HOUR | MINUTE | SECOND | MILLISECOND | MICROSECOND + | HOUR | MINUTE | SECOND | MILLISECOND | MICROSECOND | NANOSECOND ; primaryExpression diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala index fea6a813c2923..6e3a337087c2c 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala @@ -4981,8 +4981,12 @@ case class ConvertTimezone( - "SECOND" - "MILLISECOND" - "MICROSECOND" + - "NANOSECOND" - only for nanosecond-precision timestamp inputs (TIMESTAMP_NTZ(p) / + TIMESTAMP_LTZ(p), p in [7, 9]) * quantity - this is the number of units of time that you want to add. - * timestamp - this is a timestamp (w/ or w/o timezone) to which you want to add. + * timestamp - this is a timestamp (w/ or w/o timezone) to which you want to add. A + nanosecond-precision timestamp keeps its sub-microsecond fraction; units of MICROSECOND or + coarser leave the fraction unchanged. """, examples = """ Examples: @@ -5020,23 +5024,38 @@ case class TimestampAdd( override def left: Expression = quantity override def right: Expression = timestamp - override def inputTypes: Seq[AbstractDataType] = Seq(LongType, AnyTimestampType) + override def inputTypes: Seq[AbstractDataType] = + Seq(LongType, TypeCollection(AnyTimestampType, AnyTimestampNanoType)) override def dataType: DataType = timestamp.dataType + // A nanosecond-precision timestamp is carried as a TimestampNanosVal object rather than a + // primitive microsecond Long, so the nanos-aware add path preserves the sub-microsecond fraction. + private def isNanos: Boolean = timestamp.dataType.isInstanceOf[AnyTimestampNanoType] + override def withTimeZone(timeZoneId: String): TimeZoneAwareExpression = copy(timeZoneId = Option(timeZoneId)) @transient private lazy val zoneIdInEval: ZoneId = zoneIdForType(timestamp.dataType) - override def nullSafeEval(q: Any, micros: Any): Any = { - DateTimeUtils.timestampAdd(unit, q.asInstanceOf[Long], micros.asInstanceOf[Long], zoneIdInEval) + override def nullSafeEval(q: Any, ts: Any): Any = { + if (isNanos) { + DateTimeUtils.timestampAddNanos( + unit, q.asInstanceOf[Long], ts.asInstanceOf[TimestampNanosVal], zoneIdInEval) + } else { + DateTimeUtils.timestampAdd(unit, q.asInstanceOf[Long], ts.asInstanceOf[Long], zoneIdInEval) + } } override def doGenCode(ctx: CodegenContext, ev: ExprCode): ExprCode = { val dtu = DateTimeUtils.getClass.getName.stripSuffix("$") val zid = ctx.addReferenceObj("zoneId", zoneIdInEval, classOf[ZoneId].getName) - defineCodeGen(ctx, ev, (q, micros) => - s"""$dtu.timestampAdd("$unit", $q, $micros, $zid)""") + if (isNanos) { + defineCodeGen(ctx, ev, (q, ts) => + s"""$dtu.timestampAddNanos("$unit", $q, $ts, $zid)""") + } else { + defineCodeGen(ctx, ev, (q, micros) => + s"""$dtu.timestampAdd("$unit", $q, $micros, $zid)""") + } } override def prettyName: String = "timestampadd" diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala index d605cbae7bb44..f900972a95fe3 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala @@ -991,6 +991,45 @@ object DateTimeUtils extends SparkDateTimeUtils { } } + /** + * Adds the specified number of units to a nanosecond-precision timestamp, preserving its + * sub-microsecond fraction. + * + * For units of MICROSECOND or coarser the addition runs on the microsecond grid (reusing + * [[timestampAdd]] for all the calendar, DST and overflow handling) and the fraction is carried + * through unchanged. For the NANOSECOND unit the fraction absorbs `quantity` nanoseconds and any + * whole microseconds carry into `epochMicros`. NANOSECOND is only valid here: adding nanoseconds + * to a microsecond timestamp is unrepresentable, so that combination is rejected as an invalid + * unit through the microsecond [[timestampAdd]] path. + * + * @param unit A keyword that specifies the interval units to add to the input timestamp. + * @param quantity The amount of `unit`s to add. It can be positive or negative. + * @param ts The input nanosecond-precision timestamp. + * @param zoneId The time zone ID at which the operation is performed. + * @return A nanosecond-precision timestamp value. + */ + def timestampAddNanos( + unit: String, quantity: Long, ts: TimestampNanosVal, zoneId: ZoneId): TimestampNanosVal = { + if (unit.toUpperCase(Locale.ROOT) == "NANOSECOND") { + try { + val totalNanos = Math.addExact(ts.nanosWithinMicro.toLong, quantity) + val carryMicros = Math.floorDiv(totalNanos, NANOS_PER_MICROS) + val newFraction = Math.floorMod(totalNanos, NANOS_PER_MICROS).toShort + val newMicros = Math.addExact(ts.epochMicros, carryMicros) + TimestampNanosVal.fromParts(newMicros, newFraction) + } catch { + case _: ArithmeticException | _: DateTimeException => + throw QueryExecutionErrors.timestampAddOverflowError(ts.epochMicros, quantity, unit) + } + } else { + // Units of MICROSECOND or coarser do not touch the sub-microsecond fraction; add on the + // microsecond grid and re-attach the original fraction. An unknown unit is rejected by + // timestampAdd, and NANOSECOND is handled above, so this branch never drops precision. + TimestampNanosVal.fromParts( + timestampAdd(unit, quantity, ts.epochMicros, zoneId), ts.nanosWithinMicro) + } + } + private val timestampDiffMap = Map[String, (Temporal, Temporal) => Long]( "MICROSECOND" -> ChronoUnit.MICROS.between, "MILLISECOND" -> ChronoUnit.MILLIS.between, diff --git a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala index ef90fa5d68a5f..82e99502077d6 100644 --- a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala +++ b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala @@ -2687,6 +2687,56 @@ class DateExpressionsSuite extends SparkFunSuite with ExpressionEvalHelper { } } + test("SPARK-57833: timestampadd over nanosecond-precision timestamps") { + val sec = 1000000L // microseconds per second + def ntz(micros: Long, frac: Int, p: Int = 9): Literal = + Literal(TimestampNanosVal.fromParts(micros, frac.toShort), TimestampNTZNanosType(p)) + def ltz(micros: Long, frac: Int, p: Int = 9): Literal = + Literal(TimestampNanosVal.fromParts(micros, frac.toShort), TimestampLTZNanosType(p)) + def tnv(micros: Long, frac: Int): TimestampNanosVal = + TimestampNanosVal.fromParts(micros, frac.toShort) + + // Units of MICROSECOND or coarser keep the sub-microsecond fraction unchanged and the result + // stays nanosecond-typed (NTZ and LTZ, precisions 7/8/9). + checkEvaluation(TimestampAdd("SECOND", Literal(5L), ntz(0, 123)), tnv(5 * sec, 123)) + checkEvaluation(TimestampAdd("MICROSECOND", Literal(2L), ntz(0, 123)), tnv(2, 123)) + checkEvaluation(TimestampAdd("MINUTE", Literal(1L), ntz(3 * sec, 120, 8)), tnv(63 * sec, 120)) + checkEvaluation(TimestampAdd("HOUR", Literal(0L), ntz(sec, 100, 7)), tnv(sec, 100)) + // LTZ (zone-aware) with a whole-second unit: UTC keeps epoch alignment, fraction preserved. + checkEvaluation(TimestampAdd("SECOND", Literal(1L), ltz(0, 500), Some("UTC")), tnv(sec, 500)) + + // NANOSECOND unit: the fraction absorbs the quantity and whole microseconds carry into + // epochMicros. + checkEvaluation(TimestampAdd("NANOSECOND", Literal(300L), ntz(0, 123)), tnv(0, 423)) + checkEvaluation(TimestampAdd("NANOSECOND", Literal(900L), ntz(0, 200)), tnv(1, 100)) + checkEvaluation(TimestampAdd("NANOSECOND", Literal(2500L), ntz(0, 100)), tnv(2, 600)) + // A negative quantity floors the carry: 100 - 300 ns = -200 ns => -1 microsecond, fraction 800. + checkEvaluation(TimestampAdd("NANOSECOND", Literal(-300L), ntz(5, 100)), tnv(4, 800)) + // The unit keyword is case-insensitive. + checkEvaluation(TimestampAdd("Nanosecond", Literal(1L), ntz(0, 0)), tnv(0, 1)) + + // Null propagation on either operand. + checkEvaluation( + TimestampAdd("NANOSECOND", Literal.create(null, LongType), ntz(0, 1)), null) + checkEvaluation( + TimestampAdd("SECOND", Literal(1L), Literal.create(null, TimestampNTZNanosType(9))), null) + + // NANOSECOND is meaningful only for a nanosecond-precision timestamp; on a microsecond + // timestamp nanoseconds are unrepresentable, so it is rejected as an invalid unit. + checkErrorInExpression[SparkIllegalArgumentException]( + TimestampAdd("NANOSECOND", Literal(1L), Literal(0L, TimestampType)), + condition = "INVALID_PARAMETER_VALUE.DATETIME_UNIT", + parameters = Map( + "functionName" -> "`TIMESTAMPADD`", + "parameter" -> "`unit`", + "invalidValue" -> "'NANOSECOND'")) + + // Overflow while carrying nanoseconds into epochMicros surfaces as a datetime overflow. + intercept[SparkArithmeticException] { + TimestampAdd("NANOSECOND", Literal(1000L), ntz(Long.MaxValue, 999)).eval(null) + } + } + test("SPARK-42635: timestampadd near daylight saving transition") { // In America/Los_Angeles timezone, timestamp value `skippedTime` is 2011-03-13 03:00:00. // The next second of 2011-03-13 01:59:59 jumps to 2011-03-13 03:00:00. diff --git a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ltz-nanos.sql.out b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ltz-nanos.sql.out index 368ddf703b928..41dc6a9ec7a15 100644 --- a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ltz-nanos.sql.out +++ b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ltz-nanos.sql.out @@ -1621,3 +1621,31 @@ SELECT element_at(map(TIMESTAMP_LTZ '2020-01-01 00:00:00.000000001 UTC', 'a', -- !query analysis Project [element_at(map(2019-12-31 16:00:00.000000001, a, 2019-12-31 16:00:00.000000999, b), 2019-12-31 16:00:00.000000001, None, true) AS element_at(map(TIMESTAMP_LTZ '2019-12-31 16:00:00.000000001', a, TIMESTAMP_LTZ '2019-12-31 16:00:00.000000999', b), TIMESTAMP_LTZ '2019-12-31 16:00:00.000000001')#x] +- OneRowRelation + + +-- !query +SELECT timestampadd(SECOND, 5, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000123 UTC') +-- !query analysis +Project [timestampadd(SECOND, cast(5 as bigint), 2019-12-31 16:00:00.000000123, Some(America/Los_Angeles)) AS timestampadd(SECOND, 5, TIMESTAMP_LTZ '2019-12-31 16:00:00.000000123')#x] ++- OneRowRelation + + +-- !query +SELECT timestampadd(MICROSECOND, 2, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000123 UTC') +-- !query analysis +Project [timestampadd(MICROSECOND, cast(2 as bigint), 2019-12-31 16:00:00.000000123, Some(America/Los_Angeles)) AS timestampadd(MICROSECOND, 2, TIMESTAMP_LTZ '2019-12-31 16:00:00.000000123')#x] ++- OneRowRelation + + +-- !query +SELECT timestampadd(NANOSECOND, 300, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000123 UTC') +-- !query analysis +Project [timestampadd(NANOSECOND, cast(300 as bigint), 2019-12-31 16:00:00.000000123, Some(America/Los_Angeles)) AS timestampadd(NANOSECOND, 300, TIMESTAMP_LTZ '2019-12-31 16:00:00.000000123')#x] ++- OneRowRelation + + +-- !query +SELECT timestampadd(NANOSECOND, -300, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000100 UTC') +-- !query analysis +Project [timestampadd(NANOSECOND, cast(-300 as bigint), 2019-12-31 16:00:00.0000001, Some(America/Los_Angeles)) AS timestampadd(NANOSECOND, -300, TIMESTAMP_LTZ '2019-12-31 16:00:00.000000100')#x] ++- OneRowRelation diff --git a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ntz-nanos.sql.out b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ntz-nanos.sql.out index 8fa51bf5ff5c1..51282efc2376f 100644 --- a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ntz-nanos.sql.out +++ b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ntz-nanos.sql.out @@ -1575,3 +1575,45 @@ SELECT element_at(map(TIMESTAMP_NTZ '2020-01-01 00:00:00.000000001', 'a', -- !query analysis Project [element_at(map(2020-01-01 00:00:00.000000001, a, 2020-01-01 00:00:00.000000999, b), 2020-01-01 00:00:00.000000001, None, true) AS element_at(map(TIMESTAMP_NTZ '2020-01-01 00:00:00.000000001', a, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000999', b), TIMESTAMP_NTZ '2020-01-01 00:00:00.000000001')#x] +- OneRowRelation + + +-- !query +SELECT timestampadd(SECOND, 5, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000123') +-- !query analysis +Project [timestampadd(SECOND, cast(5 as bigint), 2020-01-01 00:00:00.000000123, Some(America/Los_Angeles)) AS timestampadd(SECOND, 5, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000123')#x] ++- OneRowRelation + + +-- !query +SELECT timestampadd(MICROSECOND, 2, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000123') +-- !query analysis +Project [timestampadd(MICROSECOND, cast(2 as bigint), 2020-01-01 00:00:00.000000123, Some(America/Los_Angeles)) AS timestampadd(MICROSECOND, 2, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000123')#x] ++- OneRowRelation + + +-- !query +SELECT timestampadd(NANOSECOND, 300, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000123') +-- !query analysis +Project [timestampadd(NANOSECOND, cast(300 as bigint), 2020-01-01 00:00:00.000000123, Some(America/Los_Angeles)) AS timestampadd(NANOSECOND, 300, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000123')#x] ++- OneRowRelation + + +-- !query +SELECT timestampadd(NANOSECOND, 900, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000200') +-- !query analysis +Project [timestampadd(NANOSECOND, cast(900 as bigint), 2020-01-01 00:00:00.0000002, Some(America/Los_Angeles)) AS timestampadd(NANOSECOND, 900, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000200')#x] ++- OneRowRelation + + +-- !query +SELECT timestampadd(NANOSECOND, -300, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000100') +-- !query analysis +Project [timestampadd(NANOSECOND, cast(-300 as bigint), 2020-01-01 00:00:00.0000001, Some(America/Los_Angeles)) AS timestampadd(NANOSECOND, -300, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000100')#x] ++- OneRowRelation + + +-- !query +SELECT timestampadd(NANOSECOND, 1, TIMESTAMP_NTZ '2020-01-01 00:00:00') +-- !query analysis +Project [timestampadd(NANOSECOND, cast(1 as bigint), 2020-01-01 00:00:00, Some(America/Los_Angeles)) AS timestampadd(NANOSECOND, 1, TIMESTAMP_NTZ '2020-01-01 00:00:00')#x] ++- OneRowRelation diff --git a/sql/core/src/test/resources/sql-tests/inputs/timestamp-ltz-nanos.sql b/sql/core/src/test/resources/sql-tests/inputs/timestamp-ltz-nanos.sql index 1eba70d4f4b2b..cdad2ae95e499 100644 --- a/sql/core/src/test/resources/sql-tests/inputs/timestamp-ltz-nanos.sql +++ b/sql/core/src/test/resources/sql-tests/inputs/timestamp-ltz-nanos.sql @@ -568,3 +568,11 @@ SELECT map(TIMESTAMP_LTZ '2020-01-01 00:00:00.000000001 UTC', 'a', SELECT element_at(map(TIMESTAMP_LTZ '2020-01-01 00:00:00.000000001 UTC', 'a', TIMESTAMP_LTZ '2020-01-01 00:00:00.000000999 UTC', 'b'), TIMESTAMP_LTZ '2020-01-01 00:00:00.000000001 UTC'); + +-- SPARK-57833: timestampadd over TIMESTAMP_LTZ(p). Units of MICROSECOND or coarser keep the +-- sub-microsecond fraction unchanged; the NANOSECOND unit adds whole nanoseconds and carries into +-- the microsecond (a negative quantity borrows across the boundary). The result stays nanos-typed. +SELECT timestampadd(SECOND, 5, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000123 UTC'); +SELECT timestampadd(MICROSECOND, 2, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000123 UTC'); +SELECT timestampadd(NANOSECOND, 300, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000123 UTC'); +SELECT timestampadd(NANOSECOND, -300, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000100 UTC'); diff --git a/sql/core/src/test/resources/sql-tests/inputs/timestamp-ntz-nanos.sql b/sql/core/src/test/resources/sql-tests/inputs/timestamp-ntz-nanos.sql index 266829fe7ab26..c9b4e100e4e44 100644 --- a/sql/core/src/test/resources/sql-tests/inputs/timestamp-ntz-nanos.sql +++ b/sql/core/src/test/resources/sql-tests/inputs/timestamp-ntz-nanos.sql @@ -559,3 +559,14 @@ SELECT map(TIMESTAMP_NTZ '2020-01-01 00:00:00.000000001', 'a', SELECT element_at(map(TIMESTAMP_NTZ '2020-01-01 00:00:00.000000001', 'a', TIMESTAMP_NTZ '2020-01-01 00:00:00.000000999', 'b'), TIMESTAMP_NTZ '2020-01-01 00:00:00.000000001'); + +-- SPARK-57833: timestampadd over TIMESTAMP_NTZ(p). Units of MICROSECOND or coarser keep the +-- sub-microsecond fraction unchanged; the NANOSECOND unit adds whole nanoseconds and carries into +-- the microsecond (a negative quantity borrows across the boundary). The result stays nanos-typed. +SELECT timestampadd(SECOND, 5, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000123'); +SELECT timestampadd(MICROSECOND, 2, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000123'); +SELECT timestampadd(NANOSECOND, 300, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000123'); +SELECT timestampadd(NANOSECOND, 900, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000200'); +SELECT timestampadd(NANOSECOND, -300, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000100'); +-- NANOSECOND is rejected on a microsecond-precision timestamp (nanoseconds are unrepresentable). +SELECT timestampadd(NANOSECOND, 1, TIMESTAMP_NTZ '2020-01-01 00:00:00'); diff --git a/sql/core/src/test/resources/sql-tests/results/timestamp-ltz-nanos.sql.out b/sql/core/src/test/resources/sql-tests/results/timestamp-ltz-nanos.sql.out index 5fae7cc5041e6..ea7e885f33d6e 100644 --- a/sql/core/src/test/resources/sql-tests/results/timestamp-ltz-nanos.sql.out +++ b/sql/core/src/test/resources/sql-tests/results/timestamp-ltz-nanos.sql.out @@ -1749,3 +1749,35 @@ SELECT element_at(map(TIMESTAMP_LTZ '2020-01-01 00:00:00.000000001 UTC', 'a', struct -- !query output a + + +-- !query +SELECT timestampadd(SECOND, 5, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000123 UTC') +-- !query schema +struct +-- !query output +2019-12-31 16:00:05.000000123 + + +-- !query +SELECT timestampadd(MICROSECOND, 2, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000123 UTC') +-- !query schema +struct +-- !query output +2019-12-31 16:00:00.000002123 + + +-- !query +SELECT timestampadd(NANOSECOND, 300, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000123 UTC') +-- !query schema +struct +-- !query output +2019-12-31 16:00:00.000000423 + + +-- !query +SELECT timestampadd(NANOSECOND, -300, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000100 UTC') +-- !query schema +struct +-- !query output +2019-12-31 15:59:59.9999998 diff --git a/sql/core/src/test/resources/sql-tests/results/timestamp-ntz-nanos.sql.out b/sql/core/src/test/resources/sql-tests/results/timestamp-ntz-nanos.sql.out index 46fb523e378aa..192d4cb349ea8 100644 --- a/sql/core/src/test/resources/sql-tests/results/timestamp-ntz-nanos.sql.out +++ b/sql/core/src/test/resources/sql-tests/results/timestamp-ntz-nanos.sql.out @@ -1623,3 +1623,60 @@ SELECT element_at(map(TIMESTAMP_NTZ '2020-01-01 00:00:00.000000001', 'a', struct -- !query output a + + +-- !query +SELECT timestampadd(SECOND, 5, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000123') +-- !query schema +struct +-- !query output +2020-01-01 00:00:05.000000123 + + +-- !query +SELECT timestampadd(MICROSECOND, 2, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000123') +-- !query schema +struct +-- !query output +2020-01-01 00:00:00.000002123 + + +-- !query +SELECT timestampadd(NANOSECOND, 300, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000123') +-- !query schema +struct +-- !query output +2020-01-01 00:00:00.000000423 + + +-- !query +SELECT timestampadd(NANOSECOND, 900, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000200') +-- !query schema +struct +-- !query output +2020-01-01 00:00:00.0000011 + + +-- !query +SELECT timestampadd(NANOSECOND, -300, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000100') +-- !query schema +struct +-- !query output +2019-12-31 23:59:59.9999998 + + +-- !query +SELECT timestampadd(NANOSECOND, 1, TIMESTAMP_NTZ '2020-01-01 00:00:00') +-- !query schema +struct<> +-- !query output +org.apache.spark.SparkIllegalArgumentException +{ + "errorClass" : "INVALID_PARAMETER_VALUE.DATETIME_UNIT", + "sqlState" : "22023", + "messageParameters" : { + "functionName" : "`TIMESTAMPADD`", + "invalidValue" : "'NANOSECOND'", + "parameter" : "`unit`" + } +} From 18a804ffabae5c5d36dd23a4ef8685499fe79a68 Mon Sep 17 00:00:00 2001 From: Stevo Mitric Date: Wed, 16 Sep 2026 12:29:42 +0000 Subject: [PATCH 2/5] [SPARK-57833][SQL] Address review: floor the NANOSECOND result to the input's precision Addresses the review of PR #58855. Correctness fix (blocking): the NANOSECOND branch of `timestampAddNanos` computed the remainder at full nanosecond resolution without flooring to the input's declared precision `p`. On a `TIMESTAMP_NTZ/LTZ(7)` or `(8)` value that produced an off-grid sub-precision fraction that displays identically to the aligned value but compares, hashes and orders unequal (silent wrong results in =/DISTINCT/GROUP BY/joins/sort). The precision `p` is now threaded into `timestampAddNanos` from the expression's declared type and the NANOSECOND result is floored to `p` via `truncateTimestampNanosToPrecision`. Also: - Fix a spurious overflow rejection: split the added nanoseconds into whole microseconds and a [0, 999] remainder before combining, so a large but representable NANOSECOND quantity (near `Long.MaxValue`) is no longer rejected by an intermediate `addExact(nanosWithinMicro, quantity)` that could not fit a Long even though the true microsecond result does. - Add `DateExpressionsSuite` cases exercising NANOSECOND on `p = 7`/`8` (flooring to the 100ns / 10ns step, including carry) and a near-`Long.MaxValue` representable quantity; assert the overflow case raises `DATETIME_OVERFLOW`. - Add end-to-end golden coverage: a `p = 7` flooring case in `timestamp-ntz-nanos.sql`, carry and invalid-unit cases in `timestamp-ltz-nanos.sql`, and a `timestampdiff(NANOSECOND, ...)` case in `timestamp.sql` documenting that the shared `datetimeUnit` keyword is rejected at runtime by `timestampdiff` (matching add-only units like DAYOFYEAR). - Document the NANOSECOND flooring behavior in the function description and make `isNanos` a `@transient lazy val`. Co-authored-by: Isaac --- .../expressions/datetimeExpressions.scala | 19 ++++++-- .../sql/catalyst/util/DateTimeUtils.scala | 46 +++++++++++++------ .../expressions/DateExpressionsSuite.scala | 22 ++++++++- .../nonansi/timestamp.sql.out | 6 +++ .../timestamp-ltz-nanos.sql.out | 13 ++++++ .../timestamp-ntz-nanos.sql.out | 14 ++++++ .../analyzer-results/timestamp.sql.out | 6 +++ .../timestampNTZ/timestamp.sql.out | 7 +++ .../sql-tests/inputs/timestamp-ltz-nanos.sql | 4 ++ .../sql-tests/inputs/timestamp-ntz-nanos.sql | 4 ++ .../resources/sql-tests/inputs/timestamp.sql | 5 ++ .../results/nonansi/timestamp.sql.out | 17 +++++++ .../results/timestamp-ltz-nanos.sql.out | 25 ++++++++++ .../results/timestamp-ntz-nanos.sql.out | 16 +++++++ .../sql-tests/results/timestamp.sql.out | 17 +++++++ .../results/timestampNTZ/timestamp.sql.out | 17 +++++++ 16 files changed, 218 insertions(+), 20 deletions(-) diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala index 6e3a337087c2c..c9f4d0e9fd3cd 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala @@ -4982,7 +4982,8 @@ case class ConvertTimezone( - "MILLISECOND" - "MICROSECOND" - "NANOSECOND" - only for nanosecond-precision timestamp inputs (TIMESTAMP_NTZ(p) / - TIMESTAMP_LTZ(p), p in [7, 9]) + TIMESTAMP_LTZ(p), p in [7, 9]); the result is floored to the input's precision, so a + quantity finer than the type's step (10^(9-p) ns) is truncated to it * quantity - this is the number of units of time that you want to add. * timestamp - this is a timestamp (w/ or w/o timezone) to which you want to add. A nanosecond-precision timestamp keeps its sub-microsecond fraction; units of MICROSECOND or @@ -5030,7 +5031,16 @@ case class TimestampAdd( // A nanosecond-precision timestamp is carried as a TimestampNanosVal object rather than a // primitive microsecond Long, so the nanos-aware add path preserves the sub-microsecond fraction. - private def isNanos: Boolean = timestamp.dataType.isInstanceOf[AnyTimestampNanoType] + @transient private lazy val isNanos: Boolean = + timestamp.dataType.isInstanceOf[AnyTimestampNanoType] + + // The declared fractional-second precision p in [7, 9] of a nanosecond timestamp input; -1 for a + // microsecond timestamp (where it is unused). Every produced element is floored to this p. + @transient private lazy val nanosPrecision: Int = timestamp.dataType match { + case t: TimestampNTZNanosType => t.precision + case t: TimestampLTZNanosType => t.precision + case _ => -1 + } override def withTimeZone(timeZoneId: String): TimeZoneAwareExpression = copy(timeZoneId = Option(timeZoneId)) @@ -5040,7 +5050,8 @@ case class TimestampAdd( override def nullSafeEval(q: Any, ts: Any): Any = { if (isNanos) { DateTimeUtils.timestampAddNanos( - unit, q.asInstanceOf[Long], ts.asInstanceOf[TimestampNanosVal], zoneIdInEval) + unit, q.asInstanceOf[Long], ts.asInstanceOf[TimestampNanosVal], + nanosPrecision, zoneIdInEval) } else { DateTimeUtils.timestampAdd(unit, q.asInstanceOf[Long], ts.asInstanceOf[Long], zoneIdInEval) } @@ -5051,7 +5062,7 @@ case class TimestampAdd( val zid = ctx.addReferenceObj("zoneId", zoneIdInEval, classOf[ZoneId].getName) if (isNanos) { defineCodeGen(ctx, ev, (q, ts) => - s"""$dtu.timestampAddNanos("$unit", $q, $ts, $zid)""") + s"""$dtu.timestampAddNanos("$unit", $q, $ts, $nanosPrecision, $zid)""") } else { defineCodeGen(ctx, ev, (q, micros) => s"""$dtu.timestampAdd("$unit", $q, $micros, $zid)""") diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala index f900972a95fe3..1f7502828a617 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala @@ -992,39 +992,55 @@ object DateTimeUtils extends SparkDateTimeUtils { } /** - * Adds the specified number of units to a nanosecond-precision timestamp, preserving its - * sub-microsecond fraction. + * Adds the specified number of units to a nanosecond-precision timestamp of precision `p` (in + * [7, 9]), returning a value floored to that precision. * * For units of MICROSECOND or coarser the addition runs on the microsecond grid (reusing - * [[timestampAdd]] for all the calendar, DST and overflow handling) and the fraction is carried - * through unchanged. For the NANOSECOND unit the fraction absorbs `quantity` nanoseconds and any - * whole microseconds carry into `epochMicros`. NANOSECOND is only valid here: adding nanoseconds - * to a microsecond timestamp is unrepresentable, so that combination is rejected as an invalid - * unit through the microsecond [[timestampAdd]] path. + * [[timestampAdd]] for all the calendar, DST and overflow handling) and the input's already + * `p`-aligned fraction is carried through unchanged. For the NANOSECOND unit the fraction absorbs + * `quantity` nanoseconds and any whole microseconds carry into `epochMicros`; the resulting + * fraction is then floored to `p` (via [[truncateTimestampNanosToPrecision]]) so a NANOSECOND + * quantity finer than the type's step never produces an off-grid value that would compare, hash + * or sort unequal to its displayed (aligned) form. NANOSECOND is only valid here: adding + * nanoseconds to a microsecond timestamp is unrepresentable, so that combination is rejected as + * an invalid unit through the microsecond [[timestampAdd]] path. * * @param unit A keyword that specifies the interval units to add to the input timestamp. * @param quantity The amount of `unit`s to add. It can be positive or negative. * @param ts The input nanosecond-precision timestamp. + * @param precision The declared fractional-second precision `p` in [7, 9] of the input/output. * @param zoneId The time zone ID at which the operation is performed. - * @return A nanosecond-precision timestamp value. + * @return A nanosecond-precision timestamp value floored to `precision`. */ def timestampAddNanos( - unit: String, quantity: Long, ts: TimestampNanosVal, zoneId: ZoneId): TimestampNanosVal = { + unit: String, + quantity: Long, + ts: TimestampNanosVal, + precision: Int, + zoneId: ZoneId): TimestampNanosVal = { if (unit.toUpperCase(Locale.ROOT) == "NANOSECOND") { try { - val totalNanos = Math.addExact(ts.nanosWithinMicro.toLong, quantity) - val carryMicros = Math.floorDiv(totalNanos, NANOS_PER_MICROS) - val newFraction = Math.floorMod(totalNanos, NANOS_PER_MICROS).toShort + // Split the added nanoseconds into whole microseconds and a [0, 999] remainder first, so + // the fraction sum stays within [0, 1998] and only the true microsecond total can overflow + // a Long (adding the remainder to `nanosWithinMicro` before the split would spuriously + // reject a large-but-representable quantity). + val quotientMicros = Math.floorDiv(quantity, NANOS_PER_MICROS) + val remainderNanos = Math.floorMod(quantity, NANOS_PER_MICROS) + val fractionSum = ts.nanosWithinMicro.toLong + remainderNanos + val carryMicros = + Math.addExact(quotientMicros, Math.floorDiv(fractionSum, NANOS_PER_MICROS)) + val newFraction = Math.floorMod(fractionSum, NANOS_PER_MICROS).toShort val newMicros = Math.addExact(ts.epochMicros, carryMicros) - TimestampNanosVal.fromParts(newMicros, newFraction) + truncateTimestampNanosToPrecision( + TimestampNanosVal.fromParts(newMicros, newFraction), precision) } catch { case _: ArithmeticException | _: DateTimeException => throw QueryExecutionErrors.timestampAddOverflowError(ts.epochMicros, quantity, unit) } } else { // Units of MICROSECOND or coarser do not touch the sub-microsecond fraction; add on the - // microsecond grid and re-attach the original fraction. An unknown unit is rejected by - // timestampAdd, and NANOSECOND is handled above, so this branch never drops precision. + // microsecond grid and re-attach the input's fraction, which is already `p`-aligned. An + // unknown unit is rejected by timestampAdd, and NANOSECOND is handled above. TimestampNanosVal.fromParts( timestampAdd(unit, quantity, ts.epochMicros, zoneId), ts.nanosWithinMicro) } diff --git a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala index 82e99502077d6..95100e3035938 100644 --- a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala +++ b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala @@ -2715,6 +2715,25 @@ class DateExpressionsSuite extends SparkFunSuite with ExpressionEvalHelper { // The unit keyword is case-insensitive. checkEvaluation(TimestampAdd("Nanosecond", Literal(1L), ntz(0, 0)), tnv(0, 1)) + // NANOSECOND result is floored to the input's precision, so a quantity finer than the type's + // step (10^(9-p) ns) never yields an off-grid fraction (which would compare / hash / sort + // unequal to its displayed, aligned form). p=7 -> 100ns step, p=8 -> 10ns step. + checkEvaluation(TimestampAdd("NANOSECOND", Literal(150L), ntz(0, 100, 7)), tnv(0, 200)) + checkEvaluation(TimestampAdd("NANOSECOND", Literal(50L), ntz(0, 100, 7)), tnv(0, 100)) + checkEvaluation(TimestampAdd("NANOSECOND", Literal(15L), ntz(0, 100, 8)), tnv(0, 110)) + // Carry into the microsecond then floor the remainder: 100 + 905 = 1005 -> (+1 us, 5ns), + // floored to the 10ns step -> 0ns. + checkEvaluation(TimestampAdd("NANOSECOND", Literal(905L), ntz(0, 100, 8)), tnv(1, 0)) + // LTZ at p=7 floors the same way. + checkEvaluation( + TimestampAdd("NANOSECOND", Literal(250L), ltz(0, 100, 7), Some("UTC")), tnv(0, 300)) + // A near-Long.MaxValue nanosecond quantity that stays representable is not spuriously rejected: + // it carries ~9.2e15 microseconds forward from a small epoch. 9223372036854775000 ns = + // 9223372036854775 us + 0 ns; from epochMicros 0 that lands on that microsecond, fraction 0. + checkEvaluation( + TimestampAdd("NANOSECOND", Literal(9223372036854775000L), ntz(0, 0)), + tnv(9223372036854775L, 0)) + // Null propagation on either operand. checkEvaluation( TimestampAdd("NANOSECOND", Literal.create(null, LongType), ntz(0, 1)), null) @@ -2732,9 +2751,10 @@ class DateExpressionsSuite extends SparkFunSuite with ExpressionEvalHelper { "invalidValue" -> "'NANOSECOND'")) // Overflow while carrying nanoseconds into epochMicros surfaces as a datetime overflow. - intercept[SparkArithmeticException] { + val overflow = intercept[SparkArithmeticException] { TimestampAdd("NANOSECOND", Literal(1000L), ntz(Long.MaxValue, 999)).eval(null) } + assert(overflow.getCondition == "DATETIME_OVERFLOW") } test("SPARK-42635: timestampadd near daylight saving transition") { diff --git a/sql/core/src/test/resources/sql-tests/analyzer-results/nonansi/timestamp.sql.out b/sql/core/src/test/resources/sql-tests/analyzer-results/nonansi/timestamp.sql.out index a1191af8f6c64..86dc87b07ceef 100644 --- a/sql/core/src/test/resources/sql-tests/analyzer-results/nonansi/timestamp.sql.out +++ b/sql/core/src/test/resources/sql-tests/analyzer-results/nonansi/timestamp.sql.out @@ -1233,6 +1233,12 @@ org.apache.spark.sql.catalyst.parser.ParseException } +-- !query +select timestampdiff(NANOSECOND, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 01:02:04') +-- !query analysis +[Analyzer test output redacted due to nondeterminism] + + -- !query select timediff(QUARTER, timestamp'2023-08-10 01:02:03', timestamp'2022-01-14 01:02:03') -- !query analysis diff --git a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ltz-nanos.sql.out b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ltz-nanos.sql.out index 41dc6a9ec7a15..984037f6d8d10 100644 --- a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ltz-nanos.sql.out +++ b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ltz-nanos.sql.out @@ -1649,3 +1649,16 @@ SELECT timestampadd(NANOSECOND, -300, TIMESTAMP_LTZ '2020-01-01 00:00:00.0000001 -- !query analysis Project [timestampadd(NANOSECOND, cast(-300 as bigint), 2019-12-31 16:00:00.0000001, Some(America/Los_Angeles)) AS timestampadd(NANOSECOND, -300, TIMESTAMP_LTZ '2019-12-31 16:00:00.000000100')#x] +- OneRowRelation + + +-- !query +SELECT timestampadd(NANOSECOND, 900, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000200 UTC') +-- !query analysis +Project [timestampadd(NANOSECOND, cast(900 as bigint), 2019-12-31 16:00:00.0000002, Some(America/Los_Angeles)) AS timestampadd(NANOSECOND, 900, TIMESTAMP_LTZ '2019-12-31 16:00:00.000000200')#x] ++- OneRowRelation + + +-- !query +SELECT timestampadd(NANOSECOND, 1, TIMESTAMP_LTZ '2020-01-01 00:00:00 UTC') +-- !query analysis +[Analyzer test output redacted due to nondeterminism] diff --git a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ntz-nanos.sql.out b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ntz-nanos.sql.out index 51282efc2376f..78fb4d721f477 100644 --- a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ntz-nanos.sql.out +++ b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ntz-nanos.sql.out @@ -1617,3 +1617,17 @@ SELECT timestampadd(NANOSECOND, 1, TIMESTAMP_NTZ '2020-01-01 00:00:00') -- !query analysis Project [timestampadd(NANOSECOND, cast(1 as bigint), 2020-01-01 00:00:00, Some(America/Los_Angeles)) AS timestampadd(NANOSECOND, 1, TIMESTAMP_NTZ '2020-01-01 00:00:00')#x] +- OneRowRelation + + +-- !query +SELECT timestampadd(NANOSECOND, 150, '2020-01-01 00:00:00.0000001' :: timestamp_ntz(7)) +-- !query analysis +Project [timestampadd(NANOSECOND, cast(150 as bigint), cast(2020-01-01 00:00:00.0000001 as timestamp_ntz(7)), Some(America/Los_Angeles)) AS timestampadd(NANOSECOND, 150, CAST(2020-01-01 00:00:00.0000001 AS TIMESTAMP_NTZ(7)))#x] ++- OneRowRelation + + +-- !query +SELECT timestampadd(NANOSECOND, 50, '2020-01-01 00:00:00.0000001' :: timestamp_ntz(7)) +-- !query analysis +Project [timestampadd(NANOSECOND, cast(50 as bigint), cast(2020-01-01 00:00:00.0000001 as timestamp_ntz(7)), Some(America/Los_Angeles)) AS timestampadd(NANOSECOND, 50, CAST(2020-01-01 00:00:00.0000001 AS TIMESTAMP_NTZ(7)))#x] ++- OneRowRelation diff --git a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp.sql.out b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp.sql.out index 6dad149ac7d93..95f861a0f5a8c 100644 --- a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp.sql.out +++ b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp.sql.out @@ -1161,6 +1161,12 @@ org.apache.spark.sql.catalyst.parser.ParseException } +-- !query +select timestampdiff(NANOSECOND, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 01:02:04') +-- !query analysis +[Analyzer test output redacted due to nondeterminism] + + -- !query select timediff(QUARTER, timestamp'2023-08-10 01:02:03', timestamp'2022-01-14 01:02:03') -- !query analysis diff --git a/sql/core/src/test/resources/sql-tests/analyzer-results/timestampNTZ/timestamp.sql.out b/sql/core/src/test/resources/sql-tests/analyzer-results/timestampNTZ/timestamp.sql.out index f66c0b84e8829..69c25c81dea00 100644 --- a/sql/core/src/test/resources/sql-tests/analyzer-results/timestampNTZ/timestamp.sql.out +++ b/sql/core/src/test/resources/sql-tests/analyzer-results/timestampNTZ/timestamp.sql.out @@ -1244,6 +1244,13 @@ org.apache.spark.sql.catalyst.parser.ParseException } +-- !query +select timestampdiff(NANOSECOND, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 01:02:04') +-- !query analysis +Project [timestampdiff(NANOSECOND, cast(2022-02-14 01:02:03 as timestamp), cast(2022-02-14 01:02:04 as timestamp), Some(America/Los_Angeles)) AS timestampdiff(NANOSECOND, TIMESTAMP_NTZ '2022-02-14 01:02:03', TIMESTAMP_NTZ '2022-02-14 01:02:04')#xL] ++- OneRowRelation + + -- !query select timediff(QUARTER, timestamp'2023-08-10 01:02:03', timestamp'2022-01-14 01:02:03') -- !query analysis diff --git a/sql/core/src/test/resources/sql-tests/inputs/timestamp-ltz-nanos.sql b/sql/core/src/test/resources/sql-tests/inputs/timestamp-ltz-nanos.sql index cdad2ae95e499..5a222f8488df6 100644 --- a/sql/core/src/test/resources/sql-tests/inputs/timestamp-ltz-nanos.sql +++ b/sql/core/src/test/resources/sql-tests/inputs/timestamp-ltz-nanos.sql @@ -576,3 +576,7 @@ SELECT timestampadd(SECOND, 5, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000123 UTC' SELECT timestampadd(MICROSECOND, 2, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000123 UTC'); SELECT timestampadd(NANOSECOND, 300, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000123 UTC'); SELECT timestampadd(NANOSECOND, -300, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000100 UTC'); +-- Carry into the microsecond: 200ns + 900ns = 1100ns -> +1us, 100ns. +SELECT timestampadd(NANOSECOND, 900, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000200 UTC'); +-- NANOSECOND is rejected on a microsecond-precision timestamp (nanoseconds are unrepresentable). +SELECT timestampadd(NANOSECOND, 1, TIMESTAMP_LTZ '2020-01-01 00:00:00 UTC'); diff --git a/sql/core/src/test/resources/sql-tests/inputs/timestamp-ntz-nanos.sql b/sql/core/src/test/resources/sql-tests/inputs/timestamp-ntz-nanos.sql index c9b4e100e4e44..1178d1bb9f997 100644 --- a/sql/core/src/test/resources/sql-tests/inputs/timestamp-ntz-nanos.sql +++ b/sql/core/src/test/resources/sql-tests/inputs/timestamp-ntz-nanos.sql @@ -570,3 +570,7 @@ SELECT timestampadd(NANOSECOND, 900, TIMESTAMP_NTZ '2020-01-01 00:00:00.00000020 SELECT timestampadd(NANOSECOND, -300, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000100'); -- NANOSECOND is rejected on a microsecond-precision timestamp (nanoseconds are unrepresentable). SELECT timestampadd(NANOSECOND, 1, TIMESTAMP_NTZ '2020-01-01 00:00:00'); +-- At p=7 (100ns step) the NANOSECOND result is floored to the type's precision: +150ns on a +-- .0000001 value lands on .0000002, and a sub-step +50ns is truncated back to .0000001. +SELECT timestampadd(NANOSECOND, 150, '2020-01-01 00:00:00.0000001' :: timestamp_ntz(7)); +SELECT timestampadd(NANOSECOND, 50, '2020-01-01 00:00:00.0000001' :: timestamp_ntz(7)); diff --git a/sql/core/src/test/resources/sql-tests/inputs/timestamp.sql b/sql/core/src/test/resources/sql-tests/inputs/timestamp.sql index e18c1c4f1f86c..6f8270f0c9758 100644 --- a/sql/core/src/test/resources/sql-tests/inputs/timestamp.sql +++ b/sql/core/src/test/resources/sql-tests/inputs/timestamp.sql @@ -207,6 +207,11 @@ select timestampdiff(SECOND, date'2022-02-15', timestamp'2022-02-14 23:59:59'); select timestampdiff('MINUTE', timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 02:00:03'); select timestampdiff('YEAR', date'2022-02-15', date'2023-02-15'); +-- NANOSECOND is a valid datetimeUnit keyword (timestampadd accepts it for nanosecond-precision +-- timestamps) but timestampdiff does not support it, so it is rejected at runtime, matching other +-- add-only units such as DAYOFYEAR. +select timestampdiff(NANOSECOND, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 01:02:04'); + select timediff(QUARTER, timestamp'2023-08-10 01:02:03', timestamp'2022-01-14 01:02:03'); select timediff(HOUR, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 12:00:03'); select timediff(DAY, date'2022-02-15', date'2023-02-15'); diff --git a/sql/core/src/test/resources/sql-tests/results/nonansi/timestamp.sql.out b/sql/core/src/test/resources/sql-tests/results/nonansi/timestamp.sql.out index c0aaec386bf9b..09fd5add2e0a9 100644 --- a/sql/core/src/test/resources/sql-tests/results/nonansi/timestamp.sql.out +++ b/sql/core/src/test/resources/sql-tests/results/nonansi/timestamp.sql.out @@ -1499,6 +1499,23 @@ org.apache.spark.sql.catalyst.parser.ParseException } +-- !query +select timestampdiff(NANOSECOND, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 01:02:04') +-- !query schema +struct<> +-- !query output +org.apache.spark.SparkIllegalArgumentException +{ + "errorClass" : "INVALID_PARAMETER_VALUE.DATETIME_UNIT", + "sqlState" : "22023", + "messageParameters" : { + "functionName" : "`TIMESTAMPDIFF`", + "invalidValue" : "'NANOSECOND'", + "parameter" : "`unit`" + } +} + + -- !query select timediff(QUARTER, timestamp'2023-08-10 01:02:03', timestamp'2022-01-14 01:02:03') -- !query schema diff --git a/sql/core/src/test/resources/sql-tests/results/timestamp-ltz-nanos.sql.out b/sql/core/src/test/resources/sql-tests/results/timestamp-ltz-nanos.sql.out index ea7e885f33d6e..703661a362d35 100644 --- a/sql/core/src/test/resources/sql-tests/results/timestamp-ltz-nanos.sql.out +++ b/sql/core/src/test/resources/sql-tests/results/timestamp-ltz-nanos.sql.out @@ -1781,3 +1781,28 @@ SELECT timestampadd(NANOSECOND, -300, TIMESTAMP_LTZ '2020-01-01 00:00:00.0000001 struct -- !query output 2019-12-31 15:59:59.9999998 + + +-- !query +SELECT timestampadd(NANOSECOND, 900, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000200 UTC') +-- !query schema +struct +-- !query output +2019-12-31 16:00:00.0000011 + + +-- !query +SELECT timestampadd(NANOSECOND, 1, TIMESTAMP_LTZ '2020-01-01 00:00:00 UTC') +-- !query schema +struct<> +-- !query output +org.apache.spark.SparkIllegalArgumentException +{ + "errorClass" : "INVALID_PARAMETER_VALUE.DATETIME_UNIT", + "sqlState" : "22023", + "messageParameters" : { + "functionName" : "`TIMESTAMPADD`", + "invalidValue" : "'NANOSECOND'", + "parameter" : "`unit`" + } +} diff --git a/sql/core/src/test/resources/sql-tests/results/timestamp-ntz-nanos.sql.out b/sql/core/src/test/resources/sql-tests/results/timestamp-ntz-nanos.sql.out index 192d4cb349ea8..081908fac208b 100644 --- a/sql/core/src/test/resources/sql-tests/results/timestamp-ntz-nanos.sql.out +++ b/sql/core/src/test/resources/sql-tests/results/timestamp-ntz-nanos.sql.out @@ -1680,3 +1680,19 @@ org.apache.spark.SparkIllegalArgumentException "parameter" : "`unit`" } } + + +-- !query +SELECT timestampadd(NANOSECOND, 150, '2020-01-01 00:00:00.0000001' :: timestamp_ntz(7)) +-- !query schema +struct +-- !query output +2020-01-01 00:00:00.0000002 + + +-- !query +SELECT timestampadd(NANOSECOND, 50, '2020-01-01 00:00:00.0000001' :: timestamp_ntz(7)) +-- !query schema +struct +-- !query output +2020-01-01 00:00:00.0000001 diff --git a/sql/core/src/test/resources/sql-tests/results/timestamp.sql.out b/sql/core/src/test/resources/sql-tests/results/timestamp.sql.out index 9f99af68b390e..574b795950373 100644 --- a/sql/core/src/test/resources/sql-tests/results/timestamp.sql.out +++ b/sql/core/src/test/resources/sql-tests/results/timestamp.sql.out @@ -1503,6 +1503,23 @@ org.apache.spark.sql.catalyst.parser.ParseException } +-- !query +select timestampdiff(NANOSECOND, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 01:02:04') +-- !query schema +struct<> +-- !query output +org.apache.spark.SparkIllegalArgumentException +{ + "errorClass" : "INVALID_PARAMETER_VALUE.DATETIME_UNIT", + "sqlState" : "22023", + "messageParameters" : { + "functionName" : "`TIMESTAMPDIFF`", + "invalidValue" : "'NANOSECOND'", + "parameter" : "`unit`" + } +} + + -- !query select timediff(QUARTER, timestamp'2023-08-10 01:02:03', timestamp'2022-01-14 01:02:03') -- !query schema diff --git a/sql/core/src/test/resources/sql-tests/results/timestampNTZ/timestamp.sql.out b/sql/core/src/test/resources/sql-tests/results/timestampNTZ/timestamp.sql.out index c0a154facec2b..687d608212bb8 100644 --- a/sql/core/src/test/resources/sql-tests/results/timestampNTZ/timestamp.sql.out +++ b/sql/core/src/test/resources/sql-tests/results/timestampNTZ/timestamp.sql.out @@ -1478,6 +1478,23 @@ org.apache.spark.sql.catalyst.parser.ParseException } +-- !query +select timestampdiff(NANOSECOND, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 01:02:04') +-- !query schema +struct<> +-- !query output +org.apache.spark.SparkIllegalArgumentException +{ + "errorClass" : "INVALID_PARAMETER_VALUE.DATETIME_UNIT", + "sqlState" : "22023", + "messageParameters" : { + "functionName" : "`TIMESTAMPDIFF`", + "invalidValue" : "'NANOSECOND'", + "parameter" : "`unit`" + } +} + + -- !query select timediff(QUARTER, timestamp'2023-08-10 01:02:03', timestamp'2022-01-14 01:02:03') -- !query schema From 9ef0b7acfe5b9a09a8352d59b879f5a9f1e40a2d Mon Sep 17 00:00:00 2001 From: Stevo Mitric Date: Thu, 17 Sep 2026 09:26:35 +0000 Subject: [PATCH 3/5] [SPARK-57833][SQL] Support nanosecond-precision timestamps in timestampdiff TimestampDiff previously accepted only microsecond TimestampType operands. This extends it to nanosecond-precision timestamps (TIMESTAMP_NTZ/LTZ(p), p in [7, 9]): when either operand is a nanosecond carrier, the difference is computed at full nanosecond resolution so each operand's sub-microsecond fraction participates in the truncated unit count (for every unit, not only NANOSECOND). The NANOSECOND unit is added to the shared unit map, so it is now accepted by timestampdiff for both nanosecond and microsecond operands (microsecond operands carry a zero fraction). - inputTypes widened to TypeCollection(AnyTimestampType, AnyTimestampNanoType) for both operands. - New DateTimeUtils.timestampDiffNanos folds each operand's nanosWithinMicro fraction into a nanosecond LocalDateTime before taking the difference. - Interpreted and codegen paths read epochMicros / nanosWithinMicro from the nanosecond carrier and pass a zero fraction for microsecond operands. - Adds catalyst unit tests and golden-file coverage. Co-authored-by: Isaac --- .../expressions/datetimeExpressions.scala | 66 ++++++++++++++++--- .../sql/catalyst/util/DateTimeUtils.scala | 37 +++++++++++ .../expressions/DateExpressionsSuite.scala | 46 +++++++++++++ .../timestamp-ltz-nanos.sql.out | 14 ++++ .../timestamp-ntz-nanos.sql.out | 21 ++++++ .../timestampNTZ/timestamp.sql.out | 10 +-- .../sql-tests/inputs/timestamp-ltz-nanos.sql | 5 ++ .../sql-tests/inputs/timestamp-ntz-nanos.sql | 7 ++ .../resources/sql-tests/inputs/timestamp.sql | 6 +- .../results/nonansi/timestamp.sql.out | 13 +--- .../results/timestamp-ltz-nanos.sql.out | 16 +++++ .../results/timestamp-ntz-nanos.sql.out | 24 +++++++ .../sql-tests/results/timestamp.sql.out | 13 +--- .../results/timestampNTZ/timestamp.sql.out | 17 ++--- 14 files changed, 243 insertions(+), 52 deletions(-) diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala index c9f4d0e9fd3cd..78f393814ed78 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala @@ -5100,8 +5100,11 @@ case class TimestampAdd( - "SECOND" - "MILLISECOND" - "MICROSECOND" + - "NANOSECOND" * startTimestamp - A timestamp which the expression subtracts from `endTimestamp`. * endTimestamp - A timestamp from which the expression subtracts `startTimestamp`. + Nanosecond-precision timestamps (TIMESTAMP_NTZ/LTZ(p), p in [7, 9]) are accepted, and their + sub-microsecond fraction participates in the truncated difference for every unit. """, examples = """ Examples: @@ -5113,6 +5116,8 @@ case class TimestampAdd( -10 > SELECT _FUNC_(YEAR, timestamp'2000-01-01 01:02:03.123456', timestamp'2010-01-01 01:02:03.123456'); 10 + > SELECT _FUNC_(NANOSECOND, timestamp'2022-02-11 20:30:00', timestamp'2022-02-11 20:30:00.000001'); + 1000 """, group = "datetime_funcs", since = "3.3.0") @@ -5139,27 +5144,70 @@ case class TimestampDiff( override def left: Expression = startTimestamp override def right: Expression = endTimestamp - override def inputTypes: Seq[AbstractDataType] = Seq(TimestampType, TimestampType) + override def inputTypes: Seq[AbstractDataType] = + Seq( + TypeCollection(AnyTimestampType, AnyTimestampNanoType), + TypeCollection(AnyTimestampType, AnyTimestampNanoType)) override def dataType: DataType = LongType + // A nanosecond-precision operand is carried as a TimestampNanosVal object rather than a primitive + // microsecond Long; when either operand is nanos, the difference is computed at full nanosecond + // resolution so the sub-microsecond fraction participates in the truncated unit count. + private def isNanos: Boolean = + startTimestamp.dataType.isInstanceOf[AnyTimestampNanoType] || + endTimestamp.dataType.isInstanceOf[AnyTimestampNanoType] + override def withTimeZone(timeZoneId: String): TimeZoneAwareExpression = copy(timeZoneId = Option(timeZoneId)) @transient private lazy val zoneIdInEval: ZoneId = zoneIdForType(endTimestamp.dataType) - override def nullSafeEval(startMicros: Any, endMicros: Any): Any = { - DateTimeUtils.timestampDiff( - unit, - startMicros.asInstanceOf[Long], - endMicros.asInstanceOf[Long], - zoneIdInEval) + // For the nanosecond carrier the child value is a boxed TimestampNanosVal; the microsecond + // timestamp types are already boxed Longs whose sub-microsecond remainder is zero. + private def epochMicrosOf(value: Any): Long = value match { + case v: TimestampNanosVal => v.epochMicros + case n => n.asInstanceOf[Long] + } + private def fractionOf(value: Any): Int = value match { + case v: TimestampNanosVal => v.nanosWithinMicro.toInt + case _ => 0 + } + + override def nullSafeEval(start: Any, end: Any): Any = { + if (isNanos) { + DateTimeUtils.timestampDiffNanos( + unit, epochMicrosOf(start), fractionOf(start), epochMicrosOf(end), fractionOf(end), + zoneIdInEval) + } else { + DateTimeUtils.timestampDiff( + unit, start.asInstanceOf[Long], end.asInstanceOf[Long], zoneIdInEval) + } } override def doGenCode(ctx: CodegenContext, ev: ExprCode): ExprCode = { val dtu = DateTimeUtils.getClass.getName.stripSuffix("$") val zid = ctx.addReferenceObj("zoneId", zoneIdInEval, classOf[ZoneId].getName) - defineCodeGen(ctx, ev, (s, e) => - s"""$dtu.timestampDiff("$unit", $s, $e, $zid)""") + if (isNanos) { + // The nanosecond carrier exposes epochMicros / nanosWithinMicro as public fields; the + // microsecond types are primitive longs with a zero fraction. + def microsCode(e: Expression): String => String = e.dataType match { + case _: AnyTimestampNanoType => c => s"$c.epochMicros" + case _ => c => c + } + def fractionCode(e: Expression): String => String = e.dataType match { + case _: AnyTimestampNanoType => c => s"$c.nanosWithinMicro" + case _ => _ => "0" + } + val sM = microsCode(startTimestamp) + val sF = fractionCode(startTimestamp) + val eM = microsCode(endTimestamp) + val eF = fractionCode(endTimestamp) + defineCodeGen(ctx, ev, (s, e) => + s"""$dtu.timestampDiffNanos("$unit", ${sM(s)}, ${sF(s)}, ${eM(e)}, ${eF(e)}, $zid)""") + } else { + defineCodeGen(ctx, ev, (s, e) => + s"""$dtu.timestampDiff("$unit", $s, $e, $zid)""") + } } override def prettyName: String = "timestampdiff" diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala index 1f7502828a617..8408ced6a06fe 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala @@ -1047,6 +1047,7 @@ object DateTimeUtils extends SparkDateTimeUtils { } private val timestampDiffMap = Map[String, (Temporal, Temporal) => Long]( + "NANOSECOND" -> ChronoUnit.NANOS.between, "MICROSECOND" -> ChronoUnit.MICROS.between, "MILLISECOND" -> ChronoUnit.MILLIS.between, "SECOND" -> ChronoUnit.SECONDS.between, @@ -1080,6 +1081,42 @@ object DateTimeUtils extends SparkDateTimeUtils { } } + /** + * Gets the difference between two nanosecond-precision timestamps, expressed in whole `unit`s + * (truncated toward zero), honoring the sub-microsecond fraction of each operand. + * + * Each operand is given as its `epochMicros` plus a `nanosWithinMicro` fraction in [0, 999]. The + * fraction is folded into a nanosecond-precision `LocalDateTime` before the difference is taken, + * so a fraction of up to a microsecond can move the truncated result across a unit boundary for + * every unit (not only NANOSECOND). A microsecond operand simply passes a zero fraction. The + * `NANOSECOND` unit is added to the shared unit map, so it is accepted here and by the + * microsecond-only [[timestampDiff]] (where both fractions are zero). + * + * @param unit The unit in which to express the difference. + * @param startMicros `epochMicros` of the timestamp subtracted from `end`. + * @param startFraction `nanosWithinMicro` in [0, 999] of the start timestamp. + * @param endMicros `epochMicros` of the timestamp from which `start` is subtracted. + * @param endFraction `nanosWithinMicro` in [0, 999] of the end timestamp. + * @param zoneId The time zone ID at which the operation is performed. + * @return The truncated difference in the requested unit. + */ + def timestampDiffNanos( + unit: String, + startMicros: Long, + startFraction: Int, + endMicros: Long, + endFraction: Int, + zoneId: ZoneId): Long = { + val unitInUpperCase = unit.toUpperCase(Locale.ROOT) + if (timestampDiffMap.contains(unitInUpperCase)) { + val startLocalTs = getLocalDateTime(startMicros, zoneId).plusNanos(startFraction.toLong) + val endLocalTs = getLocalDateTime(endMicros, zoneId).plusNanos(endFraction.toLong) + timestampDiffMap(unitInUpperCase)(startLocalTs, endLocalTs) + } else { + throw QueryExecutionErrors.invalidDatetimeUnitError("TIMESTAMPDIFF", unit) + } + } + /** * Converts separate time fields in a long that represents nanoseconds since the start of * the day diff --git a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala index 95100e3035938..608dccee01d87 100644 --- a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala +++ b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala @@ -2953,6 +2953,52 @@ class DateExpressionsSuite extends SparkFunSuite with ExpressionEvalHelper { } } + test("SPARK-57833: timestampdiff over nanosecond-precision timestamps") { + val sec = 1000000L // microseconds per second + def ntz(micros: Long, frac: Int, p: Int = 9): Literal = + Literal(TimestampNanosVal.fromParts(micros, frac.toShort), TimestampNTZNanosType(p)) + def ltz(micros: Long, frac: Int, p: Int = 9): Literal = + Literal(TimestampNanosVal.fromParts(micros, frac.toShort), TimestampLTZNanosType(p)) + + // NANOSECOND unit reports the exact sub-microsecond difference (within a microsecond, across + // microseconds, and negative). + checkEvaluation(TimestampDiff("NANOSECOND", ntz(0, 100), ntz(0, 900)), 800L) + checkEvaluation(TimestampDiff("NANOSECOND", ntz(0, 900), ntz(2, 100)), 1200L) + checkEvaluation(TimestampDiff("NANOSECOND", ntz(2, 100), ntz(0, 900)), -1200L) + + // The sub-microsecond fraction participates in the truncated count for coarser units too: a + // start fraction larger than the end fraction means a whole second/microsecond has NOT elapsed. + // SECOND: 1s + 100ns - 900ns = 0.9999992s -> 0 (the micros-only count would wrongly be 1). + checkEvaluation(TimestampDiff("SECOND", ntz(0, 900), ntz(sec, 100)), 0L) + // 1s + 900ns - 100ns = 1.0000008s -> 1. + checkEvaluation(TimestampDiff("SECOND", ntz(0, 100), ntz(sec, 900)), 1L) + // MICROSECOND: 2100ns - 900ns = 1200ns -> 1 (the micros-only count would wrongly be 2). + checkEvaluation(TimestampDiff("MICROSECOND", ntz(0, 900), ntz(2, 100)), 1L) + + // Precision 7 (100ns step) and 8 (10ns step) fractions. + checkEvaluation(TimestampDiff("NANOSECOND", ntz(0, 100, 7), ntz(0, 300, 7)), 200L) + checkEvaluation(TimestampDiff("NANOSECOND", ntz(0, 110, 8), ntz(0, 200, 8)), 90L) + + // LTZ (zone-aware), exact whole minute with equal fractions. + checkEvaluation( + TimestampDiff("MINUTE", ltz(0, 500), ltz(60 * sec, 500), Some("UTC")), 1L) + + // Mixed operands: a microsecond TIMESTAMP_LTZ start (zero fraction) and a nanosecond LTZ end. + checkEvaluation( + TimestampDiff("SECOND", Literal(0L, TimestampType), ltz(sec, 500), Some("UTC")), 1L) + + // NANOSECOND between two microsecond timestamps is well-defined (fractions are zero): the + // difference is a whole number of microseconds times 1000. + checkEvaluation( + TimestampDiff("NANOSECOND", Literal(0L, TimestampType), Literal(1L, TimestampType)), 1000L) + + // Null propagation on either operand. + checkEvaluation( + TimestampDiff("SECOND", Literal.create(null, TimestampNTZNanosType(9)), ntz(sec, 0)), null) + checkEvaluation( + TimestampDiff("NANOSECOND", ntz(0, 1), Literal.create(null, TimestampNTZNanosType(9))), null) + } + /** * Helper method to create a DATE literal from a string in date format. */ diff --git a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ltz-nanos.sql.out b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ltz-nanos.sql.out index 984037f6d8d10..4c0707a49f8f1 100644 --- a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ltz-nanos.sql.out +++ b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ltz-nanos.sql.out @@ -1662,3 +1662,17 @@ Project [timestampadd(NANOSECOND, cast(900 as bigint), 2019-12-31 16:00:00.00000 SELECT timestampadd(NANOSECOND, 1, TIMESTAMP_LTZ '2020-01-01 00:00:00 UTC') -- !query analysis [Analyzer test output redacted due to nondeterminism] + + +-- !query +SELECT timestampdiff(NANOSECOND, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000100 UTC', TIMESTAMP_LTZ '2020-01-01 00:00:00.000000900 UTC') +-- !query analysis +Project [timestampdiff(NANOSECOND, 2019-12-31 16:00:00.0000001, 2019-12-31 16:00:00.0000009, Some(America/Los_Angeles)) AS timestampdiff(NANOSECOND, TIMESTAMP_LTZ '2019-12-31 16:00:00.000000100', TIMESTAMP_LTZ '2019-12-31 16:00:00.000000900')#xL] ++- OneRowRelation + + +-- !query +SELECT timestampdiff(SECOND, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000900 UTC', TIMESTAMP_LTZ '2020-01-01 00:00:01.000000100 UTC') +-- !query analysis +Project [timestampdiff(SECOND, 2019-12-31 16:00:00.0000009, 2019-12-31 16:00:01.0000001, Some(America/Los_Angeles)) AS timestampdiff(SECOND, TIMESTAMP_LTZ '2019-12-31 16:00:00.000000900', TIMESTAMP_LTZ '2019-12-31 16:00:01.000000100')#xL] ++- OneRowRelation diff --git a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ntz-nanos.sql.out b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ntz-nanos.sql.out index 78fb4d721f477..c0513a8443933 100644 --- a/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ntz-nanos.sql.out +++ b/sql/core/src/test/resources/sql-tests/analyzer-results/timestamp-ntz-nanos.sql.out @@ -1631,3 +1631,24 @@ SELECT timestampadd(NANOSECOND, 50, '2020-01-01 00:00:00.0000001' :: timestamp_n -- !query analysis Project [timestampadd(NANOSECOND, cast(50 as bigint), cast(2020-01-01 00:00:00.0000001 as timestamp_ntz(7)), Some(America/Los_Angeles)) AS timestampadd(NANOSECOND, 50, CAST(2020-01-01 00:00:00.0000001 AS TIMESTAMP_NTZ(7)))#x] +- OneRowRelation + + +-- !query +SELECT timestampdiff(NANOSECOND, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000100', TIMESTAMP_NTZ '2020-01-01 00:00:00.000000900') +-- !query analysis +Project [timestampdiff(NANOSECOND, 2020-01-01 00:00:00.0000001, 2020-01-01 00:00:00.0000009, Some(America/Los_Angeles)) AS timestampdiff(NANOSECOND, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000100', TIMESTAMP_NTZ '2020-01-01 00:00:00.000000900')#xL] ++- OneRowRelation + + +-- !query +SELECT timestampdiff(SECOND, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000900', TIMESTAMP_NTZ '2020-01-01 00:00:01.000000100') +-- !query analysis +Project [timestampdiff(SECOND, 2020-01-01 00:00:00.0000009, 2020-01-01 00:00:01.0000001, Some(America/Los_Angeles)) AS timestampdiff(SECOND, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000900', TIMESTAMP_NTZ '2020-01-01 00:00:01.000000100')#xL] ++- OneRowRelation + + +-- !query +SELECT timestampdiff(MICROSECOND, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000900', TIMESTAMP_NTZ '2020-01-01 00:00:00.000002100') +-- !query analysis +Project [timestampdiff(MICROSECOND, 2020-01-01 00:00:00.0000009, 2020-01-01 00:00:00.0000021, Some(America/Los_Angeles)) AS timestampdiff(MICROSECOND, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000900', TIMESTAMP_NTZ '2020-01-01 00:00:00.000002100')#xL] ++- OneRowRelation diff --git a/sql/core/src/test/resources/sql-tests/analyzer-results/timestampNTZ/timestamp.sql.out b/sql/core/src/test/resources/sql-tests/analyzer-results/timestampNTZ/timestamp.sql.out index 69c25c81dea00..acd9cb4d24ee6 100644 --- a/sql/core/src/test/resources/sql-tests/analyzer-results/timestampNTZ/timestamp.sql.out +++ b/sql/core/src/test/resources/sql-tests/analyzer-results/timestampNTZ/timestamp.sql.out @@ -1177,14 +1177,14 @@ org.apache.spark.sql.catalyst.parser.ParseException -- !query select timestampdiff(MONTH, timestamp'2022-02-14 01:02:03', timestamp'2022-01-14 01:02:03') -- !query analysis -Project [timestampdiff(MONTH, cast(2022-02-14 01:02:03 as timestamp), cast(2022-01-14 01:02:03 as timestamp), Some(America/Los_Angeles)) AS timestampdiff(MONTH, TIMESTAMP_NTZ '2022-02-14 01:02:03', TIMESTAMP_NTZ '2022-01-14 01:02:03')#xL] +Project [timestampdiff(MONTH, 2022-02-14 01:02:03, 2022-01-14 01:02:03, Some(America/Los_Angeles)) AS timestampdiff(MONTH, TIMESTAMP_NTZ '2022-02-14 01:02:03', TIMESTAMP_NTZ '2022-01-14 01:02:03')#xL] +- OneRowRelation -- !query select timestampdiff(MINUTE, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 02:00:03') -- !query analysis -Project [timestampdiff(MINUTE, cast(2022-02-14 01:02:03 as timestamp), cast(2022-02-14 02:00:03 as timestamp), Some(America/Los_Angeles)) AS timestampdiff(MINUTE, TIMESTAMP_NTZ '2022-02-14 01:02:03', TIMESTAMP_NTZ '2022-02-14 02:00:03')#xL] +Project [timestampdiff(MINUTE, 2022-02-14 01:02:03, 2022-02-14 02:00:03, Some(America/Los_Angeles)) AS timestampdiff(MINUTE, TIMESTAMP_NTZ '2022-02-14 01:02:03', TIMESTAMP_NTZ '2022-02-14 02:00:03')#xL] +- OneRowRelation @@ -1247,21 +1247,21 @@ org.apache.spark.sql.catalyst.parser.ParseException -- !query select timestampdiff(NANOSECOND, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 01:02:04') -- !query analysis -Project [timestampdiff(NANOSECOND, cast(2022-02-14 01:02:03 as timestamp), cast(2022-02-14 01:02:04 as timestamp), Some(America/Los_Angeles)) AS timestampdiff(NANOSECOND, TIMESTAMP_NTZ '2022-02-14 01:02:03', TIMESTAMP_NTZ '2022-02-14 01:02:04')#xL] +Project [timestampdiff(NANOSECOND, 2022-02-14 01:02:03, 2022-02-14 01:02:04, Some(America/Los_Angeles)) AS timestampdiff(NANOSECOND, TIMESTAMP_NTZ '2022-02-14 01:02:03', TIMESTAMP_NTZ '2022-02-14 01:02:04')#xL] +- OneRowRelation -- !query select timediff(QUARTER, timestamp'2023-08-10 01:02:03', timestamp'2022-01-14 01:02:03') -- !query analysis -Project [timestampdiff(QUARTER, cast(2023-08-10 01:02:03 as timestamp), cast(2022-01-14 01:02:03 as timestamp), Some(America/Los_Angeles)) AS timestampdiff(QUARTER, TIMESTAMP_NTZ '2023-08-10 01:02:03', TIMESTAMP_NTZ '2022-01-14 01:02:03')#xL] +Project [timestampdiff(QUARTER, 2023-08-10 01:02:03, 2022-01-14 01:02:03, Some(America/Los_Angeles)) AS timestampdiff(QUARTER, TIMESTAMP_NTZ '2023-08-10 01:02:03', TIMESTAMP_NTZ '2022-01-14 01:02:03')#xL] +- OneRowRelation -- !query select timediff(HOUR, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 12:00:03') -- !query analysis -Project [timestampdiff(HOUR, cast(2022-02-14 01:02:03 as timestamp), cast(2022-02-14 12:00:03 as timestamp), Some(America/Los_Angeles)) AS timestampdiff(HOUR, TIMESTAMP_NTZ '2022-02-14 01:02:03', TIMESTAMP_NTZ '2022-02-14 12:00:03')#xL] +Project [timestampdiff(HOUR, 2022-02-14 01:02:03, 2022-02-14 12:00:03, Some(America/Los_Angeles)) AS timestampdiff(HOUR, TIMESTAMP_NTZ '2022-02-14 01:02:03', TIMESTAMP_NTZ '2022-02-14 12:00:03')#xL] +- OneRowRelation diff --git a/sql/core/src/test/resources/sql-tests/inputs/timestamp-ltz-nanos.sql b/sql/core/src/test/resources/sql-tests/inputs/timestamp-ltz-nanos.sql index 5a222f8488df6..f855c4e2f8121 100644 --- a/sql/core/src/test/resources/sql-tests/inputs/timestamp-ltz-nanos.sql +++ b/sql/core/src/test/resources/sql-tests/inputs/timestamp-ltz-nanos.sql @@ -580,3 +580,8 @@ SELECT timestampadd(NANOSECOND, -300, TIMESTAMP_LTZ '2020-01-01 00:00:00.0000001 SELECT timestampadd(NANOSECOND, 900, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000200 UTC'); -- NANOSECOND is rejected on a microsecond-precision timestamp (nanoseconds are unrepresentable). SELECT timestampadd(NANOSECOND, 1, TIMESTAMP_LTZ '2020-01-01 00:00:00 UTC'); + +-- SPARK-57833: timestampdiff over TIMESTAMP_LTZ(p). NANOSECOND reports the exact difference; the +-- fraction also tips coarser units (SECOND is 0 here because only 0.9999992s elapsed). +SELECT timestampdiff(NANOSECOND, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000100 UTC', TIMESTAMP_LTZ '2020-01-01 00:00:00.000000900 UTC'); +SELECT timestampdiff(SECOND, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000900 UTC', TIMESTAMP_LTZ '2020-01-01 00:00:01.000000100 UTC'); diff --git a/sql/core/src/test/resources/sql-tests/inputs/timestamp-ntz-nanos.sql b/sql/core/src/test/resources/sql-tests/inputs/timestamp-ntz-nanos.sql index 1178d1bb9f997..69a06b6688c11 100644 --- a/sql/core/src/test/resources/sql-tests/inputs/timestamp-ntz-nanos.sql +++ b/sql/core/src/test/resources/sql-tests/inputs/timestamp-ntz-nanos.sql @@ -574,3 +574,10 @@ SELECT timestampadd(NANOSECOND, 1, TIMESTAMP_NTZ '2020-01-01 00:00:00'); -- .0000001 value lands on .0000002, and a sub-step +50ns is truncated back to .0000001. SELECT timestampadd(NANOSECOND, 150, '2020-01-01 00:00:00.0000001' :: timestamp_ntz(7)); SELECT timestampadd(NANOSECOND, 50, '2020-01-01 00:00:00.0000001' :: timestamp_ntz(7)); + +-- SPARK-57833: timestampdiff over TIMESTAMP_NTZ(p). NANOSECOND reports the exact sub-microsecond +-- difference, and the fraction participates in the truncated count for coarser units too: SECOND +-- between .000000900 and the next second's .000000100 is 0 (only 0.9999992s elapsed). +SELECT timestampdiff(NANOSECOND, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000100', TIMESTAMP_NTZ '2020-01-01 00:00:00.000000900'); +SELECT timestampdiff(SECOND, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000900', TIMESTAMP_NTZ '2020-01-01 00:00:01.000000100'); +SELECT timestampdiff(MICROSECOND, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000900', TIMESTAMP_NTZ '2020-01-01 00:00:00.000002100'); diff --git a/sql/core/src/test/resources/sql-tests/inputs/timestamp.sql b/sql/core/src/test/resources/sql-tests/inputs/timestamp.sql index 6f8270f0c9758..6ccf056c2dc25 100644 --- a/sql/core/src/test/resources/sql-tests/inputs/timestamp.sql +++ b/sql/core/src/test/resources/sql-tests/inputs/timestamp.sql @@ -207,9 +207,9 @@ select timestampdiff(SECOND, date'2022-02-15', timestamp'2022-02-14 23:59:59'); select timestampdiff('MINUTE', timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 02:00:03'); select timestampdiff('YEAR', date'2022-02-15', date'2023-02-15'); --- NANOSECOND is a valid datetimeUnit keyword (timestampadd accepts it for nanosecond-precision --- timestamps) but timestampdiff does not support it, so it is rejected at runtime, matching other --- add-only units such as DAYOFYEAR. +-- NANOSECOND is supported by both timestampadd (for nanosecond-precision inputs) and timestampdiff. +-- Between two microsecond timestamps the sub-microsecond fractions are zero, so the difference is a +-- whole number of microseconds times 1000 (here one second = 1000000000 ns). select timestampdiff(NANOSECOND, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 01:02:04'); select timediff(QUARTER, timestamp'2023-08-10 01:02:03', timestamp'2022-01-14 01:02:03'); diff --git a/sql/core/src/test/resources/sql-tests/results/nonansi/timestamp.sql.out b/sql/core/src/test/resources/sql-tests/results/nonansi/timestamp.sql.out index 09fd5add2e0a9..88ec350fd3645 100644 --- a/sql/core/src/test/resources/sql-tests/results/nonansi/timestamp.sql.out +++ b/sql/core/src/test/resources/sql-tests/results/nonansi/timestamp.sql.out @@ -1502,18 +1502,9 @@ org.apache.spark.sql.catalyst.parser.ParseException -- !query select timestampdiff(NANOSECOND, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 01:02:04') -- !query schema -struct<> +struct -- !query output -org.apache.spark.SparkIllegalArgumentException -{ - "errorClass" : "INVALID_PARAMETER_VALUE.DATETIME_UNIT", - "sqlState" : "22023", - "messageParameters" : { - "functionName" : "`TIMESTAMPDIFF`", - "invalidValue" : "'NANOSECOND'", - "parameter" : "`unit`" - } -} +1000000000 -- !query diff --git a/sql/core/src/test/resources/sql-tests/results/timestamp-ltz-nanos.sql.out b/sql/core/src/test/resources/sql-tests/results/timestamp-ltz-nanos.sql.out index 703661a362d35..300d63c816611 100644 --- a/sql/core/src/test/resources/sql-tests/results/timestamp-ltz-nanos.sql.out +++ b/sql/core/src/test/resources/sql-tests/results/timestamp-ltz-nanos.sql.out @@ -1806,3 +1806,19 @@ org.apache.spark.SparkIllegalArgumentException "parameter" : "`unit`" } } + + +-- !query +SELECT timestampdiff(NANOSECOND, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000100 UTC', TIMESTAMP_LTZ '2020-01-01 00:00:00.000000900 UTC') +-- !query schema +struct +-- !query output +800 + + +-- !query +SELECT timestampdiff(SECOND, TIMESTAMP_LTZ '2020-01-01 00:00:00.000000900 UTC', TIMESTAMP_LTZ '2020-01-01 00:00:01.000000100 UTC') +-- !query schema +struct +-- !query output +0 diff --git a/sql/core/src/test/resources/sql-tests/results/timestamp-ntz-nanos.sql.out b/sql/core/src/test/resources/sql-tests/results/timestamp-ntz-nanos.sql.out index 081908fac208b..cbecb3dde8468 100644 --- a/sql/core/src/test/resources/sql-tests/results/timestamp-ntz-nanos.sql.out +++ b/sql/core/src/test/resources/sql-tests/results/timestamp-ntz-nanos.sql.out @@ -1696,3 +1696,27 @@ SELECT timestampadd(NANOSECOND, 50, '2020-01-01 00:00:00.0000001' :: timestamp_n struct -- !query output 2020-01-01 00:00:00.0000001 + + +-- !query +SELECT timestampdiff(NANOSECOND, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000100', TIMESTAMP_NTZ '2020-01-01 00:00:00.000000900') +-- !query schema +struct +-- !query output +800 + + +-- !query +SELECT timestampdiff(SECOND, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000900', TIMESTAMP_NTZ '2020-01-01 00:00:01.000000100') +-- !query schema +struct +-- !query output +0 + + +-- !query +SELECT timestampdiff(MICROSECOND, TIMESTAMP_NTZ '2020-01-01 00:00:00.000000900', TIMESTAMP_NTZ '2020-01-01 00:00:00.000002100') +-- !query schema +struct +-- !query output +1 diff --git a/sql/core/src/test/resources/sql-tests/results/timestamp.sql.out b/sql/core/src/test/resources/sql-tests/results/timestamp.sql.out index 574b795950373..78c66ef712f9e 100644 --- a/sql/core/src/test/resources/sql-tests/results/timestamp.sql.out +++ b/sql/core/src/test/resources/sql-tests/results/timestamp.sql.out @@ -1506,18 +1506,9 @@ org.apache.spark.sql.catalyst.parser.ParseException -- !query select timestampdiff(NANOSECOND, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 01:02:04') -- !query schema -struct<> +struct -- !query output -org.apache.spark.SparkIllegalArgumentException -{ - "errorClass" : "INVALID_PARAMETER_VALUE.DATETIME_UNIT", - "sqlState" : "22023", - "messageParameters" : { - "functionName" : "`TIMESTAMPDIFF`", - "invalidValue" : "'NANOSECOND'", - "parameter" : "`unit`" - } -} +1000000000 -- !query diff --git a/sql/core/src/test/resources/sql-tests/results/timestampNTZ/timestamp.sql.out b/sql/core/src/test/resources/sql-tests/results/timestampNTZ/timestamp.sql.out index 687d608212bb8..a27d1e83cf25e 100644 --- a/sql/core/src/test/resources/sql-tests/results/timestampNTZ/timestamp.sql.out +++ b/sql/core/src/test/resources/sql-tests/results/timestampNTZ/timestamp.sql.out @@ -1427,7 +1427,7 @@ select timestampdiff(SECOND, date'2022-02-15', timestamp'2022-02-14 23:59:59') -- !query schema struct -- !query output --1 +-28801 -- !query @@ -1481,18 +1481,9 @@ org.apache.spark.sql.catalyst.parser.ParseException -- !query select timestampdiff(NANOSECOND, timestamp'2022-02-14 01:02:03', timestamp'2022-02-14 01:02:04') -- !query schema -struct<> +struct -- !query output -org.apache.spark.SparkIllegalArgumentException -{ - "errorClass" : "INVALID_PARAMETER_VALUE.DATETIME_UNIT", - "sqlState" : "22023", - "messageParameters" : { - "functionName" : "`TIMESTAMPDIFF`", - "invalidValue" : "'NANOSECOND'", - "parameter" : "`unit`" - } -} +1000000000 -- !query @@ -1524,7 +1515,7 @@ select timediff(SECOND, date'2022-02-15', timestamp'2022-02-14 23:59:59') -- !query schema struct -- !query output --1 +-28801 -- !query From e4f29116e415c5994c357e670bd09005cc280956 Mon Sep 17 00:00:00 2001 From: Stevo Mitric Date: Mon, 21 Sep 2026 08:56:14 +0000 Subject: [PATCH 4/5] [SPARK-57833][SQL] Address review: DATETIME_OVERFLOW + nits in timestampdiff - timestampdiff(NANOSECOND, ...) over a range wider than ~292 years now throws a clean DATETIME_OVERFLOW instead of a raw java.lang.ArithmeticException, on both the microsecond (timestampDiff) and nanosecond (timestampDiffNanos) paths, matching the timestampadd side. Adds QueryExecutionErrors.timestampDiffOverflowError and a DateExpressionsSuite case asserting the overflow on both paths. - TimestampDiff.isNanos is now a @transient private lazy val (was a per-row def), matching TimestampAdd. - timestampDiffNanos does a single timestampDiffMap.get lookup instead of contains + apply. Co-authored-by: Isaac --- .../expressions/datetimeExpressions.scala | 2 +- .../sql/catalyst/util/DateTimeUtils.scala | 28 ++++++++++++++----- .../sql/errors/QueryExecutionErrors.scala | 9 ++++++ .../expressions/DateExpressionsSuite.scala | 13 +++++++++ 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala index 7d4604de263ff..6bc0a1af9f621 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datetimeExpressions.scala @@ -5196,7 +5196,7 @@ case class TimestampDiff( // A nanosecond-precision operand is carried as a TimestampNanosVal object rather than a primitive // microsecond Long; when either operand is nanos, the difference is computed at full nanosecond // resolution so the sub-microsecond fraction participates in the truncated unit count. - private def isNanos: Boolean = + @transient private lazy val isNanos: Boolean = startTimestamp.dataType.isInstanceOf[AnyTimestampNanoType] || endTimestamp.dataType.isInstanceOf[AnyTimestampNanoType] diff --git a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala index 1cce597ac3c00..2f2c2c8defafd 100644 --- a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala +++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala @@ -1075,7 +1075,14 @@ object DateTimeUtils extends SparkDateTimeUtils { if (timestampDiffMap.contains(unitInUpperCase)) { val startLocalTs = getLocalDateTime(startTs, zoneId) val endLocalTs = getLocalDateTime(endTs, zoneId) - timestampDiffMap(unitInUpperCase)(startLocalTs, endLocalTs) + try { + timestampDiffMap(unitInUpperCase)(startLocalTs, endLocalTs) + } catch { + // NANOSECOND is the only unit whose count overflows a 64-bit long in the supported + // ~292-year range; surface DATETIME_OVERFLOW instead of a raw ArithmeticException. + case _: ArithmeticException => + throw QueryExecutionErrors.timestampDiffOverflowError(unit) + } } else { throw QueryExecutionErrors.invalidDatetimeUnitError("TIMESTAMPDIFF", unit) } @@ -1108,12 +1115,19 @@ object DateTimeUtils extends SparkDateTimeUtils { endFraction: Int, zoneId: ZoneId): Long = { val unitInUpperCase = unit.toUpperCase(Locale.ROOT) - if (timestampDiffMap.contains(unitInUpperCase)) { - val startLocalTs = getLocalDateTime(startMicros, zoneId).plusNanos(startFraction.toLong) - val endLocalTs = getLocalDateTime(endMicros, zoneId).plusNanos(endFraction.toLong) - timestampDiffMap(unitInUpperCase)(startLocalTs, endLocalTs) - } else { - throw QueryExecutionErrors.invalidDatetimeUnitError("TIMESTAMPDIFF", unit) + timestampDiffMap.get(unitInUpperCase) match { + case Some(diff) => + val startLocalTs = getLocalDateTime(startMicros, zoneId).plusNanos(startFraction.toLong) + val endLocalTs = getLocalDateTime(endMicros, zoneId).plusNanos(endFraction.toLong) + try { + diff(startLocalTs, endLocalTs) + } catch { + // The NANOSECOND count can overflow a 64-bit long past ~292 years. + case _: ArithmeticException => + throw QueryExecutionErrors.timestampDiffOverflowError(unit) + } + case None => + throw QueryExecutionErrors.invalidDatetimeUnitError("TIMESTAMPDIFF", unit) } } 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 46eb4aaa816a1..2d5535de1c530 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 @@ -2731,6 +2731,15 @@ private[sql] object QueryExecutionErrors extends QueryErrorsBase with ExecutionE summary = "") } + def timestampDiffOverflowError(unit: String): ArithmeticException = { + new SparkArithmeticException( + errorClass = "DATETIME_OVERFLOW", + messageParameters = Map( + "operation" -> s"get the number of $unit between the two timestamps"), + context = Array.empty, + summary = "") + } + def calendarIntervalArrowNanosOverflowError( interval: CalendarInterval): SparkArithmeticException = { new SparkArithmeticException( diff --git a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala index df055db972c8e..5d77d5c199d29 100644 --- a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala +++ b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/DateExpressionsSuite.scala @@ -3055,6 +3055,19 @@ class DateExpressionsSuite extends SparkFunSuite with ExpressionEvalHelper { TimestampDiff("SECOND", Literal.create(null, TimestampNTZNanosType(9)), ntz(sec, 0)), null) checkEvaluation( TimestampDiff("NANOSECOND", ntz(0, 1), Literal.create(null, TimestampNTZNanosType(9))), null) + + // A NANOSECOND difference wider than ~292 years overflows a 64-bit nanosecond count. It is + // surfaced as DATETIME_OVERFLOW (matching the timestampadd side) rather than a raw + // ArithmeticException, on both the microsecond and the nanosecond-carrier code paths. + checkErrorInExpression[SparkArithmeticException]( + TimestampDiff("NANOSECOND", + Literal(-5000000000000000L, TimestampType), Literal(5000000000000000L, TimestampType)), + condition = "DATETIME_OVERFLOW", + parameters = Map("operation" -> "get the number of NANOSECOND between the two timestamps")) + checkErrorInExpression[SparkArithmeticException]( + TimestampDiff("NANOSECOND", ntz(-5000000000000000L, 0), ntz(5000000000000000L, 0)), + condition = "DATETIME_OVERFLOW", + parameters = Map("operation" -> "get the number of NANOSECOND between the two timestamps")) } /** From 7fcf60e57445971dac73a298645b67386b2b64de Mon Sep 17 00:00:00 2001 From: Stevo Mitric Date: Mon, 21 Sep 2026 09:02:39 +0000 Subject: [PATCH 5/5] [SPARK-57833][SQL][PYTHON] Document NANOSECOND in PySpark timestamp_diff Mirror the SQL ExpressionDescription and the timestamp_add docstring: add NANOSECOND to the timestamp_diff unit list, and note that nanosecond-precision timestamp operands (TIMESTAMP_NTZ/LTZ(p), p in [7, 9]) are accepted and their sub-microsecond fraction participates in the truncated difference. Co-authored-by: Isaac --- python/pyspark/sql/functions/builtin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/pyspark/sql/functions/builtin.py b/python/pyspark/sql/functions/builtin.py index a25d3400c4799..c2aaf18992838 100644 --- a/python/pyspark/sql/functions/builtin.py +++ b/python/pyspark/sql/functions/builtin.py @@ -14185,9 +14185,11 @@ def timestamp_diff(unit: str, start: "ColumnOrName", end: "ColumnOrName") -> Col unit : literal string This indicates the units of the difference between the given timestamps. Supported options are (case insensitive): "YEAR", "QUARTER", "MONTH", "WEEK", - "DAY", "HOUR", "MINUTE", "SECOND", "MILLISECOND" and "MICROSECOND". + "DAY", "HOUR", "MINUTE", "SECOND", "MILLISECOND", "MICROSECOND" and "NANOSECOND". start : :class:`~pyspark.sql.Column` or column name A timestamp which the expression subtracts from `endTimestamp`. + Nanosecond-precision timestamps (TIMESTAMP_NTZ/LTZ(p), p in [7, 9]) are accepted, and + their sub-microsecond fraction participates in the truncated difference for every unit. end : :class:`~pyspark.sql.Column` or column name A timestamp from which the expression subtracts `startTimestamp`.