1use crate::cost::CostModel;
2use crate::{LogicalPlan, PhysicalPlan};
3use chryso_core::ast::Expr;
4use chryso_metadata::type_inference::TypeInferencer;
5
6pub trait CardinalityEstimator {
7 fn estimate(&self, plan: &LogicalPlan, stats: &chryso_metadata::StatsCache) -> f64;
8}
9
10pub struct NaiveEstimator;
11
12impl CardinalityEstimator for NaiveEstimator {
13 fn estimate(&self, plan: &LogicalPlan, stats: &chryso_metadata::StatsCache) -> f64 {
14 match plan {
15 LogicalPlan::Scan { table } => stats
16 .table_stats(table)
17 .map(|s| s.row_count)
18 .unwrap_or(1000.0),
19 LogicalPlan::IndexScan { .. } => 100.0,
20 LogicalPlan::Dml { .. } => 1.0,
21 LogicalPlan::Derived { input, .. } => self.estimate(input, stats),
22 LogicalPlan::Filter { input, .. } => self.estimate(input, stats) * 0.25,
23 LogicalPlan::Projection { input, .. } => self.estimate(input, stats),
24 LogicalPlan::Join { left, right, .. } => {
25 self.estimate(left, stats) * self.estimate(right, stats) * 0.1
26 }
27 LogicalPlan::Aggregate { input, .. } => self.estimate(input, stats) * 0.1,
28 LogicalPlan::Distinct { input } => self.estimate(input, stats) * 0.1,
29 LogicalPlan::TopN { limit, .. } => *limit as f64,
30 LogicalPlan::Sort { input, .. } => self.estimate(input, stats),
31 LogicalPlan::Limit { limit, .. } => limit.unwrap_or(100) as f64,
32 }
33 }
34}
35
36pub struct ExplainFormatter {
37 config: ExplainConfig,
38}
39
40#[derive(Clone)]
41pub struct ExplainConfig {
42 pub show_types: bool,
43 pub show_costs: bool,
44 pub show_cardinality: bool,
45 pub compact: bool,
46 pub max_expr_length: usize,
47}
48
49impl Default for ExplainConfig {
50 fn default() -> Self {
51 Self {
52 show_types: true,
53 show_costs: true,
54 show_cardinality: false,
55 compact: false,
56 max_expr_length: 80,
57 }
58 }
59}
60
61impl ExplainFormatter {
62 pub fn new(config: ExplainConfig) -> Self {
63 Self { config }
64 }
65
66 pub fn format_logical_plan(
67 &self,
68 plan: &LogicalPlan,
69 inferencer: &dyn TypeInferencer,
70 ) -> String {
71 let mut output = String::new();
72 output.push_str("=== Logical Plan ===\n");
73 self.format_logical_node(plan, 0, inferencer, None, &mut output, true, "");
74 output
75 }
76
77 pub fn format_logical_plan_with_stats(
78 &self,
79 plan: &LogicalPlan,
80 inferencer: &dyn TypeInferencer,
81 stats: &chryso_metadata::StatsCache,
82 ) -> String {
83 let mut output = String::new();
84 output.push_str("=== Logical Plan ===\n");
85 let estimator = NaiveEstimator;
86 self.format_logical_node(
87 plan,
88 0,
89 inferencer,
90 Some((&estimator, stats)),
91 &mut output,
92 true,
93 "",
94 );
95 output
96 }
97
98 pub fn format_physical_plan(&self, plan: &PhysicalPlan, cost_model: &dyn CostModel) -> String {
99 let mut output = String::new();
100 output.push_str("=== Physical Plan ===\n");
101 self.format_physical_node(plan, 0, cost_model, None, &mut output, true, "");
102 output
103 }
104
105 pub fn format_physical_plan_with_stats(
106 &self,
107 plan: &PhysicalPlan,
108 cost_model: &dyn CostModel,
109 stats: &chryso_metadata::StatsCache,
110 ) -> String {
111 let mut output = String::new();
112 output.push_str("=== Physical Plan ===\n");
113 let estimator = NaiveEstimator;
114 self.format_physical_node(
115 plan,
116 0,
117 cost_model,
118 Some((&estimator, stats)),
119 &mut output,
120 true,
121 "",
122 );
123 output
124 }
125
126 fn estimate_physical_cardinality(
127 &self,
128 plan: &PhysicalPlan,
129 estimator: &dyn CardinalityEstimator,
130 stats: &chryso_metadata::StatsCache,
131 ) -> f64 {
132 match plan {
135 PhysicalPlan::TableScan { table } => estimator.estimate(
136 &LogicalPlan::Scan {
137 table: table.clone(),
138 },
139 stats,
140 ),
141 PhysicalPlan::IndexScan { table, .. } => {
142 estimator.estimate(
143 &LogicalPlan::Scan {
144 table: table.clone(),
145 },
146 stats,
147 ) * 0.1
148 }
149 PhysicalPlan::Dml { .. } => 1.0,
150 PhysicalPlan::Derived { input, .. }
151 | PhysicalPlan::Projection { input, .. }
152 | PhysicalPlan::Sort { input, .. } => {
153 self.estimate_physical_cardinality(input, estimator, stats)
154 }
155 PhysicalPlan::Filter { input, .. } => {
156 self.estimate_physical_cardinality(input, estimator, stats) * 0.25
157 }
158 PhysicalPlan::Join { left, right, .. } => {
159 let left_estimate = self.estimate_physical_cardinality(left, estimator, stats);
160 let right_estimate = self.estimate_physical_cardinality(right, estimator, stats);
161 left_estimate * right_estimate * 0.1
162 }
163 PhysicalPlan::Aggregate { input, .. } => {
164 self.estimate_physical_cardinality(input, estimator, stats) * 0.1
165 }
166 PhysicalPlan::Distinct { input } => {
167 self.estimate_physical_cardinality(input, estimator, stats) * 0.1
168 }
169 PhysicalPlan::Limit { limit, .. } => limit.unwrap_or(100) as f64,
170 PhysicalPlan::TopN { limit, .. } => *limit as f64,
171 }
172 }
173
174 fn format_logical_node(
175 &self,
176 plan: &LogicalPlan,
177 depth: usize,
178 inferencer: &dyn TypeInferencer,
179 estimator_stats: Option<(&dyn CardinalityEstimator, &chryso_metadata::StatsCache)>,
180 output: &mut String,
181 is_last: bool,
182 prefix: &str,
183 ) {
184 let indent = self.get_indent(depth, is_last);
185 let node_prefix = format!("{}{}", prefix, indent);
186
187 let cardinality_str = if self.config.show_cardinality {
189 if let Some((estimator, stats)) = estimator_stats {
190 let estimate = estimator.estimate(plan, stats);
191 format!(", cardinality={:.0}", estimate)
192 } else {
193 String::new()
194 }
195 } else {
196 String::new()
197 };
198
199 match plan {
200 LogicalPlan::Scan { table } => {
201 output.push_str(&format!(
202 "{}LogicalScan: table={}{}\n",
203 node_prefix, table, cardinality_str
204 ));
205 }
206 LogicalPlan::IndexScan {
207 table,
208 index,
209 predicate,
210 } => {
211 let pred_str = self.format_expr(predicate);
212 output.push_str(&format!(
213 "{}LogicalIndexScan: table={}, index={}, predicate={}{}\n",
214 node_prefix, table, index, pred_str, cardinality_str
215 ));
216 }
217 LogicalPlan::Dml { sql } => {
218 let sql_preview = self.safe_truncate(sql, 50);
219 output.push_str(&format!(
220 "{}LogicalDml: sql={}{}\n",
221 node_prefix, sql_preview, cardinality_str
222 ));
223 }
224 LogicalPlan::Derived {
225 alias,
226 column_aliases,
227 input,
228 } => {
229 output.push_str(&format!(
230 "{}LogicalDerived: alias={}, columns=[{}]{}\n",
231 node_prefix,
232 alias,
233 column_aliases.join(", "),
234 cardinality_str
235 ));
236 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
237 self.format_logical_node(
238 input,
239 depth + 1,
240 inferencer,
241 estimator_stats,
242 output,
243 true,
244 &child_prefix,
245 );
246 }
247 LogicalPlan::Filter { predicate, input } => {
248 let pred_str = self.format_expr(predicate);
249 let type_info = if self.config.show_types {
250 format!(", type={:?}", inferencer.infer_expr(predicate))
251 } else {
252 String::new()
253 };
254 output.push_str(&format!(
255 "{}LogicalFilter: predicate={}{}{}\n",
256 node_prefix, pred_str, type_info, cardinality_str
257 ));
258 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
259 self.format_logical_node(
260 input,
261 depth + 1,
262 inferencer,
263 estimator_stats,
264 output,
265 true,
266 &child_prefix,
267 );
268 }
269 LogicalPlan::Projection { exprs, input } => {
270 let expr_str = self.format_expr_list(exprs);
271 let type_info = if self.config.show_types {
272 let types = chryso_metadata::type_inference::expr_types(exprs, inferencer);
273 format!(", types={:?}", types)
274 } else {
275 String::new()
276 };
277 output.push_str(&format!(
278 "{}LogicalProject: expressions={}{}{}\n",
279 node_prefix, expr_str, type_info, cardinality_str
280 ));
281 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
282 self.format_logical_node(
283 input,
284 depth + 1,
285 inferencer,
286 estimator_stats,
287 output,
288 true,
289 &child_prefix,
290 );
291 }
292 LogicalPlan::Join {
293 join_type,
294 left,
295 right,
296 on,
297 } => {
298 let on_str = self.format_expr(on);
299 output.push_str(&format!(
300 "{}LogicalJoin: type={:?}, on={}{}\n",
301 node_prefix, join_type, on_str, cardinality_str
302 ));
303 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
304 self.format_logical_node(
305 left,
306 depth + 1,
307 inferencer,
308 estimator_stats,
309 output,
310 false,
311 &child_prefix,
312 );
313 self.format_logical_node(
314 right,
315 depth + 1,
316 inferencer,
317 estimator_stats,
318 output,
319 true,
320 &child_prefix,
321 );
322 }
323 LogicalPlan::Aggregate {
324 group_exprs,
325 aggr_exprs,
326 input,
327 } => {
328 output.push_str(&format!(
329 "{}LogicalAggregate: group_by=[{}], aggregates=[{}]{}\n",
330 node_prefix,
331 self.format_expr_list(group_exprs),
332 self.format_expr_list(aggr_exprs),
333 cardinality_str
334 ));
335 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
336 self.format_logical_node(
337 input,
338 depth + 1,
339 inferencer,
340 estimator_stats,
341 output,
342 true,
343 &child_prefix,
344 );
345 }
346 LogicalPlan::Distinct { input } => {
347 output.push_str(&format!(
348 "{}LogicalDistinct{}\n",
349 node_prefix, cardinality_str
350 ));
351 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
352 self.format_logical_node(
353 input,
354 depth + 1,
355 inferencer,
356 estimator_stats,
357 output,
358 true,
359 &child_prefix,
360 );
361 }
362 LogicalPlan::TopN {
363 order_by,
364 limit,
365 input,
366 } => {
367 output.push_str(&format!(
368 "{}LogicalTopN: order_by=[{}], limit={}{}\n",
369 node_prefix,
370 self.format_order_by_list(order_by),
371 limit,
372 cardinality_str
373 ));
374 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
375 self.format_logical_node(
376 input,
377 depth + 1,
378 inferencer,
379 estimator_stats,
380 output,
381 true,
382 &child_prefix,
383 );
384 }
385 LogicalPlan::Sort { order_by, input } => {
386 output.push_str(&format!(
387 "{}LogicalSort: order_by=[{}]{}\n",
388 node_prefix,
389 self.format_order_by_list(order_by),
390 cardinality_str
391 ));
392 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
393 self.format_logical_node(
394 input,
395 depth + 1,
396 inferencer,
397 estimator_stats,
398 output,
399 true,
400 &child_prefix,
401 );
402 }
403 LogicalPlan::Limit {
404 limit,
405 offset,
406 input,
407 } => {
408 let offset_str = offset
409 .map(|o| format!(", offset={}", o))
410 .unwrap_or_default();
411 output.push_str(&format!(
412 "{}LogicalLimit: limit={:?}{}{}\n",
413 node_prefix, limit, offset_str, cardinality_str
414 ));
415 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
416 self.format_logical_node(
417 input,
418 depth + 1,
419 inferencer,
420 estimator_stats,
421 output,
422 true,
423 &child_prefix,
424 );
425 }
426 }
427 }
428
429 fn format_physical_node(
430 &self,
431 plan: &PhysicalPlan,
432 depth: usize,
433 cost_model: &dyn CostModel,
434 estimator_stats: Option<(&dyn CardinalityEstimator, &chryso_metadata::StatsCache)>,
435 output: &mut String,
436 is_last: bool,
437 prefix: &str,
438 ) {
439 let indent = self.get_indent(depth, is_last);
440 let node_prefix = format!("{}{}", prefix, indent);
441 let cost = cost_model.cost(plan);
442 let cost_str = if self.config.show_costs {
443 format!(", cost={:.2}", cost.0)
444 } else {
445 String::new()
446 };
447
448 let cardinality_str = if self.config.show_cardinality {
450 if let Some((estimator, stats)) = estimator_stats {
451 let estimate = self.estimate_physical_cardinality(plan, estimator, stats);
454 format!(", cardinality={:.0}", estimate)
455 } else {
456 String::new()
457 }
458 } else {
459 String::new()
460 };
461
462 match plan {
463 PhysicalPlan::TableScan { table } => {
464 output.push_str(&format!(
465 "{}TableScan: table={}{}{}\n",
466 node_prefix, table, cost_str, cardinality_str
467 ));
468 }
469 PhysicalPlan::IndexScan {
470 table,
471 index,
472 predicate,
473 } => {
474 let pred_str = self.format_expr(predicate);
475 output.push_str(&format!(
476 "{}IndexScan: table={}, index={}, predicate={}{}{}\n",
477 node_prefix, table, index, pred_str, cost_str, cardinality_str
478 ));
479 }
480 PhysicalPlan::Dml { sql } => {
481 let sql_preview = self.safe_truncate(sql, 50);
482 output.push_str(&format!(
483 "{}Dml: sql={}{}{}\n",
484 node_prefix, sql_preview, cost_str, cardinality_str
485 ));
486 }
487 PhysicalPlan::Derived {
488 alias,
489 column_aliases,
490 input,
491 } => {
492 output.push_str(&format!(
493 "{}Derived: alias={}, columns=[{}]{}{}\n",
494 node_prefix,
495 alias,
496 column_aliases.join(", "),
497 cost_str,
498 cardinality_str
499 ));
500 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
501 self.format_physical_node(
502 input,
503 depth + 1,
504 cost_model,
505 estimator_stats,
506 output,
507 true,
508 &child_prefix,
509 );
510 }
511 PhysicalPlan::Filter { predicate, input } => {
512 let pred_str = self.format_expr(predicate);
513 output.push_str(&format!(
514 "{}Filter: predicate={}{}{}\n",
515 node_prefix, pred_str, cost_str, cardinality_str
516 ));
517 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
518 self.format_physical_node(
519 input,
520 depth + 1,
521 cost_model,
522 estimator_stats,
523 output,
524 true,
525 &child_prefix,
526 );
527 }
528 PhysicalPlan::Projection { exprs, input } => {
529 let expr_str = self.format_expr_list(exprs);
530 output.push_str(&format!(
531 "{}Project: expressions={}{}{}\n",
532 node_prefix, expr_str, cost_str, cardinality_str
533 ));
534 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
535 self.format_physical_node(
536 input,
537 depth + 1,
538 cost_model,
539 estimator_stats,
540 output,
541 true,
542 &child_prefix,
543 );
544 }
545 PhysicalPlan::Join {
546 join_type,
547 algorithm,
548 left,
549 right,
550 on,
551 } => {
552 let on_str = self.format_expr(on);
553 output.push_str(&format!(
554 "{}Join: type={:?}, algorithm={:?}, on={}{}{}\n",
555 node_prefix, join_type, algorithm, on_str, cost_str, cardinality_str
556 ));
557 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
558 self.format_physical_node(
559 left,
560 depth + 1,
561 cost_model,
562 estimator_stats,
563 output,
564 false,
565 &child_prefix,
566 );
567 self.format_physical_node(
568 right,
569 depth + 1,
570 cost_model,
571 estimator_stats,
572 output,
573 true,
574 &child_prefix,
575 );
576 }
577 PhysicalPlan::Aggregate {
578 group_exprs,
579 aggr_exprs,
580 input,
581 } => {
582 output.push_str(&format!(
583 "{}Aggregate: group_by=[{}], aggregates=[{}]{}{}\n",
584 node_prefix,
585 self.format_expr_list(group_exprs),
586 self.format_expr_list(aggr_exprs),
587 cost_str,
588 cardinality_str
589 ));
590 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
591 self.format_physical_node(
592 input,
593 depth + 1,
594 cost_model,
595 estimator_stats,
596 output,
597 true,
598 &child_prefix,
599 );
600 }
601 PhysicalPlan::Sort { order_by, input } => {
602 output.push_str(&format!(
603 "{}Sort: order_by=[{}]{}{}\n",
604 node_prefix,
605 self.format_order_by_list(order_by),
606 cost_str,
607 cardinality_str
608 ));
609 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
610 self.format_physical_node(
611 input,
612 depth + 1,
613 cost_model,
614 estimator_stats,
615 output,
616 true,
617 &child_prefix,
618 );
619 }
620 PhysicalPlan::TopN {
621 order_by,
622 limit,
623 input,
624 } => {
625 output.push_str(&format!(
626 "{}TopN: order_by=[{}], limit={}{}{}\n",
627 node_prefix,
628 self.format_order_by_list(order_by),
629 limit,
630 cost_str,
631 cardinality_str
632 ));
633 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
634 self.format_physical_node(
635 input,
636 depth + 1,
637 cost_model,
638 estimator_stats,
639 output,
640 true,
641 &child_prefix,
642 );
643 }
644 PhysicalPlan::Limit {
645 limit,
646 offset,
647 input,
648 } => {
649 let offset_str = offset
650 .map(|o| format!(", offset={}", o))
651 .unwrap_or_default();
652 output.push_str(&format!(
653 "{}Limit: limit={:?}{}{}{}\n",
654 node_prefix, limit, offset_str, cost_str, cardinality_str
655 ));
656 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
657 self.format_physical_node(
658 input,
659 depth + 1,
660 cost_model,
661 estimator_stats,
662 output,
663 true,
664 &child_prefix,
665 );
666 }
667 PhysicalPlan::Distinct { input } => {
668 output.push_str(&format!(
669 "{}Distinct{}{}\n",
670 node_prefix, cost_str, cardinality_str
671 ));
672 let child_prefix = format!("{}{}", prefix, self.get_child_prefix(depth, is_last));
673 self.format_physical_node(
674 input,
675 depth + 1,
676 cost_model,
677 estimator_stats,
678 output,
679 true,
680 &child_prefix,
681 );
682 }
683 }
684 }
685
686 fn get_indent(&self, depth: usize, is_last: bool) -> String {
687 if depth == 0 {
688 String::new()
689 } else if is_last {
690 "└── ".to_string()
691 } else {
692 "├── ".to_string()
693 }
694 }
695
696 fn get_child_prefix(&self, depth: usize, is_last: bool) -> String {
697 if depth == 0 {
698 String::new()
699 } else if is_last {
700 " ".to_string()
701 } else {
702 "│ ".to_string()
703 }
704 }
705
706 fn safe_truncate(&self, text: &str, max_len: usize) -> String {
708 if text.len() <= max_len {
709 return text.to_string();
710 }
711
712 let ellipsis = "...";
713 if max_len == 0 {
714 return String::new();
715 }
716 if max_len < ellipsis.len() {
717 return ellipsis.chars().take(max_len).collect();
718 }
719
720 let truncate_len = max_len - ellipsis.len();
721 let mut end = truncate_len.min(text.len());
722 while end > 0 && !text.is_char_boundary(end) {
723 end -= 1;
724 }
725
726 if end == 0 {
727 return ellipsis.to_string();
728 }
729
730 format!("{}{}", &text[..end], ellipsis)
731 }
732
733 fn format_expr(&self, expr: &Expr) -> String {
734 let sql = expr.to_sql();
735 self.safe_truncate(&sql, self.config.max_expr_length)
736 }
737
738 fn format_expr_list(&self, exprs: &[Expr]) -> String {
739 if exprs.is_empty() {
740 "[]".to_string()
741 } else {
742 let expr_strs: Vec<String> = exprs.iter().map(|e| self.format_expr(e)).collect();
743 if exprs.len() <= 3 || self.config.compact {
744 format!("[{}]", expr_strs.join(", "))
745 } else {
746 format!("[{}...] ({} items)", expr_strs[..3].join(", "), exprs.len())
747 }
748 }
749 }
750
751 fn format_order_by_list(&self, order_by: &[chryso_core::ast::OrderByExpr]) -> String {
752 if order_by.is_empty() {
753 "[]".to_string()
754 } else {
755 let items: Vec<String> = order_by
756 .iter()
757 .map(|item| {
758 let dir = if item.asc { "asc" } else { "desc" };
759 let mut rendered = format!("{} {}", self.format_expr(&item.expr), dir);
760 if let Some(nulls_first) = item.nulls_first {
761 if nulls_first {
762 rendered.push_str(" nulls first");
763 } else {
764 rendered.push_str(" nulls last");
765 }
766 }
767 rendered
768 })
769 .collect();
770 if order_by.len() <= 2 || self.config.compact {
771 format!("[{}]", items.join(", "))
772 } else {
773 format!("[{}...] ({} items)", items[..2].join(", "), order_by.len())
774 }
775 }
776 }
777}
778
779pub fn format_simple_logical_plan(plan: &LogicalPlan) -> String {
780 let formatter = ExplainFormatter::new(ExplainConfig {
781 show_types: false,
782 show_costs: false,
783 show_cardinality: false,
784 compact: true,
785 max_expr_length: 50,
786 });
787 formatter.format_logical_plan(plan, &chryso_metadata::type_inference::SimpleTypeInferencer)
788}
789
790pub fn format_logical_plan_with_stats(
791 plan: &LogicalPlan,
792 stats: &chryso_metadata::StatsCache,
793) -> String {
794 let formatter = ExplainFormatter::new(ExplainConfig {
795 show_types: true,
796 show_costs: false,
797 show_cardinality: true,
798 compact: false,
799 max_expr_length: 80,
800 });
801 formatter.format_logical_plan_with_stats(
802 plan,
803 &chryso_metadata::type_inference::SimpleTypeInferencer,
804 stats,
805 )
806}
807
808pub fn format_physical_plan_with_stats(
809 plan: &PhysicalPlan,
810 cost_model: &dyn crate::cost::CostModel,
811 stats: &chryso_metadata::StatsCache,
812) -> String {
813 let formatter = ExplainFormatter::new(ExplainConfig {
814 show_types: false,
815 show_costs: true,
816 show_cardinality: true,
817 compact: false,
818 max_expr_length: 80,
819 });
820 formatter.format_physical_plan_with_stats(plan, cost_model, stats)
821}
822
823pub fn format_simple_physical_plan(plan: &PhysicalPlan) -> String {
824 let formatter = ExplainFormatter::new(ExplainConfig {
825 show_types: false,
826 show_costs: true,
827 show_cardinality: false,
828 compact: true,
829 max_expr_length: 50,
830 });
831 struct SimpleCostModel;
833 impl crate::cost::CostModel for SimpleCostModel {
834 fn cost(&self, _plan: &PhysicalPlan) -> crate::cost::Cost {
835 crate::cost::Cost(1.0)
836 }
837 }
838 formatter.format_physical_plan(plan, &SimpleCostModel)
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844 use crate::{LogicalPlan, PhysicalPlan};
845 use chryso_core::ast::{BinaryOperator, Expr};
846
847 #[test]
848 fn test_format_simple_logical_plan() {
849 let plan = LogicalPlan::Filter {
850 predicate: Expr::BinaryOp {
851 left: Box::new(Expr::Identifier("id".to_string())),
852 op: BinaryOperator::Eq,
853 right: Box::new(Expr::Literal(chryso_core::ast::Literal::Number(1.0))),
854 },
855 input: Box::new(LogicalPlan::Scan {
856 table: "users".to_string(),
857 }),
858 };
859
860 let output = format_simple_logical_plan(&plan);
861 assert!(output.contains("=== Logical Plan ==="));
862 assert!(output.contains("LogicalFilter"));
863 assert!(output.contains("id = 1"));
864 assert!(output.contains("LogicalScan"));
865 assert!(output.contains("users"));
866 }
867
868 #[test]
869 fn test_format_simple_physical_plan() {
870 let plan = PhysicalPlan::Filter {
871 predicate: Expr::BinaryOp {
872 left: Box::new(Expr::Identifier("id".to_string())),
873 op: BinaryOperator::Eq,
874 right: Box::new(Expr::Literal(chryso_core::ast::Literal::Number(1.0))),
875 },
876 input: Box::new(PhysicalPlan::TableScan {
877 table: "users".to_string(),
878 }),
879 };
880
881 let output = format_simple_physical_plan(&plan);
882 assert!(output.contains("=== Physical Plan ==="));
883 assert!(output.contains("Filter"));
884 assert!(output.contains("id = 1"));
885 assert!(output.contains("TableScan"));
886 assert!(output.contains("users"));
887 assert!(output.contains("cost=1.00"));
888 }
889
890 #[test]
891 fn test_tree_structure_formatting() {
892 let plan = LogicalPlan::Limit {
893 limit: Some(10),
894 offset: None,
895 input: Box::new(LogicalPlan::Scan {
896 table: "users".to_string(),
897 }),
898 };
899
900 let config = ExplainConfig::default();
901 let formatter = ExplainFormatter::new(config);
902 let output = formatter.format_logical_plan(
903 &plan,
904 &chryso_metadata::type_inference::SimpleTypeInferencer,
905 );
906
907 assert!(output.contains("└──"));
909 assert!(!output.contains("├──")); }
911
912 #[test]
913 fn test_cardinality_estimation() {
914 let plan = LogicalPlan::Scan {
915 table: "users".to_string(),
916 };
917
918 let mut stats = chryso_metadata::StatsCache::new();
920 stats.insert_table_stats("users", chryso_metadata::TableStats { row_count: 5000.0 });
921
922 let config = ExplainConfig {
924 show_types: false,
925 show_costs: false,
926 show_cardinality: true,
927 compact: false,
928 max_expr_length: 80,
929 };
930 let formatter = ExplainFormatter::new(config);
931 let output = formatter.format_logical_plan_with_stats(
932 &plan,
933 &chryso_metadata::type_inference::SimpleTypeInferencer,
934 &stats,
935 );
936
937 assert!(output.contains("cardinality=5000"));
939 assert!(output.contains("LogicalScan"));
940 assert!(output.contains("users"));
941 }
942
943 #[test]
944 fn test_utf8_truncation_safety() {
945 use chryso_core::ast::Literal;
946
947 let unicode_expr = Expr::Literal(Literal::String(
949 "\u{4e2d}\u{6587}\u{5b57}\u{7b26}\u{4e32}\u{5305}\u{542b}\u{5f88}\u{591a}\u{0048}\u{0065}\u{006c}\u{006c}\u{006f}".to_string()
950 ));
951
952 let config = ExplainConfig {
954 show_types: false,
955 show_costs: false,
956 show_cardinality: false,
957 compact: true,
958 max_expr_length: 20, };
960
961 let formatter = ExplainFormatter::new(config);
962 let formatted = formatter.format_expr(&unicode_expr);
963
964 assert!(std::str::from_utf8(formatted.as_bytes()).is_ok());
966
967 assert!(formatted.ends_with("..."));
969 assert!(formatted.len() <= 20);
970 }
971
972 #[test]
973 fn test_safe_truncate_with_non_ascii() {
974 let test_cases = vec![
976 (
978 "\u{4e2d}\u{6587}\u{5b57}\u{7b26}\u{4e32}\u{5305}\u{542b}\u{5f88}\u{591a}\u{0048}\u{0065}\u{006c}\u{006c}\u{006f}",
979 20,
980 ),
981 (
983 "Hello\u{4e16}\u{754c}\u{ff0c}\u{8fd9}\u{662f}\u{4e00}\u{4e2a}mixed\u{5b57}\u{7b26}\u{4e32}with\u{4e2d}\u{6587}",
984 25,
985 ),
986 (
988 "Hello \u{1f44b} World \u{1f30d} Test \u{1f600} String \u{1f389}",
989 30,
990 ),
991 (
993 "\u{3053}\u{3093}\u{306b}\u{3061}\u{306f}\u{4e16}\u{754c}\u{3001}\u{3053}\u{308c}\u{306f}\u{9577}\u{3044}\u{65e5}\u{672c}\u{8a9e}\u{306e}\u{6587}\u{5b57}\u{5217}\u{3067}\u{3059}",
994 20,
995 ),
996 (
998 "\u{0645}\u{0631}\u{062d}\u{0628}\u{0627} \u{0628}\u{0627}\u{0644}\u{0639}\u{0627}\u{0644}\u{0645}\u{060c} \u{0647}\u{0630}\u{0647} \u{0647}\u{064a} \u{0633}\u{0644}\u{0633}\u{0644}\u{0629} \u{0646}\u{0635}\u{064a}\u{0629} \u{0637}\u{0648}\u{064a}\u{0644}\u{0629}",
999 20,
1000 ),
1001 (
1003 "\u{041f}\u{0440}\u{0438}\u{0432}\u{0435}\u{0442} \u{043c}\u{0438}\u{0440}, \u{044d}\u{0442}\u{043e} \u{0434}\u{043b}\u{0438}\u{043d}\u{043d}\u{0430}\u{044f} \u{0441}\u{0442}\u{0440}\u{043e}\u{043a}\u{0430} \u{0442}\u{0435}\u{043a}\u{0441}\u{0442}\u{0430}",
1004 20,
1005 ),
1006 ("Hello\u{4e16}\u{754c}", 5),
1008 ("Hello\u{4e16}\u{754c}Test", 11),
1010 ];
1011
1012 let config = ExplainConfig::default();
1013 let formatter = ExplainFormatter::new(config);
1014
1015 for (input, max_len) in test_cases {
1016 let truncated = formatter.safe_truncate(input, max_len);
1017
1018 assert!(
1020 std::str::from_utf8(truncated.as_bytes()).is_ok(),
1021 "Truncated string must be valid UTF-8"
1022 );
1023
1024 assert!(
1026 truncated.len() <= max_len,
1027 "Truncated string length {} must not exceed max_len {}",
1028 truncated.len(),
1029 max_len
1030 );
1031
1032 if input.len() > max_len && max_len >= 3 {
1034 assert!(
1035 truncated.ends_with("..."),
1036 "Truncated string should end with '...'"
1037 );
1038
1039 assert!(
1041 input.len() > truncated.len(),
1042 "Original should be longer than truncated"
1043 );
1044 } else if input.len() > max_len {
1045 assert!(
1047 truncated.chars().all(|c| c == '.'),
1048 "For max_len < 3, should return dots pattern"
1049 );
1050 } else {
1051 assert_eq!(
1053 input, truncated,
1054 "String not exceeding max_len should remain unchanged"
1055 );
1056 }
1057 }
1058 }
1059
1060 #[test]
1061 fn test_safe_truncate_edge_cases() {
1062 let config = ExplainConfig::default();
1063 let formatter = ExplainFormatter::new(config);
1064
1065 let test_cases = vec![
1067 (0, ""), (1, "."), (2, ".."), (3, "..."), ];
1072
1073 for (max_len, expected) in test_cases {
1074 let result = formatter.safe_truncate("Hello\u{4e16}\u{754c}Test", max_len);
1075 assert_eq!(
1076 result, expected,
1077 "For max_len={}, expected '{}', got '{}'",
1078 max_len, expected, result
1079 );
1080 assert_eq!(
1081 result.len(),
1082 max_len,
1083 "Result length should exactly match max_len"
1084 );
1085 }
1086
1087 let result = formatter.safe_truncate("Hello\u{4e16}\u{754c}Test", 4);
1089 assert_eq!(result.len(), 4, "Should respect max_len=4");
1090 assert!(result.ends_with("..."), "Should end with ellipsis");
1091
1092 let result = formatter.safe_truncate("Hello\u{4e16}\u{754c}Test", 6);
1094 assert!(result.len() <= 6, "Should respect max_len=6");
1095 assert!(result.ends_with("..."), "Should end with ellipsis");
1096 }
1097
1098 #[test]
1099 fn test_dml_preview_with_non_ascii() {
1100 let long_unicode_sql = "INSERT INTO \u{7528}\u{6237}\u{8868} (\u{59d3}\u{540d}, \u{5e74}\u{9f84}, \u{5730}\u{5740}) VALUES ('\u{5f20}\u{4e09}', 25, '\u{5317}\u{4eac}\u{5e02}\u{671d}\u{9633}\u{533a}'), ('\u{674e}\u{56db}', 30, '\u{4e0a}\u{6d77}\u{5e02}\u{6d66}\u{4e1c}\u{65b0}\u{533a}'), ('\u{738b}\u{4e94}', 35, '\u{5e7f}\u{5dde}\u{5e02}\u{5929}\u{6cb3}\u{533a}')";
1102
1103 let dml_plan = LogicalPlan::Dml {
1104 sql: long_unicode_sql.to_string(),
1105 };
1106
1107 let config = ExplainConfig {
1109 show_types: false,
1110 show_costs: false,
1111 show_cardinality: false,
1112 compact: true,
1113 max_expr_length: 80,
1114 };
1115
1116 let formatter = ExplainFormatter::new(config);
1117 let output = formatter.format_logical_plan(
1118 &dml_plan,
1119 &chryso_metadata::type_inference::SimpleTypeInferencer,
1120 );
1121
1122 assert!(output.contains("LogicalDml"));
1124 assert!(output.contains("INSERT INTO"));
1125
1126 let dml_line = output
1128 .lines()
1129 .find(|line| line.contains("LogicalDml"))
1130 .unwrap();
1131
1132 assert!(dml_line.contains("..."));
1134
1135 assert!(std::str::from_utf8(output.as_bytes()).is_ok());
1137 }
1138
1139 #[test]
1140 fn test_complex_expression_with_emoji_and_unicode() {
1141 use chryso_core::ast::Literal;
1143
1144 let complex_expr = Expr::BinaryOp {
1145 left: Box::new(Expr::FunctionCall {
1146 name: "concat".to_string(),
1147 args: vec![
1148 Expr::Literal(Literal::String("Hello \u{1f44b} ".to_string())),
1149 Expr::Identifier("name".to_string()),
1150 Expr::Literal(Literal::String(
1151 " \u{1f30d} \u{6b22}\u{8fce}\u{ff01}".to_string(),
1152 )),
1153 ],
1154 }),
1155 op: chryso_core::ast::BinaryOperator::Eq,
1156 right: Box::new(Expr::Literal(Literal::String(
1157 "\u{4f60}\u{597d}\u{4e16}\u{754c} \u{1f389}".to_string(),
1158 ))),
1159 };
1160
1161 let config = ExplainConfig {
1163 show_types: false,
1164 show_costs: false,
1165 show_cardinality: false,
1166 compact: true,
1167 max_expr_length: 15, };
1169
1170 let formatter = ExplainFormatter::new(config);
1171 let formatted = formatter.format_expr(&complex_expr);
1172
1173 assert!(std::str::from_utf8(formatted.as_bytes()).is_ok());
1175
1176 assert!(formatted.ends_with("..."));
1178 assert!(formatted.len() <= 15);
1179 }
1180
1181 #[test]
1182 fn test_tree_structure_with_nested_left_subtree() {
1183 let left_subtree = LogicalPlan::Filter {
1190 predicate: Expr::BinaryOp {
1191 left: Box::new(Expr::Identifier("u.age".to_string())),
1192 op: chryso_core::ast::BinaryOperator::Gt,
1193 right: Box::new(Expr::Literal(chryso_core::ast::Literal::Number(18.0))),
1194 },
1195 input: Box::new(LogicalPlan::Scan {
1196 table: "users".to_string(),
1197 }),
1198 };
1199
1200 let right_subtree = LogicalPlan::Scan {
1201 table: "orders".to_string(),
1202 };
1203
1204 let join_plan = LogicalPlan::Join {
1205 join_type: chryso_core::ast::JoinType::Inner,
1206 left: Box::new(left_subtree),
1207 right: Box::new(right_subtree),
1208 on: Expr::BinaryOp {
1209 left: Box::new(Expr::Identifier("u.id".to_string())),
1210 op: chryso_core::ast::BinaryOperator::Eq,
1211 right: Box::new(Expr::Identifier("o.user_id".to_string())),
1212 },
1213 };
1214
1215 let config = ExplainConfig {
1216 show_types: false,
1217 show_costs: false,
1218 show_cardinality: false,
1219 compact: false,
1220 max_expr_length: 80,
1221 };
1222
1223 let formatter = ExplainFormatter::new(config);
1224 let output = formatter.format_logical_plan(
1225 &join_plan,
1226 &chryso_metadata::type_inference::SimpleTypeInferencer,
1227 );
1228
1229 assert!(output.contains("LogicalJoin"));
1231 assert!(output.contains("LogicalFilter"));
1232 assert!(output.contains("LogicalScan: table=users"));
1233 assert!(output.contains("LogicalScan: table=orders"));
1234
1235 let lines: Vec<&str> = output.lines().collect();
1243
1244 let filter_line_idx = lines
1246 .iter()
1247 .position(|line| line.contains("LogicalFilter"))
1248 .unwrap();
1249 let filter_line = lines[filter_line_idx];
1250
1251 let users_scan_line_idx = lines
1253 .iter()
1254 .position(|line| line.contains("users"))
1255 .unwrap();
1256 let users_scan_line = lines[users_scan_line_idx];
1257
1258 assert!(filter_line.starts_with("├── "));
1261
1262 assert!(users_scan_line.starts_with("│ └── "));
1264
1265 assert!(users_scan_line_idx > filter_line_idx);
1267
1268 let orders_scan_line_idx = lines
1270 .iter()
1271 .position(|line| line.contains("orders"))
1272 .unwrap();
1273 let orders_scan_line = lines[orders_scan_line_idx];
1274
1275 assert!(orders_scan_line.starts_with("└── "));
1277 }
1278}