rudb_opt/pass.rs
1//! What a rewrite is, and what it is given besides the plan.
2//!
3//! `spec/09-optimizer.md` section 9.1 asks for a fixed sequence of passes, each one toggleable by
4//! name. The sequence is [`crate::PASSES`] and the toggle is [`Context`]. Both of those need more
5//! than one pass to mean anything, which is why neither of them existed while column pruning was
6//! the only rewrite: a trait with one implementor is a description of that implementor and a
7//! pipeline of one is a function call.
8//!
9//! The names are DuckDB's, because `SET disabled_optimizers = 'filter_pushdown'` appears in corpus
10//! files that were written against DuckDB and a corpus file that turns a pass off has to turn off
11//! the pass it meant. `SELECT name FROM duckdb_optimizers()` on the pinned binary lists forty four
12//! of them and rudb has two, so most of that list is a name rudb does not answer to yet rather
13//! than a name it disagrees about.
14
15use std::collections::VecDeque;
16
17use rudb_common::{Error, Result};
18use rudb_plan::{NodeRef, Plan};
19
20/// One rewrite from a plan to a plan.
21///
22/// Every pass preserves the plan invariant and the width of the root, which [`crate::optimize`]
23/// checks once at the end rather than each pass checking itself.
24///
25/// A pass is a unit struct rather than a closure because it has a name, and the name is what the
26/// toggle, the per pass corpus sweep and the bisector all address it by. `spec/engine/11-optimizer.md`
27/// section 11.9 makes the bisector a binary search over the set of names, which needs the set to be
28/// a value rather than a position in a list.
29pub trait Pass {
30 /// What this pass is called, in DuckDB's spelling.
31 fn name(&self) -> &'static str;
32
33 /// Rewrites the plan in place.
34 ///
35 /// # Errors
36 ///
37 /// Anything the pass cannot carry on past. A pass that merely cannot improve a plan leaves it
38 /// alone and reports success, because "there was nothing to do" and "this query is broken" are
39 /// not the same answer.
40 fn run(&self, plan: &mut Plan, context: &Context) -> Result<()>;
41}
42
43/// What the passes are given besides the plan.
44///
45/// Only the settings today. The catalog, the statistics and the planning deadline are the other
46/// three things `spec/engine/11-optimizer.md` puts in here, and each arrives with the first pass
47/// that reads it: the statistics with cardinality estimation, the deadline with join ordering,
48/// which is the only search in the plan and so the only thing that can spend real time. A field
49/// that no pass reads is a field whose meaning nobody has had to decide yet, and deciding it early
50/// is how it ends up wrong.
51#[derive(Debug, Clone, Default)]
52pub struct Context {
53 disabled: Vec<&'static str>,
54}
55
56impl Context {
57 /// Every pass enabled, which is what a query gets unless it says otherwise.
58 #[must_use]
59 pub fn new() -> Self {
60 Self::default()
61 }
62
63 /// Turns off the passes named in DuckDB's comma separated spelling.
64 ///
65 /// Empty entries are skipped, so a trailing comma is not an error, which is what the binary
66 /// does with one.
67 ///
68 /// # Errors
69 ///
70 /// For a name that is not a pass, with the sentence the binary prints for one. rudb lists
71 /// every name it has rather than the closest one by edit distance, which is the same
72 /// divergence it already has on every other complaint about a name it does not know.
73 pub fn without(names: &str) -> Result<Self> {
74 let mut context = Self::new();
75 for name in names.split(',') {
76 let name = name.trim();
77 if !name.is_empty() {
78 context.disable(name)?;
79 }
80 }
81 Ok(context)
82 }
83
84 /// Turns off one pass by name.
85 ///
86 /// # Errors
87 ///
88 /// For a name that is not a pass.
89 pub fn disable(&mut self, name: &str) -> Result<()> {
90 let Some(found) = crate::PASSES.iter().find(|pass| pass.name() == name) else {
91 let known: Vec<String> =
92 crate::PASSES.iter().map(|pass| format!("\"{}\"", pass.name())).collect();
93 return Err(Error::parser(format!(
94 "Optimizer type \"{name}\" not recognized\n\nCandidate optimizers: {}",
95 known.join(", ")
96 )));
97 };
98 if !self.is_disabled(name) {
99 self.disabled.push(found.name());
100 }
101 Ok(())
102 }
103
104 /// Whether the pass by that name has been turned off.
105 #[must_use]
106 pub fn is_disabled(&self, name: &str) -> bool {
107 self.disabled.contains(&name)
108 }
109}
110
111/// Every node the root reaches, parents before children.
112///
113/// Not every node in the arena. A rewrite that replaced a node leaves the old one behind, and a
114/// pass that walked the arena would go on rewriting nodes that nothing runs, which costs time on
115/// every later pass and can report an error about a plan nobody asked about.
116pub(crate) fn top_down(plan: &Plan) -> Vec<NodeRef> {
117 let mut found = Vec::new();
118 let mut pending = VecDeque::from([plan.root()]);
119 while let Some(node) = pending.pop_front() {
120 if found.contains(&node) {
121 continue;
122 }
123 found.push(node);
124 pending.extend(plan.node(node).children().into_iter().flatten());
125 }
126 found
127}
128
129#[cfg(test)]
130mod tests {
131 use super::Context;
132
133 #[test]
134 fn every_pass_is_on_unless_it_is_named() {
135 let context = Context::new();
136 assert!(!context.is_disabled("expression_rewriter"));
137 let context = Context::without("expression_rewriter").expect("a name that is a pass");
138 assert!(context.is_disabled("expression_rewriter"));
139 assert!(!context.is_disabled("unused_columns"));
140 }
141
142 #[test]
143 fn a_list_turns_off_each_of_them_and_a_trailing_comma_is_not_an_error() {
144 let context = Context::without("expression_rewriter, unused_columns,")
145 .expect("two names and a comma");
146 assert!(context.is_disabled("expression_rewriter"));
147 assert!(context.is_disabled("unused_columns"));
148 }
149
150 #[test]
151 fn naming_the_same_pass_twice_is_naming_it_once() {
152 let context = Context::without("unused_columns,unused_columns").expect("the same name");
153 assert!(context.is_disabled("unused_columns"));
154 }
155
156 #[test]
157 fn a_name_that_is_not_a_pass_is_the_error_duckdb_prints() {
158 let error = Context::without("bogus").expect_err("not a pass");
159 assert_eq!(error.code().duckdb_name(), "Parser Error");
160 assert!(
161 error.message().starts_with("Optimizer type \"bogus\" not recognized"),
162 "{}",
163 error.message()
164 );
165 assert!(error.message().contains("Candidate optimizers:"), "{}", error.message());
166 }
167}