Skip to main content

rudb_kernels/
prepare.rs

1//! The half of a scalar call that depends on the query rather than on the chunk.
2//!
3//! A kernel is handed a vector and a function name and works out everything else from scratch. Most
4//! of what it works out is the same on every chunk, because it comes from a literal the user wrote:
5//! the pattern of a `LIKE`, the pattern and the options of a regular expression, the part a
6//! `date_part` reads. A pipeline over `hits` runs a hundred thousand chunks, so anything decided
7//! per chunk is decided a hundred thousand times for one query, and compiling a regular expression
8//! is not a cheap thing to do a hundred thousand times.
9//!
10//! A [`Recipe`] is that work done once. The caller that builds a pipeline knows which arguments are
11//! literals, because it has the plan in front of it, so it hands them here and gets back a call it
12//! can run per chunk with the deciding already finished.
13//!
14//! # Why this is not a cache
15//!
16//! A cache keyed on the pattern would also compile once, and it would cost a hash and a lock on
17//! every chunk to find out that nothing changed, and it would be wrong the first time somebody runs
18//! two pipelines that use two patterns on two threads with a cache of one entry. The information is
19//! already sitting in the plan. Reading it there is both cheaper and simpler than rediscovering it.
20//!
21//! # Adding one
22//!
23//! The `Hoisted` enum is the list of what has been lifted so far, and it is deliberately short. A
24//! function earns a variant when the work it repeats per chunk is worth more than the branch that
25//! asks whether it was hoisted, which in practice means the function compiles something. The rule a
26//! new variant has to keep is that hoisting changes nothing a query can see: a literal that does not
27//! compile stays unhoisted rather than failing here, so the error still comes out of the chunk that
28//! reaches it and reads exactly as it did before.
29
30use rudb_common::{LogicalType, Value};
31use rudb_vector::Vector;
32
33use crate::peel::{Lookup, Peel};
34use crate::regexp;
35use crate::scalar;
36use crate::shape::single;
37
38/// A scalar call with whatever does not change from chunk to chunk already worked out.
39///
40/// Build one with [`Recipe::new`] when the pipeline is built, then run it per chunk with
41/// [`call_prepared`](crate::scalar::call_prepared).
42#[derive(Debug)]
43pub struct Recipe {
44    /// The resolved function name, so a caller carries one thing rather than two.
45    name: String,
46    hoisted: Hoisted,
47}
48
49/// What a recipe managed to lift out of the per chunk path.
50#[derive(Debug)]
51pub(crate) enum Hoisted {
52    /// Nothing, either because this function has no prepare step or because the argument that would
53    /// drive it is not a literal. The kernel does what it always did, which for the functions below
54    /// means deciding per chunk and for everything else means there was never anything to decide.
55    Nothing,
56    /// A compiled `LIKE` pattern, already folded to lower case where the spelling folds case.
57    Like(scalar::Like),
58    /// A compiled regular expression, with the replacement taken apart and the options read.
59    ///
60    /// Boxed because it is several times the size of the other variants and one function node in
61    /// four hundred is a regular expression.
62    Regexp(Box<regexp::Call>),
63}
64
65impl Recipe {
66    /// What this call can work out from the arguments that are literals.
67    ///
68    /// `literals` holds one entry per argument, which is the value where the argument is a literal
69    /// and `None` where it is anything else. An argument that is a literal in the plan arrives as a
70    /// constant vector holding that value on every chunk, so what is read here is what the kernel
71    /// would have read per chunk.
72    #[must_use]
73    pub fn new(name: &str, literals: &[Option<Value>]) -> Self {
74        let hoisted = scalar::hoist(name, literals).unwrap_or(Hoisted::Nothing);
75        Self { name: name.to_owned(), hoisted }
76    }
77
78    /// A call with nothing hoisted, for a caller with no plan to read literals out of.
79    #[must_use]
80    pub fn plain(name: &str) -> Self {
81        Self { name: name.to_owned(), hoisted: Hoisted::Nothing }
82    }
83
84    /// The function this calls.
85    #[must_use]
86    pub fn name(&self) -> &str {
87        &self.name
88    }
89
90    /// Whether anything was lifted out of the per chunk path.
91    ///
92    /// The answer a query gives is the same either way, which is the whole point, so this is what a
93    /// test has to look at to say the lifting happened at all.
94    #[must_use]
95    pub fn hoists(&self) -> bool {
96        !matches!(self.hoisted, Hoisted::Nothing)
97    }
98
99    /// What was lifted, for the kernels that look.
100    pub(crate) fn hoisted(&self) -> &Hoisted {
101        &self.hoisted
102    }
103}
104
105/// A literal with the one row column a comparison reads it through already built.
106///
107/// The comparison loops read both sides through a slice, so the constant side is turned into a one
108/// row column and read at position zero. That column is what carries a string's four byte prefix,
109/// which is the thing the string comparison resolves almost every row from, and building it costs a
110/// couple of allocations. Doing that once for the query rather than once per chunk is what this is.
111///
112/// It matters least where a chunk is full and most where it is not. A second conjunct handed the
113/// eleven rows the first one kept pays the same setup as one handed two thousand, so on a selective
114/// filter the setup was a real part of the call rather than a rounding error on it.
115///
116/// It is also where the memos live for a comparison against a dictionary, which is the other thing
117/// that only works if it is built once for the query. The `peel` module has what those are and why
118/// they belong to one comparison node rather than to the kernel.
119#[derive(Debug)]
120pub struct Held {
121    value: Value,
122    single: Vector,
123    peel: Peel,
124    lookup: Lookup,
125}
126
127impl Held {
128    /// The one row column for `value` read as `ty`, or `None` for a type with no column layout.
129    ///
130    /// A nested type answers `None` and the comparison does what it always did, which is decide per
131    /// chunk and fall through to the row at a time path if it has to.
132    #[must_use]
133    pub fn of(ty: &LogicalType, value: &Value) -> Option<Self> {
134        Some(Self {
135            value: value.clone(),
136            single: single(ty, value)?,
137            peel: Peel::default(),
138            lookup: Lookup::default(),
139        })
140    }
141
142    /// Whether this was built for the side a kernel is about to read.
143    ///
144    /// The type and the value are both checked, which costs one comparison of two literals per
145    /// chunk against the allocations it saves. The caller that builds one of these takes it from the
146    /// step it is going to hand it back with, so the answer is yes, and the check is here so that a
147    /// caller which gets that wrong is slow rather than wrong.
148    pub(crate) fn matches(&self, ty: &LogicalType, value: &Value) -> bool {
149        self.single.logical_type() == ty && self.value == *value
150    }
151
152    /// The one row column.
153    pub(crate) fn single(&self) -> &Vector {
154        &self.single
155    }
156
157    /// The literal's bytes, when it is text, so a kernel can check it is the one it is comparing
158    /// against without building a value per chunk to check with.
159    pub(crate) fn text(&self) -> Option<&[u8]> {
160        match &self.value {
161            Value::Varchar(text) => Some(text.as_bytes()),
162            _ => None,
163        }
164    }
165
166    /// The per value memo for this comparison node.
167    pub(crate) fn peel(&self) -> &Peel {
168        &self.peel
169    }
170
171    /// Where this node's literal sits in the dictionary it is compared against, when the
172    /// dictionary knows its own order.
173    pub(crate) fn lookup(&self) -> &Lookup {
174        &self.lookup
175    }
176}
177
178impl Hoisted {
179    /// The compiled `LIKE`, or `None` when this call has none and the kernel should compile its own.
180    pub(crate) fn like(&self) -> Option<&scalar::Like> {
181        match self {
182            Self::Like(like) => Some(like),
183            _ => None,
184        }
185    }
186
187    /// The compiled regular expression, or `None` for the same reason.
188    pub(crate) fn regexp(&self) -> Option<&regexp::Call> {
189        match self {
190            Self::Regexp(call) => Some(call),
191            _ => None,
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use rudb_common::Value;
199
200    use super::Recipe;
201
202    fn text(spelling: &str) -> Option<Value> {
203        Some(Value::Varchar(spelling.to_owned()))
204    }
205
206    #[test]
207    fn a_like_against_a_literal_pattern_is_compiled_here() {
208        let recipe = Recipe::new("~~", &[None, text("%google%")]);
209        assert!(recipe.hoisted().like().is_some());
210        assert_eq!(recipe.name(), "~~");
211    }
212
213    #[test]
214    fn a_pattern_that_is_not_a_literal_is_left_to_the_chunk() {
215        // Legal SQL and vanishingly rare, and the point is that it still runs. The per chunk path
216        // reads the pattern off the vector, and where the vector is not constant either it falls
217        // all the way through to the row at a time loop and counts itself there.
218        assert!(Recipe::new("~~", &[None, None]).hoisted().like().is_none());
219    }
220
221    #[test]
222    fn a_regular_expression_against_a_literal_pattern_is_compiled_here() {
223        let recipe = Recipe::new("regexp_matches", &[None, text("^a.*z$")]);
224        assert!(recipe.hoisted().regexp().is_some());
225    }
226
227    #[test]
228    fn a_pattern_that_does_not_compile_is_left_to_the_chunk() {
229        // The one rule a prepare step has to keep. Compiling early must not move an error earlier,
230        // because a query that raises while a pipeline is being built raises before the rows it
231        // would have raised on, and in the case of a pattern under a `CASE` arm it raises on rows
232        // that were never going to reach it.
233        let recipe = Recipe::new("regexp_matches", &[None, text("a(")]);
234        assert!(recipe.hoisted().regexp().is_none());
235    }
236
237    #[test]
238    fn a_function_with_nothing_to_lift_lifts_nothing() {
239        let recipe = Recipe::new("upper", &[None]);
240        assert!(recipe.hoisted().like().is_none());
241        assert!(recipe.hoisted().regexp().is_none());
242    }
243
244    #[test]
245    fn a_plain_recipe_is_the_name_and_no_more() {
246        let recipe = Recipe::plain("~~");
247        assert_eq!(recipe.name(), "~~");
248        assert!(recipe.hoisted().like().is_none());
249    }
250}