This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git
The following commit(s) were added to refs/heads/main by this push:
new 1e589287c3 fix: `time ± interval` returns a wrapped `time` instead of
an interval (#23279)
1e589287c3 is described below
commit 1e589287c3d6bcf3bc6fa897bb889a6012e1d229
Author: Vismay <[email protected]>
AuthorDate: Fri Jul 17 17:06:47 2026 +0530
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).
---
datafusion/expr-common/src/type_coercion/binary.rs | 34 ++++
datafusion/physical-expr/src/expressions/binary.rs | 195 +++++++++++++++++++++
.../test_files/datetime/arith_time_interval.slt | 125 ++++++++++---
docs/source/library-user-guide/upgrading/55.0.0.md | 19 ++
4 files changed, 347 insertions(+), 26 deletions(-)
diff --git a/datafusion/expr-common/src/type_coercion/binary.rs
b/datafusion/expr-common/src/type_coercion/binary.rs
index 23ccf7f815..77ef1f59f7 100644
--- a/datafusion/expr-common/src/type_coercion/binary.rs
+++ b/datafusion/expr-common/src/type_coercion/binary.rs
@@ -267,6 +267,23 @@ impl<'a> BinaryTypeCoercer<'a> {
ret: Int64,
});
}
+ Plus | Minus if is_time_interval_arithmetic(lhs, rhs, self.op) => {
+ // `time ± interval` yields a `time` wrapped within the 24-hour
clock,
+ // matching PostgreSQL and DuckDB (e.g. `time '23:30' + interval
'2 hours'`
+ // is `01:30:00`). The interval is normalized to `MonthDayNano`;
the time
+ // operand keeps its own unit and is also the result type --
mirroring
+ // `timestamp/date + interval`, which preserve their unit and
apply the
+ // interval at that resolution. So, like `timestamp(s) + interval
+ // '1 nanosecond'`, `time(s) + interval '1 nanosecond'` is a no-op
rather
+ // than widening the type.
+ let (lhs, rhs, ret) = match (lhs, rhs) {
+ (Interval(_), time) => {
+ (Interval(MonthDayNano), time.clone(), time.clone())
+ }
+ (time, _) => (time.clone(), Interval(MonthDayNano),
time.clone()),
+ };
+ return Ok(Signature { lhs, rhs, ret });
+ }
Plus | Minus | Multiply | Divide | Modulo => {
if let Ok(ret) = self.get_result(lhs, rhs) {
@@ -362,6 +379,23 @@ fn is_date_minus_date(lhs: &DataType, rhs: &DataType) ->
bool {
)
}
+/// Returns true for `time + interval`, `interval + time`, or `time -
interval`.
+///
+/// These follow PostgreSQL/DuckDB semantics where the result is a `time` value
+/// wrapped within the 24-hour clock, rather than being widened to an interval.
+fn is_time_interval_arithmetic(lhs: &DataType, rhs: &DataType, op: &Operator)
-> bool {
+ use DataType::{Interval, Time32, Time64};
+ match op {
+ Operator::Plus => matches!(
+ (lhs, rhs),
+ (Time32(_) | Time64(_), Interval(_)) | (Interval(_), Time32(_) |
Time64(_))
+ ),
+ // `interval - time` is not meaningful, so only `time - interval` is
accepted.
+ Operator::Minus => matches!((lhs, rhs), (Time32(_) | Time64(_),
Interval(_))),
+ _ => false,
+ }
+}
+
/// Coercion rules for mathematics operators between decimal and non-decimal
types.
fn math_decimal_coercion(
lhs_type: &DataType,
diff --git a/datafusion/physical-expr/src/expressions/binary.rs
b/datafusion/physical-expr/src/expressions/binary.rs
index 7945cbbe00..e182bc14fa 100644
--- a/datafusion/physical-expr/src/expressions/binary.rs
+++ b/datafusion/physical-expr/src/expressions/binary.rs
@@ -271,6 +271,189 @@ where
}
}
+/// Returns true for `time + interval` or `interval + time`.
+fn is_time_plus_interval(lhs: &DataType, rhs: &DataType) -> bool {
+ matches!(
+ (lhs, rhs),
+ (
+ DataType::Time32(_) | DataType::Time64(_),
+ DataType::Interval(_)
+ ) | (
+ DataType::Interval(_),
+ DataType::Time32(_) | DataType::Time64(_)
+ )
+ )
+}
+
+/// Returns true for `time - interval`.
+fn is_time_minus_interval(lhs: &DataType, rhs: &DataType) -> bool {
+ matches!(
+ (lhs, rhs),
+ (
+ DataType::Time32(_) | DataType::Time64(_),
+ DataType::Interval(_)
+ )
+ )
+}
+
+/// Evaluates `time + interval`, `interval + time`, or `time - interval`,
returning a
+/// `time` wrapped within the 24-hour clock to match PostgreSQL and DuckDB
(e.g.
+/// `time '23:30' + interval '2 hours'` is `01:30:00`). arrow's arithmetic
kernels do
+/// not implement time-of-day arithmetic, so it is handled here.
+///
+/// The result keeps the input time's unit; the interval (normalized to
`MonthDayNano`
+/// by the coercion layer) is applied at nanosecond precision and floored to
that unit,
+/// mirroring `timestamp(unit) + interval`. Only the sub-day portion of the
interval
+/// affects a time-of-day -- whole months and days are ignored, matching
PostgreSQL. The
+/// floor is applied after the sign, so `time(s) + interval '1 nanosecond'` is
a no-op
+/// while `time(s) - interval '1 nanosecond'` rolls back a second, exactly as
the
+/// timestamp case does.
+fn apply_time_interval(
+ lhs: &ColumnarValue,
+ rhs: &ColumnarValue,
+ subtract: bool,
+) -> Result<ColumnarValue> {
+ // The `time` operand determines the result type; the other is the
interval.
+ let (time, interval) = if matches!(lhs.data_type(), DataType::Interval(_))
{
+ (rhs, lhs)
+ } else {
+ (lhs, rhs)
+ };
+
+ // Dispatch on the time unit; `ns_per_unit` converts the interval's
nanoseconds to
+ // that unit, and the arithmetic is done (and wrapped) at that resolution.
+ match time.data_type() {
+ DataType::Time32(TimeUnit::Second) =>
wrap_time_interval::<Time32SecondType>(
+ time,
+ interval,
+ subtract,
+ 1_000_000_000,
+ ),
+ DataType::Time32(TimeUnit::Millisecond) => {
+ wrap_time_interval::<Time32MillisecondType>(
+ time, interval, subtract, 1_000_000,
+ )
+ }
+ DataType::Time64(TimeUnit::Microsecond) => {
+ wrap_time_interval::<Time64MicrosecondType>(time, interval,
subtract, 1_000)
+ }
+ DataType::Time64(TimeUnit::Nanosecond) => {
+ wrap_time_interval::<Time64NanosecondType>(time, interval,
subtract, 1)
+ }
+ other => internal_err!("time operand expected, got: {other}"),
+ }
+}
+
+/// Adds or subtracts an interval to/from a `time` of arrow primitive type
`T`, wrapping
+/// the result within the 24-hour clock and keeping the type `T`.
`ns_per_unit` is the
+/// number of nanoseconds in one unit of `T` (e.g. `1_000` for microseconds).
+fn wrap_time_interval<T: ArrowPrimitiveType>(
+ time: &ColumnarValue,
+ interval: &ColumnarValue,
+ subtract: bool,
+ ns_per_unit: i64,
+) -> Result<ColumnarValue>
+where
+ T::Native: Copy + Into<i64> + TryFrom<i64>,
+{
+ /// Nanoseconds in a 24-hour day.
+ const DAY_NANOS: i64 = 86_400_000_000_000;
+ // Units in a 24-hour day, at `T`'s resolution.
+ let day_units = DAY_NANOS / ns_per_unit;
+
+ // Wraps `time ± interval` into `[0, day_units)`. The interval is reduced
modulo a day
+ // (so the sum stays within `i64`), applied at nanosecond precision, then
floored to
+ // `T`'s unit -- matching `timestamp(unit) ± interval`. Because the floor
is applied
+ // after the sign, `time(s) - interval '1 nanosecond'` rolls back a full
second, just
+ // as the timestamp case does, while `time(s) + interval '1 nanosecond'`
is a no-op.
+ // `div_euclid`/`rem_euclid` floor toward negative infinity, so the
wrapped value stays
+ // in `[0, day_units)`, which always fits `T::Native`.
+ let wrap = |time_unit: i64, iv: IntervalMonthDayNano| -> T::Native {
+ let iv_ns = iv.nanoseconds % DAY_NANOS;
+ let signed_ns = if subtract { -iv_ns } else { iv_ns };
+ let delta = signed_ns.div_euclid(ns_per_unit);
+ let wrapped = (time_unit + delta).rem_euclid(day_units);
+ T::Native::try_from(wrapped).unwrap_or_default()
+ };
+
+ /// Extracts an `Interval(MonthDayNano)` scalar.
+ fn interval_scalar(scalar: &ScalarValue) ->
Result<Option<IntervalMonthDayNano>> {
+ match scalar {
+ ScalarValue::IntervalMonthDayNano(value) => Ok(*value),
+ other => internal_err!(
+ "Interval(MonthDayNano) scalar expected, got: {}",
+ other.data_type()
+ ),
+ }
+ }
+
+ /// Extracts a time scalar as its unit count since midnight.
+ fn time_scalar_units(scalar: &ScalarValue) -> Result<Option<i64>> {
+ match scalar {
+ ScalarValue::Time32Second(value) |
ScalarValue::Time32Millisecond(value) => {
+ Ok(value.map(i64::from))
+ }
+ ScalarValue::Time64Microsecond(value)
+ | ScalarValue::Time64Nanosecond(value) => Ok(*value),
+ other => {
+ internal_err!("time scalar expected, got: {}",
other.data_type())
+ }
+ }
+ }
+
+ /// Builds a time scalar of type `P` from a unit count.
+ fn time_scalar<P: ArrowPrimitiveType>(value: Option<i64>) -> ScalarValue {
+ match P::DATA_TYPE {
+ DataType::Time32(TimeUnit::Second) => {
+ ScalarValue::Time32Second(value.map(|v| v as i32))
+ }
+ DataType::Time32(TimeUnit::Millisecond) => {
+ ScalarValue::Time32Millisecond(value.map(|v| v as i32))
+ }
+ DataType::Time64(TimeUnit::Microsecond) => {
+ ScalarValue::Time64Microsecond(value)
+ }
+ _ => ScalarValue::Time64Nanosecond(value),
+ }
+ }
+
+ match (time, interval) {
+ (ColumnarValue::Array(time), ColumnarValue::Array(interval)) => {
+ let time = time.as_primitive::<T>();
+ let interval = interval.as_primitive::<IntervalMonthDayNanoType>();
+ let result: PrimitiveArray<T> =
+ arrow::compute::binary(time, interval, |t, iv| wrap(t.into(),
iv))?;
+ Ok(ColumnarValue::Array(Arc::new(result)))
+ }
+ (ColumnarValue::Array(time), ColumnarValue::Scalar(interval)) => {
+ let time = time.as_primitive::<T>();
+ match interval_scalar(interval)? {
+ Some(iv) => {
+ let result: PrimitiveArray<T> = time.unary(|t|
wrap(t.into(), iv));
+ Ok(ColumnarValue::Array(Arc::new(result)))
+ }
+ None => Ok(ColumnarValue::Scalar(time_scalar::<T>(None))),
+ }
+ }
+ (ColumnarValue::Scalar(time), ColumnarValue::Array(interval)) => {
+ let interval = interval.as_primitive::<IntervalMonthDayNanoType>();
+ match time_scalar_units(time)? {
+ Some(t) => {
+ let result: PrimitiveArray<T> = interval.unary(|iv|
wrap(t, iv));
+ Ok(ColumnarValue::Array(Arc::new(result)))
+ }
+ None => Ok(ColumnarValue::Scalar(time_scalar::<T>(None))),
+ }
+ }
+ (ColumnarValue::Scalar(time), ColumnarValue::Scalar(interval)) => {
+ let result = time_scalar_units(time)?
+ .zip(interval_scalar(interval)?)
+ .map(|(t, iv)| wrap(t, iv).into());
+ Ok(ColumnarValue::Scalar(time_scalar::<T>(result)))
+ }
+ }
+}
+
impl PhysicalExpr for BinaryExpr {
fn data_type(&self, input_schema: &Schema) -> Result<DataType> {
BinaryTypeCoercer::new(
@@ -353,6 +536,18 @@ impl PhysicalExpr for BinaryExpr {
let input_schema = schema.as_ref();
match self.op {
+ // `time ± interval` returns a wrapped `time` (PostgreSQL/DuckDB
+ // semantics); arrow's arithmetic kernels don't implement it.
+ Operator::Plus
+ if is_time_plus_interval(&left_data_type, &right_data_type) =>
+ {
+ return apply_time_interval(&lhs, &rhs, false);
+ }
+ Operator::Minus
+ if is_time_minus_interval(&left_data_type, &right_data_type) =>
+ {
+ return apply_time_interval(&lhs, &rhs, true);
+ }
Operator::Plus if self.fail_on_overflow => return apply(&lhs,
&rhs, add),
Operator::Plus => return apply(&lhs, &rhs, add_wrapping),
// Special case: Date - Date returns Int64 (days difference)
diff --git
a/datafusion/sqllogictest/test_files/datetime/arith_time_interval.slt
b/datafusion/sqllogictest/test_files/datetime/arith_time_interval.slt
index 997eae9b1b..1d2b0e15bb 100644
--- a/datafusion/sqllogictest/test_files/datetime/arith_time_interval.slt
+++ b/datafusion/sqllogictest/test_files/datetime/arith_time_interval.slt
@@ -1,70 +1,143 @@
# postgresql behavior
#
# time + interval → time
-# Add an interval to a time
+# Add an interval to a time. The result is a `time` value that wraps within the
+# 24-hour clock, matching PostgreSQL and DuckDB.
# time '01:00' + interval '3 hours' → 04:00:00
-#
-# note that while the above reflects what postgresql does
-# in the case of datafusion/arrow that is not the case. The
-# result will be an interval, not a time.
+# time '22:00' + interval '3 hours' → 01:00:00 (wraps past midnight)
-query ?
+query D
SELECT '01:00'::time + interval '3 hours'
----
-4 hours
+04:00:00
query T
SELECT arrow_typeof('01:00'::time + interval '3 hours')
----
-Interval(MonthDayNano)
+Time64(ns)
-query ?
+query D
SELECT '22:00'::time + interval '3 hours'
----
-25 hours
+01:00:00
-query ?
+query D
SELECT interval '3 hours' + '22:00'::time
----
-25 hours
+01:00:00
-query ?
+# The result keeps the input time's unit, mirroring `timestamp + interval`,
rather
+# than widening to Time64(ns).
+query D
SELECT arrow_cast('22:00', 'Time32(Second)') + interval '3 hours'
----
-25 hours
+01:00:00
+
+query T
+SELECT arrow_typeof(arrow_cast('22:00', 'Time32(Second)') + interval '3 hours')
+----
+Time32(s)
-query ?
+query D
SELECT arrow_cast('22:00', 'Time32(Millisecond)') + interval '3 hours'
----
-25 hours
+01:00:00
-query ?
+query T
+SELECT arrow_typeof(arrow_cast('22:00', 'Time32(Millisecond)') + interval '3
hours')
+----
+Time32(ms)
+
+query D
SELECT arrow_cast('22:00', 'Time64(Microsecond)') + interval '3 hours'
----
-25 hours
+01:00:00
-query ?
+query T
+SELECT arrow_typeof(arrow_cast('22:00', 'Time64(Microsecond)') + interval '3
hours')
+----
+Time64(µs)
+
+query D
SELECT arrow_cast('22:00', 'Time64(Nanosecond)') + interval '3 hours'
----
-25 hours
+01:00:00
+
+query T
+SELECT arrow_typeof(arrow_cast('22:00', 'Time64(Nanosecond)') + interval '3
hours')
+----
+Time64(ns)
+
+# The interval is applied at nanosecond precision and floored to the time's
unit, exactly
+# as for `timestamp(unit) ± interval`. Adding one nanosecond to a
second-resolution time
+# floors back to a no-op...
+query D
+SELECT arrow_cast('22:00', 'Time32(Second)') + interval '1 nanosecond'
+----
+22:00:00
+
+# ...but subtracting one nanosecond floors down a full second, matching
+# `timestamp(s) - interval '1 nanosecond'` (= 09:59:59) rather than staying
put.
+query D
+SELECT arrow_cast('10:00:00', 'Time32(Second)') - interval '1 nanosecond'
+----
+09:59:59
+
+query D
+SELECT arrow_cast('12:00:00', 'Time32(Millisecond)') + interval '1 microsecond'
+----
+12:00:00
+
+query D
+SELECT arrow_cast('12:00:00', 'Time64(Microsecond)') + interval '1 microsecond'
+----
+12:00:00.000001
+
+# Whole days and months in the interval do not affect a time-of-day
(PostgreSQL).
+query D
+SELECT '10:00'::time + interval '1 day 2 hours'
+----
+12:00:00
# postgresql behavior
#
# time - interval → time
-# Subtract an interval from a time
+# Subtract an interval from a time, wrapping within the 24-hour clock.
# time '05:00' - interval '2 hours' → 03:00:00
+# time '02:00' - interval '3 hours' → 23:00:00 (wraps before midnight)
-query ?
+query D
SELECT '05:00'::time - interval '2 hours'
----
-3 hours
+03:00:00
query T
SELECT arrow_typeof('05:00'::time - interval '2 hours')
----
-Interval(MonthDayNano)
+Time64(ns)
-query ?
+query D
SELECT '02:00'::time - interval '3 hours'
----
--1 hours
+23:00:00
+
+# Array inputs (not only scalars) exercise the columnar path, including nulls.
+statement ok
+CREATE TABLE time_vals(id INT, t TIME) AS VALUES (1, '01:00'::time), (2,
'22:00'::time), (3, NULL);
+
+query D
+SELECT t + interval '3 hours' FROM time_vals ORDER BY id
+----
+04:00:00
+01:00:00
+NULL
+
+query D
+SELECT t - interval '2 hours' FROM time_vals ORDER BY id
+----
+23:00:00
+20:00:00
+NULL
+
+statement ok
+DROP TABLE time_vals
diff --git a/docs/source/library-user-guide/upgrading/55.0.0.md
b/docs/source/library-user-guide/upgrading/55.0.0.md
index 807c0bd1b2..f52db8da93 100644
--- a/docs/source/library-user-guide/upgrading/55.0.0.md
+++ b/docs/source/library-user-guide/upgrading/55.0.0.md
@@ -631,3 +631,22 @@ field existed decode as a single partition, the previous
default, and plans
encoded after it add a field that older readers ignore.
See [PR #23643](https://github.com/apache/datafusion/pull/23643) for details.
+
+### `time ± interval` now returns a `time` instead of an `interval`
+
+Adding or subtracting an `interval` to/from a `time` value now returns a `time`
+that wraps within the 24-hour clock, matching PostgreSQL and DuckDB. Previously
+DataFusion returned an `interval`.
+
+```sql
+-- 55.0.0 onwards: returns a time
+SELECT time '23:30:00' + interval '2 hours';
+-- 01:30:00
+```
+
+Only the sub-day portion of the interval affects the result; whole days and
+months are ignored, as in PostgreSQL. The result keeps the input time's unit
+(mirroring `timestamp + interval`), and any interval precision finer than that
+unit is truncated -- so `time(s) + interval '1 nanosecond'` is a no-op.
+
+See [PR #23279](https://github.com/apache/datafusion/pull/23279) for details.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]