Skip to main content

laminar_sql/datafusion/window_udf/
mod.rs

1//! Scalar UDFs that compute window boundary timestamps for streaming
2//! `GROUP BY TUMBLE(...)` style queries.
3//!
4//! Pairs `tumble`/`tumble_end`, `hop`/`hop_end`, `cumulate`/`cumulate_end`
5//! must both appear in `GROUP BY`: DataFusion treats them as independent
6//! group keys, even though within a fixed interval they are functionally
7//! redundant. `session(ts, gap)` is a passthrough — real session
8//! boundaries are computed by Ring 0 operators.
9//!
10//! ```sql
11//! SELECT tumble(ts, INTERVAL '1' MINUTE)     AS window_start,
12//!        tumble_end(ts, INTERVAL '1' MINUTE) AS window_end, ...
13//! FROM ev
14//! GROUP BY tumble(ts, INTERVAL '1' MINUTE),
15//!          tumble_end(ts, INTERVAL '1' MINUTE), ...
16//! ```
17//!
18//! Math runs in milliseconds (matching the Ring 0 watermark). The SQL
19//! boundary returns `Timestamp(Microsecond, None)` so lakehouse sinks
20//! (Iceberg, Delta, Parquet) accept the columns directly — Iceberg
21//! rejects `Timestamp(Millisecond)`.
22
23use std::hash::{Hash, Hasher};
24use std::ops::RangeInclusive;
25use std::sync::Arc;
26
27use arrow::datatypes::{DataType, TimeUnit};
28use arrow_array::{ArrayRef, TimestampMicrosecondArray, TimestampMillisecondArray};
29use datafusion_common::{DataFusionError, Result, ScalarValue};
30use datafusion_expr::{
31    ColumnarValue, ScalarFunctionArgs, ScalarUDFImpl, Signature, TypeSignature, Volatility,
32};
33use laminar_core::time::cast_to_millis_array;
34
35// ─── window_udf! macro ──────────────────────────────────────────────────────
36//
37// Generates the trait boilerplate (Default + PartialEq + Eq + Hash +
38// ScalarUDFImpl) for a zero-state singleton UDF that returns a
39// `Timestamp(Microsecond)`. Each UDF declaration below is just SQL name,
40// arity, and a body closure.
41
42macro_rules! window_udf {
43    (
44        $(#[$attr:meta])*
45        $type:ident, $sql_name:literal, $arity:expr, |$args:ident| $body:expr
46    ) => {
47        $(#[$attr])*
48        #[derive(Debug)]
49        pub struct $type {
50            signature: Signature,
51        }
52
53        impl $type {
54            /// Constructs the UDF instance.
55            #[must_use]
56            pub fn new() -> Self {
57                Self { signature: variadic_signature($arity) }
58            }
59        }
60
61        impl Default for $type {
62            fn default() -> Self { Self::new() }
63        }
64
65        impl PartialEq for $type {
66            fn eq(&self, _: &Self) -> bool { true }
67        }
68
69        impl Eq for $type {}
70
71        impl Hash for $type {
72            fn hash<H: Hasher>(&self, state: &mut H) { $sql_name.hash(state); }
73        }
74
75        impl ScalarUDFImpl for $type {
76            fn as_any(&self) -> &dyn std::any::Any {
77                self
78            }
79
80            fn name(&self) -> &'static str { $sql_name }
81            fn signature(&self) -> &Signature { &self.signature }
82            fn return_type(&self, _: &[DataType]) -> Result<DataType> {
83                Ok(DataType::Timestamp(TimeUnit::Microsecond, None))
84            }
85            fn invoke_with_args(&self, sfa: ScalarFunctionArgs) -> Result<ColumnarValue> {
86                let $args = sfa.args;
87                check_arity(&$args, $arity, $sql_name)?;
88                $body
89            }
90        }
91    };
92}
93
94// ─── UDF declarations ───────────────────────────────────────────────────────
95
96window_udf!(
97    /// `tumble(ts, interval [, offset])` — start of the non-overlapping
98    /// window containing `ts`. Returns `Timestamp(Microsecond, None)`.
99    TumbleWindowStart,
100    "tumble",
101    2..=3,
102    |args| {
103        let interval_ms = positive_interval(&args[1], "tumble", "interval")?;
104        let offset_ms = optional_offset(&args, 2)?;
105        into_us_columnar(&args[0], |ts| tumble_start_ms(ts, interval_ms, offset_ms))
106    }
107);
108
109window_udf!(
110    /// `tumble_end(ts, interval [, offset])` — exclusive upper bound of
111    /// the tumble window containing `ts` (i.e. `tumble(...) + interval`).
112    TumbleWindowEnd,
113    "tumble_end",
114    2..=3,
115    |args| {
116        let interval_ms = positive_interval(&args[1], "tumble_end", "interval")?;
117        let offset_ms = optional_offset(&args, 2)?;
118        into_us_columnar(&args[0], |ts| {
119            tumble_start_ms(ts, interval_ms, offset_ms).saturating_add(interval_ms)
120        })
121    }
122);
123
124window_udf!(
125    /// `hop(ts, slide, size [, offset])` — earliest sliding window
126    /// (size `size`, sliding by `slide`) that contains `ts`. Full
127    /// multi-window assignment is handled by Ring 0.
128    HopWindowStart,
129    "hop",
130    3..=4,
131    |args| {
132        let slide_ms = positive_interval(&args[1], "hop", "slide")?;
133        let size_ms = positive_interval(&args[2], "hop", "size")?;
134        let offset_ms = optional_offset(&args, 3)?;
135        into_us_columnar(&args[0], |ts| {
136            hop_start_ms(ts, slide_ms, size_ms, offset_ms)
137        })
138    }
139);
140
141window_udf!(
142    /// `hop_end(ts, slide, size [, offset])` — end of the earliest
143    /// sliding window containing `ts` (i.e. `hop(...) + size`).
144    HopWindowEnd,
145    "hop_end",
146    3..=4,
147    |args| {
148        let slide_ms = positive_interval(&args[1], "hop_end", "slide")?;
149        let size_ms = positive_interval(&args[2], "hop_end", "size")?;
150        let offset_ms = optional_offset(&args, 3)?;
151        into_us_columnar(&args[0], |ts| {
152            hop_start_ms(ts, slide_ms, size_ms, offset_ms).saturating_add(size_ms)
153        })
154    }
155);
156
157window_udf!(
158    /// `session(ts, gap)` — passthrough that converts `ts` to
159    /// microsecond resolution. Real session start/end are data-dependent
160    /// and computed by Ring 0; this UDF exists so `GROUP BY session(ts,
161    /// gap)` parses. There is no `session_end` UDF for the same reason.
162    SessionWindowStart,
163    "session",
164    2..=2,
165    |args| into_us_columnar(&args[0], |ts| ts)
166);
167
168window_udf!(
169    /// `cumulate(ts, step, size)` — epoch start (size-aligned bucket)
170    /// containing `ts`. Per-step cumulating boundaries (which depend on
171    /// data) are exposed by Ring 0.
172    CumulateWindowStart,
173    "cumulate",
174    3..=3,
175    |args| {
176        let (_step_ms, size_ms) = cumulate_intervals(&args, "cumulate")?;
177        into_us_columnar(&args[0], |ts| tumble_start_ms(ts, size_ms, 0))
178    }
179);
180
181window_udf!(
182    /// `cumulate_end(ts, step, size)` — exclusive upper bound of the
183    /// epoch (size-aligned bucket) containing `ts`.
184    CumulateWindowEnd,
185    "cumulate_end",
186    3..=3,
187    |args| {
188        let (_step_ms, size_ms) = cumulate_intervals(&args, "cumulate_end")?;
189        into_us_columnar(&args[0], |ts| {
190            tumble_start_ms(ts, size_ms, 0).saturating_add(size_ms)
191        })
192    }
193);
194
195// ─── Math helpers ──────────────────────────────────────────────────────────
196//
197// All in milliseconds. Pure i64 → i64.
198
199/// Tumble window start: `floor((ts - offset) / interval) * interval + offset`.
200fn tumble_start_ms(ts: i64, interval_ms: i64, offset_ms: i64) -> i64 {
201    let adj = ts - offset_ms;
202    (adj - adj.rem_euclid(interval_ms)) + offset_ms
203}
204
205/// Earliest start of a hopping window of `size` (sliding by `slide`)
206/// that contains `ts`.
207fn hop_start_ms(ts: i64, slide_ms: i64, size_ms: i64, offset_ms: i64) -> i64 {
208    let adj = ts - size_ms + slide_ms - offset_ms;
209    (adj - adj.rem_euclid(slide_ms)) + offset_ms
210}
211
212// ─── Argument parsing helpers ──────────────────────────────────────────────
213
214/// Builds `Signature::any(N)` or `Signature::one_of([Any(N), …])` for a
215/// scalar UDF whose arity covers a contiguous range.
216fn variadic_signature(arity: RangeInclusive<usize>) -> Signature {
217    let mut arities: Vec<TypeSignature> = arity.map(TypeSignature::Any).collect();
218    let inner = if arities.len() == 1 {
219        arities.pop().unwrap()
220    } else {
221        TypeSignature::OneOf(arities)
222    };
223    Signature::new(inner, Volatility::Immutable)
224}
225
226fn check_arity(args: &[ColumnarValue], arity: RangeInclusive<usize>, fn_name: &str) -> Result<()> {
227    if arity.contains(&args.len()) {
228        return Ok(());
229    }
230    let expected = if arity.start() == arity.end() {
231        format!("exactly {}", arity.start())
232    } else {
233        format!("{}-{}", arity.start(), arity.end())
234    };
235    Err(DataFusionError::Plan(format!(
236        "{}() requires {} arguments, got {}",
237        fn_name,
238        expected,
239        args.len()
240    )))
241}
242
243fn positive_interval(value: &ColumnarValue, fn_name: &str, arg_name: &str) -> Result<i64> {
244    let ms = extract_interval_ms(value)?;
245    if ms <= 0 {
246        return Err(DataFusionError::Plan(format!(
247            "{fn_name}() {arg_name} must be positive"
248        )));
249    }
250    Ok(ms)
251}
252
253fn optional_offset(args: &[ColumnarValue], idx: usize) -> Result<i64> {
254    match args.get(idx) {
255        Some(value) => extract_interval_ms(value),
256        None => Ok(0),
257    }
258}
259
260fn cumulate_intervals(args: &[ColumnarValue], fn_name: &str) -> Result<(i64, i64)> {
261    let step_ms = positive_interval(&args[1], fn_name, "step")?;
262    let size_ms = positive_interval(&args[2], fn_name, "size")?;
263    if step_ms > size_ms {
264        return Err(DataFusionError::Plan(format!(
265            "{fn_name}() step must not exceed size"
266        )));
267    }
268    if size_ms % step_ms != 0 {
269        return Err(DataFusionError::Plan(format!(
270            "{fn_name}() size must be evenly divisible by step"
271        )));
272    }
273    Ok((step_ms, size_ms))
274}
275
276/// Extracts a scalar interval in milliseconds. Array intervals are
277/// rejected — per-row window sizes are not a valid streaming pattern.
278fn extract_interval_ms(value: &ColumnarValue) -> Result<i64> {
279    match value {
280        ColumnarValue::Scalar(scalar) => scalar_interval_to_ms(scalar),
281        ColumnarValue::Array(_) => Err(DataFusionError::NotImplemented(
282            "Array interval arguments not supported for window functions".to_string(),
283        )),
284    }
285}
286
287fn scalar_interval_to_ms(scalar: &ScalarValue) -> Result<i64> {
288    match scalar {
289        ScalarValue::IntervalDayTime(Some(v)) => {
290            Ok(i64::from(v.days) * 86_400_000 + i64::from(v.milliseconds))
291        }
292        ScalarValue::IntervalMonthDayNano(Some(v)) => {
293            if v.months != 0 {
294                return Err(DataFusionError::NotImplemented(
295                    "Month-based intervals not supported for window functions \
296                     (use days/hours/minutes/seconds)"
297                        .to_string(),
298                ));
299            }
300            Ok(i64::from(v.days) * 86_400_000 + v.nanoseconds / 1_000_000)
301        }
302        ScalarValue::IntervalYearMonth(_) => Err(DataFusionError::NotImplemented(
303            "Year-month intervals not supported for window functions".to_string(),
304        )),
305        ScalarValue::Int64(Some(ms)) => Ok(*ms),
306        _ => Err(DataFusionError::Plan(format!(
307            "Expected interval argument for window function, got: {scalar:?}"
308        ))),
309    }
310}
311
312// ─── Dispatch helper ───────────────────────────────────────────────────────
313
314/// Applies `transform` (ms → ms) to every non-null input timestamp and
315/// returns a `Timestamp(Microsecond)` result. One allocation per call;
316/// null inputs propagate.
317fn into_us_columnar(
318    value: &ColumnarValue,
319    transform: impl Fn(i64) -> i64,
320) -> Result<ColumnarValue> {
321    match value {
322        ColumnarValue::Array(array) => {
323            let input = to_millis_array(array)?;
324            let result: TimestampMicrosecondArray = input
325                .iter()
326                .map(|opt| opt.map(|ms| transform(ms).saturating_mul(1000)))
327                .collect();
328            Ok(ColumnarValue::Array(Arc::new(result)))
329        }
330        ColumnarValue::Scalar(scalar) => {
331            let result =
332                scalar_to_timestamp_ms(scalar)?.map(|ms| transform(ms).saturating_mul(1000));
333            Ok(ColumnarValue::Scalar(ScalarValue::TimestampMicrosecond(
334                result, None,
335            )))
336        }
337    }
338}
339
340fn to_millis_array(array: &ArrayRef) -> Result<TimestampMillisecondArray> {
341    cast_to_millis_array(array.as_ref()).map_err(|e| DataFusionError::Plan(e.to_string()))
342}
343
344fn scalar_to_timestamp_ms(scalar: &ScalarValue) -> Result<Option<i64>> {
345    match scalar {
346        ScalarValue::TimestampMillisecond(v, _) | ScalarValue::Int64(v) => Ok(*v),
347        ScalarValue::TimestampMicrosecond(v, _) => Ok(v.map(|v| v / 1_000)),
348        ScalarValue::TimestampNanosecond(v, _) => Ok(v.map(|v| v / 1_000_000)),
349        ScalarValue::TimestampSecond(v, _) => Ok(v.map(|v| v * 1_000)),
350        _ => Err(DataFusionError::Plan(format!(
351            "Expected timestamp argument for window function, got: {scalar:?}"
352        ))),
353    }
354}
355
356#[cfg(test)]
357mod tests;