Skip to main content

oxisql_parse/
cost.rs

1//! Query cost model for [`LogicalPlan`] trees.
2//!
3//! [`CostModel`] estimates the relative cost of executing a logical plan,
4//! returning a [`CostEstimate`] that breaks down rows, CPU cost, and I/O cost.
5//!
6//! # Example
7//!
8//! ```rust
9//! use oxisql_parse::{CostModel, TableStats, plan_statement, parse_one};
10//!
11//! let stmt = parse_one("SELECT id FROM users WHERE active = true").unwrap();
12//! let plan = plan_statement(&stmt).unwrap();
13//! let model = CostModel::new()
14//!     .with_table_stats("users", TableStats { row_count: 50_000, avg_row_size_bytes: 80, index_on: vec![] });
15//! let est = model.estimate(&plan);
16//! assert!(est.rows < 50_000.0);
17//! ```
18
19use crate::LogicalPlan;
20use std::collections::HashMap;
21
22// ── CostEstimate ─────────────────────────────────────────────────────────────
23
24/// The estimated cost breakdown for a [`LogicalPlan`] node.
25#[derive(Debug, Clone, PartialEq)]
26pub struct CostEstimate {
27    /// Estimated number of output rows.
28    pub rows: f64,
29    /// Relative CPU cost units.
30    pub cpu_cost: f64,
31    /// Relative I/O cost units (page reads).
32    pub io_cost: f64,
33    /// Weighted total: `io_cost + cpu_weight * cpu_cost`.
34    pub total_cost: f64,
35}
36
37impl CostEstimate {
38    /// A zero-cost estimate (empty plan / leaf with no work).
39    pub fn zero() -> Self {
40        Self {
41            rows: 0.0,
42            cpu_cost: 0.0,
43            io_cost: 0.0,
44            total_cost: 0.0,
45        }
46    }
47
48    /// Combine two estimates by summing all fields component-wise.
49    ///
50    /// Used for set operations and parallel branches.
51    pub fn combine(a: &Self, b: &Self) -> Self {
52        Self {
53            rows: a.rows + b.rows,
54            cpu_cost: a.cpu_cost + b.cpu_cost,
55            io_cost: a.io_cost + b.io_cost,
56            total_cost: a.total_cost + b.total_cost,
57        }
58    }
59}
60
61// ── TableStats ───────────────────────────────────────────────────────────────
62
63/// Per-table statistics used to calibrate cost estimates.
64#[derive(Debug, Clone)]
65pub struct TableStats {
66    /// Approximate number of rows in the table.
67    pub row_count: u64,
68    /// Average row size in bytes (used to estimate page reads).
69    pub avg_row_size_bytes: u64,
70    /// Column names that have an index.
71    pub index_on: Vec<String>,
72}
73
74impl Default for TableStats {
75    fn default() -> Self {
76        Self {
77            row_count: 10_000,
78            avg_row_size_bytes: 100,
79            index_on: vec![],
80        }
81    }
82}
83
84// ── CostModel ────────────────────────────────────────────────────────────────
85
86/// Query cost model that estimates relative execution cost of a [`LogicalPlan`].
87///
88/// Missing tables fall back to [`TableStats::default`].
89pub struct CostModel {
90    /// Per-table statistics keyed by lowercase table name.
91    stats: HashMap<String, TableStats>,
92    /// Weight of CPU cost in `total_cost`. Default `0.01` (I/O dominates).
93    cpu_weight: f64,
94    /// Fraction of rows passing a single filter predicate. Default `0.1`.
95    filter_selectivity: f64,
96}
97
98impl Default for CostModel {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104impl CostModel {
105    /// Create a new [`CostModel`] with default weights and no table statistics.
106    pub fn new() -> Self {
107        Self {
108            stats: HashMap::new(),
109            cpu_weight: 0.01,
110            filter_selectivity: 0.1,
111        }
112    }
113
114    /// Register per-table statistics for a specific table.
115    ///
116    /// Table names are compared case-insensitively.
117    pub fn with_table_stats(mut self, table: &str, stats: TableStats) -> Self {
118        self.stats.insert(table.to_lowercase(), stats);
119        self
120    }
121
122    /// Override the CPU-cost weight used to compute `total_cost`.
123    pub fn with_cpu_weight(mut self, w: f64) -> Self {
124        self.cpu_weight = w;
125        self
126    }
127
128    /// Override the per-predicate filter selectivity (fraction of rows passing).
129    pub fn with_filter_selectivity(mut self, s: f64) -> Self {
130        self.filter_selectivity = s;
131        self
132    }
133
134    /// Look up (or default) statistics for a table.
135    fn table_stats(&self, table: &str) -> TableStats {
136        self.stats
137            .get(&table.to_lowercase())
138            .cloned()
139            .unwrap_or_default()
140    }
141
142    /// Compute the `total_cost` from component costs.
143    fn total(&self, cpu_cost: f64, io_cost: f64) -> f64 {
144        io_cost + self.cpu_weight * cpu_cost
145    }
146
147    /// Estimate the cost of executing `plan`.
148    pub fn estimate(&self, plan: &LogicalPlan) -> CostEstimate {
149        match plan {
150            // ── Scan ──────────────────────────────────────────────────────
151            LogicalPlan::Scan { table, .. } => {
152                let s = self.table_stats(table);
153                let rows = s.row_count as f64;
154                let io_cost = rows * s.avg_row_size_bytes as f64 / 8192.0;
155                let cpu_cost = rows * self.cpu_weight;
156                CostEstimate {
157                    rows,
158                    cpu_cost,
159                    io_cost,
160                    total_cost: self.total(cpu_cost, io_cost),
161                }
162            }
163
164            // ── Filter ────────────────────────────────────────────────────
165            LogicalPlan::Filter { input, .. } => {
166                let inp = self.estimate(input);
167                let rows = inp.rows * self.filter_selectivity;
168                let cpu_cost = inp.cpu_cost + inp.rows * 0.1;
169                let io_cost = inp.io_cost;
170                CostEstimate {
171                    rows,
172                    cpu_cost,
173                    io_cost,
174                    total_cost: self.total(cpu_cost, io_cost),
175                }
176            }
177
178            // ── Project ───────────────────────────────────────────────────
179            LogicalPlan::Project { input, .. } => {
180                let inp = self.estimate(input);
181                let rows = inp.rows;
182                let cpu_cost = inp.cpu_cost + rows * 0.01;
183                let io_cost = inp.io_cost;
184                CostEstimate {
185                    rows,
186                    cpu_cost,
187                    io_cost,
188                    total_cost: self.total(cpu_cost, io_cost),
189                }
190            }
191
192            // ── Join ──────────────────────────────────────────────────────
193            LogicalPlan::Join { left, right, .. } => {
194                let l = self.estimate(left);
195                let r = self.estimate(right);
196                let rows = l.rows * r.rows * 0.1;
197                let cpu_cost = l.cpu_cost + r.cpu_cost + l.rows + r.rows + rows;
198                let io_cost = l.io_cost + r.io_cost;
199                CostEstimate {
200                    rows,
201                    cpu_cost,
202                    io_cost,
203                    total_cost: self.total(cpu_cost, io_cost),
204                }
205            }
206
207            // ── Aggregate ─────────────────────────────────────────────────
208            LogicalPlan::Aggregate { input, .. } => {
209                let inp = self.estimate(input);
210                let rows = inp.rows * 0.1;
211                let cpu_cost = inp.cpu_cost + inp.rows * 0.5;
212                let io_cost = inp.io_cost;
213                CostEstimate {
214                    rows,
215                    cpu_cost,
216                    io_cost,
217                    total_cost: self.total(cpu_cost, io_cost),
218                }
219            }
220
221            // ── Sort ──────────────────────────────────────────────────────
222            LogicalPlan::Sort { input, .. } => {
223                let inp = self.estimate(input);
224                let rows = inp.rows;
225                let sort_cpu = rows * rows.ln().max(1.0) * 0.01;
226                let cpu_cost = inp.cpu_cost + sort_cpu;
227                let io_cost = inp.io_cost;
228                CostEstimate {
229                    rows,
230                    cpu_cost,
231                    io_cost,
232                    total_cost: self.total(cpu_cost, io_cost),
233                }
234            }
235
236            // ── Limit ─────────────────────────────────────────────────────
237            LogicalPlan::Limit { input, count, .. } => {
238                let inp = self.estimate(input);
239                let rows = match count {
240                    Some(c) => inp.rows.min(*c as f64),
241                    None => inp.rows,
242                };
243                let cpu_cost = inp.cpu_cost;
244                let io_cost = inp.io_cost;
245                CostEstimate {
246                    rows,
247                    cpu_cost,
248                    io_cost,
249                    total_cost: self.total(cpu_cost, io_cost),
250                }
251            }
252
253            // ── Values / Empty ────────────────────────────────────────────
254            LogicalPlan::Values { .. } | LogicalPlan::Empty => CostEstimate::zero(),
255
256            // ── SetOp (UNION / INTERSECT / EXCEPT) ───────────────────────
257            LogicalPlan::SetOp { left, right, .. } => {
258                let l = self.estimate(left);
259                let r = self.estimate(right);
260                CostEstimate::combine(&l, &r)
261            }
262
263            // ── CTE definition ────────────────────────────────────────────
264            LogicalPlan::Cte { query, .. } => {
265                let inner = self.estimate(query);
266                let cpu_cost = inner.cpu_cost + inner.rows * 0.05;
267                let io_cost = inner.io_cost;
268                let rows = inner.rows;
269                CostEstimate {
270                    rows,
271                    cpu_cost,
272                    io_cost,
273                    total_cost: self.total(cpu_cost, io_cost),
274                }
275            }
276
277            // ── CteRef — no child plan; use default table stats ──────────
278            LogicalPlan::CteRef { name } => {
279                let s = self.table_stats(name);
280                let rows = s.row_count as f64;
281                let io_cost = rows * s.avg_row_size_bytes as f64 / 8192.0;
282                let cpu_cost = rows * self.cpu_weight + rows * 0.05;
283                CostEstimate {
284                    rows,
285                    cpu_cost,
286                    io_cost,
287                    total_cost: self.total(cpu_cost, io_cost),
288                }
289            }
290
291            // ── Window ────────────────────────────────────────────────────
292            LogicalPlan::Window { input, .. } => {
293                let inp = self.estimate(input);
294                let rows = inp.rows;
295                let cpu_cost = inp.cpu_cost + rows * 0.1;
296                let io_cost = inp.io_cost;
297                CostEstimate {
298                    rows,
299                    cpu_cost,
300                    io_cost,
301                    total_cost: self.total(cpu_cost, io_cost),
302                }
303            }
304
305            // ── Subquery ──────────────────────────────────────────────────
306            LogicalPlan::Subquery { query, .. } => {
307                let inner = self.estimate(query);
308                let rows = inner.rows;
309                let cpu_cost = inner.cpu_cost + rows * 0.2;
310                let io_cost = inner.io_cost;
311                CostEstimate {
312                    rows,
313                    cpu_cost,
314                    io_cost,
315                    total_cost: self.total(cpu_cost, io_cost),
316                }
317            }
318
319            // ── Exists ────────────────────────────────────────────────────
320            LogicalPlan::Exists { subquery, .. } => {
321                let inner = self.estimate(subquery);
322                // EXISTS short-circuits; treat output as at most 1 row probe.
323                let rows = 1.0_f64.min(inner.rows);
324                let cpu_cost = inner.cpu_cost + 0.1;
325                let io_cost = inner.io_cost;
326                CostEstimate {
327                    rows,
328                    cpu_cost,
329                    io_cost,
330                    total_cost: self.total(cpu_cost, io_cost),
331                }
332            }
333
334            // ── InSubquery ────────────────────────────────────────────────
335            LogicalPlan::InSubquery { subquery, .. } => {
336                let inner = self.estimate(subquery);
337                let rows = inner.rows * self.filter_selectivity;
338                let cpu_cost = inner.cpu_cost + inner.rows * 0.15;
339                let io_cost = inner.io_cost;
340                CostEstimate {
341                    rows,
342                    cpu_cost,
343                    io_cost,
344                    total_cost: self.total(cpu_cost, io_cost),
345                }
346            }
347        }
348    }
349}
350
351// ── Tests ─────────────────────────────────────────────────────────────────────
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::{parse_one, plan_statement};
357
358    /// Scan with no registered stats uses `TableStats::default` (10_000 rows).
359    #[test]
360    fn test_cost_scan_default() {
361        let stmt = parse_one("SELECT * FROM users").expect("parse");
362        let plan = plan_statement(&stmt).expect("plan");
363        let model = CostModel::new();
364        let est = model.estimate(&plan);
365        // Project preserves rows, so the top-level estimate rows should match scan rows.
366        assert!(
367            (est.rows - 10_000.0).abs() < 1e-9,
368            "expected 10_000 rows from default stats, got {}",
369            est.rows
370        );
371    }
372
373    /// Scan with custom stats (50_000 rows) uses registered statistics.
374    #[test]
375    fn test_cost_scan_custom() {
376        let stmt = parse_one("SELECT * FROM orders").expect("parse");
377        let plan = plan_statement(&stmt).expect("plan");
378        let model = CostModel::new().with_table_stats(
379            "orders",
380            TableStats {
381                row_count: 50_000,
382                avg_row_size_bytes: 128,
383                index_on: vec![],
384            },
385        );
386        let est = model.estimate(&plan);
387        assert!(
388            (est.rows - 50_000.0).abs() < 1e-9,
389            "expected 50_000 rows, got {}",
390            est.rows
391        );
392    }
393
394    /// Filter over Scan yields fewer rows than the raw scan.
395    #[test]
396    fn test_cost_filter_reduces_rows() {
397        let stmt = parse_one("SELECT * FROM users WHERE active = true").expect("parse");
398        let plan = plan_statement(&stmt).expect("plan");
399        let model = CostModel::new();
400        let est = model.estimate(&plan);
401        // Default scan = 10_000 rows; filter with 0.1 selectivity => 1_000 rows (then project).
402        assert!(
403            est.rows < 10_000.0,
404            "filter should reduce rows below scan rows, got {}",
405            est.rows
406        );
407    }
408
409    /// Join of two default-stats scans: rows = 10_000 * 10_000 * 0.1 = 10_000_000.
410    #[test]
411    fn test_cost_join_two_scans() {
412        let stmt = parse_one("SELECT a.id, b.name FROM a JOIN b ON a.id = b.a_id").expect("parse");
413        let plan = plan_statement(&stmt).expect("plan");
414        let model = CostModel::new();
415        let est = model.estimate(&plan);
416        let expected = 10_000.0_f64 * 10_000.0 * 0.1;
417        assert!(
418            (est.rows - expected).abs() < 1.0,
419            "join rows expected ~{expected}, got {}",
420            est.rows
421        );
422    }
423
424    /// Limit caps the row count.
425    #[test]
426    fn test_cost_limit_caps_rows() {
427        let stmt = parse_one("SELECT * FROM users LIMIT 100").expect("parse");
428        let plan = plan_statement(&stmt).expect("plan");
429        let model = CostModel::new();
430        let est = model.estimate(&plan);
431        assert!(
432            (est.rows - 100.0).abs() < 1e-9,
433            "limit should cap rows to 100, got {}",
434            est.rows
435        );
436    }
437
438    /// Aggregate reduces row count relative to scan.
439    #[test]
440    fn test_cost_aggregate_reduces_rows() {
441        let stmt = parse_one("SELECT dept, COUNT(*) FROM employees GROUP BY dept").expect("parse");
442        let plan = plan_statement(&stmt).expect("plan");
443        let model = CostModel::new();
444        let est = model.estimate(&plan);
445        assert!(
446            est.rows < 10_000.0,
447            "aggregate should reduce rows, got {}",
448            est.rows
449        );
450    }
451
452    /// Sort preserves row count and increases CPU cost.
453    #[test]
454    fn test_cost_sort_preserves_rows() {
455        let stmt = parse_one("SELECT * FROM products ORDER BY price DESC").expect("parse");
456        let plan = plan_statement(&stmt).expect("plan");
457        let model = CostModel::new();
458        let full_est = model.estimate(&plan);
459
460        // Build a plan without ORDER BY to get a baseline scan/project estimate.
461        let stmt_no_sort = parse_one("SELECT * FROM products").expect("parse_no_sort");
462        let plan_no_sort = plan_statement(&stmt_no_sort).expect("plan_no_sort");
463        let base_est = model.estimate(&plan_no_sort);
464
465        // Rows unchanged.
466        assert!(
467            (full_est.rows - base_est.rows).abs() < 1e-9,
468            "sort should preserve row count: {} vs {}",
469            full_est.rows,
470            base_est.rows
471        );
472        // Sort adds CPU cost.
473        assert!(
474            full_est.cpu_cost > base_est.cpu_cost,
475            "sort should increase cpu_cost: {} vs {}",
476            full_est.cpu_cost,
477            base_est.cpu_cost
478        );
479    }
480
481    /// `CostEstimate::combine` sums all fields correctly.
482    #[test]
483    fn test_cost_combines_correctly() {
484        let a = CostEstimate {
485            rows: 100.0,
486            cpu_cost: 5.0,
487            io_cost: 10.0,
488            total_cost: 10.05,
489        };
490        let b = CostEstimate {
491            rows: 200.0,
492            cpu_cost: 3.0,
493            io_cost: 7.0,
494            total_cost: 7.03,
495        };
496        let c = CostEstimate::combine(&a, &b);
497        assert!((c.rows - 300.0).abs() < 1e-9, "rows: {}", c.rows);
498        assert!((c.cpu_cost - 8.0).abs() < 1e-9, "cpu_cost: {}", c.cpu_cost);
499        assert!((c.io_cost - 17.0).abs() < 1e-9, "io_cost: {}", c.io_cost);
500        assert!(
501            (c.total_cost - 17.08).abs() < 1e-9,
502            "total_cost: {}",
503            c.total_cost
504        );
505    }
506}