Skip to main content

flow/batching_mode/
time_window.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Time window expr and helper functions
16//!
17
18use std::collections::BTreeSet;
19use std::sync::Arc;
20
21use api::helper::pb_value_to_value_ref;
22use arrow::array::{
23    TimestampMicrosecondArray, TimestampMillisecondArray, TimestampNanosecondArray,
24    TimestampSecondArray,
25};
26use catalog::CatalogManagerRef;
27use common_error::ext::BoxedError;
28use common_recordbatch::DfRecordBatch;
29use common_telemetry::warn;
30use common_time::Timestamp;
31use common_time::timestamp::TimeUnit;
32use datafusion::error::Result as DfResult;
33use datafusion::execution::SessionState;
34use datafusion::logical_expr::Expr;
35use datafusion::physical_planner::{DefaultPhysicalPlanner, PhysicalPlanner};
36use datafusion_common::tree_node::{
37    Transformed, TreeNode, TreeNodeRecursion, TreeNodeRewriter, TreeNodeVisitor,
38};
39use datafusion_common::{DFSchema, TableReference};
40use datafusion_expr::physical_planning_context::PhysicalPlanningContext;
41use datafusion_expr::{ColumnarValue, LogicalPlan};
42use datafusion_physical_expr::PhysicalExprRef;
43use datatypes::prelude::{ConcreteDataType, DataType};
44use datatypes::schema::TIME_INDEX_KEY;
45use datatypes::value::Value;
46use datatypes::vectors::{
47    TimestampMicrosecondVector, TimestampMillisecondVector, TimestampNanosecondVector,
48    TimestampSecondVector, Vector,
49};
50use itertools::Itertools;
51use session::context::QueryContextRef;
52use snafu::{OptionExt, ResultExt, ensure};
53
54use crate::Error;
55use crate::adapter::util::from_proto_to_data_type;
56use crate::error::{
57    ArrowSnafu, DatafusionSnafu, DatatypesSnafu, ExternalSnafu, PlanSnafu, TimeSnafu,
58    UnexpectedSnafu,
59};
60
61/// Represents a test timestamp in seconds since the Unix epoch.
62const DEFAULT_TEST_TIMESTAMP: Timestamp = Timestamp::new_second(17_0000_0000);
63
64#[derive(Default)]
65struct TimeWindowPlanShape {
66    table_scan_count: usize,
67    aggregate_count: usize,
68    has_unsupported_pruning_node: bool,
69}
70
71impl TimeWindowPlanShape {
72    fn should_skip_time_window_expr(&self) -> bool {
73        self.has_unsupported_pruning_node || self.table_scan_count != 1 || self.aggregate_count > 1
74    }
75
76    fn should_stop_inspection(&self) -> bool {
77        // This intentionally differs from the final skip predicate above:
78        // zero table scans make a fully inspected plan ineligible, but they are
79        // not an early-stop condition because a later subtree may still contain
80        // the first table scan.
81        self.has_unsupported_pruning_node || self.table_scan_count > 1 || self.aggregate_count > 1
82    }
83}
84
85impl TreeNodeVisitor<'_> for TimeWindowPlanShape {
86    type Node = LogicalPlan;
87
88    fn f_down(&mut self, node: &Self::Node) -> DfResult<TreeNodeRecursion> {
89        match node {
90            LogicalPlan::TableScan(_) => {
91                self.table_scan_count += 1;
92            }
93            LogicalPlan::Aggregate(_) => {
94                self.aggregate_count += 1;
95            }
96            // The pinned DataFusion fork has no separate
97            // `LogicalPlan::CrossJoin` variant. SQL `CROSS JOIN` is represented
98            // as `LogicalPlan::Join(_)` here, so rejecting all joins also
99            // rejects cross joins.
100            LogicalPlan::Join(_)
101            | LogicalPlan::Window(_)
102            | LogicalPlan::Union(_)
103            | LogicalPlan::Distinct(_)
104            | LogicalPlan::Limit(_)
105            | LogicalPlan::Sort(_)
106            | LogicalPlan::Extension(_)
107            | LogicalPlan::Dml(_)
108            | LogicalPlan::Ddl(_)
109            | LogicalPlan::Unnest(_)
110            | LogicalPlan::RecursiveQuery(_) => {
111                self.has_unsupported_pruning_node = true;
112            }
113            _ => {}
114        }
115
116        // These disqualifying conditions are monotonic. Once any of them is
117        // met, later traversal cannot make the plan eligible for TWE pruning
118        // again. Counts may be partial after `Stop`, but they are only used by
119        // the final skip predicate.
120        if self.should_stop_inspection() {
121            Ok(TreeNodeRecursion::Stop)
122        } else {
123            Ok(TreeNodeRecursion::Continue)
124        }
125    }
126}
127
128fn inspect_time_window_plan_shape(plan: &LogicalPlan) -> DfResult<TimeWindowPlanShape> {
129    let mut shape = TimeWindowPlanShape::default();
130    plan.visit_with_subqueries(&mut shape)?;
131    Ok(shape)
132}
133
134fn should_skip_time_window_expr(plan: &LogicalPlan) -> bool {
135    let Ok(shape) = inspect_time_window_plan_shape(plan) else {
136        return true;
137    };
138
139    shape.should_skip_time_window_expr()
140}
141
142/// Time window expr like `date_bin(INTERVAL '1' MINUTE, ts)`, this type help with
143/// evaluating the expr using given timestamp
144///
145/// The time window expr must satisfies following conditions:
146/// 1. The expr must be monotonic non-decreasing
147/// 2. The expr must only have one and only one input column with timestamp type, and the output column must be timestamp type
148/// 3. The expr must be deterministic
149///
150/// An example of time window expr is `date_bin(INTERVAL '1' MINUTE, ts)`
151#[derive(Debug, Clone)]
152pub struct TimeWindowExpr {
153    phy_expr: PhysicalExprRef,
154    pub column_name: String,
155    logical_expr: Expr,
156    df_schema: DFSchema,
157    eval_time_window_size: Option<std::time::Duration>,
158    eval_time_original: Option<Timestamp>,
159}
160
161impl std::fmt::Display for TimeWindowExpr {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        f.debug_struct("TimeWindowExpr")
164            .field("phy_expr", &self.phy_expr.to_string())
165            .field("column_name", &self.column_name)
166            .field("logical_expr", &self.logical_expr.to_string())
167            .field("df_schema", &self.df_schema)
168            .finish()
169    }
170}
171
172impl TimeWindowExpr {
173    /// The time window size of the expr, get from calling `eval` with a test timestamp
174    pub fn time_window_size(&self) -> &Option<std::time::Duration> {
175        &self.eval_time_window_size
176    }
177
178    pub fn from_expr(
179        expr: &Expr,
180        column_name: &str,
181        df_schema: &DFSchema,
182        session: &SessionState,
183    ) -> Result<Self, Error> {
184        let phy_expr: PhysicalExprRef = to_phy_expr(expr, df_schema, session)?;
185        let mut zelf = Self {
186            phy_expr,
187            column_name: column_name.to_string(),
188            logical_expr: expr.clone(),
189            df_schema: df_schema.clone(),
190            eval_time_window_size: None,
191            eval_time_original: None,
192        };
193        let test_ts = DEFAULT_TEST_TIMESTAMP;
194        let (lower, upper) = zelf.eval(test_ts)?;
195        let time_window_size = match (lower, upper) {
196            (Some(l), Some(u)) => u.sub(&l).map(|r| r.to_std()).transpose().map_err(|_| {
197                UnexpectedSnafu {
198                    reason: format!(
199                        "Expect upper bound older than lower bound, found upper={u:?} and lower={l:?}"
200                    ),
201                }
202                .build()
203            })?,
204            _ => None,
205        };
206        zelf.eval_time_window_size = time_window_size;
207        zelf.eval_time_original = lower;
208
209        Ok(zelf)
210    }
211
212    /// TODO(discord9): add `eval_batch` too
213    pub fn eval(
214        &self,
215        current: Timestamp,
216    ) -> Result<(Option<Timestamp>, Option<Timestamp>), Error> {
217        fn compute_distance(time_diff_ns: i64, stride_ns: i64) -> i64 {
218            if stride_ns == 0 {
219                return time_diff_ns;
220            }
221            // a - (a % n) impl ceil to nearest n * stride
222            let time_delta = time_diff_ns - (time_diff_ns % stride_ns);
223
224            if time_diff_ns < 0 && time_delta != time_diff_ns {
225                // The origin is later than the source timestamp, round down to the previous bin
226
227                time_delta - stride_ns
228            } else {
229                time_delta
230            }
231        }
232
233        // FAST PATH: if we have eval_time_original and eval_time_window_size,
234        // we can compute the bounds directly
235        if let (Some(original), Some(window_size)) =
236            (self.eval_time_original, self.eval_time_window_size)
237        {
238            // date_bin align current to lower bound
239            let time_diff_ns = current.sub(&original).and_then(|s|s.num_nanoseconds()).with_context(||UnexpectedSnafu {
240                reason: format!(
241                    "Failed to compute time difference between current {current:?} and original {original:?}"
242                ),
243            })?;
244
245            let window_size_ns = window_size.as_nanos() as i64;
246
247            let distance_ns = compute_distance(time_diff_ns, window_size_ns);
248
249            let lower_bound = if distance_ns >= 0 {
250                original.add_duration(std::time::Duration::from_nanos(distance_ns as u64))
251            } else {
252                original.sub_duration(std::time::Duration::from_nanos((-distance_ns) as u64))
253            }
254            .context(TimeSnafu)?;
255            let upper_bound = lower_bound.add_duration(window_size).context(TimeSnafu)?;
256
257            return Ok((Some(lower_bound), Some(upper_bound)));
258        }
259
260        let lower_bound =
261            calc_expr_time_window_lower_bound(&self.phy_expr, &self.df_schema, current)?;
262        let upper_bound =
263            probe_expr_time_window_upper_bound(&self.phy_expr, &self.df_schema, current)?;
264        Ok((lower_bound, upper_bound))
265    }
266
267    /// Find timestamps from rows using time window expr
268    ///
269    /// use column of name `self.column_name` from input rows list as input to time window expr
270    pub async fn handle_rows(
271        &self,
272        rows_list: Vec<api::v1::Rows>,
273    ) -> Result<BTreeSet<Timestamp>, Error> {
274        let mut time_windows = BTreeSet::new();
275
276        for rows in rows_list {
277            // pick the time index column and use it to eval on `self.expr`
278            // TODO(discord9): handle case where time index column is not present(i.e. DEFAULT constant value)
279            let ts_col_index = rows
280                .schema
281                .iter()
282                .map(|col| col.column_name.clone())
283                .position(|name| name == self.column_name);
284            let Some(ts_col_index) = ts_col_index else {
285                warn!("can't found time index column in schema: {:?}", rows.schema);
286                continue;
287            };
288            let col_schema = &rows.schema[ts_col_index];
289            let cdt = from_proto_to_data_type(col_schema)?;
290
291            let mut vector = cdt.create_mutable_vector(rows.rows.len());
292            for row in rows.rows {
293                let value = pb_value_to_value_ref(&row.values[ts_col_index], None);
294                vector
295                    .try_push_value_ref(&value)
296                    .map_err(BoxedError::new)
297                    .context(ExternalSnafu)?;
298            }
299            let vector = vector.to_vector();
300
301            let df_schema = create_df_schema_for_ts_column(&self.column_name, cdt)?;
302
303            let rb =
304                DfRecordBatch::try_new(df_schema.inner().clone(), vec![vector.to_arrow_array()])
305                    .with_context(|_e| ArrowSnafu {
306                        context: format!(
307                            "Failed to create record batch from {df_schema:?} and {vector:?}"
308                        ),
309                    })?;
310
311            let eval_res = self
312                .phy_expr
313                .evaluate(&rb)
314                .with_context(|_| DatafusionSnafu {
315                    context: format!(
316                        "Failed to evaluate physical expression {:?} on {rb:?}",
317                        self.phy_expr
318                    ),
319                })?;
320
321            let res = columnar_to_ts_vector(&eval_res)?;
322
323            for ts in res.into_iter().flatten() {
324                time_windows.insert(ts);
325            }
326        }
327
328        Ok(time_windows)
329    }
330}
331
332fn create_df_schema_for_ts_column(name: &str, cdt: ConcreteDataType) -> Result<DFSchema, Error> {
333    let arrow_schema = Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
334        name,
335        cdt.as_arrow_type(),
336        false,
337    )]));
338
339    let df_schema = DFSchema::from_field_specific_qualified_schema(
340        vec![Some(TableReference::bare("TimeIndexOnlyTable"))],
341        &arrow_schema,
342    )
343    .with_context(|_e| DatafusionSnafu {
344        context: format!("Failed to create DFSchema from arrow schema {arrow_schema:?}"),
345    })?;
346
347    Ok(df_schema)
348}
349
350/// Convert `ColumnarValue` to `Vec<Option<Timestamp>>`
351fn columnar_to_ts_vector(columnar: &ColumnarValue) -> Result<Vec<Option<Timestamp>>, Error> {
352    let val = match columnar {
353        datafusion_expr::ColumnarValue::Array(array) => {
354            let ty = array.data_type();
355            let ty = ConcreteDataType::from_arrow_type(ty);
356            let time_unit = if let ConcreteDataType::Timestamp(ty) = ty {
357                ty.unit()
358            } else {
359                return UnexpectedSnafu {
360                    reason: format!("Non-timestamp type: {ty:?}"),
361                }
362                .fail();
363            };
364
365            match time_unit {
366                TimeUnit::Second => array
367                    .as_ref()
368                    .as_any()
369                    .downcast_ref::<TimestampSecondArray>()
370                    .with_context(|| PlanSnafu {
371                        reason: format!("Failed to create vector from arrow array {array:?}"),
372                    })?
373                    .values()
374                    .iter()
375                    .map(|d| Some(Timestamp::new(*d, time_unit)))
376                    .collect_vec(),
377                TimeUnit::Millisecond => array
378                    .as_ref()
379                    .as_any()
380                    .downcast_ref::<TimestampMillisecondArray>()
381                    .with_context(|| PlanSnafu {
382                        reason: format!("Failed to create vector from arrow array {array:?}"),
383                    })?
384                    .values()
385                    .iter()
386                    .map(|d| Some(Timestamp::new(*d, time_unit)))
387                    .collect_vec(),
388                TimeUnit::Microsecond => array
389                    .as_ref()
390                    .as_any()
391                    .downcast_ref::<TimestampMicrosecondArray>()
392                    .with_context(|| PlanSnafu {
393                        reason: format!("Failed to create vector from arrow array {array:?}"),
394                    })?
395                    .values()
396                    .iter()
397                    .map(|d| Some(Timestamp::new(*d, time_unit)))
398                    .collect_vec(),
399                TimeUnit::Nanosecond => array
400                    .as_ref()
401                    .as_any()
402                    .downcast_ref::<TimestampNanosecondArray>()
403                    .with_context(|| PlanSnafu {
404                        reason: format!("Failed to create vector from arrow array {array:?}"),
405                    })?
406                    .values()
407                    .iter()
408                    .map(|d| Some(Timestamp::new(*d, time_unit)))
409                    .collect_vec(),
410            }
411        }
412        datafusion_expr::ColumnarValue::Scalar(scalar) => {
413            let value = Value::try_from(scalar.clone()).with_context(|_| DatatypesSnafu {
414                extra: format!("Failed to convert scalar {scalar:?} to value"),
415            })?;
416            let ts = value.as_timestamp().context(UnexpectedSnafu {
417                reason: format!("Expect Timestamp, found {:?}", value),
418            })?;
419            vec![Some(ts)]
420        }
421    };
422    Ok(val)
423}
424
425/// Return (`the column name of time index column`, `the time window expr`, `the expected time unit of time index column`, `the expr's schema for evaluating the time window`)
426///
427/// The time window expr is expected to have one input column with Timestamp type, and also return Timestamp type, the time window expr is expected
428/// to be monotonic increasing and appears in the innermost GROUP BY clause
429///
430/// note this plan should only contain one TableScan
431pub async fn find_time_window_expr(
432    plan: &LogicalPlan,
433    catalog_man: CatalogManagerRef,
434    query_ctx: QueryContextRef,
435) -> Result<(String, Option<datafusion_expr::Expr>, TimeUnit, DFSchema), Error> {
436    // TODO(discord9): find the expr that do time window
437
438    // Dirty-window pruning is only safe for simple single-source aggregate plans.
439    // For joins, window functions, set operations, distinct/sort/limit, extension
440    // nodes, or multi-scan plans, conservatively give up and let batching mode run
441    // the unfiltered full query instead.
442    if should_skip_time_window_expr(plan) {
443        // The column/schema/unit fields are placeholders when the TWE is None;
444        // callers must only use them when a TWE is present.
445        return Ok((
446            String::new(),
447            None,
448            TimeUnit::Millisecond,
449            DFSchema::empty(),
450        ));
451    }
452
453    let mut table_name = None;
454
455    // first find the table source in the logical plan
456    plan.apply(|plan| {
457        let LogicalPlan::TableScan(table_scan) = plan else {
458            return Ok(TreeNodeRecursion::Continue);
459        };
460        table_name = Some(table_scan.table_name.clone());
461        Ok(TreeNodeRecursion::Stop)
462    })
463    .with_context(|_| DatafusionSnafu {
464        context: format!("Can't find table source in plan {plan:?}"),
465    })?;
466    let Some(table_name) = table_name else {
467        UnexpectedSnafu {
468            reason: format!("Can't find table source in plan {plan:?}"),
469        }
470        .fail()?
471    };
472
473    let current_schema = query_ctx.current_schema();
474
475    let catalog_name = table_name.catalog().unwrap_or(query_ctx.current_catalog());
476    let schema_name = table_name.schema().unwrap_or(&current_schema);
477    let table_name = table_name.table();
478
479    let Some(table_ref) = catalog_man
480        .table(catalog_name, schema_name, table_name, Some(&query_ctx))
481        .await
482        .map_err(BoxedError::new)
483        .context(ExternalSnafu)?
484    else {
485        UnexpectedSnafu {
486            reason: format!(
487                "Can't find table {table_name:?} in catalog {catalog_name:?}/{schema_name:?}"
488            ),
489        }
490        .fail()?
491    };
492
493    let schema = &table_ref.table_info().meta.schema;
494
495    let ts_index = schema.timestamp_column().with_context(|| UnexpectedSnafu {
496        reason: format!("Can't find timestamp column in table {table_name:?}"),
497    })?;
498
499    let ts_col_name = ts_index.name.clone();
500
501    let expected_time_unit = ts_index.data_type.as_timestamp().with_context(|| UnexpectedSnafu {
502        reason: format!(
503            "Expected timestamp column {ts_col_name:?} in table {table_name:?} to be timestamp, but got {ts_index:?}"
504        ),
505    })?.unit();
506
507    let arrow_schema = Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
508        ts_col_name.clone(),
509        ts_index.data_type.as_arrow_type(),
510        false,
511    )]));
512
513    let df_schema = DFSchema::from_field_specific_qualified_schema(
514        vec![Some(TableReference::bare(table_name))],
515        &arrow_schema,
516    )
517    .with_context(|_e| DatafusionSnafu {
518        context: format!("Failed to create DFSchema from arrow schema {arrow_schema:?}"),
519    })?;
520
521    // find the time window expr which refers to the time index column
522    let mut aggr_expr = None;
523    let mut time_window_expr: Option<Expr> = None;
524
525    let find_inner_aggr_expr = |plan: &LogicalPlan| {
526        if let LogicalPlan::Aggregate(aggregate) = plan {
527            aggr_expr = Some(aggregate.clone());
528        };
529
530        Ok(TreeNodeRecursion::Continue)
531    };
532    plan.apply(find_inner_aggr_expr)
533        .with_context(|_| DatafusionSnafu {
534            context: format!("Can't find aggr expr in plan {plan:?}"),
535        })?;
536
537    if let Some(aggregate) = aggr_expr {
538        for group_expr in &aggregate.group_expr {
539            let refs = group_expr.column_refs();
540            if refs.len() != 1 {
541                continue;
542            }
543            let ref_col = refs.iter().next().unwrap();
544
545            let index = aggregate.input.schema().maybe_index_of_column(ref_col);
546            let Some(index) = index else {
547                continue;
548            };
549            let field = aggregate.input.schema().field(index);
550
551            // TODO(discord9): need to ensure the field has the meta key for the time index
552            let is_time_index =
553                field.metadata().get(TIME_INDEX_KEY).map(|s| s.as_str()) == Some("true");
554
555            if is_time_index {
556                let rewrite_column = group_expr.clone();
557                let rewritten = rewrite_column
558                    .rewrite(&mut RewriteColumn {
559                        table_name: table_name.to_string(),
560                    })
561                    .with_context(|_| DatafusionSnafu {
562                        context: format!("Rewrite expr failed, expr={:?}", group_expr),
563                    })?
564                    .data;
565                struct RewriteColumn {
566                    table_name: String,
567                }
568
569                impl TreeNodeRewriter for RewriteColumn {
570                    type Node = Expr;
571                    fn f_down(&mut self, node: Self::Node) -> DfResult<Transformed<Self::Node>> {
572                        let Expr::Column(mut column) = node else {
573                            return Ok(Transformed::no(node));
574                        };
575
576                        column.relation = Some(TableReference::bare(self.table_name.clone()));
577
578                        Ok(Transformed::yes(Expr::Column(column)))
579                    }
580                }
581
582                time_window_expr = Some(rewritten);
583                break;
584            }
585        }
586        Ok((ts_col_name, time_window_expr, expected_time_unit, df_schema))
587    } else {
588        // can't found time window expr, return None
589        Ok((ts_col_name, None, expected_time_unit, df_schema))
590    }
591}
592
593/// Find nearest lower bound for time `current` in given `plan` for the time window expr.
594/// i.e. for time window expr being `date_bin(INTERVAL '5 minutes', ts) as time_window` and `current="2021-07-01 00:01:01.000"`,
595/// return `Some("2021-07-01 00:00:00.000")`
596/// if `plan` doesn't contain a `TIME INDEX` column, return `None`
597///
598/// Time window expr is a expr that:
599/// 1. ref only to a time index column
600/// 2. is monotonic increasing
601/// 3. show up in GROUP BY clause
602///
603/// note this plan should only contain one TableScan
604#[cfg(test)]
605pub async fn find_plan_time_window_bound(
606    plan: &LogicalPlan,
607    current: Timestamp,
608    query_ctx: QueryContextRef,
609    engine: query::QueryEngineRef,
610) -> Result<(String, Option<Timestamp>, Option<Timestamp>), Error> {
611    // TODO(discord9): find the expr that do time window
612    let catalog_man = engine.engine_state().catalog_manager();
613
614    let (ts_col_name, time_window_expr, expected_time_unit, df_schema) =
615        find_time_window_expr(plan, catalog_man.clone(), query_ctx).await?;
616    // cast current to ts_index's type
617    let new_current = current
618        .convert_to(expected_time_unit)
619        .with_context(|| UnexpectedSnafu {
620            reason: format!("Failed to cast current timestamp {current:?} to {expected_time_unit}"),
621        })?;
622
623    // if no time_window_expr is found, return None
624    if let Some(time_window_expr) = time_window_expr {
625        let phy_expr = to_phy_expr(
626            &time_window_expr,
627            &df_schema,
628            &engine.engine_state().session_state(),
629        )?;
630        let lower_bound = calc_expr_time_window_lower_bound(&phy_expr, &df_schema, new_current)?;
631        let upper_bound = probe_expr_time_window_upper_bound(&phy_expr, &df_schema, new_current)?;
632        Ok((ts_col_name, lower_bound, upper_bound))
633    } else {
634        Ok((ts_col_name, None, None))
635    }
636}
637
638/// Find the lower bound of time window in given `expr` and `current` timestamp.
639///
640/// i.e. for `current="2021-07-01 00:01:01.000"` and `expr=date_bin(INTERVAL '5 minutes', ts) as time_window` and `ts_col=ts`,
641/// return `Some("2021-07-01 00:00:00.000")` since it's the lower bound
642/// return `Some("2021-07-01 00:00:00.000")` since it's the lower bound
643/// of current time window given the current timestamp
644///
645/// if return None, meaning this time window have no lower bound
646fn calc_expr_time_window_lower_bound(
647    phy_expr: &PhysicalExprRef,
648    df_schema: &DFSchema,
649    current: Timestamp,
650) -> Result<Option<Timestamp>, Error> {
651    let cur_time_window = eval_phy_time_window_expr(phy_expr, df_schema, current)?;
652    let input_time_unit = cur_time_window.unit();
653    Ok(cur_time_window.convert_to(input_time_unit))
654}
655
656/// Probe for the upper bound for time window expression
657fn probe_expr_time_window_upper_bound(
658    phy_expr: &PhysicalExprRef,
659    df_schema: &DFSchema,
660    current: Timestamp,
661) -> Result<Option<Timestamp>, Error> {
662    // TODO(discord9): special handling `date_bin` for faster path
663    use std::cmp::Ordering;
664
665    let cur_time_window = eval_phy_time_window_expr(phy_expr, df_schema, current)?;
666
667    // search to find the lower bound
668    let mut offset: i64 = 1;
669    let mut lower_bound = Some(current);
670    let upper_bound;
671    // first expontial probe to found a range for binary search
672    loop {
673        let Some(next_val) = current.value().checked_add(offset) else {
674            // no upper bound if overflow, which is ok
675            return Ok(None);
676        };
677
678        let next_time_probe = common_time::Timestamp::new(next_val, current.unit());
679
680        let next_time_window = eval_phy_time_window_expr(phy_expr, df_schema, next_time_probe)?;
681
682        match next_time_window.cmp(&cur_time_window) {
683            Ordering::Less => UnexpectedSnafu {
684                    reason: format!(
685                        "Unsupported time window expression, expect monotonic increasing for time window expression {phy_expr:?}"
686                    ),
687                }
688                .fail()?,
689            Ordering::Equal => {
690                lower_bound = Some(next_time_probe);
691            }
692            Ordering::Greater => {
693                upper_bound = Some(next_time_probe);
694                break
695            }
696        }
697
698        let Some(new_offset) = offset.checked_mul(2) else {
699            // no upper bound if overflow
700            return Ok(None);
701        };
702        offset = new_offset;
703    }
704
705    // binary search for the exact upper bound
706
707    binary_search_expr(
708        lower_bound,
709        upper_bound,
710        cur_time_window,
711        phy_expr,
712        df_schema,
713    )
714    .map(Some)
715}
716
717fn binary_search_expr(
718    lower_bound: Option<Timestamp>,
719    upper_bound: Option<Timestamp>,
720    cur_time_window: Timestamp,
721    phy_expr: &PhysicalExprRef,
722    df_schema: &DFSchema,
723) -> Result<Timestamp, Error> {
724    ensure!(
725        lower_bound.map(|v| v.unit()) == upper_bound.map(|v| v.unit()),
726        UnexpectedSnafu {
727            reason: format!(
728                " unit mismatch for time window expression {phy_expr:?}, found {lower_bound:?} and {upper_bound:?}"
729            ),
730        }
731    );
732
733    let output_unit = upper_bound
734        .context(UnexpectedSnafu {
735            reason: "should have lower bound",
736        })?
737        .unit();
738
739    let mut low = lower_bound
740        .context(UnexpectedSnafu {
741            reason: "should have lower bound",
742        })?
743        .value();
744    let mut high = upper_bound
745        .context(UnexpectedSnafu {
746            reason: "should have upper bound",
747        })?
748        .value();
749    while low < high {
750        let mid = (low + high) / 2;
751        let mid_probe = common_time::Timestamp::new(mid, output_unit);
752        let mid_time_window = eval_phy_time_window_expr(phy_expr, df_schema, mid_probe)?;
753
754        match mid_time_window.cmp(&cur_time_window) {
755            std::cmp::Ordering::Less => UnexpectedSnafu {
756                reason: format!("Binary search failed for time window expression {phy_expr:?}"),
757            }
758            .fail()?,
759            std::cmp::Ordering::Equal => low = mid + 1,
760            std::cmp::Ordering::Greater => high = mid,
761        }
762    }
763
764    let final_upper_bound_for_time_window = common_time::Timestamp::new(high, output_unit);
765    Ok(final_upper_bound_for_time_window)
766}
767
768/// Expect the `phy` expression only have one input column with Timestamp type, and also return Timestamp type
769fn eval_phy_time_window_expr(
770    phy: &PhysicalExprRef,
771    df_schema: &DFSchema,
772    input_value: Timestamp,
773) -> Result<Timestamp, Error> {
774    let schema_ty = df_schema.field(0).data_type();
775    let schema_cdt = ConcreteDataType::from_arrow_type(schema_ty);
776    let schema_unit = if let ConcreteDataType::Timestamp(ts) = schema_cdt {
777        ts.unit()
778    } else {
779        return UnexpectedSnafu {
780            reason: format!("Expect Timestamp, found {:?}", schema_cdt),
781        }
782        .fail();
783    };
784    let input_value = input_value
785        .convert_to(schema_unit)
786        .with_context(|| UnexpectedSnafu {
787            reason: format!("Failed to convert timestamp {input_value:?} to {schema_unit}"),
788        })?;
789    let ts_vector = match schema_unit {
790        TimeUnit::Second => {
791            TimestampSecondVector::from_vec(vec![input_value.value()]).to_arrow_array()
792        }
793        TimeUnit::Millisecond => {
794            TimestampMillisecondVector::from_vec(vec![input_value.value()]).to_arrow_array()
795        }
796        TimeUnit::Microsecond => {
797            TimestampMicrosecondVector::from_vec(vec![input_value.value()]).to_arrow_array()
798        }
799        TimeUnit::Nanosecond => {
800            TimestampNanosecondVector::from_vec(vec![input_value.value()]).to_arrow_array()
801        }
802    };
803
804    let rb = DfRecordBatch::try_new(df_schema.inner().clone(), vec![ts_vector.clone()])
805        .with_context(|_| ArrowSnafu {
806            context: format!("Failed to create record batch from {df_schema:?} and {ts_vector:?}"),
807        })?;
808
809    let eval_res = phy.evaluate(&rb).with_context(|_| DatafusionSnafu {
810        context: format!("Failed to evaluate physical expression {phy:?} on {rb:?}"),
811    })?;
812
813    if let Some(Some(ts)) = columnar_to_ts_vector(&eval_res)?.first() {
814        Ok(*ts)
815    } else {
816        UnexpectedSnafu {
817            reason: format!(
818                "Expected timestamp in expression {phy:?} but got {:?}",
819                eval_res
820            ),
821        }
822        .fail()?
823    }
824}
825
826fn to_phy_expr(
827    expr: &Expr,
828    df_schema: &DFSchema,
829    session: &SessionState,
830) -> Result<PhysicalExprRef, Error> {
831    let phy_planner = DefaultPhysicalPlanner::default();
832
833    let phy_expr: PhysicalExprRef = phy_planner
834        // Time-window expressions are standalone scalar expressions over the input
835        // time column, so they cannot contain scalar subqueries or lambda variables
836        // that would require a plan-level physical planning context.
837        .create_physical_expr(
838            expr,
839            df_schema,
840            session,
841            &PhysicalPlanningContext::default(),
842        )
843        .with_context(|_e| DatafusionSnafu {
844            context: format!(
845                "Failed to create physical expression from {expr:?} using {df_schema:?}"
846            ),
847        })?;
848    Ok(phy_expr)
849}
850
851#[cfg(test)]
852mod test {
853    use datafusion_common::tree_node::TreeNode;
854    use pretty_assertions::assert_eq;
855    use session::context::QueryContext;
856
857    use super::*;
858    use crate::batching_mode::utils::{AddFilterRewriter, df_plan_to_sql, sql_to_df_plan};
859    use crate::test_utils::create_test_query_engine;
860
861    #[tokio::test]
862    async fn test_plan_time_window_lower_bound() {
863        use datafusion_expr::{col, lit};
864        let query_engine = create_test_query_engine();
865        let ctx = QueryContext::arc();
866
867        let testcases = [
868            // same alias is not same column
869            (
870                "SELECT arrow_cast(date_bin(INTERVAL '1 MINS', numbers_with_ts.ts), 'Timestamp(Second, None)') AS ts FROM numbers_with_ts GROUP BY ts;",
871                Timestamp::new(1740394109, TimeUnit::Second),
872                (
873                    "ts".to_string(),
874                    Some(Timestamp::new(1740394109000, TimeUnit::Millisecond)),
875                    Some(Timestamp::new(1740394109001, TimeUnit::Millisecond)),
876                ),
877                r#"SELECT arrow_cast(date_bin(INTERVAL '1 MINS', numbers_with_ts.ts), 'Timestamp(Second, None)') AS ts FROM numbers_with_ts WHERE ((ts >= CAST('2025-02-24 10:48:29' AS TIMESTAMP)) AND (ts <= CAST('2025-02-24 10:48:29.001' AS TIMESTAMP))) GROUP BY numbers_with_ts.ts"#,
878            ),
879            // complex time window index
880            (
881                "SELECT arrow_cast(date_bin(INTERVAL '1 MINS', numbers_with_ts.ts), 'Timestamp(Second, None)') AS time_window FROM numbers_with_ts GROUP BY time_window;",
882                Timestamp::new(1740394109, TimeUnit::Second),
883                (
884                    "ts".to_string(),
885                    Some(Timestamp::new(1740394080, TimeUnit::Second)),
886                    Some(Timestamp::new(1740394140, TimeUnit::Second)),
887                ),
888                "SELECT arrow_cast(date_bin(INTERVAL '1 MINS', numbers_with_ts.ts), 'Timestamp(Second, None)') AS time_window FROM numbers_with_ts WHERE ((ts >= CAST('2025-02-24 10:48:00' AS TIMESTAMP)) AND (ts <= CAST('2025-02-24 10:49:00' AS TIMESTAMP))) GROUP BY arrow_cast(date_bin(INTERVAL '1 MINS', numbers_with_ts.ts), 'Timestamp(Second, None)')",
889            ),
890            // complex time window index with where
891            (
892                "SELECT arrow_cast(date_bin(INTERVAL '1 MINS', numbers_with_ts.ts), 'Timestamp(Second, None)') AS time_window FROM numbers_with_ts WHERE number in (2, 3, 4) GROUP BY time_window;",
893                Timestamp::new(1740394109, TimeUnit::Second),
894                (
895                    "ts".to_string(),
896                    Some(Timestamp::new(1740394080, TimeUnit::Second)),
897                    Some(Timestamp::new(1740394140, TimeUnit::Second)),
898                ),
899                "SELECT arrow_cast(date_bin(INTERVAL '1 MINS', numbers_with_ts.ts), 'Timestamp(Second, None)') AS time_window FROM numbers_with_ts WHERE numbers_with_ts.number IN (2, 3, 4) AND ((ts >= CAST('2025-02-24 10:48:00' AS TIMESTAMP)) AND (ts <= CAST('2025-02-24 10:49:00' AS TIMESTAMP))) GROUP BY arrow_cast(date_bin(INTERVAL '1 MINS', numbers_with_ts.ts), 'Timestamp(Second, None)')",
900            ),
901            // complex time window index with between and
902            (
903                "SELECT arrow_cast(date_bin(INTERVAL '1 MINS', numbers_with_ts.ts), 'Timestamp(Second, None)') AS time_window FROM numbers_with_ts WHERE number BETWEEN 2 AND 4 GROUP BY time_window;",
904                Timestamp::new(1740394109, TimeUnit::Second),
905                (
906                    "ts".to_string(),
907                    Some(Timestamp::new(1740394080, TimeUnit::Second)),
908                    Some(Timestamp::new(1740394140, TimeUnit::Second)),
909                ),
910                "SELECT arrow_cast(date_bin(INTERVAL '1 MINS', numbers_with_ts.ts), 'Timestamp(Second, None)') AS time_window FROM numbers_with_ts WHERE (numbers_with_ts.number BETWEEN 2 AND 4) AND ((ts >= CAST('2025-02-24 10:48:00' AS TIMESTAMP)) AND (ts <= CAST('2025-02-24 10:49:00' AS TIMESTAMP))) GROUP BY arrow_cast(date_bin(INTERVAL '1 MINS', numbers_with_ts.ts), 'Timestamp(Second, None)')",
911            ),
912            // no time index
913            (
914                "SELECT date_bin('5 minutes', ts) FROM numbers_with_ts;",
915                Timestamp::new(23, TimeUnit::Millisecond),
916                ("ts".to_string(), None, None),
917                "SELECT date_bin('5 minutes', ts) FROM numbers_with_ts;",
918            ),
919            // time index
920            (
921                "SELECT date_bin('5 minutes', ts) as time_window FROM numbers_with_ts GROUP BY time_window;",
922                Timestamp::new(23, TimeUnit::Nanosecond),
923                (
924                    "ts".to_string(),
925                    Some(Timestamp::new(0, TimeUnit::Millisecond)),
926                    Some(Timestamp::new(300000, TimeUnit::Millisecond)),
927                ),
928                "SELECT date_bin('5 minutes', numbers_with_ts.ts) AS time_window FROM numbers_with_ts WHERE ((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts <= CAST('1970-01-01 00:05:00' AS TIMESTAMP))) GROUP BY date_bin('5 minutes', numbers_with_ts.ts)",
929            ),
930            // on spot
931            (
932                "SELECT date_bin('5 minutes', ts) as time_window FROM numbers_with_ts GROUP BY time_window;",
933                Timestamp::new(0, TimeUnit::Nanosecond),
934                (
935                    "ts".to_string(),
936                    Some(Timestamp::new(0, TimeUnit::Millisecond)),
937                    Some(Timestamp::new(300000, TimeUnit::Millisecond)),
938                ),
939                "SELECT date_bin('5 minutes', numbers_with_ts.ts) AS time_window FROM numbers_with_ts WHERE ((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts <= CAST('1970-01-01 00:05:00' AS TIMESTAMP))) GROUP BY date_bin('5 minutes', numbers_with_ts.ts)",
940            ),
941            // different time unit
942            (
943                "SELECT date_bin('5 minutes', ts) as time_window FROM numbers_with_ts GROUP BY time_window;",
944                Timestamp::new(23_000_000, TimeUnit::Nanosecond),
945                (
946                    "ts".to_string(),
947                    Some(Timestamp::new(0, TimeUnit::Millisecond)),
948                    Some(Timestamp::new(300000, TimeUnit::Millisecond)),
949                ),
950                "SELECT date_bin('5 minutes', numbers_with_ts.ts) AS time_window FROM numbers_with_ts WHERE ((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts <= CAST('1970-01-01 00:05:00' AS TIMESTAMP))) GROUP BY date_bin('5 minutes', numbers_with_ts.ts)",
951            ),
952            // time index with other fields
953            (
954                "SELECT sum(number) as sum_up, date_bin('5 minutes', ts) as time_window FROM numbers_with_ts GROUP BY time_window;",
955                Timestamp::new(23, TimeUnit::Millisecond),
956                (
957                    "ts".to_string(),
958                    Some(Timestamp::new(0, TimeUnit::Millisecond)),
959                    Some(Timestamp::new(300000, TimeUnit::Millisecond)),
960                ),
961                "SELECT sum(numbers_with_ts.number) AS sum_up, date_bin('5 minutes', numbers_with_ts.ts) AS time_window FROM numbers_with_ts WHERE ((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts <= CAST('1970-01-01 00:05:00' AS TIMESTAMP))) GROUP BY date_bin('5 minutes', numbers_with_ts.ts)",
962            ),
963            // time index with other pks
964            (
965                "SELECT number, date_bin('5 minutes', ts) as time_window FROM numbers_with_ts GROUP BY time_window, number;",
966                Timestamp::new(23, TimeUnit::Millisecond),
967                (
968                    "ts".to_string(),
969                    Some(Timestamp::new(0, TimeUnit::Millisecond)),
970                    Some(Timestamp::new(300000, TimeUnit::Millisecond)),
971                ),
972                "SELECT numbers_with_ts.number, date_bin('5 minutes', numbers_with_ts.ts) AS time_window FROM numbers_with_ts WHERE ((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts <= CAST('1970-01-01 00:05:00' AS TIMESTAMP))) GROUP BY date_bin('5 minutes', numbers_with_ts.ts), numbers_with_ts.number",
973            ),
974            // subquery
975            (
976                "SELECT number, time_window FROM (SELECT number, date_bin('5 minutes', ts) as time_window FROM numbers_with_ts GROUP BY time_window, number);",
977                Timestamp::new(23, TimeUnit::Millisecond),
978                (
979                    "ts".to_string(),
980                    Some(Timestamp::new(0, TimeUnit::Millisecond)),
981                    Some(Timestamp::new(300000, TimeUnit::Millisecond)),
982                ),
983                "SELECT numbers_with_ts.number, time_window FROM (SELECT numbers_with_ts.number, date_bin('5 minutes', numbers_with_ts.ts) AS time_window FROM numbers_with_ts WHERE ((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts <= CAST('1970-01-01 00:05:00' AS TIMESTAMP))) GROUP BY date_bin('5 minutes', numbers_with_ts.ts), numbers_with_ts.number)",
984            ),
985            // cte
986            (
987                "with cte as (select number, date_bin('5 minutes', ts) as time_window from numbers_with_ts GROUP BY time_window, number) select number, time_window from cte;",
988                Timestamp::new(23, TimeUnit::Millisecond),
989                (
990                    "ts".to_string(),
991                    Some(Timestamp::new(0, TimeUnit::Millisecond)),
992                    Some(Timestamp::new(300000, TimeUnit::Millisecond)),
993                ),
994                "SELECT cte.number, cte.time_window FROM (SELECT numbers_with_ts.number, date_bin('5 minutes', numbers_with_ts.ts) AS time_window FROM numbers_with_ts WHERE ((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts <= CAST('1970-01-01 00:05:00' AS TIMESTAMP))) GROUP BY date_bin('5 minutes', numbers_with_ts.ts), numbers_with_ts.number) AS cte",
995            ),
996            // complex subquery without alias
997            (
998                "SELECT sum(number), number, date_bin('5 minutes', ts) as time_window, bucket_name FROM (SELECT number, ts, case when number < 5 THEN 'bucket_0_5' when number >= 5 THEN 'bucket_5_inf' END as bucket_name FROM numbers_with_ts) GROUP BY number, time_window, bucket_name;",
999                Timestamp::new(23, TimeUnit::Millisecond),
1000                (
1001                    "ts".to_string(),
1002                    Some(Timestamp::new(0, TimeUnit::Millisecond)),
1003                    Some(Timestamp::new(300000, TimeUnit::Millisecond)),
1004                ),
1005                "SELECT sum(number), number, date_bin('5 minutes', ts) AS time_window, bucket_name FROM (SELECT numbers_with_ts.number, numbers_with_ts.ts, CASE WHEN (numbers_with_ts.number < 5) THEN 'bucket_0_5' WHEN (numbers_with_ts.number >= 5) THEN 'bucket_5_inf' END AS bucket_name FROM numbers_with_ts WHERE ((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts <= CAST('1970-01-01 00:05:00' AS TIMESTAMP)))) GROUP BY number, date_bin('5 minutes', ts), bucket_name",
1006            ),
1007            // complex subquery alias
1008            (
1009                "SELECT sum(number), number, date_bin('5 minutes', ts) as time_window, bucket_name FROM (SELECT number, ts, case when number < 5 THEN 'bucket_0_5' when number >= 5 THEN 'bucket_5_inf' END as bucket_name FROM numbers_with_ts) as cte GROUP BY number, time_window, bucket_name;",
1010                Timestamp::new(23, TimeUnit::Millisecond),
1011                (
1012                    "ts".to_string(),
1013                    Some(Timestamp::new(0, TimeUnit::Millisecond)),
1014                    Some(Timestamp::new(300000, TimeUnit::Millisecond)),
1015                ),
1016                "SELECT sum(cte.number), cte.number, date_bin('5 minutes', cte.ts) AS time_window, cte.bucket_name FROM (SELECT numbers_with_ts.number, numbers_with_ts.ts, CASE WHEN (numbers_with_ts.number < 5) THEN 'bucket_0_5' WHEN (numbers_with_ts.number >= 5) THEN 'bucket_5_inf' END AS bucket_name FROM numbers_with_ts WHERE ((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts <= CAST('1970-01-01 00:05:00' AS TIMESTAMP)))) AS cte GROUP BY cte.number, date_bin('5 minutes', cte.ts), cte.bucket_name",
1017            ),
1018        ];
1019
1020        for (sql, current, expected, expected_unparsed) in testcases {
1021            let plan = sql_to_df_plan(ctx.clone(), query_engine.clone(), sql, true)
1022                .await
1023                .unwrap();
1024
1025            let real =
1026                find_plan_time_window_bound(&plan, current, ctx.clone(), query_engine.clone())
1027                    .await
1028                    .unwrap();
1029            assert_eq!(expected, real);
1030
1031            let plan = sql_to_df_plan(ctx.clone(), query_engine.clone(), sql, false)
1032                .await
1033                .unwrap();
1034            let (col_name, lower, upper) = real;
1035            let new_sql = if let Some(lower) = lower {
1036                let to_df_literal = |value| {
1037                    let value = Value::from(value);
1038
1039                    value.try_to_scalar_value(&value.data_type()).unwrap()
1040                };
1041                let lower = to_df_literal(lower);
1042                let upper = to_df_literal(upper.unwrap());
1043                let expr = col(&col_name)
1044                    .gt_eq(lit(lower))
1045                    .and(col(&col_name).lt_eq(lit(upper)));
1046                let mut add_filter = AddFilterRewriter::new(expr);
1047                let plan = plan.rewrite(&mut add_filter).unwrap().data;
1048                df_plan_to_sql(&plan).unwrap()
1049            } else {
1050                sql.to_string()
1051            };
1052            assert_eq!(expected_unparsed, new_sql);
1053        }
1054    }
1055
1056    #[tokio::test]
1057    async fn test_complex_plans_skip_time_window_expr() {
1058        let query_engine = create_test_query_engine();
1059        let ctx = QueryContext::arc();
1060
1061        let testcases = [
1062            // A join may duplicate or drop rows across sources, so a time window
1063            // found on one side is not safe to use as a full-query dirty-window
1064            // pruning boundary.
1065            r#"
1066SELECT
1067    l.number,
1068    date_bin('5 minutes', l.ts) AS time_window
1069FROM numbers_with_ts l
1070JOIN numbers_with_ts r ON l.number = r.number
1071GROUP BY l.number, time_window
1072"#,
1073            // Window functions can depend on rows outside the dirty window,
1074            // even if their input contains a group-by time window.
1075            r#"
1076SELECT number, time_window
1077FROM (
1078    SELECT
1079        number,
1080        time_window,
1081        row_number() OVER (PARTITION BY number ORDER BY time_window DESC) AS rn
1082    FROM (
1083        SELECT number, date_bin('5 minutes', ts) AS time_window
1084        FROM numbers_with_ts
1085        GROUP BY number, time_window
1086    )
1087)
1088WHERE rn = 1
1089"#,
1090            // Set operations combine multiple query scopes/sources.
1091            r#"
1092SELECT date_bin('5 minutes', ts) AS time_window
1093FROM numbers_with_ts
1094GROUP BY time_window
1095UNION ALL
1096SELECT date_bin('5 minutes', ts) AS time_window
1097FROM numbers_with_ts
1098GROUP BY time_window
1099"#,
1100            // Nested aggregates are unsafe: pruning source rows by the inner
1101            // time window is not equivalent for the outer/global aggregate.
1102            r#"
1103SELECT max(cnt)
1104FROM (
1105    SELECT date_bin('5 minutes', ts) AS time_window, count(number) AS cnt
1106    FROM numbers_with_ts
1107    GROUP BY time_window
1108)
1109"#,
1110            // Expression subqueries add another query scope/source and should
1111            // not be treated as a simple single-source TWE plan.
1112            r#"
1113SELECT date_bin('5 minutes', ts) AS time_window
1114FROM numbers_with_ts
1115WHERE number IN (SELECT number FROM numbers_with_ts)
1116GROUP BY time_window
1117"#,
1118            // Sorting an otherwise valid TWE query is conservatively treated
1119            // as full-query to avoid adding dirty-window predicates across
1120            // post-aggregate plan nodes.
1121            r#"
1122SELECT date_bin('5 minutes', ts) AS time_window
1123FROM numbers_with_ts
1124GROUP BY time_window
1125ORDER BY time_window
1126"#,
1127            // DISTINCT is a post-query de-duplication boundary; keep it on the
1128            // full-query path even if its input has a time window group key.
1129            r#"
1130SELECT DISTINCT time_window
1131FROM (
1132    SELECT date_bin('5 minutes', ts) AS time_window
1133    FROM numbers_with_ts
1134    GROUP BY time_window
1135)
1136"#,
1137            // LIMIT can change which rows are visible after pruning.
1138            r#"
1139SELECT date_bin('5 minutes', ts) AS time_window
1140FROM numbers_with_ts
1141GROUP BY time_window
1142LIMIT 10
1143"#,
1144            // Cross joins may appear either as a dedicated node or as multiple
1145            // table scans; either way they must not use source dirty-window
1146            // pruning from one side only.
1147            r#"
1148SELECT date_bin('5 minutes', l.ts) AS time_window
1149FROM numbers_with_ts l, numbers_with_ts r
1150GROUP BY time_window
1151"#,
1152            // A cross join with a constant relation still has only one table
1153            // scan, so it must be rejected by the join node instead of relying
1154            // on the multi-scan guard.
1155            r#"
1156SELECT date_bin('5 minutes', l.ts) AS time_window
1157FROM numbers_with_ts l CROSS JOIN (VALUES (1)) AS v(x)
1158GROUP BY time_window
1159"#,
1160        ];
1161
1162        for sql in testcases {
1163            let plan = sql_to_df_plan(ctx.clone(), query_engine.clone(), sql, true)
1164                .await
1165                .unwrap();
1166            let (_, lower, upper) = find_plan_time_window_bound(
1167                &plan,
1168                Timestamp::new(23, TimeUnit::Millisecond),
1169                ctx.clone(),
1170                query_engine.clone(),
1171            )
1172            .await
1173            .unwrap();
1174
1175            assert_eq!(None, lower, "query should not have TWE: {sql}");
1176            assert_eq!(None, upper, "query should not have TWE: {sql}");
1177        }
1178    }
1179
1180    #[tokio::test]
1181    async fn test_simple_single_source_aggregate_keeps_time_window_expr() {
1182        let query_engine = create_test_query_engine();
1183        let ctx = QueryContext::arc();
1184
1185        let sql = r#"
1186SELECT max(number) AS max_number, date_bin('5 minutes', ts) AS time_window
1187FROM numbers_with_ts
1188GROUP BY time_window
1189"#;
1190        let plan = sql_to_df_plan(ctx.clone(), query_engine.clone(), sql, true)
1191            .await
1192            .unwrap();
1193        let (_, lower, upper) = find_plan_time_window_bound(
1194            &plan,
1195            Timestamp::new(23, TimeUnit::Millisecond),
1196            ctx.clone(),
1197            query_engine.clone(),
1198        )
1199        .await
1200        .unwrap();
1201
1202        assert_eq!(Some(Timestamp::new(0, TimeUnit::Millisecond)), lower);
1203        assert_eq!(Some(Timestamp::new(300000, TimeUnit::Millisecond)), upper);
1204    }
1205}