Skip to main content

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}