Skip to main content

query/optimizer/
windowed_sort.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
15use std::sync::Arc;
16
17use arrow_schema::DataType;
18use datafusion::physical_optimizer::PhysicalOptimizerRule;
19use datafusion::physical_plan::coalesce_partitions::CoalescePartitionsExec;
20use datafusion::physical_plan::coop::CooperativeExec;
21use datafusion::physical_plan::filter::FilterExec;
22use datafusion::physical_plan::projection::ProjectionExec;
23use datafusion::physical_plan::repartition::RepartitionExec;
24use datafusion::physical_plan::sorts::sort::SortExec;
25use datafusion::physical_plan::sorts::sort_preserving_merge::SortPreservingMergeExec;
26use datafusion::physical_plan::{ChildrenPropertiesMode, ExecutionPlan, ReplaceChildrenOptions};
27use datafusion_common::Result as DataFusionResult;
28use datafusion_common::tree_node::{Transformed, TreeNode};
29use datafusion_physical_expr::expressions::{CastExpr, Column as PhysicalColumn};
30use datafusion_physical_expr::{PhysicalExpr, ScalarFunctionExpr};
31use store_api::region_engine::PartitionRange;
32use table::table::scan::RegionScanExec;
33
34use crate::part_sort::PartSortExec;
35use crate::window_sort::WindowedSortExec;
36
37/// Optimize rule for windowed sort.
38///
39/// This is expected to run after [`ScanHint`] and [`ParallelizeScan`].
40/// It would change the original sort to a custom plan. To make sure
41/// other rules are applied correctly, this rule can be run as later as
42/// possible.
43///
44/// [`ScanHint`]: crate::optimizer::scan_hint::ScanHintRule
45/// [`ParallelizeScan`]: crate::optimizer::parallelize_scan::ParallelizeScan
46#[derive(Debug)]
47pub struct WindowedSortPhysicalRule;
48
49impl PhysicalOptimizerRule for WindowedSortPhysicalRule {
50    fn optimize(
51        &self,
52        plan: Arc<dyn ExecutionPlan>,
53        config: &datafusion::config::ConfigOptions,
54    ) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
55        Self::do_optimize(plan, config)
56    }
57
58    fn name(&self) -> &str {
59        "WindowedSortRule"
60    }
61
62    fn schema_check(&self) -> bool {
63        false
64    }
65}
66
67impl WindowedSortPhysicalRule {
68    fn do_optimize(
69        plan: Arc<dyn ExecutionPlan>,
70        _config: &datafusion::config::ConfigOptions,
71    ) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
72        let result = plan
73            .transform_down(|plan| {
74                if let Some(sort_exec) = plan.downcast_ref::<SortExec>() {
75                    // TODO: support multiple expr in windowed sort
76                    if sort_exec.expr().len() != 1 {
77                        return Ok(Transformed::no(plan));
78                    }
79
80                    let preserve_partitioning = sort_exec.preserve_partitioning();
81
82                    let sort_input = remove_repartition(sort_exec.input().clone())?.data;
83
84                    // Gets scanner info from the input without repartition before filter.
85                    let Some(scanner_info) = fetch_partition_range(sort_input.clone())? else {
86                        return Ok(Transformed::no(plan));
87                    };
88                    // Windowed sorting requires partition ranges, which file-engine scans lack.
89                    if scanner_info.partition_ranges.is_empty() {
90                        return Ok(Transformed::no(plan));
91                    }
92                    let input_schema = sort_input.schema();
93
94                    let first_sort_expr = sort_exec.expr().first();
95                    if let Some(column_expr) = first_sort_expr.expr.downcast_ref::<PhysicalColumn>()
96                        && matches!(
97                            input_schema.field(column_expr.index()).data_type(),
98                            DataType::Timestamp(_, _)
99                        )
100                        && is_time_index_expr(&sort_input, &first_sort_expr.expr)?
101                        && sort_exec.fetch().is_none()
102                    // skip if there is a limit, as dyn filter along is good enough in this case
103                    {
104                    } else {
105                        return Ok(Transformed::no(plan));
106                    }
107
108                    // PartSortExec is unnecessary if:
109                    // - there is no tag column, and
110                    // - the sort is ascending on the time index column
111                    let new_input = if scanner_info.tag_columns.is_empty()
112                        && !first_sort_expr.options.descending
113                    {
114                        sort_input
115                    } else {
116                        Arc::new(PartSortExec::try_new(
117                            first_sort_expr.clone(),
118                            sort_exec.fetch(),
119                            scanner_info.partition_ranges.clone(),
120                            sort_input,
121                        )?)
122                    };
123
124                    let windowed_sort_exec = WindowedSortExec::try_new(
125                        first_sort_expr.clone(),
126                        sort_exec.fetch(),
127                        scanner_info.partition_ranges,
128                        new_input,
129                    )?;
130
131                    if !preserve_partitioning {
132                        let order_preserving_merge = SortPreservingMergeExec::new(
133                            sort_exec.expr().clone(),
134                            Arc::new(windowed_sort_exec),
135                        );
136                        return Ok(Transformed {
137                            data: Arc::new(order_preserving_merge),
138                            transformed: true,
139                            tnr: datafusion_common::tree_node::TreeNodeRecursion::Stop,
140                        });
141                    } else {
142                        return Ok(Transformed {
143                            data: Arc::new(windowed_sort_exec),
144                            transformed: true,
145                            tnr: datafusion_common::tree_node::TreeNodeRecursion::Stop,
146                        });
147                    }
148                }
149
150                Ok(Transformed::no(plan))
151            })?
152            .data;
153
154        Ok(result)
155    }
156}
157
158#[derive(Debug)]
159struct ScannerInfo {
160    partition_ranges: Vec<Vec<PartitionRange>>,
161    tag_columns: Vec<String>,
162}
163
164fn fetch_partition_range(input: Arc<dyn ExecutionPlan>) -> DataFusionResult<Option<ScannerInfo>> {
165    let mut partition_ranges = None;
166    let mut tag_columns = None;
167
168    input.transform_up(|plan| {
169        if plan.is::<CooperativeExec>() {
170            return Ok(Transformed::no(plan));
171        }
172
173        // Unappliable case, reset the state.
174        if plan.is::<RepartitionExec>()
175            || plan.is::<CoalescePartitionsExec>()
176            || plan.is::<SortExec>()
177            || plan.is::<WindowedSortExec>()
178        {
179            partition_ranges = None;
180        }
181
182        // only a very limited set of plans can exist between region scan and sort exec
183        // other plans might make this optimize wrong, so be safe here by limiting it
184        if !(plan.is::<ProjectionExec>() || plan.is::<FilterExec>()) {
185            partition_ranges = None;
186        }
187
188        if let Some(region_scan_exec) = plan.downcast_ref::<RegionScanExec>() {
189            // `PerSeries` distribution is not supported in windowed sort.
190            if region_scan_exec.distribution()
191                == Some(store_api::storage::TimeSeriesDistribution::PerSeries)
192            {
193                partition_ranges = None;
194                return Ok(Transformed::no(plan));
195            }
196
197            partition_ranges = Some(region_scan_exec.get_uncollapsed_partition_ranges());
198            tag_columns = Some(region_scan_exec.tag_columns());
199
200            region_scan_exec.with_distinguish_partition_range(true);
201        }
202
203        Ok(Transformed::no(plan))
204    })?;
205
206    let result = (|| {
207        Some(ScannerInfo {
208            partition_ranges: partition_ranges?,
209            tag_columns: tag_columns?,
210        })
211    })();
212
213    Ok(result)
214}
215
216fn is_time_index_expr(
217    plan: &Arc<dyn ExecutionPlan>,
218    expr: &Arc<dyn PhysicalExpr>,
219) -> DataFusionResult<bool> {
220    if let Some(column_expr) = expr.downcast_ref::<PhysicalColumn>() {
221        return is_time_index_column(plan, column_expr);
222    }
223
224    if let Some(cast_expr) = expr.downcast_ref::<CastExpr>() {
225        let source_type = cast_expr.expr().data_type(plan.schema().as_ref())?;
226        return if preserves_timestamp_values(cast_expr.cast_type(), &source_type) {
227            is_time_index_expr(plan, cast_expr.expr())
228        } else {
229            Ok(false)
230        };
231    }
232
233    if let Some(scalar_function_expr) = expr.downcast_ref::<ScalarFunctionExpr>() {
234        if !is_supported_time_index_wrapper(scalar_function_expr)
235            || scalar_function_expr.args().len() != 1
236        {
237            return Ok(false);
238        }
239
240        // Wrappers such as `to_timestamp_seconds` cast their argument to another time unit
241        // inside the function. That cast does not show up as a `CastExpr` in the plan, but it
242        // changes the values of the sort column exactly like a cast in the plan does, so the
243        // wrapper is only still the time index if its result keeps both the unit and the values
244        // of its argument.
245        let arg = &scalar_function_expr.args()[0];
246        let return_type = scalar_function_expr.data_type(plan.schema().as_ref())?;
247        let arg_type = arg.data_type(plan.schema().as_ref())?;
248        return if preserves_timestamp_values(&return_type, &arg_type) {
249            is_time_index_expr(plan, arg)
250        } else {
251            Ok(false)
252        };
253    }
254
255    Ok(false)
256}
257
258/// Returns true if a cast, or a function wrapper, that turns `source` into `target` keeps both the
259/// time unit and the underlying values of a timestamp.
260///
261/// A cast (or a hidden cast inside a function wrapper) that changes the time unit changes the
262/// values of the sort column, so the working ranges derived from the time index, which keep the
263/// unit of the time index, no longer apply. The optimizer conservatively rejects every cast that
264/// adds a timezone to a naive timestamp, including UTC, even though UTC has a zero offset. Only a
265/// target that keeps both the unit and the values of its source can still be the time index.
266fn preserves_timestamp_values(target: &DataType, source: &DataType) -> bool {
267    matches!(
268        (target, source),
269        (
270            DataType::Timestamp(target_unit, target_tz),
271            DataType::Timestamp(source_unit, source_tz)
272        ) if target_unit == source_unit && (source_tz.is_some() || target_tz.is_none())
273    )
274}
275
276fn is_time_index_column(
277    plan: &Arc<dyn ExecutionPlan>,
278    column_expr: &PhysicalColumn,
279) -> DataFusionResult<bool> {
280    if let Some(projection) = plan.downcast_ref::<ProjectionExec>() {
281        let Some(projection_expr) = projection.expr().get(column_expr.index()) else {
282            return Ok(false);
283        };
284        return is_time_index_expr(projection.input(), &projection_expr.expr);
285    }
286
287    if let Some(filter) = plan.downcast_ref::<FilterExec>() {
288        let child_column_expr = filter
289            .projection()
290            .as_ref()
291            .and_then(|projection| projection.get(column_expr.index()).copied())
292            .map(|input_index| {
293                PhysicalColumn::new(
294                    filter.input().schema().field(input_index).name(),
295                    input_index,
296                )
297            })
298            .unwrap_or_else(|| column_expr.clone());
299        let child_expr = Arc::new(child_column_expr) as Arc<dyn PhysicalExpr>;
300        return is_time_index_expr(filter.input(), &child_expr);
301    }
302
303    if let Some(region_scan_exec) = plan.downcast_ref::<RegionScanExec>() {
304        let schema = plan.schema();
305        let column_field = schema.field(column_expr.index());
306        return Ok(
307            matches!(column_field.data_type(), DataType::Timestamp(_, _))
308                && *column_field.name() == region_scan_exec.time_index(),
309        );
310    }
311
312    let Some(child) = passthrough_child(plan.as_ref()) else {
313        return Ok(false);
314    };
315    let child_expr = Arc::new(column_expr.clone()) as Arc<dyn PhysicalExpr>;
316    is_time_index_expr(&child, &child_expr)
317}
318
319fn passthrough_child(plan: &dyn ExecutionPlan) -> Option<Arc<dyn ExecutionPlan>> {
320    if plan.is::<CoalescePartitionsExec>()
321        || plan.is::<RepartitionExec>()
322        || plan.is::<CooperativeExec>()
323    {
324        return schema_preserving_child(plan);
325    }
326
327    None
328}
329
330fn schema_preserving_child(plan: &dyn ExecutionPlan) -> Option<Arc<dyn ExecutionPlan>> {
331    let child = plan.children().first().cloned().cloned()?;
332    (plan.schema().as_ref() == child.schema().as_ref()).then_some(child)
333}
334
335fn is_supported_time_index_wrapper(expr: &ScalarFunctionExpr) -> bool {
336    (expr.name().eq_ignore_ascii_case("to_timestamp")
337        || expr.name().eq_ignore_ascii_case("to_timestamp_seconds")
338        || expr.name().eq_ignore_ascii_case("to_timestamp_millis")
339        || expr.name().eq_ignore_ascii_case("to_timestamp_micros")
340        || expr.name().eq_ignore_ascii_case("to_timestamp_nanos"))
341        && matches!(expr.return_type(), DataType::Timestamp(_, _))
342}
343
344/// Removes the repartition plan between the filter and region scan.
345fn remove_repartition(
346    plan: Arc<dyn ExecutionPlan>,
347) -> DataFusionResult<Transformed<Arc<dyn ExecutionPlan>>> {
348    plan.transform_down(|plan| {
349        if plan.is::<FilterExec>() {
350            // Checks child.
351            let maybe_repartition = plan.children()[0];
352            if maybe_repartition.is::<RepartitionExec>() {
353                let maybe_scan = maybe_repartition.children()[0];
354                if maybe_scan.is::<RegionScanExec>() {
355                    let new_filter = plan.clone().replace_children(
356                        vec![maybe_scan.clone()],
357                        ReplaceChildrenOptions::new(ChildrenPropertiesMode::Recompute),
358                    )?;
359                    return Ok(Transformed::yes(new_filter));
360                }
361            }
362        }
363
364        Ok(Transformed::no(plan))
365    })
366}
367
368#[cfg(test)]
369mod test {
370    use std::sync::Arc;
371
372    use api::v1::SemanticType;
373    use arrow_schema::{Field, TimeUnit};
374    use common_recordbatch::RecordBatches;
375    use datafusion::config::ConfigOptions;
376    use datafusion::physical_plan::filter::FilterExecBuilder;
377    use datafusion_common::ScalarValue;
378    use datafusion_functions::datetime::{to_timestamp, to_timestamp_millis, to_timestamp_seconds};
379    use datafusion_physical_expr::expressions::{CastExpr, Literal};
380    use datatypes::data_type::ConcreteDataType;
381    use datatypes::schema::{ColumnSchema, Schema};
382    use store_api::metadata::{ColumnMetadata, RegionMetadataBuilder};
383    use store_api::region_engine::SinglePartitionScanner;
384    use store_api::storage::{RegionId, ScanRequest};
385
386    use super::*;
387
388    #[test]
389    fn test_keep_ordinary_sort_when_scan_has_no_partition_ranges() {
390        let ordering = datafusion_physical_expr::LexOrdering::new([
391            datafusion_physical_expr::PhysicalSortExpr {
392                expr: Arc::new(PhysicalColumn::new("ts", 1)),
393                options: Default::default(),
394            },
395        ])
396        .unwrap();
397        let sort = Arc::new(SortExec::new(ordering, new_region_scan()));
398        let optimized =
399            WindowedSortPhysicalRule::do_optimize(sort, &ConfigOptions::default()).unwrap();
400        assert!(optimized.is::<SortExec>());
401    }
402
403    #[test]
404    fn test_is_time_index_expr_tracks_aliases_through_projection() {
405        let scan = new_region_scan();
406        let projection = Arc::new(
407            ProjectionExec::try_new(
408                vec![(
409                    Arc::new(PhysicalColumn::new("ts", 1)) as Arc<dyn PhysicalExpr>,
410                    "alias_ts".to_string(),
411                )],
412                scan,
413            )
414            .unwrap(),
415        ) as Arc<dyn ExecutionPlan>;
416        let expr = Arc::new(PhysicalColumn::new("alias_ts", 0)) as Arc<dyn PhysicalExpr>;
417
418        assert!(is_time_index_expr(&projection, &expr).unwrap());
419    }
420
421    #[test]
422    fn test_is_time_index_expr_tracks_multi_level_aliases() {
423        let scan = new_region_scan();
424        let first_projection = Arc::new(
425            ProjectionExec::try_new(
426                vec![(
427                    Arc::new(PhysicalColumn::new("ts", 1)) as Arc<dyn PhysicalExpr>,
428                    "alias_1".to_string(),
429                )],
430                scan,
431            )
432            .unwrap(),
433        ) as Arc<dyn ExecutionPlan>;
434        let second_projection = Arc::new(
435            ProjectionExec::try_new(
436                vec![(
437                    Arc::new(PhysicalColumn::new("alias_1", 0)) as Arc<dyn PhysicalExpr>,
438                    "alias_2".to_string(),
439                )],
440                first_projection,
441            )
442            .unwrap(),
443        ) as Arc<dyn ExecutionPlan>;
444        let expr = Arc::new(PhysicalColumn::new("alias_2", 0)) as Arc<dyn PhysicalExpr>;
445
446        assert!(is_time_index_expr(&second_projection, &expr).unwrap());
447    }
448
449    #[test]
450    fn test_is_time_index_expr_tracks_value_preserving_wrapper_through_projection() {
451        let scan = new_region_scan_with_unit(TimeUnit::Millisecond);
452        let config = Arc::new(ConfigOptions::default());
453        let wrapper = ScalarFunctionExpr::try_new(
454            to_timestamp_millis(config.as_ref()),
455            vec![Arc::new(PhysicalColumn::new("ts", 1))],
456            scan.schema().as_ref(),
457            config,
458        )
459        .unwrap();
460        // `to_timestamp_millis` keeps both the unit and the values of a millisecond timestamp.
461        assert_eq!(
462            wrapper.data_type(scan.schema().as_ref()).unwrap(),
463            DataType::Timestamp(TimeUnit::Millisecond, None)
464        );
465        let projection = Arc::new(
466            ProjectionExec::try_new(
467                vec![(Arc::new(wrapper) as Arc<dyn PhysicalExpr>, "ts".to_string())],
468                scan,
469            )
470            .unwrap(),
471        ) as Arc<dyn ExecutionPlan>;
472        let expr = Arc::new(PhysicalColumn::new("ts", 0)) as Arc<dyn PhysicalExpr>;
473
474        // So the wrapper is still the time index.
475        assert!(is_time_index_expr(&projection, &expr).unwrap());
476    }
477
478    #[test]
479    fn test_is_time_index_expr_rejects_unit_changing_wrapper() {
480        let config = Arc::new(ConfigOptions::default());
481
482        // `to_timestamp_millis` casts its argument to milliseconds, which truncates the values of
483        // a nanosecond time index, so the wrapper is no longer the time index.
484        let nanosecond_scan = new_region_scan();
485        let truncates_to_millis = Arc::new(
486            ScalarFunctionExpr::try_new(
487                to_timestamp_millis(config.as_ref()),
488                vec![Arc::new(PhysicalColumn::new("ts", 1))],
489                nanosecond_scan.schema().as_ref(),
490                config.clone(),
491            )
492            .unwrap(),
493        ) as Arc<dyn PhysicalExpr>;
494        assert_eq!(
495            truncates_to_millis
496                .data_type(nanosecond_scan.schema().as_ref())
497                .unwrap(),
498            DataType::Timestamp(TimeUnit::Millisecond, None)
499        );
500        assert!(!is_time_index_expr(&nanosecond_scan, &truncates_to_millis).unwrap());
501
502        // Same for a millisecond time index wrapped in `to_timestamp_seconds`.
503        let millisecond_scan = new_region_scan_with_unit(TimeUnit::Millisecond);
504        let truncates_to_seconds = Arc::new(
505            ScalarFunctionExpr::try_new(
506                to_timestamp_seconds(config.as_ref()),
507                vec![Arc::new(PhysicalColumn::new("ts", 1))],
508                millisecond_scan.schema().as_ref(),
509                config.clone(),
510            )
511            .unwrap(),
512        ) as Arc<dyn PhysicalExpr>;
513        assert_eq!(
514            truncates_to_seconds
515                .data_type(millisecond_scan.schema().as_ref())
516                .unwrap(),
517            DataType::Timestamp(TimeUnit::Second, None)
518        );
519        assert!(!is_time_index_expr(&millisecond_scan, &truncates_to_seconds).unwrap());
520
521        // A supported wrapper whose argument is not a timestamp is never the time index.
522        let from_integer = Arc::new(
523            ScalarFunctionExpr::try_new(
524                to_timestamp_millis(config.as_ref()),
525                vec![Arc::new(Literal::new(ScalarValue::Int64(Some(0))))],
526                nanosecond_scan.schema().as_ref(),
527                config,
528            )
529            .unwrap(),
530        ) as Arc<dyn PhysicalExpr>;
531        assert!(!is_time_index_expr(&nanosecond_scan, &from_integer).unwrap());
532    }
533
534    #[test]
535    fn test_is_time_index_expr_rejects_unit_changing_cast_through_projection() {
536        let scan = new_region_scan();
537        let projection = Arc::new(
538            ProjectionExec::try_new(
539                vec![(
540                    Arc::new(CastExpr::new(
541                        Arc::new(PhysicalColumn::new("ts", 1)),
542                        DataType::Timestamp(TimeUnit::Millisecond, None),
543                        None,
544                    )) as Arc<dyn PhysicalExpr>,
545                    "ts_ms".to_string(),
546                )],
547                scan,
548            )
549            .unwrap(),
550        ) as Arc<dyn ExecutionPlan>;
551        let expr = Arc::new(PhysicalColumn::new("ts_ms", 0)) as Arc<dyn PhysicalExpr>;
552
553        // The cast changes the time unit (ns → ms), so it is no longer the time index.
554        assert!(!is_time_index_expr(&projection, &expr).unwrap());
555    }
556
557    #[test]
558    fn test_is_time_index_expr_rejects_timezone_adding_cast() {
559        let scan = new_region_scan();
560
561        // The optimizer conservatively rejects casts adding any timezone to a naive timestamp.
562        let adds_timezone = Arc::new(CastExpr::new(
563            Arc::new(PhysicalColumn::new("ts", 1)),
564            DataType::Timestamp(TimeUnit::Nanosecond, Some(Arc::from("+07:00"))),
565            None,
566        )) as Arc<dyn PhysicalExpr>;
567        assert!(!is_time_index_expr(&scan, &adds_timezone).unwrap());
568
569        // UTC has a zero offset, but naive-to-aware timestamp casts are all rejected.
570        let adds_utc_timezone = Arc::new(CastExpr::new(
571            Arc::new(PhysicalColumn::new("ts", 1)),
572            DataType::Timestamp(TimeUnit::Nanosecond, Some(Arc::from("UTC"))),
573            None,
574        )) as Arc<dyn PhysicalExpr>;
575        assert!(!is_time_index_expr(&scan, &adds_utc_timezone).unwrap());
576
577        // `to_timestamp` returns an aware timestamp in the session time zone for a naive
578        // timestamp argument. That re-interprets the underlying values in the same way as a
579        // timezone-adding cast, so the wrapper is rejected as well.
580        let mut config = ConfigOptions::default();
581        config.execution.time_zone = Some("+08:00".to_string());
582        let config = Arc::new(config);
583        let aware_source = ScalarFunctionExpr::try_new(
584            to_timestamp(config.as_ref()),
585            vec![Arc::new(PhysicalColumn::new("ts", 1))],
586            scan.schema().as_ref(),
587            config.clone(),
588        )
589        .unwrap();
590        let projection = Arc::new(
591            ProjectionExec::try_new(
592                vec![(
593                    Arc::new(aware_source) as Arc<dyn PhysicalExpr>,
594                    "ts_aware".to_string(),
595                )],
596                scan,
597            )
598            .unwrap(),
599        ) as Arc<dyn ExecutionPlan>;
600        assert_eq!(
601            projection.schema().field(0).data_type(),
602            &DataType::Timestamp(TimeUnit::Nanosecond, Some(Arc::from("+08:00")))
603        );
604        let expr = Arc::new(PhysicalColumn::new("ts_aware", 0)) as Arc<dyn PhysicalExpr>;
605
606        assert!(!is_time_index_expr(&projection, &expr).unwrap());
607
608        // A cast between two aware timestamps of the same unit keeps the underlying values, see
609        // `test_preserves_timestamp_values`, but it is still rejected here as its input is not
610        // the time index.
611        let aware_cast = Arc::new(CastExpr::new(
612            Arc::new(PhysicalColumn::new("ts_aware", 0)),
613            DataType::Timestamp(TimeUnit::Nanosecond, Some(Arc::from("+07:00"))),
614            None,
615        )) as Arc<dyn PhysicalExpr>;
616
617        assert!(!is_time_index_expr(&projection, &aware_cast).unwrap());
618    }
619
620    #[test]
621    fn test_preserves_timestamp_values() {
622        let naive_nanosecond = DataType::Timestamp(TimeUnit::Nanosecond, None);
623        let naive_millisecond = DataType::Timestamp(TimeUnit::Millisecond, None);
624        let aware_nanosecond = DataType::Timestamp(TimeUnit::Nanosecond, Some(Arc::from("+08:00")));
625        let other_aware_nanosecond =
626            DataType::Timestamp(TimeUnit::Nanosecond, Some(Arc::from("+07:00")));
627
628        // Keeping the unit and the values of a timestamp.
629        assert!(preserves_timestamp_values(
630            &naive_nanosecond,
631            &naive_nanosecond
632        ));
633        assert!(preserves_timestamp_values(
634            &aware_nanosecond,
635            &other_aware_nanosecond
636        ));
637
638        // Changing the time unit, adding a timezone to a naive timestamp, or leaving the timestamp
639        // domain entirely all change the values of the timestamp.
640        assert!(!preserves_timestamp_values(
641            &naive_millisecond,
642            &naive_nanosecond
643        ));
644        assert!(!preserves_timestamp_values(
645            &aware_nanosecond,
646            &naive_nanosecond
647        ));
648        assert!(!preserves_timestamp_values(
649            &naive_nanosecond,
650            &DataType::Int64
651        ));
652        assert!(!preserves_timestamp_values(
653            &DataType::Int64,
654            &naive_nanosecond
655        ));
656
657        // Dropping an aware timestamp's timezone keeps the underlying values. Adding one to a
658        // naive timestamp is conservatively treated as non-preserving by the predicate.
659        assert!(preserves_timestamp_values(
660            &naive_nanosecond,
661            &aware_nanosecond
662        ));
663    }
664
665    #[test]
666    fn test_is_time_index_expr_rejects_unsupported_wrappers() {
667        let scan = new_region_scan();
668        let config = Arc::new(ConfigOptions::default());
669        let return_field = Arc::new(Field::new(
670            "ts",
671            DataType::Timestamp(TimeUnit::Millisecond, None),
672            false,
673        ));
674        let projection = Arc::new(
675            ProjectionExec::try_new(
676                vec![(
677                    Arc::new(ScalarFunctionExpr::new(
678                        "date_trunc",
679                        to_timestamp_millis(config.as_ref()),
680                        vec![Arc::new(PhysicalColumn::new("ts", 1))],
681                        return_field,
682                        config,
683                    )) as Arc<dyn PhysicalExpr>,
684                    "ts".to_string(),
685                )],
686                scan,
687            )
688            .unwrap(),
689        ) as Arc<dyn ExecutionPlan>;
690        let expr = Arc::new(PhysicalColumn::new("ts", 0)) as Arc<dyn PhysicalExpr>;
691
692        assert!(!is_time_index_expr(&projection, &expr).unwrap());
693    }
694
695    #[test]
696    fn test_is_supported_time_index_wrapper_ignores_function_name_case() {
697        let config = Arc::new(ConfigOptions::default());
698        let return_field = Arc::new(Field::new(
699            "ts",
700            DataType::Timestamp(TimeUnit::Millisecond, None),
701            false,
702        ));
703        let expr = ScalarFunctionExpr::new(
704            "To_Timestamp_Millis",
705            to_timestamp_millis(config.as_ref()),
706            vec![Arc::new(PhysicalColumn::new("ts", 1))],
707            return_field,
708            config,
709        );
710
711        assert!(is_supported_time_index_wrapper(&expr));
712    }
713
714    #[test]
715    fn test_is_time_index_expr_rejects_non_timestamp_casts() {
716        let scan = new_region_scan();
717        let cast_expr = Arc::new(CastExpr::new(
718            Arc::new(PhysicalColumn::new("ts", 1)),
719            DataType::Timestamp(TimeUnit::Millisecond, None),
720            None,
721        )) as Arc<dyn PhysicalExpr>;
722        // The cast changes the time unit (ns → ms), so it is no longer the time index.
723        assert!(!is_time_index_expr(&scan, &cast_expr).unwrap());
724
725        let non_timestamp_cast = Arc::new(CastExpr::new(
726            Arc::new(PhysicalColumn::new("ts", 1)),
727            DataType::Int64,
728            None,
729        )) as Arc<dyn PhysicalExpr>;
730        assert!(!is_time_index_expr(&scan, &non_timestamp_cast).unwrap());
731    }
732
733    #[test]
734    fn test_is_time_index_expr_tracks_time_index_through_filter() {
735        let scan = new_region_scan();
736        let filter = Arc::new(
737            FilterExec::try_new(
738                Arc::new(Literal::new(ScalarValue::Boolean(Some(true)))),
739                scan,
740            )
741            .unwrap(),
742        ) as Arc<dyn ExecutionPlan>;
743        let expr = Arc::new(PhysicalColumn::new("ts", 1)) as Arc<dyn PhysicalExpr>;
744
745        assert!(is_time_index_expr(&filter, &expr).unwrap());
746    }
747
748    #[test]
749    fn test_is_time_index_expr_tracks_time_index_through_passthrough_wrapper_and_filter_projection()
750    {
751        let scan = new_region_scan();
752        let projected_filter = Arc::new(
753            FilterExecBuilder::new(
754                Arc::new(Literal::new(ScalarValue::Boolean(Some(true)))),
755                scan,
756            )
757            .apply_projection(Some(vec![1]))
758            .unwrap()
759            .build()
760            .unwrap(),
761        ) as Arc<dyn ExecutionPlan>;
762        let cooperative =
763            Arc::new(CooperativeExec::new(projected_filter)) as Arc<dyn ExecutionPlan>;
764        let expr = Arc::new(PhysicalColumn::new("ts", 0)) as Arc<dyn PhysicalExpr>;
765
766        assert!(is_time_index_expr(&cooperative, &expr).unwrap());
767    }
768
769    #[test]
770    fn test_schema_preserving_child_rejects_schema_changing_projection() {
771        let scan = new_region_scan();
772        let projection = ProjectionExec::try_new(
773            vec![(
774                Arc::new(PhysicalColumn::new("ts", 1)) as Arc<dyn PhysicalExpr>,
775                "ts".to_string(),
776            )],
777            scan,
778        )
779        .unwrap();
780
781        assert!(schema_preserving_child(&projection).is_none());
782    }
783
784    #[test]
785    fn test_cooperative_exec_satisfies_passthrough_schema_contract() {
786        let child = new_region_scan();
787        let plan = Arc::new(CooperativeExec::new(child.clone())) as Arc<dyn ExecutionPlan>;
788
789        assert_passthrough_schema_contract(plan, child);
790    }
791
792    #[test]
793    fn test_repartition_exec_satisfies_passthrough_schema_contract() {
794        let child = new_region_scan();
795        let plan = Arc::new(
796            RepartitionExec::try_new(
797                child.clone(),
798                datafusion_physical_expr::Partitioning::RoundRobinBatch(2),
799            )
800            .unwrap(),
801        ) as Arc<dyn ExecutionPlan>;
802
803        assert_passthrough_schema_contract(plan, child);
804    }
805
806    #[test]
807    fn test_coalesce_partitions_exec_satisfies_passthrough_schema_contract() {
808        let child = new_region_scan();
809        let plan = Arc::new(CoalescePartitionsExec::new(child.clone())) as Arc<dyn ExecutionPlan>;
810
811        assert_passthrough_schema_contract(plan, child);
812    }
813
814    fn assert_passthrough_schema_contract(
815        plan: Arc<dyn ExecutionPlan>,
816        child: Arc<dyn ExecutionPlan>,
817    ) {
818        assert_eq!(plan.schema().as_ref(), child.schema().as_ref());
819
820        let passthrough = passthrough_child(plan.as_ref()).expect("wrapper should preserve schema");
821        assert_eq!(passthrough.schema().as_ref(), child.schema().as_ref());
822    }
823
824    fn new_region_scan() -> Arc<dyn ExecutionPlan> {
825        new_region_scan_with_unit(TimeUnit::Nanosecond)
826    }
827
828    fn new_region_scan_with_unit(unit: TimeUnit) -> Arc<dyn ExecutionPlan> {
829        let timestamp_type = ConcreteDataType::timestamp_datatype(unit.into());
830        let schema = Arc::new(Schema::new(vec![
831            ColumnSchema::new("value", ConcreteDataType::int32_datatype(), false),
832            ColumnSchema::new("ts", timestamp_type.clone(), false),
833        ]));
834        let recordbatches = RecordBatches::try_new(schema.clone(), vec![]).unwrap();
835        let stream = recordbatches.as_stream();
836
837        let mut builder = RegionMetadataBuilder::new(RegionId::new(1, 1));
838        builder
839            .push_column_metadata(ColumnMetadata {
840                column_schema: ColumnSchema::new(
841                    "value",
842                    ConcreteDataType::int32_datatype(),
843                    false,
844                ),
845                semantic_type: SemanticType::Field,
846                column_id: 1,
847            })
848            .push_column_metadata(ColumnMetadata {
849                column_schema: ColumnSchema::new("ts", timestamp_type, false),
850                semantic_type: SemanticType::Timestamp,
851                column_id: 2,
852            });
853
854        let scanner = Box::new(SinglePartitionScanner::new(
855            stream,
856            false,
857            Arc::new(builder.build().unwrap()),
858            None,
859        ));
860        Arc::new(RegionScanExec::new(scanner, ScanRequest::default(), None).unwrap())
861    }
862}