use std::sync::Arc;
use arrow::array::{AsArray, TimestampNanosecondArray};
use arrow::datatypes::{DataType, TimeUnit, TimestampNanosecondType};
use datafusion::common::{Result as DFResult, ScalarValue};
use datafusion::logical_expr::{
ColumnarValue, ScalarFunctionArgs, ScalarUDFImpl, Signature, TypeSignature, Volatility,
};
use chronix_core::timebucket::TimeBucket;
#[derive(Debug, PartialEq, Eq, Hash)]
pub(super) struct TimeBucketUdf {
signature: Signature,
}
impl TimeBucketUdf {
pub(super) fn new() -> Self {
Self {
signature: Signature::new(
TypeSignature::OneOf(vec![
TypeSignature::Exact(vec![
DataType::Utf8,
DataType::Timestamp(TimeUnit::Nanosecond, None),
]),
TypeSignature::Exact(vec![
DataType::Utf8,
DataType::Timestamp(TimeUnit::Nanosecond, None),
DataType::Utf8,
]),
TypeSignature::Exact(vec![
DataType::Utf8,
DataType::Timestamp(TimeUnit::Nanosecond, None),
DataType::Utf8,
DataType::Timestamp(TimeUnit::Nanosecond, None),
]),
TypeSignature::Exact(vec![
DataType::Utf8,
DataType::Timestamp(TimeUnit::Nanosecond, None),
DataType::Utf8,
DataType::Utf8,
]),
]),
Volatility::Immutable,
),
}
}
}
impl ScalarUDFImpl for TimeBucketUdf {
fn name(&self) -> &'static str {
"time_bucket"
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _arg_types: &[DataType]) -> DFResult<DataType> {
Ok(DataType::Timestamp(TimeUnit::Nanosecond, None))
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DFResult<ColumnarValue> {
let args = args.args;
let interval_str = match &args[0] {
ColumnarValue::Scalar(ScalarValue::Utf8(Some(s))) => s.clone(),
_ => {
return Err(datafusion::common::DataFusionError::Plan(
"time_bucket: first argument must be a string interval like '5m'".into(),
));
}
};
let tz_str: Option<&str> = if args.len() >= 3 {
match &args[2] {
ColumnarValue::Scalar(ScalarValue::Utf8(Some(tz))) => Some(tz.as_str()),
_ => None,
}
} else {
None
};
let mut bucket = TimeBucket::parse(&interval_str, tz_str)
.map_err(|e| datafusion::common::DataFusionError::Plan(format!("time_bucket: {e}")))?;
if let Some(origin) = args.get(3) {
let plan_err = |e: chronix_core::timebucket::BucketParseError| {
datafusion::common::DataFusionError::Plan(format!("time_bucket: {e}"))
};
bucket = match origin {
ColumnarValue::Scalar(ScalarValue::TimestampNanosecond(Some(ts), _)) => {
bucket.with_origin(*ts).map_err(plan_err)?
}
ColumnarValue::Scalar(ScalarValue::Utf8(Some(text))) => {
bucket.with_origin_str(text).map_err(plan_err)?
}
ColumnarValue::Scalar(s) if s.is_null() => bucket,
_ => {
return Err(datafusion::common::DataFusionError::Plan(
"time_bucket: the origin must be a constant timestamp or date string, \
e.g. TIMESTAMP '2024-01-15 00:00:00' or '2024-01-15'"
.into(),
));
}
};
}
match &args[1] {
ColumnarValue::Array(arr) => {
let ts_array = arr.as_primitive::<TimestampNanosecondType>();
let result: TimestampNanosecondArray = ts_array
.iter()
.map(|opt: Option<i64>| opt.map(|ts| bucket.start_of(ts)))
.collect();
Ok(ColumnarValue::Array(Arc::new(result)))
}
ColumnarValue::Scalar(ScalarValue::TimestampNanosecond(Some(ts), tz_arc)) => {
let bucketed = bucket.start_of(*ts);
Ok(ColumnarValue::Scalar(ScalarValue::TimestampNanosecond(
Some(bucketed),
tz_arc.clone(),
)))
}
_ => Err(datafusion::common::DataFusionError::Plan(
"time_bucket: second argument must be a timestamp".into(),
)),
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use chronix_core::timebucket::BucketWidth;
#[test]
fn the_udf_parses_the_widths_it_documents() {
for text in ["30s", "5m", "1h", "1d", "1w", "1mo", "3mo", "1y"] {
assert!(text.parse::<BucketWidth>().is_ok(), "{text}");
}
}
#[test]
fn the_udf_buckets_an_array_in_a_zone() {
use arrow::array::{Array, TimestampNanosecondArray};
let ts = |s: &str| -> i64 {
chrono::DateTime::parse_from_rfc3339(s)
.unwrap()
.timestamp_nanos_opt()
.unwrap()
};
let input: TimestampNanosecondArray = vec![
Some(ts("2026-10-25T00:30:00Z")),
Some(ts("2026-10-25T01:30:00Z")),
None,
]
.into();
let out = super::super::helpers::invoke_udf(
&TimeBucketUdf::new(),
vec![
ColumnarValue::Scalar(ScalarValue::Utf8(Some("1h".into()))),
ColumnarValue::Array(Arc::new(input)),
ColumnarValue::Scalar(ScalarValue::Utf8(Some("Europe/Berlin".into()))),
],
3,
)
.unwrap();
let ColumnarValue::Array(arr) = out else {
panic!("expected an array")
};
let got = arr.as_primitive::<TimestampNanosecondType>();
assert_eq!(got.value(0), ts("2026-10-25T00:00:00Z"));
assert_eq!(got.value(1), ts("2026-10-25T01:00:00Z"));
assert!(got.is_null(2), "a null timestamp buckets to null");
}
#[test]
fn the_udf_buckets_a_calendar_month() {
use arrow::array::TimestampNanosecondArray;
let ts = |s: &str| -> i64 {
chrono::DateTime::parse_from_rfc3339(s)
.unwrap()
.timestamp_nanos_opt()
.unwrap()
};
let input: TimestampNanosecondArray = vec![Some(ts("2024-02-17T12:00:00Z"))].into();
let out = super::super::helpers::invoke_udf(
&TimeBucketUdf::new(),
vec![
ColumnarValue::Scalar(ScalarValue::Utf8(Some("1mo".into()))),
ColumnarValue::Array(Arc::new(input)),
ColumnarValue::Scalar(ScalarValue::Utf8(Some("Europe/Berlin".into()))),
],
1,
)
.unwrap();
let ColumnarValue::Array(arr) = out else {
panic!("expected an array")
};
assert_eq!(
arr.as_primitive::<TimestampNanosecondType>().value(0),
ts("2024-01-31T23:00:00Z"),
"1 February 2024, Berlin local midnight"
);
}
#[test]
fn a_bad_width_or_zone_is_a_planning_error_that_says_what() {
let err = TimeBucket::parse("5x", None).unwrap_err();
assert!(err.0.contains("unknown bucket unit"), "{err}");
let err = TimeBucket::parse("0m", None).unwrap_err();
assert!(err.0.contains("positive"), "{err}");
let err = TimeBucket::parse("1h", Some("Mars/Olympus")).unwrap_err();
assert!(err.0.contains("Mars/Olympus"), "{err}");
}
}