Skip to content

fix: time ± interval returns a wrapped time instead of an interval#23279

Open
vismaytiwari wants to merge 3 commits into
apache:mainfrom
vismaytiwari:time-interval-returns-time
Open

fix: time ± interval returns a wrapped time instead of an interval#23279
vismaytiwari wants to merge 3 commits into
apache:mainfrom
vismaytiwari:time-interval-returns-time

Conversation

@vismaytiwari

@vismaytiwari vismaytiwari commented Jul 1, 2026

Copy link
Copy Markdown

Which issue does this PR close?

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) with a time result type (the interval is normalized to MonthDayNano). 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. All four time units (Time32(Second|Millisecond), Time64(Microsecond|Nanosecond)) and both operand orders are supported. Only the interval's nanoseconds affect 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) and ignoring whole days.

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.

@github-actions github-actions Bot added logical-expr Logical plan and expressions physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt) labels Jul 1, 2026
`time + interval`, `interval + time`, and `time - interval` previously widened
the time operand into an interval, so `time '23:30' + interval '2 hours'`
returned a `25 hours 30 mins` interval rather than wrapping within the 24-hour
clock. Following the existing `Date - Date` special case, coercion now keeps the
result as `time`, and a new `apply_time_interval` kernel adds/subtracts the
interval's sub-day component modulo 24 hours to match PostgreSQL and DuckDB.
Whole months and days are ignored, and all four time units are supported.

Closes apache#22265
@vismaytiwari vismaytiwari force-pushed the time-interval-returns-time branch from 0a33c81 to 5b6be8b Compare July 1, 2026 13:38
@vismaytiwari

Copy link
Copy Markdown
Author

Fixed the rustfmt failure from the first run — fmt, the unit tests, and the datetime sqllogictests all pass locally. The workflows just need approval to run on the updated commit.

@Jefffrey Jefffrey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it makes sense to make this change to me, given two other systems behave similarly and having time + interval = interval seems non-obvious (our current behaviour)

we'll need a note in the upgrade guide since this is a user facing change

let time_ns = time_ns_arr.as_primitive::<Time64NanosecondType>();
let interval_arr = cast(
interval_array,
&DataType::Interval(IntervalUnit::MonthDayNano),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this cast to monthdaynano should be unnecessary as logical layer coerces to the right type

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — dropped this one. The coercion already normalizes the interval to MonthDayNano, so we can downcast directly here.


let left = lhs.to_array(num_rows)?;
let right = rhs.to_array(num_rows)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have some optimization opportunity here to operate directly on columnarvalue instead of converting to arrays; see apply_date_subtraction for reference

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — reworked it to match on ColumnarValue directly (following apply_date_subtraction), so the scalar paths no longer materialize a full array. Thanks for the pointer.

Comment on lines +331 to +332
// Normalize to a single representation: time as Time64(ns), interval as MonthDayNano.
let time_ns_arr = cast(time_array, &DataType::Time64(TimeUnit::Nanosecond))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similarly here i feel we can avoid this cast

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I moved the normalization up into the coercion layer, so the time operand now arrives as Time64(Nanosecond) and there's no cast here anymore. That does make the result Time64(Nanosecond) regardless of the input time unit — I called that out in the upgrade guide.


let wrapped: Time64NanosecondArray =
arrow::compute::binary(time_ns, interval, |t, iv| {
let delta = iv.nanoseconds as i128;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is operating on i128 necessary here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It isn't — switched to i64. Reducing the interval's nanoseconds modulo a day first keeps the sum in range, and rem_euclid handles the wrap, so i128 isn't needed.

@Jefffrey Jefffrey added the api change Changes the API exposed to users of the crate label Jul 5, 2026
- Coerce the operands of `time ± interval` to Time64(Nanosecond) and
  Interval(MonthDayNano) in the type coercion layer, so the physical layer no
  longer casts either operand. The result type is Time64(Nanosecond).
- Rewrite apply_time_interval to operate directly on ColumnarValue (like
  apply_date_subtraction) instead of materializing arrays, and use i64
  arithmetic with rem_euclid rather than i128.
- Note the user-facing behavior change in the 55.0.0 upgrade guide and cover
  array/null inputs in the sqllogictest.
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api change Changes the API exposed to users of the crate documentation Improvements or additions to documentation logical-expr Logical plan and expressions physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt)

Projects

None yet

2 participants