This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-go.git
The following commit(s) were added to refs/heads/main by this push:
new 6542b630 fix(compute): detect temporal rounding overflow (#1127)
6542b630 is described below
commit 6542b630f84dda25715f6238027cc1eed9d114c6
Author: Minh Vu <[email protected]>
AuthorDate: Fri Sep 4 22:29:57 2026 +0200
fix(compute): detect temporal rounding overflow (#1127)
### Rationale for this change
Temporal rounding can overflow while converting timestamps to
nanoseconds, calculating the rounding interval, or multiplying a rounded
quotient. The current unchecked arithmetic can return an incorrect
timestamp.
### What changes are included in this PR?
Use checked arithmetic for temporal conversions, interval setup,
rounding results, calendar conversion, and origin adjustments. Propagate
overflow as an Arrow invalid-value error.
### Are these changes tested?
- `go test ./arrow/compute`
### Are there any user-facing changes?
Inputs and options that cannot be represented during temporal rounding
now return an error instead of a wrapped result.
---
arrow/compute/internal/kernels/rounding.go | 1358 +++++++++++++++++---
.../kernels/temporal_rounding_odd_multiple_test.go | 5 +-
.../kernels/temporal_rounding_overflow_test.go | 189 +++
arrow/compute/temporal_rounding_origins_test.go | 707 ++++++++++
arrow/compute/temporal_rounding_overflow_test.go | 741 +++++++++++
arrow/compute/temporal_rounding_test.go | 167 ++-
6 files changed, 2999 insertions(+), 168 deletions(-)
diff --git a/arrow/compute/internal/kernels/rounding.go
b/arrow/compute/internal/kernels/rounding.go
index 9f13d281..9e46573c 100644
--- a/arrow/compute/internal/kernels/rounding.go
+++ b/arrow/compute/internal/kernels/rounding.go
@@ -21,6 +21,7 @@ package kernels
import (
"fmt"
"math"
+ "math/big"
"time"
"github.com/apache/arrow-go/v18/arrow"
@@ -847,10 +848,9 @@ type roundTemporalState struct {
mode RoundMode
// Pre-calculated values to avoid repeated computation
- unitNanos int64 // Duration of the unit in nanoseconds
- roundingInterval int64 // unitNanos * Multiple
- isSubDay bool // true if this is a sub-day unit (can use fast
path)
- useCalendarOrigin bool // true if using calendar-based origin
+ unitNanos int64 // Duration of the unit in nanoseconds
+ roundingInterval int64 // unitNanos * Multiple
+ isSubDay bool // true if this is a sub-day unit (can use fast
path)
}
func InitRoundTemporalState(_ *exec.KernelCtx, args exec.KernelInitArgs)
(exec.KernelState, error) {
@@ -873,8 +873,11 @@ func InitRoundTemporalState(_ *exec.KernelCtx, args
exec.KernelInitArgs) (exec.K
// Pre-calculate constants for this rounding operation
rs.unitNanos, rs.isSubDay = unitInNanos(rs.Unit)
if rs.isSubDay {
- rs.roundingInterval = rs.unitNanos * rs.Multiple
- rs.useCalendarOrigin = rs.CalendarBasedOrigin && rs.Unit <=
RoundTemporalDay
+ var err error
+ rs.roundingInterval, err = checkedMulInt64(rs.unitNanos,
rs.Multiple)
+ if err != nil {
+ return nil, err
+ }
}
return rs, nil
@@ -910,71 +913,546 @@ func roundTimestamp(ts int64, inputUnit arrow.TimeUnit,
tz *time.Location, opts
tz = time.UTC
}
- // Calendar units with variable duration (year, quarter, month, week)
require date arithmetic
+ // Calendar units with variable duration (year, quarter, month, week)
require date arithmetic.
if !opts.isSubDay {
- tsNanos := convertToNanos(ts, inputUnit)
- return roundTimestampCalendar(tsNanos, inputUnit, tz, opts)
+ return roundTimestampCalendar(ts, inputUnit, tz, opts)
}
- // Day rounding with timezone requires calendar arithmetic (days vary:
23/24/25 hours due to DST)
- isUTC := tz == time.UTC || tz.String() == "UTC"
- if !isUTC && opts.Unit == RoundTemporalDay {
- tsNanos := convertToNanos(ts, inputUnit)
- return roundTimestampCalendar(tsNanos, inputUnit, tz, opts)
+ // A day is a calendar unit when it is associated with a timezone. This
also
+ // handles CalendarBasedOrigin for day multiples, whose origin is the
start
+ // of the month rather than the Unix epoch.
+ if opts.Unit == RoundTemporalDay {
+ return roundTimestampCalendar(ts, inputUnit, tz, opts)
}
- // Sub-day units (hour, minute, second, etc.) use fixed-duration
arithmetic
- // Fast path: round directly in input unit if possible (no origin,
compatible units)
- if canRoundInInputUnit(inputUnit, opts.unitNanos) &&
!opts.useCalendarOrigin {
- intervalInInputUnit := opts.roundingInterval /
int64(inputUnit.Multiplier())
- rounded := roundToMultipleInt64(ts, intervalInInputUnit,
opts.mode, opts.CeilIsStrictlyGreater)
- return rounded, nil
+ // Zoned timestamps are rounded in their local wall-clock timeline.
This is
+ // observable for offsets that are not whole hours (for example,
Kathmandu)
+ // and around daylight-saving transitions.
+ if tz != time.UTC && tz.String() != "UTC" {
+ if opts.CalendarBasedOrigin {
+ return roundTimestampCalendarOrigin(ts, inputUnit, tz,
opts)
+ }
+ return roundTimestampFixedTimezone(ts, inputUnit, tz, opts)
}
- // Slow path: convert to nanoseconds for calendar origin or
incompatible units
- tsNanos := convertToNanos(ts, inputUnit)
+ // CalendarBasedOrigin rounds fixed-duration units against the preceding
+ // calendar boundary (for example, minutes from the start of the hour).
+ // Use local wall-clock arithmetic here: time.Time.Sub measures elapsed
time
+ // and therefore gives the wrong answer across daylight-saving
transitions.
+ if opts.CalendarBasedOrigin {
+ return roundTimestampCalendarOrigin(ts, inputUnit, tz, opts)
+ }
- var origin int64 = 0
- if opts.useCalendarOrigin {
- // Calendar origin: round relative to start of day
(timezone-aware if tz != nil)
- if tz != nil {
- t := time.Unix(0, tsNanos).In(tz)
- startOfDay := time.Date(t.Year(), t.Month(), t.Day(),
0, 0, 0, 0, tz)
- origin = startOfDay.UnixNano()
- } else {
- origin = tsNanos
+ // Sub-day units (hour, minute, second, etc.) use fixed-duration
arithmetic.
+ // Fast path: round directly in input unit when possible. A finer
interval that
+ // divides the input unit is already aligned and needs no calculation.
+ if canRoundInInputUnit(inputUnit, opts.roundingInterval) {
+ inputUnitNanos := int64(inputUnit.Multiplier())
+ if opts.roundingInterval < inputUnitNanos {
+ // Every input value is already aligned to a finer
interval that divides
+ // the input unit. Keep the calculation in the input
unit so wider
+ // timestamp ranges do not overflow during a nanosecond
conversion.
+ return ts, nil
}
+ intervalInInputUnit := opts.roundingInterval / inputUnitNanos
+ return roundToMultipleInt64(ts, intervalInInputUnit, opts.mode,
opts.CeilIsStrictlyGreater)
+ }
+
+ // Slow path: calculate the floor in nanoseconds, then cast the floor
and
+ // ceiling back to the input resolution before comparing them. This
mirrors
+ // the C++ kernel's duration_cast behavior when the requested interval
is not
+ // representable in the input unit.
+ return roundTimestampInInputUnit(ts, inputUnit, opts)
+}
+
+func roundTimestampInInputUnit(ts int64, inputUnit arrow.TimeUnit, opts
roundTemporalState) (int64, error) {
+ // Keep the input in the target unit's arithmetic domain. Converting a
wide
+ // timestamp to nanoseconds first would reject values that are valid in
the
+ // requested unit (for example timestamp[s] in year 2300 rounded to 3
ms).
+ inputUnitNanos := big.NewInt(int64(inputUnit.Multiplier()))
+ valueNanos := new(big.Int).Mul(big.NewInt(ts), inputUnitNanos)
+ floorNanos := floorBigIntToMultiple(valueNanos, opts.roundingInterval)
+ targetUnit := big.NewInt(opts.unitNanos)
+ floorTarget := new(big.Int).Quo(new(big.Int).Set(floorNanos),
targetUnit)
+ if !floorTarget.IsInt64() {
+ return 0, overflowError()
+ }
+ floorInput := new(big.Int).Quo(floorNanos, inputUnitNanos)
+ if !floorInput.IsInt64() {
+ return 0, overflowError()
}
+ floor := floorInput.Int64()
- adjusted := tsNanos - origin
- rounded := roundToMultipleInt64(adjusted, opts.roundingInterval,
opts.mode, opts.CeilIsStrictlyGreater)
- result := origin + rounded
+ ceil := floor
+ increment := opts.roundingInterval / inputUnitNanos.Int64()
+ if opts.CeilIsStrictlyGreater || floor < ts {
+ var err error
+ ceil, err = checkedAddInt64(floor, increment)
+ if err != nil {
+ return 0, err
+ }
+ }
- return convertFromNanos(result, inputUnit), nil
+ switch opts.mode {
+ case RoundDown:
+ return floor, nil
+ case RoundUp:
+ return ceil, nil
+ default:
+ fromFloor := new(big.Int).Sub(big.NewInt(ts), big.NewInt(floor))
+ toCeil := new(big.Int).Sub(big.NewInt(ceil), big.NewInt(ts))
+ if fromFloor.Cmp(toCeil) >= 0 {
+ return ceil, nil
+ }
+ return floor, nil
+ }
}
// canRoundInInputUnit checks if rounding can be done in the input unit
-// without converting to nanoseconds (true when rounding interval is evenly
divisible).
+// without converting to nanoseconds (true when either unit is evenly divisible
+// by the other).
func canRoundInInputUnit(inputUnit arrow.TimeUnit, roundingIntervalNanos
int64) bool {
- return roundingIntervalNanos%int64(inputUnit.Multiplier()) == 0
+ if roundingIntervalNanos <= 0 {
+ return false
+ }
+
+ inputUnitNanos := int64(inputUnit.Multiplier())
+ return roundingIntervalNanos%inputUnitNanos == 0 ||
+ (roundingIntervalNanos < inputUnitNanos &&
inputUnitNanos%roundingIntervalNanos == 0)
+}
+
+// roundTimestampCalendarOrigin rounds a fixed-duration unit relative to the
+// preceding calendar boundary. Candidate boundaries are calculated in local
+// wall time, then compared as system times so daylight-saving transitions are
+// handled consistently with the temporal rounding reference implementation.
+func roundTimestampCalendarOrigin(ts int64, inputUnit arrow.TimeUnit, tz
*time.Location, opts roundTemporalState) (int64, error) {
+ t, err := timestampToCalendarTime(ts, inputUnit, tz)
+ if err != nil {
+ return 0, err
+ }
+ origin, err := fixedCalendarOrigin(t, opts.Unit)
+ if err != nil {
+ return 0, err
+ }
+ return roundTimestampFromWallOrigin(t, inputUnit, tz, opts, origin)
+}
+
+func roundTimestampFixedTimezone(ts int64, inputUnit arrow.TimeUnit, tz
*time.Location, opts roundTemporalState) (int64, error) {
+ t, err := timestampToCalendarTime(ts, inputUnit, tz)
+ if err != nil {
+ return 0, err
+ }
+ origin := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
+ return roundTimestampFromWallOrigin(t, inputUnit, tz, opts, origin)
+}
+
+func roundTimestampFromWallOrigin(t time.Time, inputUnit arrow.TimeUnit, tz
*time.Location, opts roundTemporalState, origin time.Time) (int64, error) {
+ inputTime := t
+ wallTime := calendarWallTime(t)
+
+ elapsed, err := wallClockDifference(wallTime, origin)
+ var floor time.Time
+ if err == nil {
+ flooredElapsed, roundErr := roundToMultipleInt64(elapsed,
opts.roundingInterval, RoundDown, false)
+ if roundErr != nil {
+ return 0, roundErr
+ }
+ floor, err = addWallNanos(origin, flooredElapsed, tz)
+ } else {
+ // Keep the wide timestamp range available for zoned
timestamps. The
+ // reference implementation performs this arithmetic in the
input
+ // duration, while an int64 nanosecond distance only spans
about 292
+ // years.
+ wideElapsed, wideErr := wallClockDifferenceBig(wallTime, origin)
+ if wideErr != nil {
+ return 0, wideErr
+ }
+ flooredElapsed := floorBigIntToMultiple(wideElapsed,
opts.roundingInterval)
+ floor, err = addWallNanosBig(origin, flooredElapsed, tz)
+ }
+ if err != nil {
+ return 0, err
+ }
+ floorWall := calendarWallTime(floor.In(tz))
+ floorWallAtInputResolution, err :=
truncateWallToInputResolution(floorWall, origin, inputUnit)
+ if err != nil {
+ return 0, err
+ }
+ floorAtInputResolution, err :=
checkedLocalTime(floorWallAtInputResolution, tz)
+ if err != nil {
+ return 0, err
+ }
+
+ var rounded time.Time
+ switch opts.mode {
+ case RoundDown:
+ rounded = floorAtInputResolution
+ case RoundUp:
+ rounded, err = ceilCalendarOrigin(inputTime,
floorAtInputResolution, inputUnit, opts, tz)
+ if err != nil {
+ return 0, err
+ }
+ default:
+ ceil, ceilErr := ceilCalendarOrigin(inputTime,
floorAtInputResolution, inputUnit, opts, tz)
+ if ceilErr != nil {
+ return 0, ceilErr
+ }
+ rounded, err = halfRoundPeriod(inputTime,
floorAtInputResolution, ceil)
+ if err != nil {
+ return 0, err
+ }
+ }
+
+ result, err := timestampFromCalendarTime(rounded, inputUnit)
+ if err != nil {
+ return 0, err
+ }
+ return int64(result), nil
+}
+
+func truncateWallToInputResolution(value, origin time.Time, inputUnit
arrow.TimeUnit) (time.Time, error) {
+ elapsed, err := wallClockDifferenceBig(value, origin)
+ if err != nil {
+ return time.Time{}, err
+ }
+ unitNanos := big.NewInt(int64(inputUnit.Multiplier()))
+ elapsed.Quo(elapsed, unitNanos)
+ elapsed.Mul(elapsed, unitNanos)
+ return addWallNanosBig(origin, elapsed, time.UTC)
+}
+
+func ceilCalendarOrigin(value, floor time.Time, inputUnit arrow.TimeUnit, opts
roundTemporalState, tz *time.Location) (time.Time, error) {
+ // Convert the floored system time back to local wall time before
deciding
+ // whether to advance. This matters when the period crosses a timezone
+ // transition because the elapsed system duration need not equal the
wall
+ // clock duration.
+ floorWall := calendarWallTime(floor.In(tz))
+ cs, err := checkedLocalTime(floorWall, tz)
+ if err != nil {
+ return time.Time{}, err
+ }
+ if !opts.CeilIsStrictlyGreater && !cs.Before(value) {
+ return cs, nil
+ }
+
+ inputUnitNanos := int64(inputUnit.Multiplier())
+ increment := (opts.roundingInterval / inputUnitNanos) * inputUnitNanos
+ nextWall, err := addWallNanos(floorWall, increment, tz)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return nextWall, nil
+}
+
+// Keep the origin's civil fields in UTC because its local time may not exist.
+// The requested location is applied only after rounding the wall-clock value.
+func fixedCalendarOrigin(value time.Time, unit RoundTemporalUnit) (time.Time,
error) {
+ switch unit {
+ case RoundTemporalHour:
+ return time.Date(value.Year(), value.Month(), value.Day(), 0,
0, 0, 0, time.UTC), nil
+ case RoundTemporalMinute:
+ return time.Date(value.Year(), value.Month(), value.Day(),
value.Hour(), 0, 0, 0, time.UTC), nil
+ case RoundTemporalSecond:
+ return time.Date(value.Year(), value.Month(), value.Day(),
value.Hour(), value.Minute(), 0, 0, time.UTC), nil
+ case RoundTemporalMillisecond:
+ return time.Date(value.Year(), value.Month(), value.Day(),
value.Hour(), value.Minute(), value.Second(), 0, time.UTC), nil
+ case RoundTemporalMicrosecond:
+ return time.Date(value.Year(), value.Month(), value.Day(),
value.Hour(), value.Minute(), value.Second(),
+ (value.Nanosecond()/1_000_000)*1_000_000, time.UTC), nil
+ case RoundTemporalNanosecond:
+ return time.Date(value.Year(), value.Month(), value.Day(),
value.Hour(), value.Minute(), value.Second(),
+ (value.Nanosecond()/1_000)*1_000, time.UTC), nil
+ default:
+ return time.Time{}, fmt.Errorf("%w: unsupported fixed calendar
unit", arrow.ErrNotImplemented)
+ }
+}
+
+func wallClockNanos(value time.Time) (int64, error) {
+ seconds := int64(value.Hour())*3600 + int64(value.Minute())*60 +
int64(value.Second())
+ nanos, err := checkedMulInt64(seconds, int64(time.Second))
+ if err != nil {
+ return 0, err
+ }
+ return checkedAddInt64(nanos, int64(value.Nanosecond()))
+}
+
+func wallClockDifference(value, origin time.Time) (int64, error) {
+ days, err := calendarDayDifference(value, origin)
+ if err != nil {
+ return 0, err
+ }
+ daysNanos, err := checkedMulInt64(days, nanosPerDay)
+ if err != nil {
+ return 0, err
+ }
+ valueNanos, err := wallClockNanos(value)
+ if err != nil {
+ return 0, err
+ }
+ originNanos, err := wallClockNanos(origin)
+ if err != nil {
+ return 0, err
+ }
+ daysNanos, err = checkedAddInt64(daysNanos, valueNanos)
+ if err != nil {
+ return 0, err
+ }
+ return checkedSubInt64(daysNanos, originNanos)
+}
+
+func wallClockDifferenceBig(value, origin time.Time) (*big.Int, error) {
+ days, err := calendarDayDifference(value, origin)
+ if err != nil {
+ return nil, err
+ }
+ valueNanos, err := wallClockNanos(value)
+ if err != nil {
+ return nil, err
+ }
+ originNanos, err := wallClockNanos(origin)
+ if err != nil {
+ return nil, err
+ }
+
+ result := new(big.Int).Mul(big.NewInt(days), big.NewInt(nanosPerDay))
+ result.Add(result, big.NewInt(valueNanos))
+ result.Sub(result, big.NewInt(originNanos))
+ return result, nil
+}
+
+func floorBigIntToMultiple(value *big.Int, multiple int64) *big.Int {
+ divisor := big.NewInt(multiple)
+ quotient := new(big.Int)
+ remainder := new(big.Int)
+ quotient.QuoRem(value, divisor, remainder)
+ if remainder.Sign() < 0 {
+ quotient.Sub(quotient, big.NewInt(1))
+ }
+ return quotient.Mul(quotient, divisor)
}
-// convertToNanos converts a timestamp value to nanoseconds
-func convertToNanos(ts int64, unit arrow.TimeUnit) int64 {
- return ts * int64(unit.Multiplier())
+func calendarWallTime(value time.Time) time.Time {
+ return time.Date(value.Year(), value.Month(), value.Day(),
value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), time.UTC)
}
-// convertFromNanos converts a nanosecond timestamp to the specified unit
-func convertFromNanos(nanos int64, unit arrow.TimeUnit) int64 {
- return nanos / int64(unit.Multiplier())
+func checkedLocalTime(value time.Time, tz *time.Location) (time.Time, error) {
+ result := time.Date(value.Year(), value.Month(), value.Day(),
value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), tz)
+ if result.Year() != value.Year() || result.Month() != value.Month() ||
result.Day() != value.Day() ||
+ result.Hour() != value.Hour() || result.Minute() !=
value.Minute() || result.Second() != value.Second() ||
+ result.Nanosecond() != value.Nanosecond() {
+ return time.Time{}, fmt.Errorf("%w: local time does not exist",
arrow.ErrInvalid)
+ }
+ if ambiguousLocalTime(value, result, tz) {
+ return time.Time{}, fmt.Errorf("%w: local time is ambiguous",
arrow.ErrInvalid)
+ }
+ return result, nil
+}
+
+func ambiguousLocalTime(value, result time.Time, tz *time.Location) bool {
+ _, selectedOffset := result.Zone()
+ wall := calendarWallTime(value)
+ for _, delta := range []time.Duration{-2 * time.Hour, 2 * time.Hour,
-48 * time.Hour, 48 * time.Hour} {
+ _, offset := result.Add(delta).Zone()
+ if offset == selectedOffset {
+ continue
+ }
+ candidate := wall.Add(-time.Duration(offset) *
time.Second).In(tz)
+ if candidate.Year() == value.Year() && candidate.Month() ==
value.Month() && candidate.Day() == value.Day() &&
+ candidate.Hour() == value.Hour() && candidate.Minute()
== value.Minute() &&
+ candidate.Second() == value.Second() &&
candidate.Nanosecond() == value.Nanosecond() {
+ return true
+ }
+ }
+ return false
+}
+
+// addWallNanos adds a duration to a local wall-clock value without using
+// time.Time.Add, which applies elapsed-time arithmetic across timezone
+// transitions. The result is normalized through calendar dates in UTC and then
+// reconstructed in the requested location.
+func addWallNanos(origin time.Time, nanos int64, tz *time.Location)
(time.Time, error) {
+ days := floorDivInt64(nanos, nanosPerDay)
+ daysNanos, err := checkedMulInt64(days, nanosPerDay)
+ if err != nil {
+ return time.Time{}, err
+ }
+ remainder, err := checkedSubInt64(nanos, daysNanos)
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ originNanos, err := wallClockNanos(origin)
+ if err != nil {
+ return time.Time{}, err
+ }
+ dayTimeNanos, err := checkedAddInt64(originNanos, remainder)
+ if err != nil {
+ return time.Time{}, err
+ }
+ if dayTimeNanos >= nanosPerDay {
+ days, err = checkedAddInt64(days, 1)
+ if err != nil {
+ return time.Time{}, err
+ }
+ dayTimeNanos -= nanosPerDay
+ }
+
+ date := time.Date(origin.Year(), origin.Month(), origin.Day(), 0, 0, 0,
0, time.UTC)
+ date, err = checkedCalendarAddDays(date, days)
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ hour := dayTimeNanos / int64(time.Hour)
+ dayTimeNanos -= hour * int64(time.Hour)
+ minute := dayTimeNanos / int64(time.Minute)
+ dayTimeNanos -= minute * int64(time.Minute)
+ second := dayTimeNanos / int64(time.Second)
+ nanosecond := dayTimeNanos - second*int64(time.Second)
+ return checkedLocalTime(time.Date(date.Year(), date.Month(),
date.Day(), int(hour), int(minute), int(second), int(nanosecond), time.UTC), tz)
+}
+
+func addWallNanosBig(origin time.Time, nanos *big.Int, tz *time.Location)
(time.Time, error) {
+ days := new(big.Int)
+ remainder := new(big.Int)
+ days.QuoRem(nanos, big.NewInt(nanosPerDay), remainder)
+ if remainder.Sign() < 0 {
+ days.Sub(days, big.NewInt(1))
+ remainder.Add(remainder, big.NewInt(nanosPerDay))
+ }
+
+ originNanos, err := wallClockNanos(origin)
+ if err != nil {
+ return time.Time{}, err
+ }
+ dayTimeNanos := new(big.Int).Add(remainder, big.NewInt(originNanos))
+ if dayTimeNanos.Cmp(big.NewInt(nanosPerDay)) >= 0 {
+ days.Add(days, big.NewInt(1))
+ dayTimeNanos.Sub(dayTimeNanos, big.NewInt(nanosPerDay))
+ }
+ if !days.IsInt64() {
+ return time.Time{}, overflowError()
+ }
+
+ date := time.Date(origin.Year(), origin.Month(), origin.Day(), 0, 0, 0,
0, time.UTC)
+ date, err = checkedCalendarAddDays(date, days.Int64())
+ if err != nil {
+ return time.Time{}, err
+ }
+ dayTime := dayTimeNanos.Int64()
+ hour := dayTime / int64(time.Hour)
+ dayTime -= hour * int64(time.Hour)
+ minute := dayTime / int64(time.Minute)
+ dayTime -= minute * int64(time.Minute)
+ second := dayTime / int64(time.Second)
+ nanosecond := dayTime - second*int64(time.Second)
+ return checkedLocalTime(time.Date(date.Year(), date.Month(),
date.Day(), int(hour), int(minute), int(second), int(nanosecond), time.UTC), tz)
+}
+
+func overflowError() error {
+ return fmt.Errorf("%w: temporal rounding overflow", arrow.ErrInvalid)
+}
+
+// time.Time's calendar representation wraps for Unix seconds before this
+// boundary. time.Unix(math.MaxInt64, 0) also overflows its internal signed
+// seconds field. Keep both cases out of calendar arithmetic before
constructing
+// a time.Time. The public timestamp conversion intentionally remains bijective
+// over the full int64 range, so this check belongs in the calendar kernel.
+const minCalendarTimestampSecond = -9223372028741760000
+
+func timestampToCalendarTime(ts int64, inputUnit arrow.TimeUnit, tz
*time.Location) (time.Time, error) {
+ if inputUnit == arrow.Second && (ts < minCalendarTimestampSecond || ts
== math.MaxInt64) {
+ return time.Time{}, overflowError()
+ }
+ return arrow.Timestamp(ts).ToTime(inputUnit).In(tz), nil
+}
+
+func checkedAddInt64(left, right int64) (int64, error) {
+ if (right > 0 && left > math.MaxInt64-right) || (right < 0 && left <
math.MinInt64-right) {
+ return 0, overflowError()
+ }
+ return left + right, nil
+}
+
+func checkedSubInt64(left, right int64) (int64, error) {
+ if (right > 0 && left < math.MinInt64+right) || (right < 0 && left >
math.MaxInt64+right) {
+ return 0, overflowError()
+ }
+ return left - right, nil
+}
+
+func checkedMulInt64(left, right int64) (int64, error) {
+ if left == 0 || right == 0 {
+ return 0, nil
+ }
+ if (left == math.MinInt64 && right == -1) || (right == math.MinInt64 &&
left == -1) {
+ return 0, overflowError()
+ }
+
+ result := left * right
+ if result/right != left {
+ return 0, overflowError()
+ }
+ return result, nil
}
-func roundToMultipleInt64(value, multiple int64, mode RoundMode, strictCeil
bool) int64 {
+func checkedInt64ToInt(value int64) (int, error) {
+ result := int(value)
+ if int64(result) != value {
+ return 0, overflowError()
+ }
+ return result, nil
+}
+
+// floorDivInt64 divides by a positive divisor and rounds towards negative
+// infinity. Go's integer division rounds towards zero, which would place
+// negative calendar values in the wrong period.
+func floorDivInt64(dividend, divisor int64) int64 {
+ quotient := dividend / divisor
+ if dividend%divisor < 0 {
+ quotient--
+ }
+ return quotient
+}
+
+func checkedCalendarDate(year, month int64, tz *time.Location) (time.Time,
error) {
+ dateYear, err := checkedInt64ToInt(year)
+ if err != nil {
+ return time.Time{}, err
+ }
+ dateMonth, err := checkedInt64ToInt(month)
+ if err != nil {
+ return time.Time{}, err
+ }
+ wall := time.Date(dateYear, time.Month(dateMonth), 1, 0, 0, 0, 0,
time.UTC)
+ if int64(wall.Year()) != year {
+ return time.Time{}, overflowError()
+ }
+ return checkedLocalTime(wall, tz)
+}
+
+func checkedCalendarMidnight(year int, month time.Month, day int, tz
*time.Location) (time.Time, error) {
+ return checkedLocalTime(time.Date(year, month, day, 0, 0, 0, 0,
time.UTC), tz)
+}
+
+func checkedCalendarAddDays(value time.Time, days int64) (time.Time, error) {
+ dayOffset, err := checkedInt64ToInt(days)
+ if err != nil {
+ return time.Time{}, err
+ }
+ result := value.AddDate(0, 0, dayOffset)
+ if (days > 0 && result.Before(value)) || (days < 0 &&
result.After(value)) {
+ return time.Time{}, overflowError()
+ }
+ return result, nil
+}
+
+func roundToMultipleInt64(value, multiple int64, mode RoundMode, strictCeil
bool) (int64, error) {
if multiple == 0 || value%multiple == 0 {
if strictCeil && mode == RoundUp {
- return value + multiple
+ return checkedAddInt64(value, multiple)
}
- return value
+ return value, nil
}
quotient := value / multiple
@@ -983,17 +1461,29 @@ func roundToMultipleInt64(value, multiple int64, mode
RoundMode, strictCeil bool
switch mode {
case RoundDown:
if remainder < 0 {
- return (quotient - 1) * multiple
+ quotient, err := checkedSubInt64(quotient, 1)
+ if err != nil {
+ return 0, err
+ }
+ return checkedMulInt64(quotient, multiple)
}
- return quotient * multiple
+ return checkedMulInt64(quotient, multiple)
case RoundUp:
if remainder > 0 || (strictCeil && remainder == 0) {
- return (quotient + 1) * multiple
+ quotient, err := checkedAddInt64(quotient, 1)
+ if err != nil {
+ return 0, err
+ }
+ return checkedMulInt64(quotient, multiple)
}
if remainder < 0 {
- return quotient * multiple
+ return checkedMulInt64(quotient, multiple)
+ }
+ quotient, err := checkedAddInt64(quotient, 1)
+ if err != nil {
+ return 0, err
}
- return (quotient + 1) * multiple
+ return checkedMulInt64(quotient, multiple)
case HalfUp, HalfDown, HalfToEven:
half := multiple / 2
absRemainder := remainder
@@ -1005,204 +1495,741 @@ func roundToMultipleInt64(value, multiple int64, mode
RoundMode, strictCeil bool
// a remainder of 1 when rounding to multiples of 3 is closer
to 0
// than to 3, so it must not be treated as a tie.
if absRemainder < half || (multiple%2 != 0 && absRemainder ==
half) {
- return quotient * multiple
+ return checkedMulInt64(quotient, multiple)
} else if absRemainder > half {
if remainder > 0 {
- return (quotient + 1) * multiple
+ quotient, err := checkedAddInt64(quotient, 1)
+ if err != nil {
+ return 0, err
+ }
+ return checkedMulInt64(quotient, multiple)
+ }
+ quotient, err := checkedSubInt64(quotient, 1)
+ if err != nil {
+ return 0, err
}
- return (quotient - 1) * multiple
+ return checkedMulInt64(quotient, multiple)
} else {
// Exactly on the halfway point
switch mode {
case HalfDown:
if remainder > 0 {
- return quotient * multiple
+ return checkedMulInt64(quotient,
multiple)
}
- return (quotient - 1) * multiple
+ quotient, err := checkedSubInt64(quotient, 1)
+ if err != nil {
+ return 0, err
+ }
+ return checkedMulInt64(quotient, multiple)
case HalfUp:
if remainder > 0 {
- return (quotient + 1) * multiple
+ quotient, err :=
checkedAddInt64(quotient, 1)
+ if err != nil {
+ return 0, err
+ }
+ return checkedMulInt64(quotient,
multiple)
}
- return quotient * multiple
+ return checkedMulInt64(quotient, multiple)
case HalfToEven:
if quotient%2 == 0 {
- return quotient * multiple
+ return checkedMulInt64(quotient,
multiple)
}
if remainder > 0 {
- return (quotient + 1) * multiple
+ quotient, err :=
checkedAddInt64(quotient, 1)
+ if err != nil {
+ return 0, err
+ }
+ return checkedMulInt64(quotient,
multiple)
}
- return (quotient - 1) * multiple
+ quotient, err := checkedSubInt64(quotient, 1)
+ if err != nil {
+ return 0, err
+ }
+ return checkedMulInt64(quotient, multiple)
}
}
}
- return quotient * multiple
+ return checkedMulInt64(quotient, multiple)
}
-// halfRoundPeriod performs half-rounding by finding the midpoint between
period start and end
-func halfRoundPeriod(t, periodStart, periodEnd time.Time) time.Time {
- midPoint := periodStart.Add(periodEnd.Sub(periodStart) / 2)
- if t.Before(midPoint) {
- return periodStart
+// halfRoundPeriod performs half-rounding by finding the midpoint between
period start and end.
+// It does not use time.Time.Sub or time.Time.Unix because those methods
cannot represent
+// sufficiently large calendar periods and timestamps.
+func halfRoundPeriod(t, periodStart, periodEnd time.Time) (time.Time, error) {
+ startDays, startNanos, err := calendarTimeParts(periodStart)
+ if err != nil {
+ return time.Time{}, err
+ }
+ endDays, endNanos, err := calendarTimeParts(periodEnd)
+ if err != nil {
+ return time.Time{}, err
+ }
+ if endDays < startDays || (endDays == startDays && endNanos <
startNanos) {
+ return time.Time{}, overflowError()
+ }
+ deltaDays, err := checkedSubInt64(endDays, startDays)
+ if err != nil {
+ return time.Time{}, err
+ }
+ deltaNanos, err := checkedSubInt64(endNanos, startNanos)
+ if err != nil {
+ return time.Time{}, err
+ }
+ if deltaNanos < 0 {
+ deltaDays, err = checkedSubInt64(deltaDays, 1)
+ if err != nil {
+ return time.Time{}, err
+ }
+ deltaNanos += nanosPerDay
+ }
+
+ tDays, tNanos, err := calendarTimeParts(t)
+ if err != nil {
+ return time.Time{}, err
+ }
+ relativeDays, err := checkedSubInt64(tDays, startDays)
+ if err != nil {
+ return time.Time{}, err
+ }
+ relativeNanos, err := checkedSubInt64(tNanos, startNanos)
+ if err != nil {
+ return time.Time{}, err
+ }
+ if relativeNanos < 0 {
+ relativeDays, err = checkedSubInt64(relativeDays, 1)
+ if err != nil {
+ return time.Time{}, err
+ }
+ relativeNanos += nanosPerDay
+ }
+
+ // Compare 2*(t-periodStart) with periodEnd-periodStart without ever
+ // converting the whole interval to a time.Duration.
+ doubledDays, err := checkedMulInt64(relativeDays, 2)
+ if err != nil {
+ return time.Time{}, err
+ }
+ doubledNanos, err := checkedMulInt64(relativeNanos, 2)
+ if err != nil {
+ return time.Time{}, err
+ }
+ if doubledNanos >= nanosPerDay {
+ doubledDays, err = checkedAddInt64(doubledDays, 1)
+ if err != nil {
+ return time.Time{}, err
+ }
+ doubledNanos -= nanosPerDay
+ }
+
+ if doubledDays < deltaDays || (doubledDays == deltaDays && doubledNanos
< deltaNanos) {
+ return periodStart, nil
+ }
+ return periodEnd, nil
+}
+
+const nanosPerDay = int64(24 * time.Hour)
+
+// calendarTimeParts returns a time instant as a day number and nanoseconds
into
+// the corresponding UTC day. Unlike Unix timestamps, this representation
+// remains valid for all time.Time values supported by the time package.
+func calendarTimeParts(value time.Time) (int64, int64, error) {
+ days, err := calendarDays(value)
+ if err != nil {
+ return 0, 0, err
+ }
+
+ seconds := int64(value.Hour())*3600 + int64(value.Minute())*60 +
int64(value.Second())
+ nanos, err := checkedMulInt64(seconds, int64(time.Second))
+ if err != nil {
+ return 0, 0, err
+ }
+ nanos, err = checkedAddInt64(nanos, int64(value.Nanosecond()))
+ if err != nil {
+ return 0, 0, err
+ }
+ _, offset := value.Zone()
+ offsetNanos, err := checkedMulInt64(int64(offset), int64(time.Second))
+ if err != nil {
+ return 0, 0, err
+ }
+ nanos, err = checkedSubInt64(nanos, offsetNanos)
+ if err != nil {
+ return 0, 0, err
+ }
+
+ if nanos < 0 {
+ days, err = checkedSubInt64(days, 1)
+ if err != nil {
+ return 0, 0, err
+ }
+ nanos += nanosPerDay
+ } else if nanos >= nanosPerDay {
+ days, err = checkedAddInt64(days, 1)
+ if err != nil {
+ return 0, 0, err
+ }
+ nanos -= nanosPerDay
+ }
+
+ return days, nanos, nil
+}
+
+// calendarDays returns the proleptic Gregorian day number for a local date.
+// The calculation is based on civil calendar fields, so it does not saturate
+// at time.Duration or int64 Unix-second boundaries.
+func calendarDays(value time.Time) (int64, error) {
+ year := int64(value.Year())
+ month := int64(value.Month())
+ day := int64(value.Day())
+ if month <= 2 {
+ var err error
+ year, err = checkedSubInt64(year, 1)
+ if err != nil {
+ return 0, err
+ }
+ }
+
+ era := year / 400
+ if year < 0 && year%400 != 0 {
+ era--
+ }
+ eraYear, err := checkedMulInt64(era, 400)
+ if err != nil {
+ return 0, err
+ }
+ yearOfEra, err := checkedSubInt64(year, eraYear)
+ if err != nil {
+ return 0, err
+ }
+
+ monthOfMarch := month
+ if month > 2 {
+ monthOfMarch -= 3
+ } else {
+ monthOfMarch += 9
+ }
+ daysOfYear := (153*monthOfMarch+2)/5 + day - 1
+ daysOfEra := yearOfEra*365 + yearOfEra/4 - yearOfEra/100 + daysOfYear
+ days, err := checkedMulInt64(era, 146097)
+ if err != nil {
+ return 0, err
+ }
+ days, err = checkedAddInt64(days, daysOfEra)
+ if err != nil {
+ return 0, err
+ }
+ return checkedSubInt64(days, 719468)
+}
+
+func calendarDayDifference(left, right time.Time) (int64, error) {
+ leftDays, err := calendarDays(left)
+ if err != nil {
+ return 0, err
+ }
+ rightDays, err := calendarDays(right)
+ if err != nil {
+ return 0, err
+ }
+ return checkedSubInt64(leftDays, rightDays)
+}
+
+// floorCalendarIndex returns the start of the current calendar period as an
+// index in either months or quarters. Without a calendar origin, periods are
+// anchored at 1970-01-01. With one, multiple periods start at the beginning of
+// the current year.
+func floorCalendarIndex(year, offset, periodsPerYear, multiple int64,
calendarOrigin bool) (int64, error) {
+ yearIndex, err := checkedMulInt64(year, periodsPerYear)
+ if err != nil {
+ return 0, err
+ }
+ currentIndex, err := checkedAddInt64(yearIndex, offset)
+ if err != nil {
+ return 0, err
+ }
+
+ if calendarOrigin && multiple != 1 {
+ roundedOffset, err := checkedMulInt64(floorDivInt64(offset,
multiple), multiple)
+ if err != nil {
+ return 0, err
+ }
+ return checkedAddInt64(yearIndex, roundedOffset)
+ }
+
+ epochIndex, err := checkedMulInt64(1970, periodsPerYear)
+ if err != nil {
+ return 0, err
+ }
+ relative, err := checkedSubInt64(currentIndex, epochIndex)
+ if err != nil {
+ return 0, err
+ }
+ roundedRelative, err := checkedMulInt64(floorDivInt64(relative,
multiple), multiple)
+ if err != nil {
+ return 0, err
+ }
+ return checkedAddInt64(epochIndex, roundedRelative)
+}
+
+func calendarDateFromIndex(index, periodsPerYear, monthsPerPeriod int64, tz
*time.Location) (time.Time, error) {
+ year := floorDivInt64(index, periodsPerYear)
+ yearIndex, err := checkedMulInt64(year, periodsPerYear)
+ if err != nil {
+ return time.Time{}, err
+ }
+ offset, err := checkedSubInt64(index, yearIndex)
+ if err != nil {
+ return time.Time{}, err
+ }
+ monthOffset, err := checkedMulInt64(offset, monthsPerPeriod)
+ if err != nil {
+ return time.Time{}, err
+ }
+ month, err := checkedAddInt64(monthOffset, 1)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return checkedCalendarDate(year, month, tz)
+}
+
+func floorCalendarDay(value time.Time, multiple int64, calendarOrigin bool, tz
*time.Location) (time.Time, error) {
+ valueDay := time.Date(value.Year(), value.Month(), value.Day(), 0, 0,
0, 0, time.UTC)
+ if multiple == 1 {
+ return checkedCalendarMidnight(value.Year(), value.Month(),
value.Day(), tz)
+ }
+
+ origin := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
+ if calendarOrigin {
+ origin = time.Date(value.Year(), value.Month(), 1, 0, 0, 0, 0,
time.UTC)
+ }
+ daysSinceOrigin, err := calendarDayDifference(valueDay, origin)
+ if err != nil {
+ return time.Time{}, err
+ }
+ roundedDays, err := checkedMulInt64(floorDivInt64(daysSinceOrigin,
multiple), multiple)
+ if err != nil {
+ return time.Time{}, err
+ }
+ result, err := checkedCalendarAddDays(origin, roundedDays)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return checkedCalendarMidnight(result.Year(), result.Month(),
result.Day(), tz)
+}
+
+func floorCalendarWeek(value time.Time, multiple int64, weekStartsMonday bool,
tz *time.Location) (time.Time, error) {
+ valueDay := time.Date(value.Year(), value.Month(), value.Day(), 0, 0,
0, 0, time.UTC)
+ weekday := int(valueDay.Weekday())
+ targetWeekday := time.Wednesday
+ if weekStartsMonday {
+ weekday = (weekday + 6) % 7
+ targetWeekday = time.Thursday
+ }
+ weekStart, err := checkedCalendarAddDays(valueDay, -int64(weekday))
+ if err != nil {
+ return time.Time{}, err
+ }
+ // Use a fixed representative day from the input's week to identify the
+ // calendar week-year. Adding a constant to the input can cross into the
+ // following week for late-week values.
+ weekYearDate, err := checkedCalendarAddDays(weekStart, 3)
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ previousYear, err := checkedSubInt64(int64(weekYearDate.Year()), 1)
+ if err != nil {
+ return time.Time{}, err
+ }
+ december := time.Date(int(previousYear), time.December, 31, 0, 0, 0, 0,
time.UTC)
+ daysBack := (int(december.Weekday()) - int(targetWeekday) + 7) % 7
+ lastWeekday, err := checkedCalendarAddDays(december, -int64(daysBack))
+ if err != nil {
+ return time.Time{}, err
+ }
+ origin, err := checkedCalendarAddDays(lastWeekday, 4)
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ daysSinceOrigin, err := calendarDayDifference(valueDay, origin)
+ if err != nil {
+ return time.Time{}, err
+ }
+ weeksSinceOrigin := floorDivInt64(daysSinceOrigin, 7)
+ roundedWeeks, err := checkedMulInt64(floorDivInt64(weeksSinceOrigin,
multiple), multiple)
+ if err != nil {
+ return time.Time{}, err
+ }
+ roundedDays, err := checkedMulInt64(roundedWeeks, 7)
+ if err != nil {
+ return time.Time{}, err
+ }
+ result, err := checkedCalendarAddDays(origin, roundedDays)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return checkedLocalTime(result, tz)
+}
+
+func floorFixedCalendarWeek(value time.Time, multiple int64, weekStartsMonday
bool, tz *time.Location) (time.Time, error) {
+ weekdayOffset := int64(4)
+ if weekStartsMonday {
+ weekdayOffset = 3
+ }
+
+ // The reference algorithm shifts the local wall-clock timestamp by the
+ // weekday offset, floors fixed seven-day periods from the Unix epoch,
and
+ // only then converts the candidate back to system time. Keep the
wall-clock
+ // date in UTC while doing the arithmetic so a missing local midnight
cannot
+ // move the period into a different civil date.
+ shifted, err := checkedCalendarAddDays(calendarWallTime(value),
weekdayOffset)
+ if err != nil {
+ return time.Time{}, err
+ }
+ shiftedDay := time.Date(shifted.Year(), shifted.Month(), shifted.Day(),
0, 0, 0, 0, time.UTC)
+ dayIndex, err := calendarDays(shiftedDay)
+ if err != nil {
+ return time.Time{}, err
+ }
+ weekIndex := floorDivInt64(dayIndex, 7)
+ roundedWeeks, err := checkedMulInt64(floorDivInt64(weekIndex,
multiple), multiple)
+ if err != nil {
+ return time.Time{}, err
+ }
+ roundedDays, err := checkedMulInt64(roundedWeeks, 7)
+ if err != nil {
+ return time.Time{}, err
+ }
+ boundaryWall, err := checkedCalendarAddDays(time.Date(1970, 1, 1, 0, 0,
0, 0, time.UTC), roundedDays)
+ if err != nil {
+ return time.Time{}, err
+ }
+ weekStartWall, err := checkedCalendarAddDays(boundaryWall,
-weekdayOffset)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return time.Date(weekStartWall.Year(), weekStartWall.Month(),
weekStartWall.Day(), 0, 0, 0, 0, tz), nil
+}
+
+func ceilFixedCalendarWeek(value, floor time.Time, multiple int64, strict
bool, tz *time.Location) (time.Time, error) {
+ floorWall := calendarWallTime(floor.In(tz))
+ cs, err := checkedLocalTime(floorWall, tz)
+ if err != nil {
+ return time.Time{}, err
+ }
+ if !strict && !cs.Before(value) {
+ return cs, nil
+ }
+
+ weekDays, err := checkedMulInt64(multiple, 7)
+ if err != nil {
+ return time.Time{}, err
+ }
+ nextWall, err := checkedCalendarAddDays(floorWall, weekDays)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return checkedLocalTime(nextWall, tz)
+}
+
+func roundCalendarOriginWeek(value time.Time, opts roundTemporalState, tz
*time.Location) (time.Time, error) {
+ floor, err := floorCalendarWeek(value, opts.Multiple,
opts.WeekStartsMonday, tz)
+ if err != nil {
+ return time.Time{}, err
+ }
+ if opts.mode == RoundDown {
+ return floor, nil
+ }
+
+ var next time.Time
+ makeNext := func() (time.Time, error) {
+ if next.IsZero() {
+ weekDays, err := checkedMulInt64(opts.Multiple, 7)
+ if err != nil {
+ return time.Time{}, err
+ }
+ nextWall, err :=
checkedCalendarAddDays(calendarWallTime(floor.In(tz)), weekDays)
+ if err != nil {
+ return time.Time{}, err
+ }
+ next, err = checkedLocalTime(nextWall, tz)
+ if err != nil {
+ return time.Time{}, err
+ }
+ }
+ return next, nil
+ }
+
+ cs := floor
+
+ switch opts.mode {
+ case RoundUp:
+ if opts.CeilIsStrictlyGreater || cs.Before(value) {
+ return makeNext()
+ }
+ return cs, nil
+ default:
+ if cs.After(value) {
+ return cs, nil
+ }
+ next, err := makeNext()
+ if err != nil {
+ return time.Time{}, err
+ }
+ return halfRoundPeriod(value, floor, next)
}
- return periodEnd
}
// roundTimestampCalendar handles calendar-based rounding (year, quarter,
month, week, day).
// Requires date arithmetic for variable-length periods and timezone-aware
boundaries.
-func roundTimestampCalendar(tsNanos int64, inputUnit arrow.TimeUnit, tz
*time.Location, opts roundTemporalState) (int64, error) {
+func roundTimestampCalendar(ts int64, inputUnit arrow.TimeUnit, tz
*time.Location, opts roundTemporalState) (int64, error) {
// Convert to time.Time for calendar operations in the specified
timezone
- secs := tsNanos / 1000000000
- nanos := tsNanos % 1000000000
- t := time.Unix(secs, nanos).In(tz)
+ t, err := timestampToCalendarTime(ts, inputUnit, tz)
+ if err != nil {
+ return 0, err
+ }
var rounded time.Time
+ multiple := opts.Multiple
switch opts.Unit {
case RoundTemporalYear:
- year := t.Year()
- roundedYear := (year / int(opts.Multiple)) * int(opts.Multiple)
+ year := int64(t.Year())
+ roundedYear, err := checkedMulInt64(floorDivInt64(year,
multiple), multiple)
+ if err != nil {
+ return 0, err
+ }
switch opts.mode {
case RoundDown:
- rounded = time.Date(roundedYear, 1, 1, 0, 0, 0, 0, tz)
+ var dateErr error
+ rounded, dateErr = checkedCalendarDate(roundedYear, 1,
tz)
+ if dateErr != nil {
+ return 0, dateErr
+ }
case RoundUp:
- periodStart := time.Date(roundedYear, 1, 1, 0, 0, 0, 0,
tz)
- if opts.CeilIsStrictlyGreater || !t.Equal(periodStart) {
- roundedYear += int(opts.Multiple)
- rounded = time.Date(roundedYear, 1, 1, 0, 0, 0,
0, tz)
- } else {
- rounded = periodStart
+ roundedYear, err = checkedAddInt64(roundedYear,
multiple)
+ if err != nil {
+ return 0, err
+ }
+ rounded, err = checkedCalendarDate(roundedYear, 1, tz)
+ if err != nil {
+ return 0, err
}
default:
- yearStart := time.Date(roundedYear, 1, 1, 0, 0, 0, 0,
tz)
- nextYear := roundedYear + int(opts.Multiple)
- yearEnd := time.Date(nextYear, 1, 1, 0, 0, 0, 0, tz)
- rounded = halfRoundPeriod(t, yearStart, yearEnd)
+ yearStart, dateErr := checkedCalendarDate(roundedYear,
1, tz)
+ if dateErr != nil {
+ return 0, dateErr
+ }
+ nextYear, dateErr := checkedAddInt64(roundedYear,
multiple)
+ if dateErr != nil {
+ return 0, dateErr
+ }
+ yearEnd, dateErr := checkedCalendarDate(nextYear, 1, tz)
+ if dateErr != nil {
+ return 0, dateErr
+ }
+ rounded, err = halfRoundPeriod(t, yearStart, yearEnd)
+ if err != nil {
+ return 0, err
+ }
}
case RoundTemporalQuarter:
// Q1=Jan-Mar, Q2=Apr-Jun, Q3=Jul-Sep, Q4=Oct-Dec
- month := int(t.Month())
- year := t.Year()
- totalQuarters := year*4 + (month-1)/3
- roundedQuarters := (totalQuarters / int(opts.Multiple)) *
int(opts.Multiple)
- roundedYear := roundedQuarters / 4
- roundedQuarter := roundedQuarters % 4
- roundedMonth := roundedQuarter*3 + 1 // First month of the
quarter
+ month := int64(t.Month())
+ year := int64(t.Year())
+ roundedQuarters, err := floorCalendarIndex(
+ year, (month-1)/3, 4, multiple,
opts.CalendarBasedOrigin,
+ )
+ if err != nil {
+ return 0, err
+ }
switch opts.mode {
case RoundDown:
- rounded = time.Date(roundedYear,
time.Month(roundedMonth), 1, 0, 0, 0, 0, tz)
+ rounded, err = calendarDateFromIndex(roundedQuarters,
4, 3, tz)
+ if err != nil {
+ return 0, err
+ }
case RoundUp:
- periodStart := time.Date(roundedYear,
time.Month(roundedMonth), 1, 0, 0, 0, 0, tz)
- if opts.CeilIsStrictlyGreater || !t.Equal(periodStart) {
- roundedQuarters += int(opts.Multiple)
- roundedYear = roundedQuarters / 4
- roundedQuarter = roundedQuarters % 4
- roundedMonth = roundedQuarter*3 + 1
- rounded = time.Date(roundedYear,
time.Month(roundedMonth), 1, 0, 0, 0, 0, tz)
- } else {
- rounded = periodStart
+ nextQuarters, dateErr :=
checkedAddInt64(roundedQuarters, multiple)
+ if dateErr != nil {
+ return 0, dateErr
+ }
+ rounded, dateErr = calendarDateFromIndex(nextQuarters,
4, 3, tz)
+ if dateErr != nil {
+ return 0, dateErr
}
default:
- quarterStart := time.Date(roundedYear,
time.Month(roundedMonth), 1, 0, 0, 0, 0, tz)
- nextQuarterNum := roundedQuarters + int(opts.Multiple)
- nextYear := nextQuarterNum / 4
- nextQuarter := nextQuarterNum % 4
- nextMonth := nextQuarter*3 + 1
- quarterEnd := time.Date(nextYear,
time.Month(nextMonth), 1, 0, 0, 0, 0, tz)
- rounded = halfRoundPeriod(t, quarterStart, quarterEnd)
+ quarterStart, dateErr :=
calendarDateFromIndex(roundedQuarters, 4, 3, tz)
+ if dateErr != nil {
+ return 0, dateErr
+ }
+ nextQuarterNum, dateErr :=
checkedAddInt64(roundedQuarters, multiple)
+ if dateErr != nil {
+ return 0, dateErr
+ }
+ quarterEnd, dateErr :=
calendarDateFromIndex(nextQuarterNum, 4, 3, tz)
+ if dateErr != nil {
+ return 0, dateErr
+ }
+ rounded, err = halfRoundPeriod(t, quarterStart,
quarterEnd)
+ if err != nil {
+ return 0, err
+ }
}
case RoundTemporalMonth:
- month := int(t.Month())
- year := t.Year()
- totalMonths := year*12 + month - 1
- roundedMonths := (totalMonths / int(opts.Multiple)) *
int(opts.Multiple)
- roundedYear := roundedMonths / 12
- roundedMonth := (roundedMonths % 12) + 1
+ month := int64(t.Month())
+ year := int64(t.Year())
+ roundedMonths, err := floorCalendarIndex(
+ year, month-1, 12, multiple, opts.CalendarBasedOrigin,
+ )
+ if err != nil {
+ return 0, err
+ }
switch opts.mode {
case RoundDown:
- rounded = time.Date(roundedYear,
time.Month(roundedMonth), 1, 0, 0, 0, 0, tz)
+ rounded, err = calendarDateFromIndex(roundedMonths, 12,
1, tz)
+ if err != nil {
+ return 0, err
+ }
case RoundUp:
- periodStart := time.Date(roundedYear,
time.Month(roundedMonth), 1, 0, 0, 0, 0, tz)
- if opts.CeilIsStrictlyGreater || !t.Equal(periodStart) {
- roundedMonths += int(opts.Multiple)
- roundedYear = roundedMonths / 12
- roundedMonth = (roundedMonths % 12) + 1
- rounded = time.Date(roundedYear,
time.Month(roundedMonth), 1, 0, 0, 0, 0, tz)
- } else {
- rounded = periodStart
+ nextMonths, dateErr := checkedAddInt64(roundedMonths,
multiple)
+ if dateErr != nil {
+ return 0, dateErr
+ }
+ rounded, dateErr = calendarDateFromIndex(nextMonths,
12, 1, tz)
+ if dateErr != nil {
+ return 0, dateErr
}
default:
- monthStart := time.Date(roundedYear,
time.Month(roundedMonth), 1, 0, 0, 0, 0, tz)
- nextMonthNum := roundedMonths + int(opts.Multiple)
- nextYear := nextMonthNum / 12
- nextMonth := (nextMonthNum % 12) + 1
- monthEnd := time.Date(nextYear, time.Month(nextMonth),
1, 0, 0, 0, 0, tz)
- rounded = halfRoundPeriod(t, monthStart, monthEnd)
+ monthStart, dateErr :=
calendarDateFromIndex(roundedMonths, 12, 1, tz)
+ if dateErr != nil {
+ return 0, dateErr
+ }
+ nextMonthNum, dateErr := checkedAddInt64(roundedMonths,
multiple)
+ if dateErr != nil {
+ return 0, dateErr
+ }
+ monthEnd, dateErr :=
calendarDateFromIndex(nextMonthNum, 12, 1, tz)
+ if dateErr != nil {
+ return 0, dateErr
+ }
+ rounded, err = halfRoundPeriod(t, monthStart, monthEnd)
+ if err != nil {
+ return 0, err
+ }
}
case RoundTemporalWeek:
- weekday := int(t.Weekday())
- if opts.WeekStartsMonday {
- weekday = (weekday + 6) % 7
+ if opts.CalendarBasedOrigin && multiple != 1 {
+ rounded, err = roundCalendarOriginWeek(t, opts, tz)
+ if err != nil {
+ return 0, err
+ }
+ break
}
- startOfWeek := t.AddDate(0, 0, -weekday)
- startOfWeek = time.Date(startOfWeek.Year(),
startOfWeek.Month(), startOfWeek.Day(), 0, 0, 0, 0, tz)
- // Calculate N-week periods from epoch for Multiple > 1
- epochInTz := time.Unix(0, 0).In(tz)
- epochWeekday := int(epochInTz.Weekday())
- if opts.WeekStartsMonday {
- epochWeekday = (epochWeekday + 6) % 7
+ floor, dateErr := floorFixedCalendarWeek(t, multiple,
opts.WeekStartsMonday, tz)
+ if dateErr != nil {
+ return 0, dateErr
+ }
+ ceil, dateErr := ceilFixedCalendarWeek(t, floor, multiple,
opts.CeilIsStrictlyGreater, tz)
+ if dateErr != nil {
+ return 0, dateErr
}
- epochWeekStart := epochInTz.AddDate(0, 0, -epochWeekday)
- epochWeekStart = time.Date(epochWeekStart.Year(),
epochWeekStart.Month(), epochWeekStart.Day(), 0, 0, 0, 0, tz)
-
- daysSinceEpochWeek :=
int(startOfWeek.Sub(epochWeekStart).Hours() / 24)
- weeksSinceEpoch := daysSinceEpochWeek / 7
- roundedWeeks := (weeksSinceEpoch / int(opts.Multiple)) *
int(opts.Multiple)
- roundedWeekStart := epochWeekStart.AddDate(0, 0, roundedWeeks*7)
switch opts.mode {
case RoundDown:
- rounded = roundedWeekStart
+ rounded = floor
case RoundUp:
- if opts.CeilIsStrictlyGreater ||
!t.Equal(roundedWeekStart) {
- rounded = roundedWeekStart.AddDate(0, 0,
7*int(opts.Multiple))
- } else {
- rounded = roundedWeekStart
- }
+ rounded = ceil
default:
- weekEnd := roundedWeekStart.AddDate(0, 0,
7*int(opts.Multiple))
- rounded = halfRoundPeriod(t, roundedWeekStart, weekEnd)
+ rounded, err = halfRoundPeriod(t, floor, ceil)
+ if err != nil {
+ return 0, err
+ }
}
case RoundTemporalDay:
- startOfDay := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0,
0, tz)
+ startOfDay, dateErr := floorCalendarDay(t, multiple,
opts.CalendarBasedOrigin, tz)
+ if dateErr != nil {
+ return 0, dateErr
+ }
+ makeNextDay := func() (time.Time, error) {
+ nextWall, err :=
checkedCalendarAddDays(calendarWallTime(startOfDay), multiple)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return checkedLocalTime(nextWall, tz)
+ }
switch opts.mode {
case RoundDown:
rounded = startOfDay
case RoundUp:
if opts.CeilIsStrictlyGreater || !t.Equal(startOfDay) {
- rounded = startOfDay.AddDate(0, 0, 1)
+ rounded, dateErr = makeNextDay()
+ if dateErr != nil {
+ return 0, dateErr
+ }
} else {
rounded = startOfDay
}
default:
- nextDay := startOfDay.AddDate(0, 0, 1)
- rounded = halfRoundPeriod(t, startOfDay, nextDay)
+ nextDay, nextErr := makeNextDay()
+ if nextErr != nil {
+ return 0, nextErr
+ }
+ rounded, err = halfRoundPeriod(t, startOfDay, nextDay)
+ if err != nil {
+ return 0, err
+ }
}
default:
return 0, fmt.Errorf("%w: unsupported calendar unit",
arrow.ErrNotImplemented)
}
+ if err != nil {
+ return 0, err
+ }
- // Convert back to the input unit
- roundedNanos := rounded.UnixNano()
- return convertFromNanos(roundedNanos, inputUnit), nil
+ // Convert back to the input unit, validating only the selected result.
+ roundedTimestamp, err := timestampFromCalendarTime(rounded, inputUnit)
+ if err != nil {
+ return 0, err
+ }
+ return int64(roundedTimestamp), nil
+}
+
+func timestampFromCalendarTime(value time.Time, unit arrow.TimeUnit)
(arrow.Timestamp, error) {
+ if unit != arrow.Second {
+ return arrow.TimestampFromTime(value, unit)
+ }
+
+ days, nanos, err := calendarTimeParts(value)
+ if err != nil {
+ return 0, err
+ }
+ seconds, err := checkedMulInt64(days, int64(24*time.Hour/time.Second))
+ if err != nil {
+ return 0, err
+ }
+ seconds, err = checkedAddInt64(seconds, nanos/int64(time.Second))
+ if err != nil {
+ return 0, err
+ }
+ return arrow.Timestamp(seconds), nil
+}
+
+func timeToNanos(value time.Time) (int64, error) {
+ timestamp, err := arrow.TimestampFromTime(value, arrow.Nanosecond)
+ if err != nil {
+ return 0, err
+ }
+ return int64(timestamp), nil
}
// Kernel execution functions for temporal rounding
@@ -1240,7 +2267,12 @@ func roundTemporalExec(ctx *exec.KernelCtx, batch
*exec.ExecSpan, out *exec.Exec
return 0
}
// Convert back to days
- return int32(result / 86400)
+ daysResult := result / 86400
+ if daysResult < math.MinInt32 || daysResult >
math.MaxInt32 {
+ *e = overflowError()
+ return 0
+ }
+ return int32(daysResult)
}
return ScalarUnaryNotNull(fn)(ctx, batch, out)
diff --git
a/arrow/compute/internal/kernels/temporal_rounding_odd_multiple_test.go
b/arrow/compute/internal/kernels/temporal_rounding_odd_multiple_test.go
index 3f445c1b..e356be8a 100644
--- a/arrow/compute/internal/kernels/temporal_rounding_odd_multiple_test.go
+++ b/arrow/compute/internal/kernels/temporal_rounding_odd_multiple_test.go
@@ -33,7 +33,10 @@ func TestRoundToMultipleInt64OddMultipleAcrossModes(t
*testing.T) {
{value: -1, want: 0},
{value: -2, want: -3},
} {
- got := roundToMultipleInt64(tc.value, 3, mode,
false)
+ got, err := roundToMultipleInt64(tc.value, 3,
mode, false)
+ if err != nil {
+ t.Fatalf("roundToMultipleInt64(%d, 3,
%s) returned an error: %v", tc.value, mode, err)
+ }
if got != tc.want {
t.Errorf("roundToMultipleInt64(%d, 3,
%s) = %d, want %d", tc.value, mode, got, tc.want)
}
diff --git a/arrow/compute/internal/kernels/temporal_rounding_overflow_test.go
b/arrow/compute/internal/kernels/temporal_rounding_overflow_test.go
new file mode 100644
index 00000000..9ba61b92
--- /dev/null
+++ b/arrow/compute/internal/kernels/temporal_rounding_overflow_test.go
@@ -0,0 +1,189 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build go1.18
+
+package kernels
+
+import (
+ "errors"
+ "math"
+ "testing"
+ "time"
+
+ "github.com/apache/arrow-go/v18/arrow"
+)
+
+func TestTemporalCheckedArithmeticBoundaries(t *testing.T) {
+ tests := []struct {
+ name string
+ fn func() (int64, error)
+ }{
+ {name: "positive multiply overflow", fn: func() (int64, error) {
+ return checkedMulInt64(math.MaxInt64, 2)
+ }},
+ {name: "negative multiply overflow", fn: func() (int64, error) {
+ return checkedMulInt64(math.MinInt64, 2)
+ }},
+ {name: "minimum times negative one", fn: func() (int64, error) {
+ return checkedMulInt64(math.MinInt64, -1)
+ }},
+ {name: "subtraction below minimum", fn: func() (int64, error) {
+ return checkedSubInt64(math.MinInt64, 1)
+ }},
+ {name: "subtraction above maximum", fn: func() (int64, error) {
+ return checkedSubInt64(math.MaxInt64, -1)
+ }},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := tc.fn()
+ if !errors.Is(err, arrow.ErrInvalid) {
+ t.Fatalf("got error %v, want arrow.ErrInvalid",
err)
+ }
+ })
+ }
+}
+
+func TestTimeToNanosBoundaries(t *testing.T) {
+ minTime := time.Unix(0, math.MinInt64)
+ maxTime := time.Unix(0, math.MaxInt64)
+ tests := []struct {
+ name string
+ value time.Time
+ want int64
+ err bool
+ }{
+ {name: "exact minimum", value: minTime, want: math.MinInt64},
+ {name: "exact maximum", value: maxTime, want: math.MaxInt64},
+ {name: "one below minimum", value:
minTime.Add(-time.Nanosecond), err: true},
+ {name: "one above maximum", value:
maxTime.Add(time.Nanosecond), err: true},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := timeToNanos(tc.value)
+ if tc.err {
+ if !errors.Is(err, arrow.ErrInvalid) {
+ t.Fatalf("got error %v, want
arrow.ErrInvalid", err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("timeToNanos returned an unexpected
error: %v", err)
+ }
+ if got != tc.want {
+ t.Fatalf("timeToNanos(%s) = %d, want %d",
tc.value, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestRoundToMultipleInt64OverflowBoundaries(t *testing.T) {
+ tests := []struct {
+ name string
+ value int64
+ multiple int64
+ mode RoundMode
+ strict bool
+ want int64
+ err bool
+ }{
+ {name: "strict ceiling past maximum", value: math.MaxInt64,
multiple: 2, mode: RoundUp, strict: true, err: true},
+ {name: "strict ceiling exact past maximum", value:
math.MaxInt64 - 1, multiple: 2, mode: RoundUp, strict: true, err: true},
+ {name: "floor at minimum", value: math.MinInt64, multiple: 2,
mode: RoundDown, want: math.MinInt64},
+ {name: "odd multiple three", value: 2, multiple: 3, mode:
HalfToEven, want: 3},
+ {name: "odd multiple five", value: 2, multiple: 5, mode:
HalfDown, want: 0},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := roundToMultipleInt64(tc.value, tc.multiple,
tc.mode, tc.strict)
+ if tc.err {
+ if !errors.Is(err, arrow.ErrInvalid) {
+ t.Fatalf("got error %v, want
arrow.ErrInvalid", err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("roundToMultipleInt64 returned an
unexpected error: %v", err)
+ }
+ if got != tc.want {
+ t.Fatalf("roundToMultipleInt64(%d, %d) = %d,
want %d", tc.value, tc.multiple, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestRoundToMultipleInt64HalfModes(t *testing.T) {
+ tests := []struct {
+ name string
+ mode RoundMode
+ value int64
+ want int64
+ }{
+ {name: "half down positive", mode: HalfDown, value: 2, want: 0},
+ {name: "half down negative", mode: HalfDown, value: -2, want:
-4},
+ {name: "half up positive", mode: HalfUp, value: 2, want: 4},
+ {name: "half up negative", mode: HalfUp, value: -2, want: 0},
+ {name: "half to even lower", mode: HalfToEven, value: 2, want:
0},
+ {name: "half to even upper", mode: HalfToEven, value: 6, want:
8},
+ {name: "half to even negative lower", mode: HalfToEven, value:
-2, want: 0},
+ {name: "half to even negative upper", mode: HalfToEven, value:
-6, want: -8},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := roundToMultipleInt64(tc.value, 4, tc.mode,
false)
+ if err != nil {
+ t.Fatalf("roundToMultipleInt64 returned an
unexpected error: %v", err)
+ }
+ if got != tc.want {
+ t.Fatalf("roundToMultipleInt64(%d, 4, %s) = %d,
want %d", tc.value, tc.mode, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestTemporalRoundingExtremeOrdering(t *testing.T) {
+ values := []int64{
+ math.MinInt64, math.MinInt64 + 1, minCalendarTimestampSecond -
1,
+ minCalendarTimestampSecond, -1, 0, 1, math.MaxInt64 - 86400,
math.MaxInt64 - 1, math.MaxInt64,
+ }
+ for _, inputUnit := range []arrow.TimeUnit{arrow.Second,
arrow.Millisecond, arrow.Microsecond, arrow.Nanosecond} {
+ for _, unit := range []RoundTemporalUnit{RoundTemporalYear,
RoundTemporalQuarter, RoundTemporalMonth, RoundTemporalWeek, RoundTemporalDay} {
+ for _, multiple := range []int64{1, 2, 7, 300} {
+ for _, value := range values {
+ state :=
roundTemporalState{RoundTemporalOptions: RoundTemporalOptions{Unit: unit,
Multiple: multiple}}
+ state.unitNanos, state.isSubDay =
unitInNanos(unit)
+ state.roundingInterval =
state.unitNanos * multiple
+ state.mode = RoundDown
+ floor, err := roundTimestamp(value,
inputUnit, time.UTC, state)
+ if err == nil && floor > value {
+ t.Errorf("floor(%d, %s, %d %v)
= %d exceeds the input", value, inputUnit, multiple, unit, floor)
+ }
+ state.mode = RoundUp
+ ceil, err := roundTimestamp(value,
inputUnit, time.UTC, state)
+ if err == nil && ceil < value {
+ t.Errorf("ceil(%d, %s, %d %v) =
%d precedes the input", value, inputUnit, multiple, unit, ceil)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/arrow/compute/temporal_rounding_origins_test.go
b/arrow/compute/temporal_rounding_origins_test.go
new file mode 100644
index 00000000..574de347
--- /dev/null
+++ b/arrow/compute/temporal_rounding_origins_test.go
@@ -0,0 +1,707 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build go1.18
+
+package compute_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/compute"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/stretchr/testify/require"
+)
+
+type temporalRoundingFunc func(context.Context, compute.RoundTemporalOptions,
compute.Datum) (compute.Datum, error)
+
+func requireTemporalRoundingTime(t *testing.T, fn temporalRoundingFunc, input,
expected time.Time,
+ inputUnit arrow.TimeUnit, timezone string, opts
compute.RoundTemporalOptions) {
+ t.Helper()
+
+ value, err := arrow.TimestampFromTime(input, inputUnit)
+ require.NoError(t, err)
+
+ builder := array.NewTimestampBuilder(memory.DefaultAllocator,
&arrow.TimestampType{
+ Unit: inputUnit,
+ TimeZone: timezone,
+ })
+ builder.Append(value)
+ inputArray := builder.NewArray()
+ builder.Release()
+ defer inputArray.Release()
+
+ inputDatum := compute.NewDatum(inputArray)
+ defer inputDatum.Release()
+ result, err := fn(context.Background(), opts, inputDatum)
+ require.NoError(t, err)
+ defer result.Release()
+
+ output := result.(*compute.ArrayDatum).MakeArray().(*array.Timestamp)
+ defer output.Release()
+
+ expectedValue, err := arrow.TimestampFromTime(expected, inputUnit)
+ require.NoError(t, err)
+ require.Equal(t, expectedValue, output.Value(0))
+}
+
+func requireTemporalRoundingError(t *testing.T, fn temporalRoundingFunc, input
time.Time,
+ inputUnit arrow.TimeUnit, timezone string, opts
compute.RoundTemporalOptions, expected error) {
+ t.Helper()
+
+ value, err := arrow.TimestampFromTime(input, inputUnit)
+ require.NoError(t, err)
+
+ builder := array.NewTimestampBuilder(memory.DefaultAllocator,
&arrow.TimestampType{
+ Unit: inputUnit,
+ TimeZone: timezone,
+ })
+ builder.Append(value)
+ inputArray := builder.NewArray()
+ builder.Release()
+ defer inputArray.Release()
+
+ inputDatum := compute.NewDatum(inputArray)
+ defer inputDatum.Release()
+ result, err := fn(context.Background(), opts, inputDatum)
+ if result != nil {
+ result.Release()
+ }
+ require.ErrorIs(t, err, expected)
+}
+
+func TestTemporalRoundingUsesUnixEpochForCalendarMultiples(t *testing.T) {
+ input := time.Date(2024, time.January, 15, 10, 37, 0, 0, time.UTC)
+ tests := []struct {
+ name string
+ fn temporalRoundingFunc
+ unit compute.RoundTemporalUnit
+ multiple int64
+ expected time.Time
+ }{
+ {
+ name: "floor month",
+ fn: compute.FloorTemporal,
+ unit: compute.RoundTemporalMonth,
+ multiple: 7,
+ expected: time.Date(2023, time.September, 1, 0, 0, 0,
0, time.UTC),
+ },
+ {
+ name: "ceil month",
+ fn: compute.CeilTemporal,
+ unit: compute.RoundTemporalMonth,
+ multiple: 7,
+ expected: time.Date(2024, time.April, 1, 0, 0, 0, 0,
time.UTC),
+ },
+ {
+ name: "floor quarter",
+ fn: compute.FloorTemporal,
+ unit: compute.RoundTemporalQuarter,
+ multiple: 3,
+ expected: time.Date(2024, time.January, 1, 0, 0, 0, 0,
time.UTC),
+ },
+ {
+ name: "ceil quarter",
+ fn: compute.CeilTemporal,
+ unit: compute.RoundTemporalQuarter,
+ multiple: 3,
+ expected: time.Date(2024, time.October, 1, 0, 0, 0, 0,
time.UTC),
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ requireTemporalRoundingTime(t, test.fn, input,
test.expected, arrow.Microsecond, "", compute.RoundTemporalOptions{
+ Multiple: test.multiple,
+ Unit: test.unit,
+ })
+ })
+ }
+}
+
+func TestTemporalRoundingCalendarBasedOrigins(t *testing.T) {
+ tests := []struct {
+ name string
+ fn temporalRoundingFunc
+ input time.Time
+ unit compute.RoundTemporalUnit
+ multiple int64
+ expected time.Time
+ }{
+ {
+ name: "floor day from month",
+ fn: compute.FloorTemporal,
+ input: time.Date(2024, time.March, 10, 12, 0, 0, 0,
time.UTC),
+ unit: compute.RoundTemporalDay,
+ multiple: 2,
+ expected: time.Date(2024, time.March, 9, 0, 0, 0, 0,
time.UTC),
+ },
+ {
+ name: "ceil day from month",
+ fn: compute.CeilTemporal,
+ input: time.Date(2024, time.March, 10, 12, 0, 0, 0,
time.UTC),
+ unit: compute.RoundTemporalDay,
+ multiple: 2,
+ expected: time.Date(2024, time.March, 11, 0, 0, 0, 0,
time.UTC),
+ },
+ {
+ name: "floor minute from hour",
+ fn: compute.FloorTemporal,
+ input: time.Date(2024, time.January, 15, 10, 37, 30,
123000, time.UTC),
+ unit: compute.RoundTemporalMinute,
+ multiple: 7,
+ expected: time.Date(2024, time.January, 15, 10, 35, 0,
0, time.UTC),
+ },
+ {
+ name: "ceil minute from hour",
+ fn: compute.CeilTemporal,
+ input: time.Date(2024, time.January, 15, 10, 37, 30,
123000, time.UTC),
+ unit: compute.RoundTemporalMinute,
+ multiple: 7,
+ expected: time.Date(2024, time.January, 15, 10, 42, 0,
0, time.UTC),
+ },
+ {
+ name: "floor month from year",
+ fn: compute.FloorTemporal,
+ input: time.Date(2024, time.May, 15, 12, 0, 0, 0,
time.UTC),
+ unit: compute.RoundTemporalMonth,
+ multiple: 5,
+ expected: time.Date(2024, time.January, 1, 0, 0, 0, 0,
time.UTC),
+ },
+ {
+ name: "ceil month from year",
+ fn: compute.CeilTemporal,
+ input: time.Date(2024, time.May, 15, 12, 0, 0, 0,
time.UTC),
+ unit: compute.RoundTemporalMonth,
+ multiple: 5,
+ expected: time.Date(2024, time.June, 1, 0, 0, 0, 0,
time.UTC),
+ },
+ {
+ name: "floor quarter from year",
+ fn: compute.FloorTemporal,
+ input: time.Date(2024, time.May, 15, 12, 0, 0, 0,
time.UTC),
+ unit: compute.RoundTemporalQuarter,
+ multiple: 2,
+ expected: time.Date(2024, time.January, 1, 0, 0, 0, 0,
time.UTC),
+ },
+ {
+ name: "ceil quarter from year",
+ fn: compute.CeilTemporal,
+ input: time.Date(2024, time.May, 15, 12, 0, 0, 0,
time.UTC),
+ unit: compute.RoundTemporalQuarter,
+ multiple: 2,
+ expected: time.Date(2024, time.July, 1, 0, 0, 0, 0,
time.UTC),
+ },
+ {
+ name: "floor week from year",
+ fn: compute.FloorTemporal,
+ input: time.Date(2024, time.January, 8, 12, 0, 0, 0,
time.UTC),
+ unit: compute.RoundTemporalWeek,
+ multiple: 5,
+ expected: time.Date(2024, time.January, 1, 0, 0, 0, 0,
time.UTC),
+ },
+ {
+ name: "ceil week from year",
+ fn: compute.CeilTemporal,
+ input: time.Date(2024, time.January, 8, 12, 0, 0, 0,
time.UTC),
+ unit: compute.RoundTemporalWeek,
+ multiple: 5,
+ expected: time.Date(2024, time.February, 5, 0, 0, 0, 0,
time.UTC),
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ requireTemporalRoundingTime(t, test.fn, test.input,
test.expected, arrow.Microsecond, "", compute.RoundTemporalOptions{
+ Multiple: test.multiple,
+ Unit: test.unit,
+ WeekStartsMonday: test.unit ==
compute.RoundTemporalWeek,
+ CalendarBasedOrigin: true,
+ })
+ })
+ }
+}
+
+func TestTemporalRoundingCalendarOriginMultiWeek(t *testing.T) {
+ input := time.Date(1078, time.June, 14, 4, 11, 8, 0, time.UTC)
+ tests := []struct {
+ name string
+ multiple int64
+ weekStartsMonday bool
+ floor time.Time
+ ceil time.Time
+ }{
+ {
+ name: "monday start, two weeks",
+ multiple: 2,
+ weekStartsMonday: true,
+ floor: time.Date(1078, time.June, 3, 0, 0,
0, 0, time.UTC),
+ ceil: time.Date(1078, time.June, 17, 0, 0,
0, 0, time.UTC),
+ },
+ {
+ name: "sunday start, two weeks",
+ multiple: 2,
+ floor: time.Date(1078, time.June, 2, 0, 0, 0, 0,
time.UTC),
+ ceil: time.Date(1078, time.June, 16, 0, 0, 0, 0,
time.UTC),
+ },
+ {
+ name: "monday start, three weeks",
+ multiple: 3,
+ weekStartsMonday: true,
+ floor: time.Date(1078, time.May, 27, 0, 0,
0, 0, time.UTC),
+ ceil: time.Date(1078, time.June, 17, 0, 0,
0, 0, time.UTC),
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ opts := compute.RoundTemporalOptions{
+ Multiple: test.multiple,
+ Unit: compute.RoundTemporalWeek,
+ WeekStartsMonday: test.weekStartsMonday,
+ CalendarBasedOrigin: true,
+ }
+ requireTemporalRoundingTime(t, compute.FloorTemporal,
input, test.floor, arrow.Second, "", opts)
+ requireTemporalRoundingTime(t, compute.CeilTemporal,
input, test.ceil, arrow.Second, "", opts)
+ })
+ }
+}
+
+func TestTemporalRoundingCalendarOriginMultiWeekYearBoundaries(t *testing.T) {
+ tests := []struct {
+ name string
+ input time.Time
+ multiple int64
+ weekStartsMonday bool
+ floor time.Time
+ ceil time.Time
+ }{
+ {
+ name: "sunday start at end of year",
+ input: time.Date(2025, time.December, 31, 4, 58, 46,
0, time.UTC),
+ multiple: 2,
+ floor: time.Date(2025, time.December, 28, 0, 0, 0,
0, time.UTC),
+ ceil: time.Date(2026, time.January, 11, 0, 0, 0, 0,
time.UTC),
+ },
+ {
+ name: "monday start at end of year",
+ input: time.Date(2025, time.December, 31, 4,
58, 46, 0, time.UTC),
+ multiple: 2,
+ weekStartsMonday: true,
+ floor: time.Date(2025, time.December, 29, 0,
0, 0, 0, time.UTC),
+ ceil: time.Date(2026, time.January, 12, 0,
0, 0, 0, time.UTC),
+ },
+ {
+ name: "monday start before first week",
+ input: time.Date(2023, time.January, 1, 4,
58, 46, 0, time.UTC),
+ multiple: 2,
+ weekStartsMonday: true,
+ floor: time.Date(2022, time.December, 19, 0,
0, 0, 0, time.UTC),
+ ceil: time.Date(2023, time.January, 2, 0,
0, 0, 0, time.UTC),
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ opts := compute.RoundTemporalOptions{
+ Multiple: test.multiple,
+ Unit: compute.RoundTemporalWeek,
+ WeekStartsMonday: test.weekStartsMonday,
+ CalendarBasedOrigin: true,
+ }
+ requireTemporalRoundingTime(t, compute.FloorTemporal,
test.input, test.floor, arrow.Second, "", opts)
+ requireTemporalRoundingTime(t, compute.FloorTemporal,
test.floor, test.floor, arrow.Second, "", opts)
+ requireTemporalRoundingTime(t, compute.CeilTemporal,
test.input, test.ceil, arrow.Second, "", opts)
+ })
+ }
+}
+
+func TestTemporalRoundingCalendarOriginUsesWallClockAcrossDST(t *testing.T) {
+ location, err := time.LoadLocation("America/New_York")
+ require.NoError(t, err)
+
+ input := time.Date(2024, time.March, 10, 12, 0, 0, 0, time.UTC)
+ opts := compute.RoundTemporalOptions{
+ Multiple: 5,
+ Unit: compute.RoundTemporalHour,
+ CalendarBasedOrigin: true,
+ }
+
+ requireTemporalRoundingTime(t, compute.FloorTemporal, input,
+ time.Date(2024, time.March, 10, 5, 0, 0, 0, location),
arrow.Microsecond,
+ location.String(), opts)
+ requireTemporalRoundingTime(t, compute.CeilTemporal, input,
+ time.Date(2024, time.March, 10, 10, 0, 0, 0, location),
arrow.Microsecond,
+ location.String(), opts)
+}
+
+func TestTemporalRoundingZonedFixedUnitsUseLocalTime(t *testing.T) {
+ location, err := time.LoadLocation("Asia/Kathmandu")
+ require.NoError(t, err)
+ input := time.Date(2024, time.July, 15, 10, 37, 0, 0, location)
+
+ requireTemporalRoundingTime(t, compute.FloorTemporal, input,
+ time.Date(2024, time.July, 15, 10, 0, 0, 0, location),
arrow.Second,
+ location.String(), compute.RoundTemporalOptions{Multiple: 1,
Unit: compute.RoundTemporalHour})
+ requireTemporalRoundingTime(t, compute.CeilTemporal, input,
+ time.Date(2024, time.July, 15, 11, 0, 0, 0, location),
arrow.Second,
+ location.String(), compute.RoundTemporalOptions{Multiple: 1,
Unit: compute.RoundTemporalHour})
+}
+
+func TestTemporalRoundingZonedFixedUnitsSupportWideDates(t *testing.T) {
+ location, err := time.LoadLocation("Asia/Kathmandu")
+ require.NoError(t, err)
+ input := time.Date(1000, time.July, 15, 10, 37, 0, 0, location)
+
+ for _, unit := range []arrow.TimeUnit{arrow.Second, arrow.Millisecond,
arrow.Microsecond} {
+ t.Run(unit.String(), func(t *testing.T) {
+ opts := compute.RoundTemporalOptions{Multiple: 1, Unit:
compute.RoundTemporalHour}
+ requireTemporalRoundingTime(t, compute.FloorTemporal,
input,
+ time.Date(1000, time.July, 15, 10, 0, 0, 0,
location), unit,
+ location.String(), opts)
+ requireTemporalRoundingTime(t, compute.CeilTemporal,
input,
+ time.Date(1000, time.July, 15, 11, 0, 0, 0,
location), unit,
+ location.String(), opts)
+ requireTemporalRoundingTime(t, compute.RoundTemporal,
input,
+ time.Date(1000, time.July, 15, 11, 0, 0, 0,
location), unit,
+ location.String(), opts)
+ })
+ }
+}
+
+func TestTemporalRoundingCalendarOriginAfterMidnightGap(t *testing.T) {
+ // Sao Paulo skipped midnight to 01:00. Monrovia skipped midnight to
+ // 00:44:30, so even an hour or minute boundary may not exist locally.
+ for _, tc := range []struct {
+ zone, input string
+ unit compute.RoundTemporalUnit
+ multiple int64
+ interval time.Duration
+ minInputUnit arrow.TimeUnit
+ }{
+ {"America/Sao_Paulo", "2018-11-04T02:00:00-02:00",
compute.RoundTemporalHour, 2, 2 * time.Hour, arrow.Second},
+ {"America/Sao_Paulo", "2018-11-04T01:30:00-02:00",
compute.RoundTemporalMinute, 30, 30 * time.Minute, arrow.Second},
+ {"Africa/Monrovia", "1972-01-07T01:00:00Z",
compute.RoundTemporalHour, 1, time.Hour, arrow.Second},
+ {"Africa/Monrovia", "1972-01-07T00:45:00Z",
compute.RoundTemporalMinute, 15, 15 * time.Minute, arrow.Second},
+ {"Africa/Monrovia", "1972-01-07T00:44:30Z",
compute.RoundTemporalSecond, 10, 10 * time.Second, arrow.Second},
+ {"Africa/Monrovia", "1972-01-07T00:44:30.125Z",
compute.RoundTemporalMillisecond, 5, 5 * time.Millisecond, arrow.Millisecond},
+ {"Africa/Monrovia", "1972-01-07T00:44:30.123455Z",
compute.RoundTemporalMicrosecond, 5, 5 * time.Microsecond, arrow.Microsecond},
+ {"Africa/Monrovia", "1972-01-07T00:44:30.123456785Z",
compute.RoundTemporalNanosecond, 5, 5 * time.Nanosecond, arrow.Nanosecond},
+ } {
+ t.Run(tc.zone+"/"+tc.input, func(t *testing.T) {
+ input, err := time.Parse(time.RFC3339Nano, tc.input)
+ require.NoError(t, err)
+ for _, unit := range []arrow.TimeUnit{arrow.Second,
arrow.Millisecond, arrow.Microsecond, arrow.Nanosecond} {
+ if unit < tc.minInputUnit {
+ continue
+ }
+ t.Run(unit.String(), func(t *testing.T) {
+ opts := compute.RoundTemporalOptions{
+ Multiple:
tc.multiple,
+ Unit: tc.unit,
+ CalendarBasedOrigin: true,
+ }
+ for _, fn := range
[]temporalRoundingFunc{compute.FloorTemporal, compute.CeilTemporal,
compute.RoundTemporal} {
+ requireTemporalRoundingTime(t,
fn, input, input, unit, tc.zone, opts)
+ }
+
+ unaligned := input.Add(tc.interval * 4
/ 5)
+ requireTemporalRoundingTime(t,
compute.FloorTemporal, unaligned, input, unit, tc.zone, opts)
+ for _, fn := range
[]temporalRoundingFunc{compute.CeilTemporal, compute.RoundTemporal} {
+ requireTemporalRoundingTime(t,
fn, unaligned, input.Add(tc.interval), unit, tc.zone, opts)
+ }
+ opts.CeilIsStrictlyGreater = true
+ requireTemporalRoundingTime(t,
compute.CeilTemporal, input, input.Add(tc.interval), unit, tc.zone, opts)
+ })
+ }
+ })
+ }
+}
+
+func TestTemporalRoundingCalendarOriginCrossesMidnightGap(t *testing.T) {
+ input, err := time.Parse(time.RFC3339, "2018-11-03T23:30:00-03:00")
+ require.NoError(t, err)
+ floor, err := time.Parse(time.RFC3339, "2018-11-03T20:00:00-03:00")
+ require.NoError(t, err)
+ ceil, err := time.Parse(time.RFC3339, "2018-11-04T01:00:00-02:00")
+ require.NoError(t, err)
+ opts := compute.RoundTemporalOptions{
+ Multiple: 5,
+ Unit: compute.RoundTemporalHour,
+ CalendarBasedOrigin: true,
+ }
+ for _, unit := range []arrow.TimeUnit{arrow.Second, arrow.Millisecond,
arrow.Microsecond, arrow.Nanosecond} {
+ t.Run(unit.String(), func(t *testing.T) {
+ requireTemporalRoundingTime(t, compute.FloorTemporal,
input, floor, unit, "America/Sao_Paulo", opts)
+ for _, fn := range
[]temporalRoundingFunc{compute.CeilTemporal, compute.RoundTemporal} {
+ requireTemporalRoundingTime(t, fn, input, ceil,
unit, "America/Sao_Paulo", opts)
+ }
+ })
+ }
+}
+
+func TestTemporalRoundingCalendarOriginRejectsDSTGap(t *testing.T) {
+ for _, tc := range []struct {
+ zone, input string
+ fn temporalRoundingFunc
+ unit compute.RoundTemporalUnit
+ multiple int64
+ }{
+ {"America/New_York", "2024-03-10T01:30:00-05:00",
compute.CeilTemporal, compute.RoundTemporalHour, 1},
+ {"America/Sao_Paulo", "2018-11-04T01:30:00-02:00",
compute.FloorTemporal, compute.RoundTemporalHour, 2},
+ {"America/Sao_Paulo", "2018-11-03T23:30:00-03:00",
compute.CeilTemporal, compute.RoundTemporalHour, 1},
+ {"Africa/Monrovia", "1972-01-07T00:45:00Z",
compute.FloorTemporal, compute.RoundTemporalMinute, 30},
+ {"Africa/Monrovia", "1972-01-07T00:44:35Z",
compute.FloorTemporal, compute.RoundTemporalSecond, 60},
+ {"Pacific/Apia", "2011-12-29T23:30:00-10:00",
compute.CeilTemporal, compute.RoundTemporalHour, 5},
+ } {
+ t.Run(tc.zone+"/"+tc.input, func(t *testing.T) {
+ input, err := time.Parse(time.RFC3339, tc.input)
+ require.NoError(t, err)
+ for _, unit := range []arrow.TimeUnit{arrow.Second,
arrow.Millisecond, arrow.Microsecond, arrow.Nanosecond} {
+ t.Run(unit.String(), func(t *testing.T) {
+ value, err :=
arrow.TimestampFromTime(input, unit)
+ require.NoError(t, err)
+
+ builder :=
array.NewTimestampBuilder(memory.DefaultAllocator, &arrow.TimestampType{
+ Unit: unit,
+ TimeZone: tc.zone,
+ })
+ builder.Append(value)
+ inputArray := builder.NewArray()
+ builder.Release()
+ defer inputArray.Release()
+ inputDatum :=
compute.NewDatum(inputArray)
+ defer inputDatum.Release()
+
+ result, err :=
tc.fn(context.Background(), compute.RoundTemporalOptions{
+ Multiple:
tc.multiple,
+ Unit: tc.unit,
+ CalendarBasedOrigin: true,
+ }, inputDatum)
+ if err == nil && result != nil {
+ defer result.Release()
+ }
+ require.ErrorIs(t, err,
arrow.ErrInvalid)
+ })
+ }
+ })
+ }
+}
+
+func TestTemporalRoundingRejectsMissingCalendarBoundary(t *testing.T) {
+ for _, tc := range []struct {
+ name, zone, input string
+ multiple int64
+ unit compute.RoundTemporalUnit
+ calendarOrigin bool
+ }{
+ {
+ name: "month start",
+ zone: "America/Argentina/Buenos_Aires",
+ input: "1930-12-02T12:00:00-03:00",
+ multiple: 1,
+ unit: compute.RoundTemporalMonth,
+ calendarOrigin: true,
+ },
+ {
+ name: "day start",
+ zone: "America/Sao_Paulo",
+ input: "2018-11-04T01:30:00-02:00",
+ multiple: 1,
+ unit: compute.RoundTemporalDay,
+ },
+ {
+ name: "calendar-origin week start",
+ zone: "America/Sao_Paulo",
+ input: "2018-11-05T12:00:00-02:00",
+ multiple: 2,
+ unit: compute.RoundTemporalWeek,
+ calendarOrigin: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ input, err := time.Parse(time.RFC3339, tc.input)
+ require.NoError(t, err)
+ value, err := arrow.TimestampFromTime(input,
arrow.Second)
+ require.NoError(t, err)
+
+ builder :=
array.NewTimestampBuilder(memory.DefaultAllocator, &arrow.TimestampType{
+ Unit: arrow.Second,
+ TimeZone: tc.zone,
+ })
+ builder.Append(value)
+ inputArray := builder.NewArray()
+ builder.Release()
+ defer inputArray.Release()
+
+ inputDatum := compute.NewDatum(inputArray)
+ defer inputDatum.Release()
+ result, err :=
compute.FloorTemporal(context.Background(), compute.RoundTemporalOptions{
+ Multiple: tc.multiple,
+ Unit: tc.unit,
+ CalendarBasedOrigin: tc.calendarOrigin,
+ }, inputDatum)
+ if result != nil {
+ defer result.Release()
+ }
+ require.ErrorIs(t, err, arrow.ErrInvalid)
+ })
+ }
+}
+
+func TestTemporalRoundingRejectsMissingNextCalendarDay(t *testing.T) {
+ input, err := time.Parse(time.RFC3339, "1995-10-14T23:37:29-03:00")
+ require.NoError(t, err)
+ value, err := arrow.TimestampFromTime(input, arrow.Second)
+ require.NoError(t, err)
+
+ builder := array.NewTimestampBuilder(memory.DefaultAllocator,
&arrow.TimestampType{
+ Unit: arrow.Second,
+ TimeZone: "America/Sao_Paulo",
+ })
+ builder.Append(value)
+ inputArray := builder.NewArray()
+ builder.Release()
+ defer inputArray.Release()
+
+ result, err := compute.CeilTemporal(context.Background(),
compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalDay,
+ }, compute.NewDatum(inputArray))
+ if result != nil {
+ defer result.Release()
+ }
+ require.ErrorIs(t, err, arrow.ErrInvalid)
+}
+
+func TestTemporalRoundingWeekUsesFixedWallClockPeriods(t *testing.T) {
+ input, err := time.Parse(time.RFC3339, "2015-10-23T23:37:29-02:00")
+ require.NoError(t, err)
+ location, err := time.LoadLocation("America/Sao_Paulo")
+ require.NoError(t, err)
+ for _, tc := range []struct {
+ name string
+ fn temporalRoundingFunc
+ expected time.Time
+ }{
+ {
+ name: "floor",
+ fn: compute.FloorTemporal,
+ expected: time.Date(2015, time.October, 17, 23, 0, 0,
0, location),
+ },
+ {
+ name: "ceil",
+ fn: compute.CeilTemporal,
+ expected: time.Date(2015, time.October, 24, 23, 0, 0,
0, location),
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ requireTemporalRoundingTime(t, tc.fn, input,
tc.expected, arrow.Second, "America/Sao_Paulo", compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalWeek,
+ WeekStartsMonday: false,
+ })
+ })
+ }
+}
+
+func TestTemporalRoundingWeekPreservesWallBoundaryAcrossDST(t *testing.T) {
+ location, err := time.LoadLocation("America/New_York")
+ require.NoError(t, err)
+ input := time.Date(2024, time.March, 12, 12, 0, 0, 0, time.UTC)
+
+ for _, tc := range []struct {
+ name string
+ weekStartsMonday bool
+ floor time.Time
+ ceil time.Time
+ }{
+ {
+ name: "sunday start",
+ floor: time.Date(2024, time.March, 10, 0, 0, 0, 0,
location),
+ ceil: time.Date(2024, time.March, 17, 0, 0, 0, 0,
location),
+ },
+ {
+ name: "monday start",
+ weekStartsMonday: true,
+ floor: time.Date(2024, time.March, 11, 0, 0,
0, 0, location),
+ ceil: time.Date(2024, time.March, 18, 0, 0,
0, 0, location),
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ opts := compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalWeek,
+ WeekStartsMonday: tc.weekStartsMonday,
+ }
+ requireTemporalRoundingTime(t, compute.FloorTemporal,
input, tc.floor, arrow.Second, location.String(), opts)
+ requireTemporalRoundingTime(t, compute.CeilTemporal,
input, tc.ceil, arrow.Second, location.String(), opts)
+ requireTemporalRoundingTime(t, compute.FloorTemporal,
tc.floor, tc.floor, arrow.Second, location.String(), opts)
+ })
+ }
+}
+
+func TestTemporalRoundingRejectsAmbiguousCalendarBoundary(t *testing.T) {
+ for _, tc := range []struct {
+ zone, input string
+ }{
+ {"America/New_York", "2024-11-03T01:30:00-04:00"},
+ {"America/New_York", "2024-11-03T01:30:00-05:00"},
+ {"Europe/Berlin", "2024-10-27T02:30:00+02:00"},
+ {"Europe/Berlin", "2024-10-27T02:30:00+01:00"},
+ {"Australia/Lord_Howe", "2024-04-07T01:45:00+11:00"},
+ {"Australia/Lord_Howe", "2024-04-07T01:45:00+10:30"},
+ } {
+ t.Run(tc.zone+"/"+tc.input, func(t *testing.T) {
+ input, err := time.Parse(time.RFC3339, tc.input)
+ require.NoError(t, err)
+ for _, unit := range []arrow.TimeUnit{arrow.Second,
arrow.Millisecond, arrow.Microsecond, arrow.Nanosecond} {
+ t.Run(unit.String(), func(t *testing.T) {
+ opts := compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit:
compute.RoundTemporalMinute,
+ CalendarBasedOrigin: true,
+ }
+ for _, fn := range
[]temporalRoundingFunc{compute.FloorTemporal, compute.CeilTemporal,
compute.RoundTemporal} {
+ requireTemporalRoundingError(t,
fn, input, unit, tc.zone, opts, arrow.ErrInvalid)
+ }
+ })
+ }
+ })
+ }
+}
+
+func TestTemporalRoundingTimezoneDayMultipleUsesEpochOrigin(t *testing.T) {
+ location, err := time.LoadLocation("America/New_York")
+ require.NoError(t, err)
+
+ input := time.Date(2024, time.January, 15, 10, 37, 0, 0, time.UTC)
+ opts := compute.RoundTemporalOptions{
+ Multiple: 2,
+ Unit: compute.RoundTemporalDay,
+ }
+
+ requireTemporalRoundingTime(t, compute.FloorTemporal, input,
+ time.Date(2024, time.January, 14, 0, 0, 0, 0, location),
arrow.Microsecond,
+ location.String(), opts)
+ requireTemporalRoundingTime(t, compute.CeilTemporal, input,
+ time.Date(2024, time.January, 16, 0, 0, 0, 0, location),
arrow.Microsecond,
+ location.String(), opts)
+}
diff --git a/arrow/compute/temporal_rounding_overflow_test.go
b/arrow/compute/temporal_rounding_overflow_test.go
new file mode 100644
index 00000000..cac85103
--- /dev/null
+++ b/arrow/compute/temporal_rounding_overflow_test.go
@@ -0,0 +1,741 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build go1.18
+
+package compute_test
+
+import (
+ "context"
+ "math"
+ "testing"
+ "time"
+
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/compute"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTemporalRoundingOverflow(t *testing.T) {
+ tests := []struct {
+ name string
+ unit arrow.TimeUnit
+ value arrow.Timestamp
+ opt compute.RoundTemporalOptions
+ }{
+ {
+ name: "input conversion",
+ unit: arrow.Second,
+ value: arrow.Timestamp(math.MaxInt64),
+ opt: compute.RoundTemporalOptions{Multiple: 3, Unit:
compute.RoundTemporalNanosecond},
+ },
+ {
+ name: "millisecond input conversion",
+ unit: arrow.Millisecond,
+ value: arrow.Timestamp(math.MaxInt64),
+ opt: compute.RoundTemporalOptions{Multiple: 3, Unit:
compute.RoundTemporalNanosecond},
+ },
+ {
+ name: "microsecond input conversion",
+ unit: arrow.Microsecond,
+ value: arrow.Timestamp(math.MaxInt64),
+ opt: compute.RoundTemporalOptions{Multiple: 3, Unit:
compute.RoundTemporalNanosecond},
+ },
+ {
+ name: "interval conversion",
+ unit: arrow.Second,
+ value: 1,
+ opt: compute.RoundTemporalOptions{Multiple:
math.MaxInt64, Unit: compute.RoundTemporalSecond},
+ },
+ {
+ name: "rounded result",
+ unit: arrow.Nanosecond,
+ value: arrow.Timestamp(math.MaxInt64),
+ opt: compute.RoundTemporalOptions{Multiple: 2, Unit:
compute.RoundTemporalNanosecond},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ builder :=
array.NewTimestampBuilder(memory.DefaultAllocator, &arrow.TimestampType{Unit:
tc.unit})
+ builder.Append(tc.value)
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ _, err := compute.RoundTemporal(context.Background(),
tc.opt, compute.NewDatum(input))
+ require.ErrorIs(t, err, arrow.ErrInvalid)
+ })
+ }
+}
+
+func TestTemporalRoundingCalendarMultipleOverflow(t *testing.T) {
+ value, err := arrow.TimestampFromTime(time.Date(2024, time.January, 15,
10, 37, 0, 0, time.UTC), arrow.Nanosecond)
+ require.NoError(t, err)
+
+ builder := array.NewTimestampBuilder(memory.DefaultAllocator,
&arrow.TimestampType{Unit: arrow.Nanosecond})
+ builder.Append(value)
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ tests := []struct {
+ name string
+ fn func(context.Context, compute.RoundTemporalOptions,
compute.Datum) (compute.Datum, error)
+ unit compute.RoundTemporalUnit
+ }{
+ {name: "year ceiling", fn: compute.CeilTemporal, unit:
compute.RoundTemporalYear},
+ {name: "year half-rounding", fn: compute.RoundTemporal, unit:
compute.RoundTemporalYear},
+ {name: "quarter ceiling", fn: compute.CeilTemporal, unit:
compute.RoundTemporalQuarter},
+ {name: "quarter half-rounding", fn: compute.RoundTemporal,
unit: compute.RoundTemporalQuarter},
+ {name: "month ceiling", fn: compute.CeilTemporal, unit:
compute.RoundTemporalMonth},
+ {name: "month half-rounding", fn: compute.RoundTemporal, unit:
compute.RoundTemporalMonth},
+ {name: "week ceiling", fn: compute.CeilTemporal, unit:
compute.RoundTemporalWeek},
+ {name: "week half-rounding", fn: compute.RoundTemporal, unit:
compute.RoundTemporalWeek},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := tc.fn(context.Background(),
compute.RoundTemporalOptions{
+ Multiple: math.MaxInt64,
+ Unit: tc.unit,
+ }, compute.NewDatum(input))
+ require.ErrorIs(t, err, arrow.ErrInvalid)
+ })
+ }
+}
+
+func TestTemporalRoundingDate32Overflow(t *testing.T) {
+ tests := []struct {
+ name string
+ value arrow.Date32
+ fn func(context.Context, compute.RoundTemporalOptions,
compute.Datum) (compute.Datum, error)
+ opts compute.RoundTemporalOptions
+ }{
+ {
+ name: "ceiling past maximum",
+ value: math.MaxInt32,
+ fn: compute.CeilTemporal,
+ opts: compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalDay,
+ CeilIsStrictlyGreater: true,
+ },
+ },
+ {
+ name: "floor below minimum",
+ value: math.MinInt32,
+ fn: compute.FloorTemporal,
+ opts: compute.RoundTemporalOptions{
+ Multiple: 3,
+ Unit: compute.RoundTemporalDay,
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ builder :=
array.NewDate32Builder(memory.DefaultAllocator)
+ builder.Append(tc.value)
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ _, err := tc.fn(context.Background(), tc.opts,
compute.NewDatum(input))
+ require.ErrorIs(t, err, arrow.ErrInvalid)
+ })
+ }
+}
+
+func TestTemporalRoundingTimezonePaths(t *testing.T) {
+ loc, err := time.LoadLocation("America/New_York")
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ value time.Time
+ unit compute.RoundTemporalUnit
+ multiple int64
+ calendar bool
+ want time.Time
+ }{
+ {
+ name: "timezone input",
+ value: time.Date(2024, time.January, 15, 10, 37, 0,
0, loc),
+ unit: compute.RoundTemporalHour,
+ multiple: 2,
+ want: time.Date(2024, time.January, 15, 10, 0, 0,
0, loc),
+ },
+ {
+ name: "spring DST day",
+ value: time.Date(2024, time.March, 10, 12, 0, 0, 0,
loc),
+ unit: compute.RoundTemporalDay,
+ multiple: 1,
+ want: time.Date(2024, time.March, 10, 0, 0, 0, 0,
loc),
+ },
+ {
+ name: "timezone calendar origin",
+ value: time.Date(2024, time.January, 15, 10, 37, 0,
0, time.UTC),
+ unit: compute.RoundTemporalHour,
+ multiple: 2,
+ calendar: true,
+ want: time.Date(2024, time.January, 15, 9, 0, 0, 0,
time.UTC),
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ value, err := arrow.TimestampFromTime(tc.value,
arrow.Nanosecond)
+ require.NoError(t, err)
+ builder :=
array.NewTimestampBuilder(memory.DefaultAllocator, &arrow.TimestampType{
+ Unit: arrow.Nanosecond,
+ TimeZone: loc.String(),
+ })
+ builder.Append(value)
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ result, err :=
compute.FloorTemporal(context.Background(), compute.RoundTemporalOptions{
+ Multiple: tc.multiple,
+ Unit: tc.unit,
+ CalendarBasedOrigin: tc.calendar,
+ }, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output :=
result.(*compute.ArrayDatum).MakeArray().(*array.Timestamp)
+ defer output.Release()
+ want, err := arrow.TimestampFromTime(tc.want,
arrow.Nanosecond)
+ require.NoError(t, err)
+ require.Equal(t, want, output.Value(0))
+ })
+ }
+}
+
+func TestTemporalRoundingCalendarBoundaries(t *testing.T) {
+ tests := []struct {
+ name string
+ value time.Time
+ want time.Time
+ opts compute.RoundTemporalOptions
+ }{
+ {
+ name: "year result before unused end boundary",
+ value: time.Date(2262, time.January, 5, 0, 0, 0, 0,
time.UTC),
+ want: time.Date(2262, time.January, 1, 0, 0, 0, 0,
time.UTC),
+ opts: compute.RoundTemporalOptions{Multiple: 1, Unit:
compute.RoundTemporalYear},
+ },
+ {
+ name: "quarter result before unused end boundary",
+ value: time.Date(2262, time.January, 5, 0, 0, 0, 0,
time.UTC),
+ want: time.Date(2262, time.January, 1, 0, 0, 0, 0,
time.UTC),
+ opts: compute.RoundTemporalOptions{Multiple: 4, Unit:
compute.RoundTemporalQuarter},
+ },
+ {
+ name: "month result before unused end boundary",
+ value: time.Date(2262, time.January, 5, 0, 0, 0, 0,
time.UTC),
+ want: time.Date(2262, time.January, 1, 0, 0, 0, 0,
time.UTC),
+ opts: compute.RoundTemporalOptions{Multiple: 12, Unit:
compute.RoundTemporalMonth},
+ },
+ {
+ name: "wide year multiple",
+ value: time.Date(2201, time.January, 1, 0, 0, 0, 0,
time.UTC),
+ want: time.Date(2200, time.January, 1, 0, 0, 0, 0,
time.UTC),
+ opts: compute.RoundTemporalOptions{Multiple: 100,
Unit: compute.RoundTemporalYear},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ value, err := arrow.TimestampFromTime(tc.value,
arrow.Nanosecond)
+ require.NoError(t, err)
+
+ builder :=
array.NewTimestampBuilder(memory.DefaultAllocator, &arrow.TimestampType{Unit:
arrow.Nanosecond})
+ builder.Append(value)
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ result, err :=
compute.RoundTemporal(context.Background(), tc.opts, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output :=
result.(*compute.ArrayDatum).MakeArray().(*array.Timestamp)
+ defer output.Release()
+ want, err := arrow.TimestampFromTime(tc.want,
arrow.Nanosecond)
+ require.NoError(t, err)
+ require.Equal(t, want, output.Value(0))
+ })
+ }
+}
+
+func TestTemporalRoundingCalendarPreservesInputUnit(t *testing.T) {
+ value := time.Date(1500, time.June, 15, 12, 34, 56, 0, time.UTC)
+ want := time.Date(1500, time.January, 1, 0, 0, 0, 0, time.UTC)
+ opts := compute.RoundTemporalOptions{Multiple: 1, Unit:
compute.RoundTemporalYear}
+
+ t.Run("date32", func(t *testing.T) {
+ builder := array.NewDate32Builder(memory.DefaultAllocator)
+ builder.Append(arrow.Date32FromTime(value))
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ result, err := compute.FloorTemporal(context.Background(),
opts, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output :=
result.(*compute.ArrayDatum).MakeArray().(*array.Date32)
+ defer output.Release()
+ require.Equal(t, arrow.Date32FromTime(want), output.Value(0))
+ })
+
+ t.Run("date64", func(t *testing.T) {
+ builder := array.NewDate64Builder(memory.DefaultAllocator)
+ builder.Append(arrow.Date64FromTime(value))
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ result, err := compute.FloorTemporal(context.Background(),
opts, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output :=
result.(*compute.ArrayDatum).MakeArray().(*array.Date64)
+ defer output.Release()
+ require.Equal(t, arrow.Date64FromTime(want), output.Value(0))
+ })
+
+ for _, unit := range []arrow.TimeUnit{arrow.Second, arrow.Millisecond,
arrow.Microsecond} {
+ t.Run(unit.String(), func(t *testing.T) {
+ value, err := arrow.TimestampFromTime(value, unit)
+ require.NoError(t, err)
+
+ builder :=
array.NewTimestampBuilder(memory.DefaultAllocator, &arrow.TimestampType{Unit:
unit})
+ builder.Append(value)
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ result, err :=
compute.FloorTemporal(context.Background(), opts, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output :=
result.(*compute.ArrayDatum).MakeArray().(*array.Timestamp)
+ defer output.Release()
+ want, err := arrow.TimestampFromTime(want, unit)
+ require.NoError(t, err)
+ require.Equal(t, want, output.Value(0))
+ })
+ }
+}
+
+func TestTemporalRoundingFinerUnitPreservesInputPrecision(t *testing.T) {
+ value := time.Date(1500, time.June, 15, 12, 34, 56, 0, time.UTC)
+
+ for _, unit := range []arrow.TimeUnit{arrow.Second, arrow.Millisecond,
arrow.Microsecond} {
+ t.Run(unit.String(), func(t *testing.T) {
+ inputValue, err := arrow.TimestampFromTime(value, unit)
+ require.NoError(t, err)
+
+ builder :=
array.NewTimestampBuilder(memory.DefaultAllocator, &arrow.TimestampType{Unit:
unit})
+ builder.Append(inputValue)
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ result, err :=
compute.FloorTemporal(context.Background(), compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalNanosecond,
+ }, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output :=
result.(*compute.ArrayDatum).MakeArray().(*array.Timestamp)
+ defer output.Release()
+ require.Equal(t, inputValue, output.Value(0))
+ })
+ }
+
+ t.Run("date32", func(t *testing.T) {
+ builder := array.NewDate32Builder(memory.DefaultAllocator)
+ builder.Append(arrow.Date32FromTime(value))
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ result, err := compute.FloorTemporal(context.Background(),
compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalNanosecond,
+ }, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output :=
result.(*compute.ArrayDatum).MakeArray().(*array.Date32)
+ defer output.Release()
+ require.Equal(t, arrow.Date32FromTime(value), output.Value(0))
+ })
+
+ t.Run("date64", func(t *testing.T) {
+ builder := array.NewDate64Builder(memory.DefaultAllocator)
+ builder.Append(arrow.Date64FromTime(value))
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ result, err := compute.FloorTemporal(context.Background(),
compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalNanosecond,
+ }, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output :=
result.(*compute.ArrayDatum).MakeArray().(*array.Date64)
+ defer output.Release()
+ require.Equal(t, arrow.Date64FromTime(value), output.Value(0))
+ })
+}
+
+func TestTemporalRoundingCalendarOriginWideUnits(t *testing.T) {
+ valueTime := time.Date(1500, time.June, 15, 12, 34, 56, 0, time.UTC)
+ wantTime := time.Date(1500, time.June, 15, 12, 0, 0, 0, time.UTC)
+
+ for _, unit := range []arrow.TimeUnit{arrow.Second, arrow.Millisecond,
arrow.Microsecond} {
+ t.Run(unit.String(), func(t *testing.T) {
+ value, err := arrow.TimestampFromTime(valueTime, unit)
+ require.NoError(t, err)
+
+ builder :=
array.NewTimestampBuilder(memory.DefaultAllocator, &arrow.TimestampType{Unit:
unit})
+ builder.Append(value)
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ result, err :=
compute.FloorTemporal(context.Background(), compute.RoundTemporalOptions{
+ Multiple: 2,
+ Unit: compute.RoundTemporalHour,
+ CalendarBasedOrigin: true,
+ }, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output :=
result.(*compute.ArrayDatum).MakeArray().(*array.Timestamp)
+ defer output.Release()
+ want, err := arrow.TimestampFromTime(wantTime, unit)
+ require.NoError(t, err)
+ require.Equal(t, want, output.Value(0))
+ })
+ }
+}
+
+func TestTemporalRoundingZonedWideInputNoOp(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ input time.Time
+ calendarOrigin bool
+ }{
+ {
+ name: "before nanosecond range",
+ input: time.Date(1500, time.June, 15, 12, 34, 56, 0,
time.UTC),
+ },
+ {
+ name: "after nanosecond range",
+ input: time.Date(2300, time.June, 15, 12, 34, 56, 0,
time.UTC),
+ },
+ {
+ name: "before nanosecond range with calendar
origin",
+ input: time.Date(1500, time.June, 15, 12, 34,
56, 0, time.UTC),
+ calendarOrigin: true,
+ },
+ {
+ name: "after nanosecond range with calendar
origin",
+ input: time.Date(2300, time.June, 15, 12, 34,
56, 0, time.UTC),
+ calendarOrigin: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, rounding := range []struct {
+ name string
+ fn temporalRoundingFunc
+ }{
+ {name: "floor", fn: compute.FloorTemporal},
+ {name: "ceil", fn: compute.CeilTemporal},
+ {name: "round", fn: compute.RoundTemporal},
+ } {
+ t.Run(rounding.name, func(t *testing.T) {
+ requireTemporalRoundingTime(t,
rounding.fn, tc.input, tc.input, arrow.Second, "Asia/Kathmandu",
compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit:
compute.RoundTemporalNanosecond,
+ CalendarBasedOrigin:
tc.calendarOrigin,
+ })
+ })
+ }
+ })
+ }
+}
+
+func TestTemporalRoundingCalendarOriginAtMinimum(t *testing.T) {
+ minTime := arrow.Timestamp(math.MinInt64).ToTime(arrow.Nanosecond)
+ wantTime := time.Date(minTime.Year(), minTime.Month(), minTime.Day(),
minTime.Hour()+1, 0, 0, 0, time.UTC)
+
+ builder := array.NewTimestampBuilder(memory.DefaultAllocator,
&arrow.TimestampType{Unit: arrow.Nanosecond})
+ builder.Append(arrow.Timestamp(math.MinInt64))
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ result, err := compute.CeilTemporal(context.Background(),
compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalHour,
+ CalendarBasedOrigin: true,
+ }, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output := result.(*compute.ArrayDatum).MakeArray().(*array.Timestamp)
+ defer output.Release()
+ want, err := arrow.TimestampFromTime(wantTime, arrow.Nanosecond)
+ require.NoError(t, err)
+ require.Equal(t, want, output.Value(0))
+}
+
+func TestTemporalRoundingCalendarLongRangeWeek(t *testing.T) {
+ value, err := arrow.TimestampFromTime(
+ time.Date(1500, time.June, 15, 12, 34, 56, 0, time.UTC),
+ arrow.Second,
+ )
+ require.NoError(t, err)
+
+ builder := array.NewTimestampBuilder(memory.DefaultAllocator,
&arrow.TimestampType{Unit: arrow.Second})
+ builder.Append(value)
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ result, err := compute.FloorTemporal(context.Background(),
compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalWeek,
+ }, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output := result.(*compute.ArrayDatum).MakeArray().(*array.Timestamp)
+ defer output.Release()
+ want, err := arrow.TimestampFromTime(time.Date(1500, time.June, 10, 0,
0, 0, 0, time.UTC), arrow.Second)
+ require.NoError(t, err)
+ require.Equal(t, want, output.Value(0))
+}
+
+func TestTemporalRoundingCalendarSecondBoundary(t *testing.T) {
+ maxSecondTime := arrow.Timestamp(math.MaxInt64).ToTime(arrow.Second)
+ valueTime := time.Date(maxSecondTime.Year(), time.January, 2, 0, 0, 0,
0, time.UTC)
+ value, err := arrow.TimestampFromTime(valueTime, arrow.Second)
+ require.NoError(t, err)
+
+ builder := array.NewTimestampBuilder(memory.DefaultAllocator,
&arrow.TimestampType{Unit: arrow.Second})
+ builder.Append(value)
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ result, err := compute.RoundTemporal(context.Background(),
compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalYear,
+ }, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output := result.(*compute.ArrayDatum).MakeArray().(*array.Timestamp)
+ defer output.Release()
+ want, err := arrow.TimestampFromTime(time.Date(maxSecondTime.Year(),
time.January, 1, 0, 0, 0, 0, time.UTC), arrow.Second)
+ require.NoError(t, err)
+ require.Equal(t, want, output.Value(0))
+}
+
+func TestTemporalRoundingSecondOverflow(t *testing.T) {
+ builder := array.NewTimestampBuilder(memory.DefaultAllocator,
&arrow.TimestampType{Unit: arrow.Second})
+ builder.Append(arrow.Timestamp(math.MaxInt64))
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ _, err := compute.CeilTemporal(context.Background(),
compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalYear,
+ }, compute.NewDatum(input))
+ require.ErrorIs(t, err, arrow.ErrInvalid)
+}
+
+func TestTemporalRoundingSecondCalendarExtremes(t *testing.T) {
+ tests := []struct {
+ name string
+ value arrow.Timestamp
+ fn func(context.Context, compute.RoundTemporalOptions,
compute.Datum) (compute.Datum, error)
+ opts compute.RoundTemporalOptions
+ }{
+ {
+ name: "floor at minimum",
+ value: arrow.Timestamp(math.MinInt64),
+ fn: compute.FloorTemporal,
+ opts: compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalYear,
+ },
+ },
+ {
+ name: "ceil at maximum to day",
+ value: arrow.Timestamp(math.MaxInt64),
+ fn: compute.CeilTemporal,
+ opts: compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalDay,
+ },
+ },
+ {
+ name: "ceil immediately below maximum to day",
+ value: arrow.Timestamp(math.MaxInt64 - 1),
+ fn: compute.CeilTemporal,
+ opts: compute.RoundTemporalOptions{
+ Multiple: 1,
+ Unit: compute.RoundTemporalDay,
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ builder :=
array.NewTimestampBuilder(memory.DefaultAllocator, &arrow.TimestampType{Unit:
arrow.Second})
+ builder.Append(tc.value)
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ _, err := tc.fn(context.Background(), tc.opts,
compute.NewDatum(input))
+ require.ErrorIs(t, err, arrow.ErrInvalid)
+ })
+ }
+}
+
+func TestTemporalRoundingNegativeCalendarMultiples(t *testing.T) {
+ tests := []struct {
+ name string
+ value time.Time
+ floor time.Time
+ ceil time.Time
+ multiple int64
+ unit compute.RoundTemporalUnit
+ }{
+ {
+ name: "year",
+ value: time.Date(-5, time.June, 15, 12, 0, 0, 0,
time.UTC),
+ floor: time.Date(-6, time.January, 1, 0, 0, 0, 0,
time.UTC),
+ ceil: time.Date(-4, time.January, 1, 0, 0, 0, 0,
time.UTC),
+ multiple: 2,
+ unit: compute.RoundTemporalYear,
+ },
+ {
+ name: "quarter",
+ value: time.Date(-5, time.June, 15, 12, 0, 0, 0,
time.UTC),
+ floor: time.Date(-5, time.January, 1, 0, 0, 0, 0,
time.UTC),
+ ceil: time.Date(-5, time.July, 1, 0, 0, 0, 0,
time.UTC),
+ multiple: 2,
+ unit: compute.RoundTemporalQuarter,
+ },
+ {
+ name: "month",
+ value: time.Date(-5, time.June, 15, 12, 0, 0, 0,
time.UTC),
+ floor: time.Date(-5, time.May, 1, 0, 0, 0, 0,
time.UTC),
+ ceil: time.Date(-5, time.July, 1, 0, 0, 0, 0,
time.UTC),
+ multiple: 2,
+ unit: compute.RoundTemporalMonth,
+ },
+ {
+ name: "pre-epoch weeks",
+ value: time.Date(1969, time.December, 21, 12, 0, 0,
0, time.UTC),
+ floor: time.Date(1969, time.December, 14, 0, 0, 0,
0, time.UTC),
+ ceil: time.Date(1969, time.December, 28, 0, 0, 0,
0, time.UTC),
+ multiple: 2,
+ unit: compute.RoundTemporalWeek,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ value, err := arrow.TimestampFromTime(tc.value,
arrow.Second)
+ require.NoError(t, err)
+
+ builder :=
array.NewTimestampBuilder(memory.DefaultAllocator, &arrow.TimestampType{Unit:
arrow.Second})
+ builder.Append(value)
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ for _, round := range []struct {
+ name string
+ fn func(context.Context,
compute.RoundTemporalOptions, compute.Datum) (compute.Datum, error)
+ want time.Time
+ }{
+ {name: "floor", fn: compute.FloorTemporal,
want: tc.floor},
+ {name: "ceil", fn: compute.CeilTemporal, want:
tc.ceil},
+ } {
+ t.Run(round.name, func(t *testing.T) {
+ result, err :=
round.fn(context.Background(), compute.RoundTemporalOptions{
+ Multiple: tc.multiple,
+ Unit: tc.unit,
+ }, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output :=
result.(*compute.ArrayDatum).MakeArray().(*array.Timestamp)
+ defer output.Release()
+ want, err :=
arrow.TimestampFromTime(round.want, arrow.Second)
+ require.NoError(t, err)
+ require.Equal(t, want, output.Value(0))
+ })
+ }
+ })
+ }
+}
+
+func TestTemporalRoundingCalendarLongPeriod(t *testing.T) {
+ value, err := arrow.TimestampFromTime(
+ time.Date(1948, time.January, 1, 0, 0, 0, 0, time.UTC),
+ arrow.Nanosecond,
+ )
+ require.NoError(t, err)
+
+ builder := array.NewTimestampBuilder(memory.DefaultAllocator,
&arrow.TimestampType{Unit: arrow.Nanosecond})
+ builder.Append(value)
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ result, err := compute.RoundTemporal(context.Background(),
compute.RoundTemporalOptions{
+ Multiple: 300,
+ Unit: compute.RoundTemporalYear,
+ }, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output := result.(*compute.ArrayDatum).MakeArray().(*array.Timestamp)
+ defer output.Release()
+ want, err := arrow.TimestampFromTime(time.Date(1800, time.January, 1,
0, 0, 0, 0, time.UTC), arrow.Nanosecond)
+ require.NoError(t, err)
+ require.Equal(t, want, output.Value(0))
+}
diff --git a/arrow/compute/temporal_rounding_test.go
b/arrow/compute/temporal_rounding_test.go
index 4de60730..e3fd6beb 100644
--- a/arrow/compute/temporal_rounding_test.go
+++ b/arrow/compute/temporal_rounding_test.go
@@ -385,9 +385,9 @@ func TestTemporalRoundingVectors(t *testing.T) {
time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC),
time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC),
time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC),
- time.Date(2023, 3, 1, 0, 0, 0, 0, time.UTC),
time.Date(2023, 4, 1, 0, 0, 0, 0, time.UTC),
- time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
+ time.Date(2023, 4, 1, 0, 0, 0, 0, time.UTC),
+ time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC),
time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC),
@@ -435,7 +435,7 @@ func TestTemporalRoundingVectors(t *testing.T) {
time.Date(2023, 4, 1, 0, 0, 0, 0, time.UTC), //
-> Q2 2023 (not at quarter boundary)
time.Date(2023, 4, 1, 0, 0, 0, 0, time.UTC), //
-> Q2 2023 (March 1 is in Q1)
time.Date(2023, 4, 1, 0, 0, 0, 0, time.UTC), //
-> Q2 2023
- time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), //
already at Q1 start
+ time.Date(1970, 4, 1, 0, 0, 0, 0, time.UTC), //
exact Q1 start advances to Q2
time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), //
-> Q1 1970
time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), //
-> Q1 2024
time.Date(2024, 4, 1, 0, 0, 0, 0, time.UTC), //
-> Q2 2024
@@ -603,6 +603,165 @@ func TestTemporalRoundingVectors(t *testing.T) {
}
}
+func TestTemporalRoundingInputResolutionCasting(t *testing.T) {
+ values := []int64{2, 1, 0, -1, -2}
+ tests := []struct {
+ name string
+ timezone string
+ multiple int64
+ unit compute.RoundTemporalUnit
+ floor []int64
+ ceil []int64
+ strict []int64
+ round []int64
+ }{
+ {
+ name: "nanosecond multiple in seconds UTC",
+ multiple: 1_500_000_000,
+ unit: compute.RoundTemporalNanosecond,
+ floor: []int64{1, 0, 0, -1, -3},
+ ceil: []int64{2, 1, 0, -1, -2},
+ strict: []int64{2, 1, 1, 0, -2},
+ round: []int64{2, 1, 0, -1, -2},
+ },
+ {
+ name: "nanosecond multiple in seconds New York",
+ timezone: "America/New_York",
+ multiple: 1_500_000_000,
+ unit: compute.RoundTemporalNanosecond,
+ floor: []int64{2, 0, 0, -1, -3},
+ ceil: []int64{2, 1, 0, -1, -2},
+ strict: []int64{3, 1, 1, 0, -2},
+ round: []int64{2, 1, 0, -1, -2},
+ },
+ {
+ name: "nanosecond multiple in seconds Kathmandu",
+ timezone: "Asia/Kathmandu",
+ multiple: 1_500_000_000,
+ unit: compute.RoundTemporalNanosecond,
+ floor: []int64{1, 0, 0, -2, -3},
+ ceil: []int64{2, 1, 0, -1, -2},
+ strict: []int64{2, 1, 1, -1, -2},
+ round: []int64{2, 1, 0, -1, -2},
+ },
+ {
+ name: "millisecond multiple in seconds UTC",
+ multiple: 999,
+ unit: compute.RoundTemporalMillisecond,
+ floor: []int64{1, 0, 0, -1, -2},
+ ceil: []int64{1, 0, 0, -1, -2},
+ strict: []int64{1, 0, 0, -1, -2},
+ round: []int64{1, 0, 0, -1, -2},
+ },
+ {
+ name: "millisecond multiple in seconds New York",
+ timezone: "America/New_York",
+ multiple: 999,
+ unit: compute.RoundTemporalMillisecond,
+ floor: []int64{2, 1, 0, -1, -2},
+ ceil: []int64{2, 1, 0, -1, -2},
+ strict: []int64{2, 1, 0, -1, -2},
+ round: []int64{2, 1, 0, -1, -2},
+ },
+ {
+ name: "millisecond multiple in seconds Kathmandu",
+ timezone: "Asia/Kathmandu",
+ multiple: 999,
+ unit: compute.RoundTemporalMillisecond,
+ floor: []int64{1, 0, -1, -2, -3},
+ ceil: []int64{1, 0, -1, -2, -3},
+ strict: []int64{1, 0, -1, -2, -3},
+ round: []int64{1, 0, -1, -2, -3},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ builder :=
array.NewTimestampBuilder(memory.DefaultAllocator, &arrow.TimestampType{
+ Unit: arrow.Second, TimeZone: tc.timezone,
+ })
+ for _, value := range values {
+ builder.Append(arrow.Timestamp(value))
+ }
+ input := builder.NewArray()
+ builder.Release()
+ defer input.Release()
+
+ for _, rounding := range []struct {
+ name string
+ fn func(context.Context,
compute.RoundTemporalOptions, compute.Datum) (compute.Datum, error)
+ strict bool
+ want []int64
+ }{
+ {name: "floor", fn: compute.FloorTemporal,
want: tc.floor},
+ {name: "ceil", fn: compute.CeilTemporal, want:
tc.ceil},
+ {name: "ceil strictly greater", fn:
compute.CeilTemporal, strict: true, want: tc.strict},
+ {name: "round", fn: compute.RoundTemporal,
want: tc.round},
+ } {
+ t.Run(rounding.name, func(t *testing.T) {
+ result, err :=
rounding.fn(context.Background(), compute.RoundTemporalOptions{
+ Multiple:
tc.multiple,
+ Unit: tc.unit,
+ CeilIsStrictlyGreater:
rounding.strict,
+ }, compute.NewDatum(input))
+ require.NoError(t, err)
+ defer result.Release()
+
+ output :=
result.(*compute.ArrayDatum).MakeArray().(*array.Timestamp)
+ defer output.Release()
+ for i, want := range rounding.want {
+ require.Equal(t,
arrow.Timestamp(want), output.Value(i), "value %d", i)
+ }
+ })
+ }
+ })
+ }
+}
+
+func TestTemporalRoundingWideInputResolution(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ input time.Time
+ expected time.Time
+ timezone string
+ }{
+ {
+ name: "before epoch",
+ input: time.Date(1500, time.June, 15, 12, 34, 56, 0,
time.UTC),
+ expected: time.Date(1500, time.June, 15, 12, 34, 56, 0,
time.UTC),
+ },
+ {
+ name: "after nanosecond range",
+ input: time.Date(2300, time.June, 15, 12, 34, 56, 0,
time.UTC),
+ expected: time.Date(2300, time.June, 15, 12, 34, 55, 0,
time.UTC),
+ },
+ {
+ name: "after nanosecond range in Kathmandu",
+ input: time.Date(2300, time.June, 15, 12, 34, 56, 0,
time.UTC),
+ expected: time.Date(2300, time.June, 15, 12, 34, 55, 0,
time.UTC),
+ timezone: "Asia/Kathmandu",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, rounding := range []struct {
+ name string
+ fn temporalRoundingFunc
+ }{
+ {name: "floor", fn: compute.FloorTemporal},
+ {name: "ceil", fn: compute.CeilTemporal},
+ {name: "round", fn: compute.RoundTemporal},
+ } {
+ t.Run(rounding.name, func(t *testing.T) {
+ requireTemporalRoundingTime(t,
rounding.fn, tc.input, tc.expected, arrow.Second, tc.timezone,
compute.RoundTemporalOptions{
+ Multiple: 3,
+ Unit:
compute.RoundTemporalMillisecond,
+ })
+ })
+ }
+ })
+ }
+}
+
func TestTemporalWithNulls(t *testing.T) {
ctx := context.Background()
mem := memory.DefaultAllocator
@@ -758,7 +917,7 @@ func TestTemporalTimezoneAware(t *testing.T) {
{name: "week_utc", tz: "UTC", unit: compute.RoundTemporalWeek,
expectedUTC: time.Date(2024, 7, 15, 0, 0, 0, 0,
time.UTC)}, // Floor to Monday 00:00 UTC
{name: "week_ny", tz: "America/New_York", unit:
compute.RoundTemporalWeek,
- expectedUTC: time.Date(2024, 7, 8, 4, 0, 0, 0,
time.UTC)}, // Floor to Monday 00:00 EDT (previous Monday in NY time)
+ expectedUTC: time.Date(2024, 7, 15, 4, 0, 0, 0,
time.UTC)}, // Floor to Monday 00:00 EDT
{name: "week_tokyo", tz: "Asia/Tokyo", unit:
compute.RoundTemporalWeek,
expectedUTC: time.Date(2024, 7, 14, 15, 0, 0, 0,
time.UTC)}, // Floor to Monday 00:00 JST