Skip to main content

chryso_planner/
explain.rs

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        // Convert physical plan to logical plan for estimation (simplified mapping).
133        // This is a basic implementation - in a real system, you'd have more sophisticated mapping.
134        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        // Get cardinality estimate if available
188        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        // Get cardinality estimate if available
449        let cardinality_str = if self.config.show_cardinality {
450            if let Some((estimator, stats)) = estimator_stats {
451                // Convert PhysicalPlan to LogicalPlan for estimation (simplified approach)
452                // For now, we'll use a basic estimate based on the physical plan type
453                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    /// Safely truncate a string to the specified length while preserving UTF-8 boundaries.
707    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    // Use a simple cost model for basic formatting
832    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        // Check for tree structure characters
908        assert!(output.contains("└──"));
909        assert!(!output.contains("├──")); // Should be only one child
910    }
911
912    #[test]
913    fn test_cardinality_estimation() {
914        let plan = LogicalPlan::Scan {
915            table: "users".to_string(),
916        };
917
918        // Create mock stats
919        let mut stats = chryso_metadata::StatsCache::new();
920        stats.insert_table_stats("users", chryso_metadata::TableStats { row_count: 5000.0 });
921
922        // Test with cardinality enabled
923        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        // Should contain cardinality estimate
938        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        // Create a long expression with Unicode characters using Unicode escape sequences
948        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        // Test with short max length to force truncation
953        let config = ExplainConfig {
954            show_types: false,
955            show_costs: false,
956            show_cardinality: false,
957            compact: true,
958            max_expr_length: 20, // Very short to force truncation
959        };
960
961        let formatter = ExplainFormatter::new(config);
962        let formatted = formatter.format_expr(&unicode_expr);
963
964        // Verify it's valid UTF-8
965        assert!(std::str::from_utf8(formatted.as_bytes()).is_ok());
966
967        // Should be truncated but still valid
968        assert!(formatted.ends_with("..."));
969        assert!(formatted.len() <= 20);
970    }
971
972    #[test]
973    fn test_safe_truncate_with_non_ascii() {
974        // Test various non-ASCII scenarios using Unicode escape sequences
975        let test_cases = vec![
976            // Chinese characters
977            (
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            // Mixed ASCII and Unicode
982            (
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            // Emoji (4 bytes each)
987            (
988                "Hello \u{1f44b} World \u{1f30d} Test \u{1f600} String \u{1f389}",
989                30,
990            ),
991            // Japanese
992            (
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            // Arabic
997            (
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            // Russian
1002            (
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            // Edge case: very short truncation
1007            ("Hello\u{4e16}\u{754c}", 5),
1008            // Edge case: exactly at boundary
1009            ("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            // Verify UTF-8 validity
1019            assert!(
1020                std::str::from_utf8(truncated.as_bytes()).is_ok(),
1021                "Truncated string must be valid UTF-8"
1022            );
1023
1024            // Verify length constraint
1025            assert!(
1026                truncated.len() <= max_len,
1027                "Truncated string length {} must not exceed max_len {}",
1028                truncated.len(),
1029                max_len
1030            );
1031
1032            // Verify it ends with ... if truncated
1033            if input.len() > max_len && max_len >= 3 {
1034                assert!(
1035                    truncated.ends_with("..."),
1036                    "Truncated string should end with '...'"
1037                );
1038
1039                // Verify original is longer
1040                assert!(
1041                    input.len() > truncated.len(),
1042                    "Original should be longer than truncated"
1043                );
1044            } else if input.len() > max_len {
1045                // For max_len < 3, should use dots pattern
1046                assert!(
1047                    truncated.chars().all(|c| c == '.'),
1048                    "For max_len < 3, should return dots pattern"
1049                );
1050            } else {
1051                // If not truncated, should be identical
1052                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        // Test edge cases for max_len < 3
1066        let test_cases = vec![
1067            (0, ""),    // max_len = 0: empty string
1068            (1, "."),   // max_len = 1: single dot
1069            (2, ".."),  // max_len = 2: two dots
1070            (3, "..."), // max_len = 3: three dots
1071        ];
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        // Test that max_len >= 3 uses ellipsis truncation
1088        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        // Test boundary case: exactly 3 characters available for content
1093        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        // Test DML preview with non-ASCII SQL using Unicode escape sequences
1101        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        // Test with short preview length
1108        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        // Verify output contains truncated DML
1123        assert!(output.contains("LogicalDml"));
1124        assert!(output.contains("INSERT INTO"));
1125
1126        // Verify safe truncation (should end with ...)
1127        let dml_line = output
1128            .lines()
1129            .find(|line| line.contains("LogicalDml"))
1130            .unwrap();
1131
1132        // The SQL part should be truncated and end with ...
1133        assert!(dml_line.contains("..."));
1134
1135        // Verify UTF-8 validity of the entire output
1136        assert!(std::str::from_utf8(output.as_bytes()).is_ok());
1137    }
1138
1139    #[test]
1140    fn test_complex_expression_with_emoji_and_unicode() {
1141        // Test complex expression formatting with emoji and various Unicode using escape sequences
1142        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        // Test with very short truncation to force Unicode handling
1162        let config = ExplainConfig {
1163            show_types: false,
1164            show_costs: false,
1165            show_cardinality: false,
1166            compact: true,
1167            max_expr_length: 15, // Very short to test Unicode boundaries
1168        };
1169
1170        let formatter = ExplainFormatter::new(config);
1171        let formatted = formatter.format_expr(&complex_expr);
1172
1173        // Verify UTF-8 validity
1174        assert!(std::str::from_utf8(formatted.as_bytes()).is_ok());
1175
1176        // Should be truncated
1177        assert!(formatted.ends_with("..."));
1178        assert!(formatted.len() <= 15);
1179    }
1180
1181    #[test]
1182    fn test_tree_structure_with_nested_left_subtree() {
1183        // Build a Join structure where left subtree has multiple levels, test if │ appears correctly
1184        // Join
1185        // ├── Filter (left subtree, not last)
1186        // │   └── Scan (users)
1187        // └── Scan (orders, right subtree, last)
1188
1189        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        // Verify tree structure correctness
1230        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        // Key test: verify │ connection line exists on left subtree path
1236        // Expected structure:
1237        // LogicalJoin
1238        // ├── LogicalFilter
1239        // │   └── LogicalScan: table=users
1240        // └── LogicalScan: table=orders
1241
1242        let lines: Vec<&str> = output.lines().collect();
1243
1244        // Find Filter line (should be direct child of Join)
1245        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        // Find users scan line (child of Filter)
1252        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        // Verify connection lines
1259        // Filter line should start with ├── (it's the first child of Join, not last)
1260        assert!(filter_line.starts_with("├── "));
1261
1262        // users scan line should start with │   └── (it's the only child of Filter, with proper indentation)
1263        assert!(users_scan_line.starts_with("│   └── "));
1264
1265        // Verify depth consistency
1266        assert!(users_scan_line_idx > filter_line_idx);
1267
1268        // Verify orders scan line (right child of Join, last one)
1269        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        // orders line should start with └── (it's the last child of Join)
1276        assert!(orders_scan_line.starts_with("└── "));
1277    }
1278}