This is an automated email from the ASF dual-hosted git repository.
rok pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow.git
The following commit(s) were added to refs/heads/main by this push:
new 35c5ffd121 GH-50395: [C++] Support duration inputs in temporal
rounding (#50675)
35c5ffd121 is described below
commit 35c5ffd12173284406e4a2c86405415444e596d7
Author: snigdha choppa <[email protected]>
AuthorDate: Fri Jul 31 11:21:16 2026 -0500
GH-50395: [C++] Support duration inputs in temporal rounding (#50675)
### Rationale for this change
The `ceil_temporal`, `floor_temporal`, and `round_temporal` functions
currently support date, time, and timestamp inputs, but not duration inputs.
### What changes are included in this PR?
- Add kernel registration for duration values with second, millisecond,
microsecond, and nanosecond resolutions
- Support rounding duration inputs using physical units through day
- Treat week as seven physical days for duration inputs
- Reject ambiguous calendar units such as month, quarter, and year
- Reject `calendar_based_origin` for duration inputs
- Add focused C++ tests covering all four duration resolutions, positive
and negative values, null propagation, day and week rounding, and unsupported
calendar behavior
### Are these changes tested?
Yes.
- `arrow-compute-scalar-temporal-test`: 55 tests passed
- Applicable pre-commit C++ formatting and lint checks passed
### Are there any user-facing changes?
Yes. Users can now pass duration values to `ceil_temporal`,
`floor_temporal`, and `round_temporal` for supported physical units.
### AI assistance
I used ChatGPT to help navigate the codebase and draft the initial
implementation. I reviewed, revised, and tested the changes locally.
* GitHub Issue: #50395
Authored-by: snigdhachoppac <[email protected]>
Signed-off-by: Rok Mihevc <[email protected]>
---
.../arrow/compute/kernels/scalar_temporal_test.cc | 120 +++++++++++++++++++++
.../arrow/compute/kernels/scalar_temporal_unary.cc | 62 ++++++++---
cpp/src/arrow/compute/kernels/temporal_internal.h | 13 +++
python/pyarrow/tests/test_compute.py | 43 ++++++++
4 files changed, 223 insertions(+), 15 deletions(-)
diff --git a/cpp/src/arrow/compute/kernels/scalar_temporal_test.cc
b/cpp/src/arrow/compute/kernels/scalar_temporal_test.cc
index 60b2d79114..1b9d9254c5 100644
--- a/cpp/src/arrow/compute/kernels/scalar_temporal_test.cc
+++ b/cpp/src/arrow/compute/kernels/scalar_temporal_test.cc
@@ -3779,6 +3779,126 @@ TEST_F(ScalarTemporalTest,
TestCeilFloorRoundTemporalDate) {
CheckScalarUnary("ceil_temporal", arr_ns, arr_ns, &round_to_2_hours);
}
+TEST_F(ScalarTemporalTest, TestCeilFloorRoundTemporalDuration) {
+ auto check_unit = [&](TimeUnit::type time_unit, CalendarUnit calendar_unit) {
+ RoundTemporalOptions round_to_2_units(2, calendar_unit);
+ auto values = ArrayFromJSON(duration(time_unit), "[0, 1, 2, 3, -1, -2, -3,
null]");
+
+ CheckScalarUnary("ceil_temporal", values,
+ ArrayFromJSON(duration(time_unit), "[0, 2, 2, 4, 0, -2,
-2, null]"),
+ &round_to_2_units);
+ CheckScalarUnary("floor_temporal", values,
+ ArrayFromJSON(duration(time_unit), "[0, 0, 2, 2, -2, -2,
-4, null]"),
+ &round_to_2_units);
+ CheckScalarUnary("round_temporal", values,
+ ArrayFromJSON(duration(time_unit), "[0, 2, 2, 4, 0, -2,
-2, null]"),
+ &round_to_2_units);
+ };
+
+ check_unit(TimeUnit::SECOND, CalendarUnit::SECOND);
+ check_unit(TimeUnit::MILLI, CalendarUnit::MILLISECOND);
+ check_unit(TimeUnit::MICRO, CalendarUnit::MICROSECOND);
+ check_unit(TimeUnit::NANO, CalendarUnit::NANOSECOND);
+
+ auto check_second_based_unit = [&](CalendarUnit calendar_unit, const char*
values_json,
+ const char* ceil_round_json,
+ const char* floor_json) {
+ RoundTemporalOptions round_to_2_units(2, calendar_unit);
+ auto values = ArrayFromJSON(duration(TimeUnit::SECOND), values_json);
+
+ CheckScalarUnary("ceil_temporal", values,
+ ArrayFromJSON(duration(TimeUnit::SECOND),
ceil_round_json),
+ &round_to_2_units);
+ CheckScalarUnary("floor_temporal", values,
+ ArrayFromJSON(duration(TimeUnit::SECOND), floor_json),
+ &round_to_2_units);
+ CheckScalarUnary("round_temporal", values,
+ ArrayFromJSON(duration(TimeUnit::SECOND),
ceil_round_json),
+ &round_to_2_units);
+ };
+
+ check_second_based_unit(CalendarUnit::MINUTE,
+ "[0, 60, 120, 180, -60, -120, -180, null]",
+ "[0, 120, 120, 240, 0, -120, -120, null]",
+ "[0, 0, 120, 120, -120, -120, -240, null]");
+
+ check_second_based_unit(CalendarUnit::HOUR,
+ "[0, 3600, 7200, 10800, -3600, -7200, -10800, null]",
+ "[0, 7200, 7200, 14400, 0, -7200, -7200, null]",
+ "[0, 0, 7200, 7200, -7200, -7200, -14400, null]");
+
+ // A day is treated as a physical 24-hour unit for duration values.
+ RoundTemporalOptions round_to_day(1, CalendarUnit::DAY);
+ auto day_values = ArrayFromJSON(duration(TimeUnit::SECOND),
+ "[0, 43200, 86399, 86400, -1, -43200,
null]");
+
+ CheckScalarUnary(
+ "ceil_temporal", day_values,
+ ArrayFromJSON(duration(TimeUnit::SECOND), "[0, 86400, 86400, 86400, 0,
0, null]"),
+ &round_to_day);
+ CheckScalarUnary(
+ "floor_temporal", day_values,
+ ArrayFromJSON(duration(TimeUnit::SECOND), "[0, 0, 0, 86400, -86400,
-86400, null]"),
+ &round_to_day);
+ CheckScalarUnary(
+ "round_temporal", day_values,
+ ArrayFromJSON(duration(TimeUnit::SECOND), "[0, 86400, 86400, 86400, 0,
0, null]"),
+ &round_to_day);
+
+ // A week is treated as exactly seven physical days for duration values.
+ RoundTemporalOptions round_to_week(1, CalendarUnit::WEEK);
+ auto week_values =
+ ArrayFromJSON(duration(TimeUnit::SECOND),
+ "[0, 302400, 604799, 604800, -1, -302400, -604800, null]");
+
+ CheckScalarUnary("ceil_temporal", week_values,
+ ArrayFromJSON(duration(TimeUnit::SECOND),
+ "[0, 604800, 604800, 604800, 0, 0, -604800,
null]"),
+ &round_to_week);
+ CheckScalarUnary("floor_temporal", week_values,
+ ArrayFromJSON(duration(TimeUnit::SECOND),
+ "[0, 0, 0, 604800, -604800, -604800, -604800,
null]"),
+ &round_to_week);
+ CheckScalarUnary("round_temporal", week_values,
+ ArrayFromJSON(duration(TimeUnit::SECOND),
+ "[0, 604800, 604800, 604800, 0, 0, -604800,
null]"),
+ &round_to_week);
+
+ auto values = ArrayFromJSON(duration(TimeUnit::SECOND), "[0, 1, -1, null]");
+
+ RoundTemporalOptions round_to_month(1, CalendarUnit::MONTH);
+ for (const auto* function_name :
+ {"ceil_temporal", "floor_temporal", "round_temporal"}) {
+ EXPECT_RAISES_WITH_MESSAGE_THAT(
+ Invalid, ::testing::HasSubstr("Duration values can only be rounded
using units"),
+ CallFunction(function_name, {values}, &round_to_month));
+ }
+
+ RoundTemporalOptions calendar_origin(2, CalendarUnit::SECOND);
+ calendar_origin.calendar_based_origin = true;
+ for (const auto* function_name :
+ {"ceil_temporal", "floor_temporal", "round_temporal"}) {
+ EXPECT_RAISES_WITH_MESSAGE_THAT(
+ Invalid,
+ ::testing::HasSubstr(
+ "calendar_based_origin is not supported for duration inputs"),
+ CallFunction(function_name, {values}, &calendar_origin));
+ }
+}
+
+TEST_F(ScalarTemporalTest, TestDurationTemporalWeekMultipleOverflow) {
+ auto values = ArrayFromJSON(duration(TimeUnit::SECOND), "[0]");
+ RoundTemporalOptions options(std::numeric_limits<int>::max(),
CalendarUnit::WEEK);
+
+ for (const auto* function_name :
+ {"ceil_temporal", "floor_temporal", "round_temporal"}) {
+ EXPECT_RAISES_WITH_MESSAGE_THAT(
+ Invalid,
+ ::testing::HasSubstr("Duration week multiple would not fit in 32-bit
integer"),
+ CallFunction(function_name, {values}, &options));
+ }
+}
+
TEST_F(ScalarTemporalTest, DurationUnaryArithmetics) {
auto arr = ArrayFromJSON(duration(TimeUnit::SECOND), "[2, -1, null, 3, 0]");
CheckScalarUnary("negate", arr,
diff --git a/cpp/src/arrow/compute/kernels/scalar_temporal_unary.cc
b/cpp/src/arrow/compute/kernels/scalar_temporal_unary.cc
index 1bad2d0a11..74a29081ae 100644
--- a/cpp/src/arrow/compute/kernels/scalar_temporal_unary.cc
+++ b/cpp/src/arrow/compute/kernels/scalar_temporal_unary.cc
@@ -18,6 +18,7 @@
#include <cmath>
#include <initializer_list>
#include <sstream>
+#include <type_traits>
#include "arrow/builder.h"
#include "arrow/compute/api_scalar.h"
@@ -26,6 +27,7 @@
#include "arrow/compute/kernels/temporal_internal.h"
#include "arrow/compute/registry_internal.h"
#include "arrow/util/checked_cast.h"
+#include "arrow/util/int_util_overflow.h"
#include "arrow/util/logging_internal.h"
#include "arrow/util/time.h"
#include "arrow/util/value_parsing.h"
@@ -177,6 +179,36 @@ struct TemporalComponentExtractRound
static Status Exec(KernelContext* ctx, const ExecSpan& batch, ExecResult*
out) {
const RoundTemporalOptions& options = RoundTemporalState::Get(ctx);
+
+ if constexpr (std::is_same_v<InType, DurationType>) {
+ if (options.calendar_based_origin) {
+ return Status::Invalid(
+ "calendar_based_origin is not supported for duration inputs");
+ }
+
+ if (options.unit == CalendarUnit::WEEK) {
+ RoundTemporalOptions duration_options = options;
+ duration_options.unit = CalendarUnit::DAY;
+ if (::arrow::internal::MultiplyWithOverflow(options.multiple, 7,
+
&duration_options.multiple)) {
+ return Status::Invalid(
+ "Duration week multiple would not fit in 32-bit integer");
+ }
+ return Base::ExecWithOptions(ctx, &duration_options, batch, out);
+ }
+
+ switch (options.unit) {
+ case CalendarUnit::MONTH:
+ case CalendarUnit::QUARTER:
+ case CalendarUnit::YEAR:
+ return Status::Invalid(
+ "Duration values can only be rounded using units "
+ "from nanoseconds through weeks");
+ default:
+ break;
+ }
+ }
+
return Base::ExecWithOptions(ctx, &options, batch, out);
}
};
@@ -2014,23 +2046,23 @@ void RegisterScalarTemporalUnary(FunctionRegistry*
registry) {
// output type. See TemporalComponentExtractRound for more.
static const auto default_round_temporal_options =
RoundTemporalOptions::Defaults();
- auto floor_temporal = UnaryTemporalFactory<FloorTemporal,
TemporalComponentExtractRound,
- TimestampType>::Make<WithDates,
WithTimes,
-
WithTimestamps>(
- "floor_temporal", OutputType(FirstType), floor_temporal_doc,
- &default_round_temporal_options, RoundTemporalState::Init);
+ auto floor_temporal =
+ UnaryTemporalFactory<FloorTemporal, TemporalComponentExtractRound,
TimestampType>::
+ Make<WithDates, WithTimes, WithTimestamps, WithDurations>(
+ "floor_temporal", OutputType(FirstType), floor_temporal_doc,
+ &default_round_temporal_options, RoundTemporalState::Init);
DCHECK_OK(registry->AddFunction(std::move(floor_temporal)));
- auto ceil_temporal = UnaryTemporalFactory<CeilTemporal,
TemporalComponentExtractRound,
- TimestampType>::Make<WithDates,
WithTimes,
-
WithTimestamps>(
- "ceil_temporal", OutputType(FirstType), ceil_temporal_doc,
- &default_round_temporal_options, RoundTemporalState::Init);
+ auto ceil_temporal =
+ UnaryTemporalFactory<CeilTemporal, TemporalComponentExtractRound,
TimestampType>::
+ Make<WithDates, WithTimes, WithTimestamps, WithDurations>(
+ "ceil_temporal", OutputType(FirstType), ceil_temporal_doc,
+ &default_round_temporal_options, RoundTemporalState::Init);
DCHECK_OK(registry->AddFunction(std::move(ceil_temporal)));
- auto round_temporal = UnaryTemporalFactory<RoundTemporal,
TemporalComponentExtractRound,
- TimestampType>::Make<WithDates,
WithTimes,
-
WithTimestamps>(
- "round_temporal", OutputType(FirstType), round_temporal_doc,
- &default_round_temporal_options, RoundTemporalState::Init);
+ auto round_temporal =
+ UnaryTemporalFactory<RoundTemporal, TemporalComponentExtractRound,
TimestampType>::
+ Make<WithDates, WithTimes, WithTimestamps, WithDurations>(
+ "round_temporal", OutputType(FirstType), round_temporal_doc,
+ &default_round_temporal_options, RoundTemporalState::Init);
DCHECK_OK(registry->AddFunction(std::move(round_temporal)));
}
diff --git a/cpp/src/arrow/compute/kernels/temporal_internal.h
b/cpp/src/arrow/compute/kernels/temporal_internal.h
index bc3b388815..02f5965522 100644
--- a/cpp/src/arrow/compute/kernels/temporal_internal.h
+++ b/cpp/src/arrow/compute/kernels/temporal_internal.h
@@ -200,6 +200,7 @@ struct TimestampFormatter {
struct WithDates {};
struct WithTimes {};
struct WithTimestamps {};
+struct WithDurations {};
struct WithStringTypes {};
// This helper allows generating temporal kernels for selected type categories
@@ -224,6 +225,18 @@ void AddTemporalKernels(Factory* fac, WithTimes,
WithOthers... others) {
AddTemporalKernels(fac, std::forward<WithOthers>(others)...);
}
+template <typename Factory, typename... WithOthers>
+void AddTemporalKernels(Factory* fac, WithDurations, WithOthers... others) {
+ fac->template AddKernel<std::chrono::seconds,
DurationType>(duration(TimeUnit::SECOND));
+ fac->template AddKernel<std::chrono::milliseconds, DurationType>(
+ duration(TimeUnit::MILLI));
+ fac->template AddKernel<std::chrono::microseconds, DurationType>(
+ duration(TimeUnit::MICRO));
+ fac->template AddKernel<std::chrono::nanoseconds, DurationType>(
+ duration(TimeUnit::NANO));
+ AddTemporalKernels(fac, std::forward<WithOthers>(others)...);
+}
+
template <typename Factory, typename... WithOthers>
void AddTemporalKernels(Factory* fac, WithTimestamps, WithOthers... others) {
fac->template AddKernel<std::chrono::seconds, TimestampType>(
diff --git a/python/pyarrow/tests/test_compute.py
b/python/pyarrow/tests/test_compute.py
index 1e08e73668..d350c81157 100644
--- a/python/pyarrow/tests/test_compute.py
+++ b/python/pyarrow/tests/test_compute.py
@@ -2974,6 +2974,49 @@ def test_round_temporal(unit):
_check_temporal_rounding(ts_zoned, values, unit)
[email protected](
+ ("unit", "base_frequency", "round_frequency"),
+ (
+ ("nanosecond", "1ns", "4ns"),
+ ("microsecond", "1us", "4us"),
+ ("millisecond", "1ms", "4ms"),
+ ("second", "1s", "4s"),
+ ("minute", "1min", "4min"),
+ ("hour", "1h", "4h"),
+ ("day", "1D", "4D"),
+ ("week", "7D", "28D"),
+ ),
+)
[email protected]
+def test_round_temporal_duration(unit, base_frequency, round_frequency):
+ base = pd.Timedelta(base_frequency)
+ values = pd.Series([
+ -7 * base,
+ -4 * base,
+ -1 * base,
+ 0 * base,
+ 1 * base,
+ 4 * base,
+ 7 * base,
+ pd.NaT,
+ ])
+ arrow_values = pa.array(values)
+ assert pa.types.is_duration(arrow_values.type)
+
+ options = pc.RoundTemporalOptions(4, unit)
+
+ for arrow_round, pandas_round in (
+ (pc.ceil_temporal, values.dt.ceil),
+ (pc.floor_temporal, values.dt.floor),
+ (pc.round_temporal, values.dt.round),
+ ):
+ result = arrow_round(
+ arrow_values, options=options
+ ).to_pandas()
+ expected = pandas_round(round_frequency)
+ np.testing.assert_array_equal(result, expected)
+
+
def test_count():
arr = pa.array([1, 2, 3, None, None])
assert pc.count(arr).as_py() == 3