Skip to main content

chryso_planner/
lib.rs

1use chryso_core::ChrysoResult;
2use chryso_core::ast::Literal;
3use chryso_core::ast::{Expr, JoinType, OrderByExpr, SelectStatement, Statement};
4use chryso_metadata::type_inference::TypeInferencer;
5
6pub mod cost;
7pub mod explain;
8pub mod plan_diff;
9pub mod serde;
10pub mod validate;
11
12pub use cost::{Cost, CostModel};
13pub use explain::{
14    ExplainConfig, ExplainFormatter, format_simple_logical_plan, format_simple_physical_plan,
15};
16
17#[derive(Debug, Clone)]
18pub enum LogicalPlan {
19    Scan {
20        table: String,
21    },
22    IndexScan {
23        table: String,
24        index: String,
25        predicate: Expr,
26    },
27    Dml {
28        sql: String,
29    },
30    Derived {
31        input: Box<LogicalPlan>,
32        alias: String,
33        column_aliases: Vec<String>,
34    },
35    Filter {
36        predicate: Expr,
37        input: Box<LogicalPlan>,
38    },
39    Projection {
40        exprs: Vec<Expr>,
41        input: Box<LogicalPlan>,
42    },
43    Join {
44        join_type: JoinType,
45        left: Box<LogicalPlan>,
46        right: Box<LogicalPlan>,
47        on: Expr,
48    },
49    Aggregate {
50        group_exprs: Vec<Expr>,
51        aggr_exprs: Vec<Expr>,
52        input: Box<LogicalPlan>,
53    },
54    Distinct {
55        input: Box<LogicalPlan>,
56    },
57    TopN {
58        order_by: Vec<OrderByExpr>,
59        limit: u64,
60        input: Box<LogicalPlan>,
61    },
62    Sort {
63        order_by: Vec<OrderByExpr>,
64        input: Box<LogicalPlan>,
65    },
66    Limit {
67        limit: Option<u64>,
68        offset: Option<u64>,
69        input: Box<LogicalPlan>,
70    },
71}
72
73#[derive(Debug, Clone)]
74pub enum PhysicalPlan {
75    TableScan {
76        table: String,
77    },
78    IndexScan {
79        table: String,
80        index: String,
81        predicate: Expr,
82    },
83    Dml {
84        sql: String,
85    },
86    Derived {
87        input: Box<PhysicalPlan>,
88        alias: String,
89        column_aliases: Vec<String>,
90    },
91    Filter {
92        predicate: Expr,
93        input: Box<PhysicalPlan>,
94    },
95    Projection {
96        exprs: Vec<Expr>,
97        input: Box<PhysicalPlan>,
98    },
99    Join {
100        join_type: JoinType,
101        algorithm: JoinAlgorithm,
102        left: Box<PhysicalPlan>,
103        right: Box<PhysicalPlan>,
104        on: Expr,
105    },
106    Aggregate {
107        group_exprs: Vec<Expr>,
108        aggr_exprs: Vec<Expr>,
109        input: Box<PhysicalPlan>,
110    },
111    Distinct {
112        input: Box<PhysicalPlan>,
113    },
114    TopN {
115        order_by: Vec<OrderByExpr>,
116        limit: u64,
117        input: Box<PhysicalPlan>,
118    },
119    Sort {
120        order_by: Vec<OrderByExpr>,
121        input: Box<PhysicalPlan>,
122    },
123    Limit {
124        limit: Option<u64>,
125        offset: Option<u64>,
126        input: Box<PhysicalPlan>,
127    },
128}
129
130pub struct PlanBuilder;
131
132impl PlanBuilder {
133    pub fn build(statement: Statement) -> ChrysoResult<LogicalPlan> {
134        let statement = chryso_core::ast::normalize_statement(&statement);
135        let formatted_sql = chryso_core::sql_format::format_statement(&statement);
136        let plan = match statement {
137            Statement::With(_) => Ok(LogicalPlan::Dml {
138                sql: formatted_sql.clone(),
139            }),
140            Statement::Select(select) => build_select(select),
141            Statement::SetOp { .. } => Ok(LogicalPlan::Dml {
142                sql: formatted_sql.clone(),
143            }),
144            Statement::Explain(_) => Ok(LogicalPlan::Dml {
145                sql: formatted_sql.clone(),
146            }),
147            Statement::CreateTable(_)
148            | Statement::DropTable(_)
149            | Statement::Truncate(_)
150            | Statement::Analyze(_) => Ok(LogicalPlan::Dml {
151                sql: formatted_sql.clone(),
152            }),
153            Statement::Insert(_) | Statement::Update(_) | Statement::Delete(_) => {
154                Ok(LogicalPlan::Dml {
155                    sql: formatted_sql.clone(),
156                })
157            }
158        }?;
159        Ok(simplify_plan(plan))
160    }
161}
162
163fn build_select(select: SelectStatement) -> ChrysoResult<LogicalPlan> {
164    if select.qualify.is_some() || !select.distinct_on.is_empty() {
165        return Ok(LogicalPlan::Dml {
166            sql: chryso_core::sql_format::format_statement(&Statement::Select(select)),
167        });
168    }
169    let mut plan = if let Some(from) = select.from {
170        build_from(from)?
171    } else {
172        return Ok(LogicalPlan::Dml {
173            sql: chryso_core::sql_format::format_statement(&Statement::Select(select)),
174        });
175    };
176    if let Some(predicate) = select.selection {
177        plan = LogicalPlan::Filter {
178            predicate,
179            input: Box::new(plan),
180        };
181    }
182    let projection_exprs = select
183        .projection
184        .into_iter()
185        .map(|item| item.expr)
186        .collect::<Vec<_>>();
187    let has_aggregate = projection_exprs.iter().any(expr_is_aggregate);
188    if has_aggregate || !select.group_by.is_empty() {
189        let aggr_exprs = projection_exprs.clone();
190        plan = LogicalPlan::Aggregate {
191            group_exprs: select.group_by,
192            aggr_exprs,
193            input: Box::new(plan),
194        };
195        if let Some(having) = select.having {
196            plan = LogicalPlan::Filter {
197                predicate: having,
198                input: Box::new(plan),
199            };
200        }
201    }
202    plan = LogicalPlan::Projection {
203        exprs: projection_exprs,
204        input: Box::new(plan),
205    };
206    if select.distinct {
207        plan = LogicalPlan::Distinct {
208            input: Box::new(plan),
209        };
210    }
211    if !select.order_by.is_empty() {
212        if let (Some(limit), None) = (select.limit, select.offset) {
213            plan = LogicalPlan::TopN {
214                order_by: select.order_by,
215                limit,
216                input: Box::new(plan),
217            };
218        } else {
219            plan = LogicalPlan::Sort {
220                order_by: select.order_by,
221                input: Box::new(plan),
222            };
223        }
224    }
225    if select.limit.is_some() || select.offset.is_some() {
226        if matches!(plan, LogicalPlan::TopN { .. }) {
227            return Ok(plan);
228        }
229        plan = LogicalPlan::Limit {
230            limit: select.limit,
231            offset: select.offset,
232            input: Box::new(plan),
233        };
234    }
235    Ok(plan)
236}
237
238fn build_from(table: chryso_core::ast::TableRef) -> ChrysoResult<LogicalPlan> {
239    let mut plan = match table.factor {
240        chryso_core::ast::TableFactor::Table { name } => LogicalPlan::Scan { table: name },
241        chryso_core::ast::TableFactor::Derived { query } => {
242            let alias = table.alias.clone().unwrap_or_else(|| "derived".to_string());
243            LogicalPlan::Derived {
244                input: Box::new(build_query_plan(*query)?),
245                alias,
246                column_aliases: table.column_aliases.clone(),
247            }
248        }
249    };
250    for join in table.joins {
251        let right = build_from(join.right)?;
252        plan = LogicalPlan::Join {
253            join_type: join.join_type,
254            left: Box::new(plan),
255            right: Box::new(right),
256            on: join.on,
257        };
258    }
259    Ok(plan)
260}
261
262fn build_query_plan(statement: Statement) -> ChrysoResult<LogicalPlan> {
263    match statement {
264        Statement::Select(select) => build_select(select),
265        Statement::SetOp { .. } | Statement::With(_) | Statement::Explain(_) => {
266            Ok(LogicalPlan::Dml {
267                sql: chryso_core::sql_format::format_statement(&statement),
268            })
269        }
270        Statement::CreateTable(_)
271        | Statement::DropTable(_)
272        | Statement::Truncate(_)
273        | Statement::Analyze(_)
274        | Statement::Insert(_)
275        | Statement::Update(_)
276        | Statement::Delete(_) => Err(chryso_core::ChrysoError::new(
277            "subquery in FROM must be a query",
278        )),
279    }
280}
281
282fn simplify_plan(plan: LogicalPlan) -> LogicalPlan {
283    match plan {
284        LogicalPlan::Scan { .. } | LogicalPlan::IndexScan { .. } | LogicalPlan::Dml { .. } => plan,
285        LogicalPlan::Derived {
286            input,
287            alias,
288            column_aliases,
289        } => LogicalPlan::Derived {
290            input: Box::new(simplify_plan(*input)),
291            alias,
292            column_aliases,
293        },
294        LogicalPlan::Filter { predicate, input } => {
295            let predicate = predicate.normalize();
296            let input = simplify_plan(*input);
297            if matches!(predicate, Expr::Literal(Literal::Bool(true))) {
298                return input;
299            }
300            LogicalPlan::Filter {
301                predicate,
302                input: Box::new(input),
303            }
304        }
305        LogicalPlan::Projection { exprs, input } => {
306            let exprs = exprs
307                .into_iter()
308                .map(|expr| expr.normalize())
309                .collect::<Vec<_>>();
310            let input = simplify_plan(*input);
311            if exprs.len() == 1 && matches!(exprs[0], Expr::Wildcard) {
312                return input;
313            }
314            LogicalPlan::Projection {
315                exprs,
316                input: Box::new(input),
317            }
318        }
319        LogicalPlan::Join {
320            join_type,
321            left,
322            right,
323            on,
324        } => LogicalPlan::Join {
325            join_type,
326            left: Box::new(simplify_plan(*left)),
327            right: Box::new(simplify_plan(*right)),
328            on: on.normalize(),
329        },
330        LogicalPlan::Aggregate {
331            group_exprs,
332            aggr_exprs,
333            input,
334        } => LogicalPlan::Aggregate {
335            group_exprs: group_exprs
336                .into_iter()
337                .map(|expr| expr.normalize())
338                .collect(),
339            aggr_exprs: aggr_exprs
340                .into_iter()
341                .map(|expr| expr.normalize())
342                .collect(),
343            input: Box::new(simplify_plan(*input)),
344        },
345        LogicalPlan::Distinct { input } => LogicalPlan::Distinct {
346            input: Box::new(simplify_plan(*input)),
347        },
348        LogicalPlan::TopN {
349            order_by,
350            limit,
351            input,
352        } => LogicalPlan::TopN {
353            order_by: order_by
354                .into_iter()
355                .map(|item| OrderByExpr {
356                    expr: item.expr.normalize(),
357                    asc: item.asc,
358                    nulls_first: item.nulls_first,
359                })
360                .collect(),
361            limit,
362            input: Box::new(simplify_plan(*input)),
363        },
364        LogicalPlan::Sort { order_by, input } => LogicalPlan::Sort {
365            order_by: order_by
366                .into_iter()
367                .map(|item| OrderByExpr {
368                    expr: item.expr.normalize(),
369                    asc: item.asc,
370                    nulls_first: item.nulls_first,
371                })
372                .collect(),
373            input: Box::new(simplify_plan(*input)),
374        },
375        LogicalPlan::Limit {
376            limit,
377            offset,
378            input,
379        } => LogicalPlan::Limit {
380            limit,
381            offset,
382            input: Box::new(simplify_plan(*input)),
383        },
384    }
385}
386
387impl LogicalPlan {
388    pub fn explain(&self, indent: usize) -> String {
389        let padding = " ".repeat(indent);
390        match self {
391            LogicalPlan::Scan { table } => format!("{padding}LogicalScan table={table}"),
392            LogicalPlan::IndexScan {
393                table,
394                index,
395                predicate,
396            } => format!(
397                "{padding}LogicalIndexScan table={table} index={index} predicate={}",
398                fmt_expr(predicate)
399            ),
400            LogicalPlan::Dml { sql } => format!("{padding}LogicalDml sql={sql}"),
401            LogicalPlan::Derived {
402                alias,
403                column_aliases,
404                input,
405            } => format!(
406                "{padding}LogicalDerived alias={alias} cols={}\n{}",
407                column_aliases.join(","),
408                input.explain(indent + 2)
409            ),
410            LogicalPlan::Filter { predicate, input } => format!(
411                "{padding}LogicalFilter predicate={}\n{}",
412                fmt_expr(predicate),
413                input.explain(indent + 2)
414            ),
415            LogicalPlan::Projection { exprs, input } => format!(
416                "{padding}LogicalProject exprs={}\n{}",
417                fmt_expr_list(exprs),
418                input.explain(indent + 2)
419            ),
420            LogicalPlan::Join {
421                join_type,
422                left,
423                right,
424                on,
425            } => format!(
426                "{padding}LogicalJoin type={join_type:?} on={}\n{}\n{}",
427                fmt_expr(on),
428                left.explain(indent + 2),
429                right.explain(indent + 2)
430            ),
431            LogicalPlan::Aggregate {
432                group_exprs,
433                aggr_exprs,
434                input,
435            } => format!(
436                "{padding}LogicalAggregate group={} aggr={}\n{}",
437                fmt_expr_list(group_exprs),
438                fmt_expr_list(aggr_exprs),
439                input.explain(indent + 2)
440            ),
441            LogicalPlan::Distinct { input } => {
442                format!("{padding}LogicalDistinct\n{}", input.explain(indent + 2))
443            }
444            LogicalPlan::TopN {
445                order_by,
446                limit,
447                input,
448            } => format!(
449                "{padding}LogicalTopN order_by={} limit={limit}\n{}",
450                fmt_order_by_list(order_by),
451                input.explain(indent + 2)
452            ),
453            LogicalPlan::Sort { order_by, input } => format!(
454                "{padding}LogicalSort order_by={}\n{}",
455                fmt_order_by_list(order_by),
456                input.explain(indent + 2)
457            ),
458            LogicalPlan::Limit {
459                limit,
460                offset,
461                input,
462            } => format!(
463                "{padding}LogicalLimit limit={limit:?} offset={offset:?}\n{}",
464                input.explain(indent + 2)
465            ),
466        }
467    }
468
469    pub fn explain_formatted(
470        &self,
471        config: &ExplainConfig,
472        inferencer: &dyn TypeInferencer,
473    ) -> String {
474        let formatter = ExplainFormatter::new(config.clone());
475        formatter.format_logical_plan(self, inferencer)
476    }
477
478    pub fn explain_typed(
479        &self,
480        indent: usize,
481        inferencer: &dyn chryso_metadata::type_inference::TypeInferencer,
482    ) -> String {
483        let padding = " ".repeat(indent);
484        match self {
485            LogicalPlan::Scan { table } => format!("{padding}LogicalScan table={table}"),
486            LogicalPlan::IndexScan {
487                table,
488                index,
489                predicate,
490            } => format!(
491                "{padding}LogicalIndexScan table={table} index={index} predicate={}",
492                fmt_expr(predicate)
493            ),
494            LogicalPlan::Dml { sql } => format!("{padding}LogicalDml sql={sql}"),
495            LogicalPlan::Derived {
496                alias,
497                column_aliases,
498                input,
499            } => format!(
500                "{padding}LogicalDerived alias={alias} cols={}\n{}",
501                column_aliases.join(","),
502                input.explain_typed(indent + 2, inferencer)
503            ),
504            LogicalPlan::Filter { predicate, input } => format!(
505                "{padding}LogicalFilter predicate={} type={:?}\n{}",
506                fmt_expr(predicate),
507                inferencer.infer_expr(predicate),
508                input.explain_typed(indent + 2, inferencer)
509            ),
510            LogicalPlan::Projection { exprs, input } => {
511                let types = chryso_metadata::type_inference::expr_types(exprs, inferencer);
512                format!(
513                    "{padding}LogicalProject exprs={} types={types:?}\n{}",
514                    fmt_expr_list(exprs),
515                    input.explain_typed(indent + 2, inferencer)
516                )
517            }
518            LogicalPlan::Join {
519                join_type,
520                left,
521                right,
522                on,
523            } => format!(
524                "{padding}LogicalJoin type={join_type:?} on={}\n{}\n{}",
525                fmt_expr(on),
526                left.explain_typed(indent + 2, inferencer),
527                right.explain_typed(indent + 2, inferencer)
528            ),
529            LogicalPlan::Aggregate {
530                group_exprs,
531                aggr_exprs,
532                input,
533            } => format!(
534                "{padding}LogicalAggregate group={} aggr={}\n{}",
535                fmt_expr_list(group_exprs),
536                fmt_expr_list(aggr_exprs),
537                input.explain_typed(indent + 2, inferencer)
538            ),
539            LogicalPlan::Distinct { input } => format!(
540                "{padding}LogicalDistinct\n{}",
541                input.explain_typed(indent + 2, inferencer)
542            ),
543            LogicalPlan::TopN {
544                order_by,
545                limit,
546                input,
547            } => format!(
548                "{padding}LogicalTopN order_by={} limit={limit}\n{}",
549                fmt_order_by_list(order_by),
550                input.explain_typed(indent + 2, inferencer)
551            ),
552            LogicalPlan::Sort { order_by, input } => format!(
553                "{padding}LogicalSort order_by={}\n{}",
554                fmt_order_by_list(order_by),
555                input.explain_typed(indent + 2, inferencer)
556            ),
557            LogicalPlan::Limit {
558                limit,
559                offset,
560                input,
561            } => format!(
562                "{padding}LogicalLimit limit={limit:?} offset={offset:?}\n{}",
563                input.explain_typed(indent + 2, inferencer)
564            ),
565        }
566    }
567}
568
569impl PhysicalPlan {
570    pub fn explain(&self, indent: usize) -> String {
571        let padding = " ".repeat(indent);
572        match self {
573            PhysicalPlan::TableScan { table } => format!("{padding}TableScan table={table}"),
574            PhysicalPlan::IndexScan {
575                table,
576                index,
577                predicate,
578            } => format!(
579                "{padding}IndexScan table={table} index={index} predicate={}",
580                fmt_expr(predicate)
581            ),
582            PhysicalPlan::Dml { sql } => format!("{padding}Dml sql={sql}"),
583            PhysicalPlan::Derived {
584                alias,
585                column_aliases,
586                input,
587            } => format!(
588                "{padding}Derived alias={alias} cols={}\n{}",
589                column_aliases.join(","),
590                input.explain(indent + 2)
591            ),
592            PhysicalPlan::Filter { predicate, input } => format!(
593                "{padding}Filter predicate={}\n{}",
594                fmt_expr(predicate),
595                input.explain(indent + 2)
596            ),
597            PhysicalPlan::Projection { exprs, input } => format!(
598                "{padding}Project exprs={}\n{}",
599                fmt_expr_list(exprs),
600                input.explain(indent + 2)
601            ),
602            PhysicalPlan::Join {
603                join_type,
604                algorithm,
605                left,
606                right,
607                on,
608            } => format!(
609                "{padding}Join type={join_type:?} algorithm={algorithm:?} on={}\n{}\n{}",
610                fmt_expr(on),
611                left.explain(indent + 2),
612                right.explain(indent + 2)
613            ),
614            PhysicalPlan::Aggregate {
615                group_exprs,
616                aggr_exprs,
617                input,
618            } => format!(
619                "{padding}Aggregate group={} aggr={}\n{}",
620                fmt_expr_list(group_exprs),
621                fmt_expr_list(aggr_exprs),
622                input.explain(indent + 2)
623            ),
624            PhysicalPlan::Distinct { input } => {
625                format!("{padding}Distinct\n{}", input.explain(indent + 2))
626            }
627            PhysicalPlan::TopN {
628                order_by,
629                limit,
630                input,
631            } => format!(
632                "{padding}TopN order_by={} limit={limit}\n{}",
633                fmt_order_by_list(order_by),
634                input.explain(indent + 2)
635            ),
636            PhysicalPlan::Sort { order_by, input } => format!(
637                "{padding}Sort order_by={}\n{}",
638                fmt_order_by_list(order_by),
639                input.explain(indent + 2)
640            ),
641            PhysicalPlan::Limit {
642                limit,
643                offset,
644                input,
645            } => format!(
646                "{padding}Limit limit={limit:?} offset={offset:?}\n{}",
647                input.explain(indent + 2)
648            ),
649        }
650    }
651
652    pub fn explain_costed(&self, indent: usize, cost_model: &dyn crate::cost::CostModel) -> String {
653        let padding = " ".repeat(indent);
654        let cost = cost_model.cost(self).0;
655        match self {
656            PhysicalPlan::TableScan { table } => {
657                format!("{padding}TableScan table={table} cost={cost}")
658            }
659            PhysicalPlan::IndexScan {
660                table,
661                index,
662                predicate,
663            } => format!(
664                "{padding}IndexScan table={table} index={index} predicate={} cost={cost}",
665                fmt_expr(predicate)
666            ),
667            PhysicalPlan::Dml { sql } => format!("{padding}Dml sql={sql} cost={cost}"),
668            PhysicalPlan::Derived {
669                alias,
670                column_aliases,
671                input,
672            } => format!(
673                "{padding}Derived alias={alias} cols={} cost={cost}\n{}",
674                column_aliases.join(","),
675                input.explain_costed(indent + 2, cost_model)
676            ),
677            PhysicalPlan::Filter { predicate, input } => format!(
678                "{padding}Filter predicate={} cost={cost}\n{}",
679                fmt_expr(predicate),
680                input.explain_costed(indent + 2, cost_model)
681            ),
682            PhysicalPlan::Projection { exprs, input } => format!(
683                "{padding}Project exprs={} cost={cost}\n{}",
684                fmt_expr_list(exprs),
685                input.explain_costed(indent + 2, cost_model)
686            ),
687            PhysicalPlan::Join {
688                join_type,
689                algorithm,
690                left,
691                right,
692                on,
693            } => format!(
694                "{padding}Join type={join_type:?} algorithm={algorithm:?} on={} cost={cost}\n{}\n{}",
695                fmt_expr(on),
696                left.explain_costed(indent + 2, cost_model),
697                right.explain_costed(indent + 2, cost_model)
698            ),
699            PhysicalPlan::Aggregate {
700                group_exprs,
701                aggr_exprs,
702                input,
703            } => format!(
704                "{padding}Aggregate group={} aggr={} cost={cost}\n{}",
705                fmt_expr_list(group_exprs),
706                fmt_expr_list(aggr_exprs),
707                input.explain_costed(indent + 2, cost_model)
708            ),
709            PhysicalPlan::Distinct { input } => format!(
710                "{padding}Distinct cost={cost}\n{}",
711                input.explain_costed(indent + 2, cost_model)
712            ),
713            PhysicalPlan::TopN {
714                order_by,
715                limit,
716                input,
717            } => format!(
718                "{padding}TopN order_by={} limit={limit} cost={cost}\n{}",
719                fmt_order_by_list(order_by),
720                input.explain_costed(indent + 2, cost_model)
721            ),
722            PhysicalPlan::Sort { order_by, input } => format!(
723                "{padding}Sort order_by={} cost={cost}\n{}",
724                fmt_order_by_list(order_by),
725                input.explain_costed(indent + 2, cost_model)
726            ),
727            PhysicalPlan::Limit {
728                limit,
729                offset,
730                input,
731            } => format!(
732                "{padding}Limit limit={limit:?} offset={offset:?} cost={cost}\n{}",
733                input.explain_costed(indent + 2, cost_model)
734            ),
735        }
736    }
737}
738
739#[derive(Debug, Clone, Copy)]
740pub enum JoinAlgorithm {
741    Hash,
742    NestedLoop,
743}
744
745#[cfg(test)]
746mod tests {
747    use super::{LogicalPlan, PlanBuilder};
748    use chryso_parser::{Dialect, ParserConfig, SimpleParser, SqlParser};
749
750    #[test]
751    fn planner_simplifies_select_star() {
752        let sql = "select * from t";
753        let parser = SimpleParser::new(ParserConfig {
754            dialect: Dialect::Postgres,
755        });
756        let stmt = parser.parse(sql).expect("parse");
757        let logical = PlanBuilder::build(stmt).expect("plan");
758        assert!(matches!(logical, LogicalPlan::Scan { .. }));
759    }
760
761    #[test]
762    fn planner_removes_true_filter() {
763        let sql = "select * from t where not false";
764        let parser = SimpleParser::new(ParserConfig {
765            dialect: Dialect::Postgres,
766        });
767        let stmt = parser.parse(sql).expect("parse");
768        let logical = PlanBuilder::build(stmt).expect("plan");
769        assert!(matches!(logical, LogicalPlan::Scan { .. }));
770    }
771
772    #[test]
773    fn planner_builds_topn_for_ordered_limit() {
774        let sql = "select * from t order by id limit 5";
775        let parser = SimpleParser::new(ParserConfig {
776            dialect: Dialect::Postgres,
777        });
778        let stmt = parser.parse(sql).expect("parse");
779        let logical = PlanBuilder::build(stmt).expect("plan");
780        assert!(matches!(logical, LogicalPlan::TopN { .. }));
781    }
782}
783
784fn expr_is_aggregate(expr: &Expr) -> bool {
785    match expr {
786        Expr::FunctionCall { name, .. } => {
787            matches!(
788                name.to_ascii_lowercase().as_str(),
789                "sum" | "count" | "avg" | "min" | "max"
790            )
791        }
792        _ => false,
793    }
794}
795
796fn fmt_expr(expr: &Expr) -> String {
797    expr.to_sql()
798}
799
800fn fmt_expr_list(exprs: &[Expr]) -> String {
801    exprs.iter().map(fmt_expr).collect::<Vec<_>>().join(", ")
802}
803
804fn fmt_order_by_list(order_by: &[OrderByExpr]) -> String {
805    order_by
806        .iter()
807        .map(|item| {
808            let dir = if item.asc { "asc" } else { "desc" };
809            let mut rendered = format!("{} {dir}", fmt_expr(&item.expr));
810            if let Some(nulls_first) = item.nulls_first {
811                if nulls_first {
812                    rendered.push_str(" nulls first");
813                } else {
814                    rendered.push_str(" nulls last");
815                }
816            }
817            rendered
818        })
819        .collect::<Vec<_>>()
820        .join(", ")
821}