Skip to content

Commit 1e58928

Browse files
authored
fix: time ± interval returns a wrapped time instead of an interval (#23279)
## Which issue does this PR close? - Closes #22265 - Closes #22255 ## Rationale for this change `time + interval` (and `interval + time` / `time - interval`) returned an `Interval` instead of a `time`, so the value was never wrapped within the 24-hour clock: ``` > SELECT time '23:30' + interval '2 hours'; 25 hours 30 mins -- an Interval, not a time ``` PostgreSQL (and DuckDB) return a `time` that wraps around midnight: ``` 01:30:00 ``` The root cause is in type coercion: `time <op> interval` was coerced by widening the `time` operand into an `Interval`, so the addition happened between two intervals and the result kept the `Interval` type. ## What changes are included in this PR? Following the existing `Date - Date` special case (in the same two files), `time ± interval` is now handled explicitly: - **Coercion** (`expr-common/src/type_coercion/binary.rs`): `time + interval`, `interval + time`, and `time - interval` now coerce to `(time, interval)` — the interval normalized to `MonthDayNano`, and the time operand kept at its own unit, which is also the result type. `interval - time`, which is not meaningful, is left unchanged. - **Evaluation** (`physical-expr/src/expressions/binary.rs`): a new `apply_time_interval` adds/subtracts the interval's sub-day component and wraps the result modulo 24 hours, for all four time units (`Time32(Second|Millisecond)`, `Time64(Microsecond|Nanosecond)`) and both operand orders. The result **keeps the input time's unit**, mirroring `timestamp/date + interval` (which preserve their unit and apply the interval at that resolution); interval precision finer than the time's unit is truncated, so `time(s) + interval '1 nanosecond'` is a no-op, exactly like `timestamp(s) + interval '1 nanosecond'`. Only the interval's sub-day portion affects a time-of-day; whole months and days are ignored, matching PostgreSQL (e.g. `time '10:00' + interval '1 day 2 hours'` = `12:00:00`). `time - time` (→ `Interval`) is unchanged. ## Are these changes tested? Yes. `datafusion/sqllogictest/test_files/datetime/arith_time_interval.slt` was previously a characterization test that documented the incorrect `Interval` output; it now asserts the correct wrapped `time` results — including wrapping past midnight in both directions (`22:00 + 3h → 01:00:00`, `02:00 - 3h → 23:00:00`), ignoring whole days, preserving each input time unit (`arrow_typeof` per unit), and the finer-than-unit interval truncation. ## Are there any user-facing changes? Yes — `time ± interval` now returns a `time` value (wrapped within 24 hours) instead of an `Interval`, aligning DataFusion with PostgreSQL and DuckDB. The result keeps the input time's unit rather than widening it (see the 55.0.0 upgrade guide).
1 parent d5703bd commit 1e58928

4 files changed

Lines changed: 347 additions & 26 deletions

File tree

datafusion/expr-common/src/type_coercion/binary.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,23 @@ impl<'a> BinaryTypeCoercer<'a> {
267267
ret: Int64,
268268
});
269269
}
270+
Plus | Minus if is_time_interval_arithmetic(lhs, rhs, self.op) => {
271+
// `time ± interval` yields a `time` wrapped within the 24-hour clock,
272+
// matching PostgreSQL and DuckDB (e.g. `time '23:30' + interval '2 hours'`
273+
// is `01:30:00`). The interval is normalized to `MonthDayNano`; the time
274+
// operand keeps its own unit and is also the result type -- mirroring
275+
// `timestamp/date + interval`, which preserve their unit and apply the
276+
// interval at that resolution. So, like `timestamp(s) + interval
277+
// '1 nanosecond'`, `time(s) + interval '1 nanosecond'` is a no-op rather
278+
// than widening the type.
279+
let (lhs, rhs, ret) = match (lhs, rhs) {
280+
(Interval(_), time) => {
281+
(Interval(MonthDayNano), time.clone(), time.clone())
282+
}
283+
(time, _) => (time.clone(), Interval(MonthDayNano), time.clone()),
284+
};
285+
return Ok(Signature { lhs, rhs, ret });
286+
}
270287
Plus | Minus | Multiply | Divide | Modulo => {
271288
if let Ok(ret) = self.get_result(lhs, rhs) {
272289

@@ -362,6 +379,23 @@ fn is_date_minus_date(lhs: &DataType, rhs: &DataType) -> bool {
362379
)
363380
}
364381

382+
/// Returns true for `time + interval`, `interval + time`, or `time - interval`.
383+
///
384+
/// These follow PostgreSQL/DuckDB semantics where the result is a `time` value
385+
/// wrapped within the 24-hour clock, rather than being widened to an interval.
386+
fn is_time_interval_arithmetic(lhs: &DataType, rhs: &DataType, op: &Operator) -> bool {
387+
use DataType::{Interval, Time32, Time64};
388+
match op {
389+
Operator::Plus => matches!(
390+
(lhs, rhs),
391+
(Time32(_) | Time64(_), Interval(_)) | (Interval(_), Time32(_) | Time64(_))
392+
),
393+
// `interval - time` is not meaningful, so only `time - interval` is accepted.
394+
Operator::Minus => matches!((lhs, rhs), (Time32(_) | Time64(_), Interval(_))),
395+
_ => false,
396+
}
397+
}
398+
365399
/// Coercion rules for mathematics operators between decimal and non-decimal types.
366400
fn math_decimal_coercion(
367401
lhs_type: &DataType,

datafusion/physical-expr/src/expressions/binary.rs

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,189 @@ where
271271
}
272272
}
273273

274+
/// Returns true for `time + interval` or `interval + time`.
275+
fn is_time_plus_interval(lhs: &DataType, rhs: &DataType) -> bool {
276+
matches!(
277+
(lhs, rhs),
278+
(
279+
DataType::Time32(_) | DataType::Time64(_),
280+
DataType::Interval(_)
281+
) | (
282+
DataType::Interval(_),
283+
DataType::Time32(_) | DataType::Time64(_)
284+
)
285+
)
286+
}
287+
288+
/// Returns true for `time - interval`.
289+
fn is_time_minus_interval(lhs: &DataType, rhs: &DataType) -> bool {
290+
matches!(
291+
(lhs, rhs),
292+
(
293+
DataType::Time32(_) | DataType::Time64(_),
294+
DataType::Interval(_)
295+
)
296+
)
297+
}
298+
299+
/// Evaluates `time + interval`, `interval + time`, or `time - interval`, returning a
300+
/// `time` wrapped within the 24-hour clock to match PostgreSQL and DuckDB (e.g.
301+
/// `time '23:30' + interval '2 hours'` is `01:30:00`). arrow's arithmetic kernels do
302+
/// not implement time-of-day arithmetic, so it is handled here.
303+
///
304+
/// The result keeps the input time's unit; the interval (normalized to `MonthDayNano`
305+
/// by the coercion layer) is applied at nanosecond precision and floored to that unit,
306+
/// mirroring `timestamp(unit) + interval`. Only the sub-day portion of the interval
307+
/// affects a time-of-day -- whole months and days are ignored, matching PostgreSQL. The
308+
/// floor is applied after the sign, so `time(s) + interval '1 nanosecond'` is a no-op
309+
/// while `time(s) - interval '1 nanosecond'` rolls back a second, exactly as the
310+
/// timestamp case does.
311+
fn apply_time_interval(
312+
lhs: &ColumnarValue,
313+
rhs: &ColumnarValue,
314+
subtract: bool,
315+
) -> Result<ColumnarValue> {
316+
// The `time` operand determines the result type; the other is the interval.
317+
let (time, interval) = if matches!(lhs.data_type(), DataType::Interval(_)) {
318+
(rhs, lhs)
319+
} else {
320+
(lhs, rhs)
321+
};
322+
323+
// Dispatch on the time unit; `ns_per_unit` converts the interval's nanoseconds to
324+
// that unit, and the arithmetic is done (and wrapped) at that resolution.
325+
match time.data_type() {
326+
DataType::Time32(TimeUnit::Second) => wrap_time_interval::<Time32SecondType>(
327+
time,
328+
interval,
329+
subtract,
330+
1_000_000_000,
331+
),
332+
DataType::Time32(TimeUnit::Millisecond) => {
333+
wrap_time_interval::<Time32MillisecondType>(
334+
time, interval, subtract, 1_000_000,
335+
)
336+
}
337+
DataType::Time64(TimeUnit::Microsecond) => {
338+
wrap_time_interval::<Time64MicrosecondType>(time, interval, subtract, 1_000)
339+
}
340+
DataType::Time64(TimeUnit::Nanosecond) => {
341+
wrap_time_interval::<Time64NanosecondType>(time, interval, subtract, 1)
342+
}
343+
other => internal_err!("time operand expected, got: {other}"),
344+
}
345+
}
346+
347+
/// Adds or subtracts an interval to/from a `time` of arrow primitive type `T`, wrapping
348+
/// the result within the 24-hour clock and keeping the type `T`. `ns_per_unit` is the
349+
/// number of nanoseconds in one unit of `T` (e.g. `1_000` for microseconds).
350+
fn wrap_time_interval<T: ArrowPrimitiveType>(
351+
time: &ColumnarValue,
352+
interval: &ColumnarValue,
353+
subtract: bool,
354+
ns_per_unit: i64,
355+
) -> Result<ColumnarValue>
356+
where
357+
T::Native: Copy + Into<i64> + TryFrom<i64>,
358+
{
359+
/// Nanoseconds in a 24-hour day.
360+
const DAY_NANOS: i64 = 86_400_000_000_000;
361+
// Units in a 24-hour day, at `T`'s resolution.
362+
let day_units = DAY_NANOS / ns_per_unit;
363+
364+
// Wraps `time ± interval` into `[0, day_units)`. The interval is reduced modulo a day
365+
// (so the sum stays within `i64`), applied at nanosecond precision, then floored to
366+
// `T`'s unit -- matching `timestamp(unit) ± interval`. Because the floor is applied
367+
// after the sign, `time(s) - interval '1 nanosecond'` rolls back a full second, just
368+
// as the timestamp case does, while `time(s) + interval '1 nanosecond'` is a no-op.
369+
// `div_euclid`/`rem_euclid` floor toward negative infinity, so the wrapped value stays
370+
// in `[0, day_units)`, which always fits `T::Native`.
371+
let wrap = |time_unit: i64, iv: IntervalMonthDayNano| -> T::Native {
372+
let iv_ns = iv.nanoseconds % DAY_NANOS;
373+
let signed_ns = if subtract { -iv_ns } else { iv_ns };
374+
let delta = signed_ns.div_euclid(ns_per_unit);
375+
let wrapped = (time_unit + delta).rem_euclid(day_units);
376+
T::Native::try_from(wrapped).unwrap_or_default()
377+
};
378+
379+
/// Extracts an `Interval(MonthDayNano)` scalar.
380+
fn interval_scalar(scalar: &ScalarValue) -> Result<Option<IntervalMonthDayNano>> {
381+
match scalar {
382+
ScalarValue::IntervalMonthDayNano(value) => Ok(*value),
383+
other => internal_err!(
384+
"Interval(MonthDayNano) scalar expected, got: {}",
385+
other.data_type()
386+
),
387+
}
388+
}
389+
390+
/// Extracts a time scalar as its unit count since midnight.
391+
fn time_scalar_units(scalar: &ScalarValue) -> Result<Option<i64>> {
392+
match scalar {
393+
ScalarValue::Time32Second(value) | ScalarValue::Time32Millisecond(value) => {
394+
Ok(value.map(i64::from))
395+
}
396+
ScalarValue::Time64Microsecond(value)
397+
| ScalarValue::Time64Nanosecond(value) => Ok(*value),
398+
other => {
399+
internal_err!("time scalar expected, got: {}", other.data_type())
400+
}
401+
}
402+
}
403+
404+
/// Builds a time scalar of type `P` from a unit count.
405+
fn time_scalar<P: ArrowPrimitiveType>(value: Option<i64>) -> ScalarValue {
406+
match P::DATA_TYPE {
407+
DataType::Time32(TimeUnit::Second) => {
408+
ScalarValue::Time32Second(value.map(|v| v as i32))
409+
}
410+
DataType::Time32(TimeUnit::Millisecond) => {
411+
ScalarValue::Time32Millisecond(value.map(|v| v as i32))
412+
}
413+
DataType::Time64(TimeUnit::Microsecond) => {
414+
ScalarValue::Time64Microsecond(value)
415+
}
416+
_ => ScalarValue::Time64Nanosecond(value),
417+
}
418+
}
419+
420+
match (time, interval) {
421+
(ColumnarValue::Array(time), ColumnarValue::Array(interval)) => {
422+
let time = time.as_primitive::<T>();
423+
let interval = interval.as_primitive::<IntervalMonthDayNanoType>();
424+
let result: PrimitiveArray<T> =
425+
arrow::compute::binary(time, interval, |t, iv| wrap(t.into(), iv))?;
426+
Ok(ColumnarValue::Array(Arc::new(result)))
427+
}
428+
(ColumnarValue::Array(time), ColumnarValue::Scalar(interval)) => {
429+
let time = time.as_primitive::<T>();
430+
match interval_scalar(interval)? {
431+
Some(iv) => {
432+
let result: PrimitiveArray<T> = time.unary(|t| wrap(t.into(), iv));
433+
Ok(ColumnarValue::Array(Arc::new(result)))
434+
}
435+
None => Ok(ColumnarValue::Scalar(time_scalar::<T>(None))),
436+
}
437+
}
438+
(ColumnarValue::Scalar(time), ColumnarValue::Array(interval)) => {
439+
let interval = interval.as_primitive::<IntervalMonthDayNanoType>();
440+
match time_scalar_units(time)? {
441+
Some(t) => {
442+
let result: PrimitiveArray<T> = interval.unary(|iv| wrap(t, iv));
443+
Ok(ColumnarValue::Array(Arc::new(result)))
444+
}
445+
None => Ok(ColumnarValue::Scalar(time_scalar::<T>(None))),
446+
}
447+
}
448+
(ColumnarValue::Scalar(time), ColumnarValue::Scalar(interval)) => {
449+
let result = time_scalar_units(time)?
450+
.zip(interval_scalar(interval)?)
451+
.map(|(t, iv)| wrap(t, iv).into());
452+
Ok(ColumnarValue::Scalar(time_scalar::<T>(result)))
453+
}
454+
}
455+
}
456+
274457
impl PhysicalExpr for BinaryExpr {
275458
fn data_type(&self, input_schema: &Schema) -> Result<DataType> {
276459
BinaryTypeCoercer::new(
@@ -353,6 +536,18 @@ impl PhysicalExpr for BinaryExpr {
353536
let input_schema = schema.as_ref();
354537

355538
match self.op {
539+
// `time ± interval` returns a wrapped `time` (PostgreSQL/DuckDB
540+
// semantics); arrow's arithmetic kernels don't implement it.
541+
Operator::Plus
542+
if is_time_plus_interval(&left_data_type, &right_data_type) =>
543+
{
544+
return apply_time_interval(&lhs, &rhs, false);
545+
}
546+
Operator::Minus
547+
if is_time_minus_interval(&left_data_type, &right_data_type) =>
548+
{
549+
return apply_time_interval(&lhs, &rhs, true);
550+
}
356551
Operator::Plus if self.fail_on_overflow => return apply(&lhs, &rhs, add),
357552
Operator::Plus => return apply(&lhs, &rhs, add_wrapping),
358553
// Special case: Date - Date returns Int64 (days difference)

0 commit comments

Comments
 (0)