1use std::sync::Arc;
16
17use arrow_schema::DataType;
18use datafusion::functions_aggregate::min_max::{max_udaf, min_udaf};
19use datafusion::functions_aggregate::sum::sum_udaf;
20use datafusion_common::tree_node::{Transformed, TreeNode};
21use datafusion_common::{Column, DFSchema, Result, ScalarValue};
22use datafusion_expr::expr::AggregateFunction;
23use datafusion_expr::utils::split_conjunction;
24use datafusion_expr::{Aggregate, Expr, ExprSchemable, Filter, LogicalPlan, Operator};
25use datafusion_optimizer::optimizer::ApplyOrder;
26use datafusion_optimizer::{OptimizerConfig, OptimizerRule};
27
28use crate::dist_plan::MergeScanLogicalPlan;
29
30#[derive(Debug)]
42pub(crate) struct InferAggregateInputBoundsRule;
43
44impl OptimizerRule for InferAggregateInputBoundsRule {
45 fn name(&self) -> &str {
46 "infer_aggregate_input_bounds"
47 }
48
49 fn apply_order(&self) -> Option<ApplyOrder> {
50 Some(ApplyOrder::TopDown)
51 }
52
53 fn rewrite(
54 &self,
55 plan: LogicalPlan,
56 _config: &dyn OptimizerConfig,
57 ) -> Result<Transformed<LogicalPlan>> {
58 let LogicalPlan::Filter(mut having) = plan else {
59 return Ok(Transformed::no(plan));
60 };
61 let LogicalPlan::Aggregate(aggregate) = having.input.as_ref() else {
62 return Ok(Transformed::no(LogicalPlan::Filter(having)));
63 };
64 if aggregate.input.exists(|plan| {
67 Ok(matches!(plan, LogicalPlan::Extension(extension)
68 if extension.node.as_any().is::<MergeScanLogicalPlan>()))
69 })? {
70 return Ok(Transformed::no(LogicalPlan::Filter(having)));
71 }
72 let Some(bound) = infer_bound(aggregate, &having.predicate)? else {
73 return Ok(Transformed::no(LogicalPlan::Filter(having)));
74 };
75 if already_filtered(&aggregate.input, &bound) {
76 return Ok(Transformed::no(LogicalPlan::Filter(having)));
77 }
78
79 let mut aggregate = aggregate.clone();
80 aggregate.input = Arc::new(LogicalPlan::Filter(Filter::try_new(
81 bound,
82 aggregate.input,
83 )?));
84 having.input = Arc::new(LogicalPlan::Aggregate(aggregate));
85 Ok(Transformed::yes(LogicalPlan::Filter(having)))
86 }
87}
88
89fn infer_bound(aggregate: &Aggregate, having: &Expr) -> Result<Option<Expr>> {
90 if aggregate.aggr_expr.len() != 2
91 || aggregate.group_expr.is_empty()
92 || !aggregate
93 .group_expr
94 .iter()
95 .all(|e| matches!(e, Expr::Column(_)))
96 || !safe_predicate(having, aggregate.schema.as_ref())?
97 {
98 return Ok(None);
99 }
100
101 for conjunct in split_conjunction(having) {
102 let Some(sum_index) = positive_sum_index(conjunct, aggregate) else {
103 continue;
104 };
105 let Some(sum) = plain_aggregate(&aggregate.aggr_expr[sum_index]) else {
106 continue;
107 };
108 let Some(extremum) = plain_aggregate(&aggregate.aggr_expr[1 - sum_index]) else {
109 continue;
110 };
111 if let Some(bound) = bound_from_aggregates(sum, extremum, &aggregate.input)? {
112 return Ok(Some(bound));
113 }
114 }
115 Ok(None)
116}
117
118fn positive_sum_index(predicate: &Expr, aggregate: &Aggregate) -> Option<usize> {
119 let Expr::BinaryExpr(comparison) = predicate else {
120 return None;
121 };
122 let (output, threshold, operator) = match (comparison.left.as_ref(), comparison.right.as_ref())
123 {
124 (Expr::Column(column), literal) => (column, literal, comparison.op),
125 (literal, Expr::Column(column)) => (column, literal, comparison.op.swap()?),
126 _ => return None,
127 };
128 if !((operator == Operator::Gt && integer_literal(threshold) == Some(0))
129 || (operator == Operator::GtEq && integer_literal(threshold) == Some(1)))
130 {
131 return None;
132 }
133 let index = aggregate.schema.index_of_column(output).ok()?;
134 index
135 .checked_sub(aggregate.group_expr.len())
136 .filter(|&i| i < 2)
137}
138
139fn plain_aggregate(expr: &Expr) -> Option<&AggregateFunction> {
140 let expr = match expr {
141 Expr::Alias(alias) => alias.expr.as_ref(),
142 _ => expr,
143 };
144 let Expr::AggregateFunction(function) = expr else {
145 return None;
146 };
147 let params = &function.params;
148 (params.args.len() == 1
149 && !params.distinct
150 && params.filter.is_none()
151 && params.order_by.is_empty()
152 && params.null_treatment.is_none())
153 .then_some(function)
154}
155
156fn bound_from_aggregates(
157 sum: &AggregateFunction,
158 extremum: &AggregateFunction,
159 input: &LogicalPlan,
160) -> Result<Option<Expr>> {
161 let schema = input.schema();
162 if sum.func != sum_udaf() || (extremum.func != min_udaf() && extremum.func != max_udaf()) {
165 return Ok(None);
166 }
167 let argument = &extremum.params.args[0];
168 let argument_type = argument.get_type(schema)?;
169 if !(argument_type.is_integer() || matches!(argument_type, DataType::Timestamp(..)))
170 || !safe_operand(argument, schema)?
171 {
172 return Ok(None);
173 }
174 let Some(condition) = indicator_condition(&sum.params.args[0]) else {
175 return Ok(None);
176 };
177 if !safe_predicate(condition, schema)?
178 || !columns_are_from_scan(argument, input)?
179 || !columns_are_from_scan(condition, input)?
180 {
181 return Ok(None);
182 }
183 Ok(split_conjunction(condition)
184 .into_iter()
185 .find_map(|predicate| extremum_bound(predicate, argument, extremum, &argument_type)))
186}
187
188fn indicator_condition(expr: &Expr) -> Option<&Expr> {
189 let Expr::Case(case) = expr else {
190 return None;
191 };
192 let [(condition, contribution)] = case.when_then_expr.as_slice() else {
193 return None;
194 };
195 (case.expr.is_none()
196 && integer_literal(contribution) == Some(1)
197 && case.else_expr.as_deref().and_then(integer_literal) == Some(0))
198 .then_some(condition)
199}
200
201fn extremum_bound(
202 predicate: &Expr,
203 argument: &Expr,
204 extremum: &AggregateFunction,
205 argument_type: &DataType,
206) -> Option<Expr> {
207 let Expr::BinaryExpr(comparison) = predicate else {
208 return None;
209 };
210 let (literal, operator) = if comparison.left.as_ref() == argument {
211 (comparison.right.as_ref(), comparison.op)
212 } else if comparison.right.as_ref() == argument {
213 (comparison.left.as_ref(), comparison.op.swap()?)
214 } else {
215 return None;
216 };
217 let Expr::Literal(bound, _) = literal else {
218 return None;
219 };
220 if bound.is_null() || &bound.data_type() != argument_type {
221 return None;
222 }
223 let preserves_extremum = if extremum.func == min_udaf() {
224 matches!(operator, Operator::Lt | Operator::LtEq)
225 } else {
226 matches!(operator, Operator::Gt | Operator::GtEq)
227 };
228 preserves_extremum.then(|| predicate.clone())
229}
230
231fn integer_literal(expr: &Expr) -> Option<i64> {
232 match expr {
233 Expr::Literal(ScalarValue::Int64(integer), _) => *integer,
234 Expr::Literal(ScalarValue::Int32(integer), _) => integer.map(i64::from),
235 _ => None,
236 }
237}
238
239fn safe_predicate(expr: &Expr, schema: &DFSchema) -> Result<bool> {
242 match expr {
243 Expr::BinaryExpr(binary) => Ok(matches!(
244 binary.op,
245 Operator::And
246 | Operator::Or
247 | Operator::Eq
248 | Operator::NotEq
249 | Operator::Lt
250 | Operator::LtEq
251 | Operator::Gt
252 | Operator::GtEq
253 ) && safe_predicate(&binary.left, schema)?
254 && safe_predicate(&binary.right, schema)?),
255 Expr::IsNull(expr) | Expr::IsNotNull(expr) => safe_operand(expr, schema),
256 _ => safe_operand(expr, schema),
257 }
258}
259
260fn safe_operand(expr: &Expr, schema: &DFSchema) -> Result<bool> {
261 match expr {
262 Expr::Column(_) | Expr::Literal(..) => Ok(true),
263 Expr::Cast(cast) => Ok(cast.field.data_type() == &DataType::Int64
266 && matches!(cast.expr.as_ref(), Expr::Column(_))
267 && matches!(cast.expr.get_type(schema)?, DataType::Timestamp(..))),
268 _ => Ok(false),
269 }
270}
271
272fn columns_are_from_scan(expr: &Expr, input: &LogicalPlan) -> Result<bool> {
275 for column in expr.column_refs() {
276 if !column_is_from_scan(column, input)? {
277 return Ok(false);
278 }
279 }
280 Ok(true)
281}
282
283fn column_is_from_scan(column: &Column, input: &LogicalPlan) -> Result<bool> {
284 let index = input.schema().index_of_column(column)?;
285 match input {
286 LogicalPlan::TableScan(_) => Ok(true),
287 LogicalPlan::Filter(filter) => column_is_from_scan(column, &filter.input),
288 LogicalPlan::SubqueryAlias(alias) => column_at_is_from_scan(index, &alias.input),
289 LogicalPlan::Projection(projection) => {
290 let expr = match &projection.expr[index] {
291 Expr::Alias(alias) => alias.expr.as_ref(),
292 expr => expr,
293 };
294 Ok(safe_operand(expr, projection.input.schema())?
295 && columns_are_from_scan(expr, &projection.input)?)
296 }
297 LogicalPlan::Join(join) => {
298 match (
299 join.left.schema().has_column(column),
300 join.right.schema().has_column(column),
301 ) {
302 (true, false) => column_is_from_scan(column, &join.left),
303 (false, true) => column_is_from_scan(column, &join.right),
304 _ => Ok(false),
305 }
306 }
307 LogicalPlan::Union(union) => {
308 for input in &union.inputs {
309 if !column_at_is_from_scan(index, input)? {
310 return Ok(false);
311 }
312 }
313 Ok(true)
314 }
315 _ => Ok(false),
317 }
318}
319
320fn column_at_is_from_scan(index: usize, input: &LogicalPlan) -> Result<bool> {
321 let (qualifier, field) = input.schema().qualified_field(index);
322 column_is_from_scan(&Column::new(qualifier.cloned(), field.name()), input)
323}
324
325fn already_filtered(input: &LogicalPlan, bound: &Expr) -> bool {
326 match input {
327 LogicalPlan::Filter(filter) => {
328 split_conjunction(&filter.predicate).contains(&bound)
329 || already_filtered(&filter.input, bound)
330 }
331 LogicalPlan::TableScan(scan) => scan
332 .filters
333 .iter()
334 .any(|predicate| split_conjunction(predicate).contains(&bound)),
335 _ => false,
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use arrow::array::{
342 BooleanArray, DictionaryArray, Float64Array, Int64Array, StringArray,
343 TimestampMillisecondArray, TimestampNanosecondArray, UInt32Array,
344 };
345 use arrow::datatypes::UInt32Type;
346 use arrow::record_batch::RecordBatch;
347 use arrow::util::pretty::pretty_format_batches;
348 use datafusion::prelude::SessionContext;
349 use datafusion_optimizer::OptimizerContext;
350 use datafusion_optimizer::optimizer::Optimizer;
351 use datafusion_optimizer::push_down_filter::PushDownFilter;
352 use datafusion_optimizer::simplify_expressions::SimplifyExpressions;
353
354 use super::*;
355
356 fn context() -> SessionContext {
357 let keys = vec![
360 Some("a"),
361 Some("a"),
362 Some("a"),
363 Some("a"),
364 Some("a"),
365 Some("b"),
366 Some("c"),
367 Some("c"),
368 None,
369 None,
370 None,
371 ];
372 let numbers = vec![
373 Some(5),
374 Some(10),
375 Some(20),
376 Some(25),
377 None,
378 Some(25),
379 Some(15),
380 Some(4),
381 Some(2),
382 Some(12),
383 Some(22),
384 ];
385 let flags = vec![
386 Some(false),
387 Some(true),
388 Some(true),
389 Some(true),
390 Some(true),
391 Some(true),
392 None,
393 Some(false),
394 Some(false),
395 Some(true),
396 Some(false),
397 ];
398 let services = DictionaryArray::<UInt32Type>::try_new(
399 UInt32Array::from(
400 flags
401 .iter()
402 .map(|flag| flag.map(|f| u32::from(!f)))
403 .collect::<Vec<_>>(),
404 ),
405 Arc::new(StringArray::from(vec!["cart", "other"])),
406 )
407 .unwrap();
408 let batch = RecordBatch::try_from_iter(vec![
409 ("k", Arc::new(StringArray::from(keys)) as _),
410 ("service", Arc::new(services) as _),
411 ("v", Arc::new(Int64Array::from(numbers.clone())) as _),
412 (
413 "f",
414 Arc::new(Float64Array::from(vec![1.0; numbers.len()])) as _,
415 ),
416 (
417 "ts",
418 Arc::new(TimestampNanosecondArray::from(numbers.clone())) as _,
419 ),
420 (
421 "ts_ms",
422 Arc::new(TimestampMillisecondArray::from(numbers)) as _,
423 ),
424 ("flag", Arc::new(BooleanArray::from(flags)) as _),
425 ])
426 .unwrap();
427 let ctx = SessionContext::new();
428 ctx.register_batch("t", batch).unwrap();
429 ctx
430 }
431
432 async fn analyzed(ctx: &SessionContext, sql: &str) -> LogicalPlan {
433 let state = ctx.state();
434 let plan = state.create_logical_plan(sql).await.unwrap();
435 let plan = state
436 .analyzer()
437 .execute_and_check(plan, state.config_options(), |_, _| {})
438 .unwrap();
439 Optimizer::with_rules(vec![Arc::new(SimplifyExpressions::new())])
440 .optimize(plan, &OptimizerContext::new(), |_, _| {})
441 .unwrap()
442 }
443
444 fn optimize(plan: LogicalPlan) -> LogicalPlan {
445 Optimizer::with_rules(vec![Arc::new(InferAggregateInputBoundsRule)])
446 .optimize(plan, &OptimizerContext::new(), |_, _| {})
447 .unwrap()
448 }
449
450 fn aggregate_input_bound(plan: &LogicalPlan) -> Option<&Expr> {
451 if let LogicalPlan::Aggregate(aggregate) = plan
452 && let LogicalPlan::Filter(filter) = aggregate.input.as_ref()
453 {
454 return Some(&filter.predicate);
455 }
456 plan.inputs().into_iter().find_map(aggregate_input_bound)
457 }
458
459 async fn rows(ctx: &SessionContext, plan: LogicalPlan) -> String {
460 let batches = ctx
461 .execute_logical_plan(plan)
462 .await
463 .unwrap()
464 .collect()
465 .await
466 .unwrap();
467 pretty_format_batches(&batches).unwrap().to_string()
468 }
469
470 #[tokio::test]
471 async fn existential_having_preserves_extrema_and_conditional_sum() {
472 let ctx = context();
473 for (extremum, argument, condition, having, expected_bound) in [
474 (
475 "MIN",
476 "v",
477 "service = 'cart' AND v >= 10 AND v < 20",
478 "n > 0",
479 "t.v < Int64(20)",
480 ),
481 ("MIN", "v", "flag AND v <= 20", "n >= 1", "t.v <= Int64(20)"),
482 (
483 "MAX",
484 "v",
485 "flag AND v >= 10 AND v < 20",
486 "0 < n",
487 "t.v >= Int64(10)",
488 ),
489 ("MAX", "v", "flag AND 10 < v", "1 <= n", "t.v > Int64(10)"),
490 (
491 "MIN",
492 "CAST(ts AS BIGINT)",
493 "flag AND CAST(ts AS BIGINT) < 20",
494 "n > 0",
495 "CAST(t.ts AS Int64) < Int64(20)",
496 ),
497 (
498 "MIN",
499 "CAST(ts_ms AS BIGINT)",
500 "flag AND CAST(ts_ms AS BIGINT) < 20",
501 "n > 0",
502 "CAST(t.ts_ms AS Int64) < Int64(20)",
503 ),
504 (
505 "MIN",
506 "ts",
507 "flag AND ts < TIMESTAMP '1970-01-01 00:00:00.000000020'",
508 "n > 0",
509 "t.ts < TimestampNanosecond(20, None)",
510 ),
511 ] {
512 let sql = format!(
513 "SELECT k, {extremum}({argument}) AS endpoint,
514 SUM(CASE WHEN {condition} THEN 1 ELSE 0 END) AS n
515 FROM t GROUP BY k HAVING {having} ORDER BY k NULLS FIRST"
516 );
517 let original = analyzed(&ctx, &sql).await;
518 let optimized = optimize(original.clone());
519 assert_eq!(
520 aggregate_input_bound(&optimized).unwrap().to_string(),
521 expected_bound,
522 "{sql}"
523 );
524 assert_eq!(
525 optimized,
526 optimize(optimized.clone()),
527 "rule must be idempotent: {sql}"
528 );
529 assert_eq!(
530 rows(&ctx, original).await,
531 rows(&ctx, optimized).await,
532 "{sql}"
533 );
534 }
535 }
536
537 #[tokio::test]
538 async fn guarded_derived_operands_are_not_pruned() {
539 let ctx = SessionContext::new();
540 let batch = RecordBatch::try_from_iter(vec![
541 ("k", Arc::new(StringArray::from(vec!["a", "a", "b"])) as _),
542 ("v", Arc::new(Int64Array::from(vec![12, 5, 25])) as _),
543 ("d", Arc::new(Int64Array::from(vec![0, 1, 1])) as _),
544 (
545 "raw",
546 Arc::new(StringArray::from(vec!["bad", "5", "25"])) as _,
547 ),
548 ])
549 .unwrap();
550 ctx.register_batch("guarded", batch).unwrap();
551 ctx.sql("CREATE VIEW guarded_view AS SELECT k, v / d AS r FROM guarded WHERE abs(d) > 0")
552 .await
553 .unwrap()
554 .collect()
555 .await
556 .unwrap();
557 for source in [
558 "SELECT k, v / d AS r FROM guarded WHERE abs(d) > 0",
559 "SELECT k, quotient AS r FROM (SELECT k, v / d AS quotient FROM guarded WHERE abs(d) > 0) q",
560 "SELECT k, CAST(raw AS BIGINT) AS r FROM guarded WHERE abs(d) > 0",
561 "SELECT k, r FROM guarded_view",
562 "SELECT k, v AS r FROM guarded UNION ALL SELECT k, v / d AS r FROM guarded WHERE abs(d) > 0",
563 "SELECT l.k, l.r FROM guarded_view l JOIN guarded q ON l.k = q.k",
564 ] {
565 let sql = format!(
566 "SELECT k, MIN(r) FROM ({source}) s GROUP BY k
567 HAVING SUM(CASE WHEN r < 20 THEN 1 ELSE 0 END) > 0 ORDER BY k"
568 );
569 let original = analyzed(&ctx, &sql).await;
570 let optimized = optimize(original.clone());
571 assert_eq!(
572 rows(&ctx, original.clone()).await,
573 rows(&ctx, optimized.clone()).await,
574 "{sql}"
575 );
576 assert_eq!(original, optimized, "{sql}");
577 }
578 }
579
580 #[tokio::test]
581 async fn scan_column_aliases_still_allow_bounds() {
582 let ctx = context();
583 for source in [
584 "SELECT k, v AS r FROM t",
585 "SELECT k, renamed AS r FROM (SELECT k, v AS renamed FROM t) q",
586 "SELECT k, CAST(ts AS BIGINT) AS r FROM t",
587 "SELECT k, v AS r FROM t UNION ALL SELECT k, v AS r FROM t",
588 "SELECT l.k, l.v AS r FROM t l JOIN t r ON l.k = r.k",
589 ] {
590 let sql = format!(
591 "SELECT k, MIN(r) FROM ({source}) s GROUP BY k
592 HAVING SUM(CASE WHEN r < 20 THEN 1 ELSE 0 END) > 0 ORDER BY k NULLS FIRST"
593 );
594 let original = analyzed(&ctx, &sql).await;
595 let optimized = optimize(original.clone());
596 assert!(aggregate_input_bound(&optimized).is_some(), "{sql}");
597 assert_eq!(
598 rows(&ctx, original).await,
599 rows(&ctx, optimized).await,
600 "{sql}"
601 );
602 }
603 }
604
605 #[tokio::test]
606 async fn unsafe_or_unsupported_aggregates_are_not_pruned() {
607 let ctx = context();
608 let indicator = "SUM(CASE WHEN flag AND v < 20 THEN 1 ELSE 0 END)";
609 for sql in [
610 format!("SELECT k, MIN(v), COUNT(*) FROM t GROUP BY k HAVING {indicator}>0"),
611 format!("SELECT k, MIN(v), MAX(v) FROM t GROUP BY k HAVING {indicator}>0"),
612 format!("SELECT k, MIN(f) FROM t GROUP BY k HAVING {indicator}>0"),
613 format!("SELECT k, MAX(v) FROM t GROUP BY k HAVING {indicator}>0"),
614 format!("SELECT MIN(v) FROM t HAVING {indicator}>0"),
615 format!("SELECT k, MIN(v) FROM t GROUP BY ROLLUP(k) HAVING {indicator}>0"),
616 format!("SELECT k, MIN(v) FROM t GROUP BY k HAVING {indicator}=0"),
617 format!("SELECT k, MIN(v) FROM t GROUP BY k HAVING {indicator}>0 OR MIN(v)>0"),
618 format!("SELECT k, MIN(v) FILTER (WHERE flag) FROM t GROUP BY k HAVING {indicator}>0"),
619 "SELECT k, MIN(v) FROM t GROUP BY k HAVING SUM(CASE WHEN v<20 THEN 1 ELSE 2 END)>0".into(),
620 "SELECT k, MIN(v) FROM t GROUP BY k HAVING SUM(CASE WHEN v<20 THEN 1 END)>0".into(),
621 "SELECT k, MIN(v) FROM t GROUP BY k HAVING SUM(DISTINCT CASE WHEN v<20 THEN 1 ELSE 0 END)>0".into(),
622 "SELECT k, MIN(v) FROM t GROUP BY k HAVING SUM(CASE WHEN v<20 OR flag THEN 1 ELSE 0 END)>0".into(),
623 "SELECT k, MIN(v) FROM t GROUP BY k HAVING SUM(CASE WHEN random()>0.5 AND v<20 THEN 1 ELSE 0 END)>0".into(),
624 "SELECT k, MIN(CAST(k AS BIGINT)) FROM t GROUP BY k HAVING SUM(CASE WHEN CAST(k AS BIGINT)<20 THEN 1 ELSE 0 END)>0".into(),
625 "SELECT k, MIN(v) FROM t GROUP BY k HAVING SUM(CASE WHEN v<20 THEN 1 WHEN flag THEN 1 ELSE 0 END)>0".into(),
626 "SELECT k, MIN(f) FROM t GROUP BY k HAVING SUM(CASE WHEN f<20.0 THEN 1 ELSE 0 END)>0".into(),
627 "SELECT k, MIN(v) FROM t GROUP BY k HAVING SUM(CASE WHEN v<20 THEN 1 ELSE 0 END)>0 AND SUM(CASE WHEN v<100 THEN 1 ELSE 0 END)>0".into(),
628 ] {
629 let original = analyzed(&ctx, &sql).await;
630 assert_eq!(original, optimize(original.clone()), "{sql}");
631 }
632 }
633
634 #[tokio::test]
635 async fn nonpositive_thresholds_preserve_zero_match_groups() {
636 let ctx = context();
637 let expected = [
638 "+---+----------+",
639 "| k | endpoint |",
640 "+---+----------+",
641 "| | 2 |",
642 "| a | 5 |",
643 "| b | 25 |",
644 "| c | 4 |",
645 "+---+----------+",
646 ]
647 .join("\n");
648 for threshold in [">= 0", "> -1"] {
649 let sql = format!(
650 "SELECT k, MIN(v) AS endpoint FROM t GROUP BY k
651 HAVING SUM(CASE WHEN flag AND v < 20 THEN 1 ELSE 0 END) {threshold}
652 ORDER BY k NULLS FIRST"
653 );
654 let original = analyzed(&ctx, &sql).await;
655 let optimized = optimize(original.clone());
656 assert_eq!(original, optimized, "{sql}");
657 assert_eq!(expected, rows(&ctx, optimized).await, "{sql}");
658 }
659 }
660
661 #[tokio::test]
662 async fn bound_survives_filter_pushdown_without_being_added_again() {
663 let ctx = context();
664 let original = analyzed(
665 &ctx,
666 "SELECT k, MIN(v) FROM t GROUP BY k
667 HAVING SUM(CASE WHEN flag AND v<20 THEN 1 ELSE 0 END)>0",
668 )
669 .await;
670 let optimizer = Optimizer::with_rules(vec![
671 Arc::new(InferAggregateInputBoundsRule),
672 Arc::new(PushDownFilter::new()),
673 ]);
674 let optimized = optimizer
675 .optimize(original, &OptimizerContext::new(), |_, _| {})
676 .unwrap();
677 let repeated = optimizer
678 .optimize(optimized.clone(), &OptimizerContext::new(), |_, _| {})
679 .unwrap();
680 assert_eq!(optimized, repeated);
681 assert!(aggregate_input_bound(&optimized).is_some());
682 }
683}