Skip to main content

nedb_engine/
sqlpush.rs

1// SPDX-License-Identifier: BUSL-1.1
2// SPDX-FileCopyrightText: © 2026 INTERCHAINED LLC × Claude Sonnet 4.6
3
4//! Predicate pushdown — conservative, and narrower than the phrase sounds.
5//!
6//! # What this is NOT
7//!
8//! It is not a rewrite engine that turns
9//!
10//! ```text
11//!   Filter(Join(A, B))   ->   Join(Filter(A), B)
12//! ```
13//!
14//! on the basis of column ownership. That transformation is unsound in
15//! general, and the specific way it fails is already pinned in the semantic
16//! corpus:
17//!
18//! ```text
19//!   LEFT JOIN ... WHERE d.dname = 'eng'        2 rows
20//!   LEFT JOIN ... ON ... AND d.dname = 'eng'   5 rows
21//! ```
22//!
23//! Moving a predicate from `WHERE` to the join's `ON` changes which rows get
24//! NULL-synthesised, so it changes the answer. Three-valued logic is what
25//! makes it dangerous: the outer rows survive the join and are then dropped by
26//! `WHERE` because a comparison against the synthesised NULL is UNKNOWN.
27//!
28//! # What it IS: a pre-filter on a relation that is never NULL-synthesised
29//!
30//! A qualifying conjunct is COPIED to run against its own relation before the
31//! join. The `WHERE` clause is left untouched and still runs after the join.
32//!
33//! Retaining the original is necessary but **not sufficient**, and getting
34//! that wrong is instructive. The first version of this module argued that a
35//! copy-not-move was safe for every join type, reasoning:
36//!
37//! > Removing rows from a relation can only create MORE unmatched rows on the
38//! > other side; those get NULL-synthesised, and the retained `WHERE` then
39//! > evaluates the same predicate against a NULL, yields UNKNOWN, and drops
40//! > them.
41//!
42//! That is wrong, and the semantic corpus caught it immediately. A predicate
43//! can be SATISFIED by a synthesised NULL:
44//!
45//! ```text
46//!   SELECT e.name FROM emp e LEFT JOIN dept d ON e.dept_id = d.id
47//!    WHERE d.dname IS NULL
48//! ```
49//!
50//! No `dept` row has a NULL `dname`, so pre-filtering `dept` empties it
51//! entirely; every `emp` row then becomes unmatched, gets NULL-extended, and
52//! `IS NULL` is TRUE for all of them. The answer went from 1 row to 5.
53//!
54//! So the real condition is about NULL SYNTHESIS, not about retention:
55//!
56//! > A predicate may be pre-applied to relation `R` only if `R` is never
57//! > NULL-synthesised in this query's output.
58//!
59//! When `R` cannot be synthesised, every output row carries a real `R` row, so
60//! the retained `WHERE` sees exactly the values the pre-filter saw, and the
61//! pre-filter can only remove rows the `WHERE` would have removed. When `R`
62//! CAN be synthesised, removing a row can manufacture an outer row whose
63//! values differ from anything the pre-filter examined — and whether that row
64//! survives depends on the predicate, which is not something to guess at.
65//!
66//! [`nullable_bindings`] computes that set:
67//!
68//! * a join's right binding is nullable when the join is `LEFT` or `FULL`;
69//! * every binding accumulated so far becomes nullable when a LATER join is
70//!   `RIGHT` or `FULL`, because those synthesise NULLs across the whole left
71//!   side — including the `FROM` relation.
72//!
73//! An `INNER` (or `CROSS`) join synthesises nothing, which is why an
74//! all-inner query can push everything and is the common case.
75//!
76//! # Refusals are recorded, not silent
77//!
78//! When a conjunct cannot be pushed, the reason is kept on the plan
79//! (`Filter retained above join: ...`). An optimiser that silently declines is
80//! impossible to audit — you cannot tell "correctly refused" from "forgot to
81//! look". The reasons are inspectable in tests today and are the natural thing
82//! for `EXPLAIN` to show later.
83
84use crate::sqlselect::Expr;
85use std::collections::HashMap;
86
87/// Functions safe to evaluate while pre-filtering.
88///
89/// An allowlist, for the same fail-safe reason as the hash-join key planner:
90/// a volatile function added to the evaluator and not added here is REFUSED
91/// rather than silently evaluated twice with different answers.
92const PURE_FUNCS: &[&str] = &[
93    "lower", "upper", "length", "char_length", "character_length", "coalesce",
94    "nullif", "int2", "int4", "int8", "text", "quote_ident", "format_type",
95    "array_to_string", "current_schema", "current_database", "current_catalog",
96    "current_user", "session_user", "user", "version", "pg_get_userbyid",
97    "pg_table_is_visible", "pg_type_is_visible", "pg_function_is_visible",
98    "pg_encoding_to_char", "pg_get_expr", "pg_get_indexdef",
99    "pg_get_constraintdef",
100];
101
102/// What the planner decided, per relation, plus why it declined the rest.
103#[derive(Debug, Clone, Default)]
104pub struct Pushdown {
105    /// binding (lowercased) -> conjuncts to pre-filter that relation with.
106    pub per_binding: HashMap<String, Vec<Expr>>,
107    /// Human-readable refusal reasons, in the order the conjuncts appeared.
108    pub refusals: Vec<String>,
109}
110
111impl Pushdown {
112    pub fn for_binding(&self, binding: &str) -> Option<&Vec<Expr>> {
113        self.per_binding.get(&binding.to_ascii_lowercase())
114    }
115
116    pub fn pushed_count(&self) -> usize {
117        self.per_binding.values().map(|v| v.len()).sum()
118    }
119}
120
121/// Split an expression into top-level `AND` conjuncts.
122///
123/// Only `AND` may be split. An `OR` branch constrains the row as a whole, so
124/// pre-filtering on one side of it would drop rows the predicate accepts.
125fn conjuncts<'a>(e: &'a Expr, out: &mut Vec<&'a Expr>) {
126    match e {
127        Expr::Binary { op, left, right } if op == "AND" => {
128            conjuncts(left, out);
129            conjuncts(right, out);
130        }
131        other => out.push(other),
132    }
133}
134
135/// Which bindings a predicate reads, and whether it is safe to evaluate early.
136enum Reads {
137    /// Exactly one binding, and nothing that prevents early evaluation.
138    One(String),
139    /// Reads no column at all — a constant. Pre-filtering on it would be
140    /// pointless (it is the same answer for every row) so it is left alone.
141    Constant,
142    Refused(&'static str),
143}
144
145fn reads(e: &Expr, known: &[String]) -> Reads {
146    let mut seen: Vec<String> = vec![];
147    let mut why: Option<&'static str> = None;
148    walk(e, known, &mut seen, &mut why);
149    if let Some(w) = why {
150        return Reads::Refused(w);
151    }
152    match seen.len() {
153        0 => Reads::Constant,
154        1 => Reads::One(seen.pop().expect("one")),
155        _ => Reads::Refused("spans more than one relation"),
156    }
157}
158
159fn walk(e: &Expr, known: &[String], seen: &mut Vec<String>, why: &mut Option<&'static str>) {
160    match e {
161        Expr::Column { qual, .. } => match qual {
162            Some(q) => {
163                let lower = q.to_ascii_lowercase();
164                if !known.iter().any(|b| b.eq_ignore_ascii_case(q)) {
165                    // An unknown binding is a query error, reported with a
166                    // better message by the evaluator than by the planner.
167                    *why = Some("references an unknown relation");
168                } else if !seen.contains(&lower) {
169                    seen.push(lower);
170                }
171            }
172            // A bare column resolves by scanning bindings in order AT
173            // EVALUATION TIME, so it cannot be attributed to one relation
174            // here. Guessing would pre-filter the wrong relation.
175            None => *why = Some("unqualified column cannot be attributed to a relation"),
176        },
177        Expr::Literal(_) => {}
178        Expr::Star | Expr::QualifiedStar(_) => *why = Some("contains `*`"),
179        Expr::Func { name, args } => {
180            if !PURE_FUNCS.iter().any(|f| f.eq_ignore_ascii_case(name)) {
181                *why = Some("calls a function not known to be pure");
182            }
183            for a in args {
184                walk(a, known, seen, why);
185            }
186        }
187        // An aggregate is reduced over a GROUP, so it has no value for the
188        // single row a pre-filter sees. Pushing one below the join would
189        // evaluate it against the wrong set of rows entirely.
190        Expr::Agg { .. } => *why = Some("contains an aggregate"),
191        Expr::Case { operand, whens, else_ } => {
192            if let Some(o) = operand {
193                walk(o, known, seen, why);
194            }
195            for (w, t) in whens {
196                walk(w, known, seen, why);
197                walk(t, known, seen, why);
198            }
199            if let Some(x) = else_ {
200                walk(x, known, seen, why);
201            }
202        }
203        Expr::Binary { left, right, .. } => {
204            walk(left, known, seen, why);
205            walk(right, known, seen, why);
206        }
207        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
208            walk(expr, known, seen, why)
209        }
210        Expr::InList { expr, list, .. } => {
211            walk(expr, known, seen, why);
212            for i in list {
213                walk(i, known, seen, why);
214            }
215        }
216        Expr::Index { expr, index } => {
217            walk(expr, known, seen, why);
218            walk(index, known, seen, why);
219        }
220        Expr::ArrayLit(items) => {
221            for i in items {
222                walk(i, known, seen, why);
223            }
224        }
225        // A subquery may read ANY binding of the enclosing query through
226        // correlation, and which ones cannot be told without running it. So a
227        // predicate containing one is never pushed below a join.
228        Expr::Subquery(_) | Expr::Exists { .. } | Expr::ArrayQuery(_) | Expr::InSubquery { .. } => {
229            *why = Some("contains a subquery")
230        }
231        Expr::Quantified { left, right, .. } => {
232            walk(left, known, seen, why);
233            walk(right, known, seen, why);
234        }
235    }
236}
237
238/// The bindings this query can NULL-synthesise.
239///
240/// Pre-filtering any of these is refused: removing a row can manufacture an
241/// outer row carrying NULLs the pre-filter never examined, and whether that
242/// row survives the retained `WHERE` depends on the predicate.
243pub fn nullable_bindings(sel: &crate::sqlselect::Select) -> Vec<String> {
244    use crate::sqlselect::JoinKind;
245    let mut out: Vec<String> = vec![];
246    let mut accumulated: Vec<String> = sel
247        .from
248        .iter()
249        .map(|t| t.binding().to_ascii_lowercase())
250        .collect();
251
252    for j in &sel.joins {
253        let rb = j.table.binding().to_ascii_lowercase();
254        // LEFT/FULL: the RIGHT side is synthesised when a left row has no
255        // partner.
256        if matches!(j.kind, JoinKind::Left | JoinKind::Full) && !out.contains(&rb) {
257            out.push(rb.clone());
258        }
259        // RIGHT/FULL: the whole accumulated LEFT side is synthesised when a
260        // right row has no partner — which retroactively makes every earlier
261        // binding nullable, the `FROM` relation included.
262        if matches!(j.kind, JoinKind::Right | JoinKind::Full) {
263            for a in &accumulated {
264                if !out.contains(a) {
265                    out.push(a.clone());
266                }
267            }
268        }
269        accumulated.push(rb);
270    }
271    out
272}
273
274/// Decide which `WHERE` conjuncts may be pre-applied to which relation.
275///
276/// `bindings` must list every relation in the query. The returned predicates
277/// are COPIES — the caller keeps evaluating the original `WHERE` after the
278/// join, which is what makes this safe.
279pub fn plan(
280    where_: Option<&Expr>,
281    bindings: &[String],
282    nullable: &[String],
283) -> Pushdown {
284    let mut out = Pushdown::default();
285    let Some(w) = where_ else { return out };
286
287    // With a single relation there is no join to push below, and the filter
288    // already runs directly over it. Pushing would only duplicate the work.
289    if bindings.len() < 2 {
290        return out;
291    }
292
293    let mut parts = vec![];
294    conjuncts(w, &mut parts);
295    for p in parts {
296        match reads(p, bindings) {
297            Reads::One(b) if nullable.iter().any(|n| n.eq_ignore_ascii_case(&b)) => {
298                // Oracle's wording, because it names the actual hazard rather
299                // than restating the rule.
300                out.refusals.push(format!(
301                    "Filter retained above join: predicate references nullable \
302                     side of an outer join ({b})"
303                ));
304            }
305            Reads::One(b) => out.per_binding.entry(b).or_default().push(p.clone()),
306            Reads::Constant => out
307                .refusals
308                .push("Filter retained above join: predicate reads no column".into()),
309            Reads::Refused(why) => out
310                .refusals
311                .push(format!("Filter retained above join: {why}")),
312        }
313    }
314    out
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::sqlselect::parse;
321
322    fn plan_for(sql: &str) -> Pushdown {
323        let sel = parse(sql).expect("parses");
324        let mut b = vec![];
325        if let Some(f) = &sel.from {
326            b.push(f.binding());
327        }
328        for j in &sel.joins {
329            b.push(j.table.binding());
330        }
331        let nullable = nullable_bindings(&sel);
332        plan(sel.where_.as_ref(), &b, &nullable)
333    }
334
335    #[test]
336    fn a_single_relation_predicate_is_pushed_to_that_relation() {
337        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE a.v > 5");
338        assert_eq!(p.pushed_count(), 1);
339        assert_eq!(p.for_binding("a").map(|v| v.len()), Some(1));
340        assert!(p.for_binding("b").is_none());
341        assert!(p.refusals.is_empty(), "{:?}", p.refusals);
342    }
343
344    #[test]
345    fn conjuncts_are_pushed_to_their_own_relations_independently() {
346        let p = plan_for(
347            "SELECT 1 FROM a JOIN b ON a.x = b.x WHERE a.v > 5 AND b.w < 2 AND a.z = 'q'",
348        );
349        assert_eq!(p.pushed_count(), 3);
350        assert_eq!(p.for_binding("a").map(|v| v.len()), Some(2));
351        assert_eq!(p.for_binding("b").map(|v| v.len()), Some(1));
352    }
353
354    #[test]
355    fn a_predicate_on_the_nullable_side_of_a_left_join_is_REFUSED() {
356        // This test asserted the opposite in the first version of this module,
357        // and it was wrong. `WHERE d.dname IS NULL` over a LEFT JOIN is
358        // SATISFIED by the synthesised NULL, so emptying the right relation
359        // manufactures outer rows that pass the retained WHERE — 1 row became
360        // 5. The semantic corpus caught it.
361        let p = plan_for("SELECT 1 FROM a LEFT JOIN b ON a.x = b.x WHERE b.w = 5");
362        assert_eq!(p.pushed_count(), 0);
363        assert!(p.refusals[0].contains("nullable side"), "{:?}", p.refusals);
364    }
365
366    #[test]
367    fn the_non_nullable_side_of_a_left_join_is_still_pushed() {
368        // `a` is never synthesised by a LEFT JOIN, so its own predicates are
369        // safe. This is the case that matters in practice — a selective filter
370        // on the driving relation.
371        let p = plan_for("SELECT 1 FROM a LEFT JOIN b ON a.x = b.x WHERE a.v > 5");
372        assert_eq!(p.for_binding("a").map(|v| v.len()), Some(1));
373        assert!(p.refusals.is_empty(), "{:?}", p.refusals);
374    }
375
376    #[test]
377    fn a_right_join_makes_the_LEFT_side_nullable_including_the_from_relation() {
378        let p = plan_for("SELECT 1 FROM a RIGHT JOIN b ON a.x = b.x WHERE a.v > 5");
379        assert_eq!(p.pushed_count(), 0, "a is synthesised by the RIGHT join");
380        assert!(p.refusals[0].contains("nullable side"), "{:?}", p.refusals);
381        // The right side of a RIGHT join is never synthesised.
382        let p = plan_for("SELECT 1 FROM a RIGHT JOIN b ON a.x = b.x WHERE b.w > 5");
383        assert_eq!(p.for_binding("b").map(|v| v.len()), Some(1));
384    }
385
386    #[test]
387    fn a_full_join_makes_both_sides_nullable() {
388        for w in ["a.v > 5", "b.w > 5"] {
389            let p = plan_for(&format!("SELECT 1 FROM a FULL JOIN b ON a.x = b.x WHERE {w}"));
390            assert_eq!(p.pushed_count(), 0, "{w}");
391        }
392    }
393
394    #[test]
395    fn a_later_right_join_retroactively_protects_earlier_relations() {
396        // `a` and `b` are fine on their own, but the RIGHT join to `c`
397        // synthesises NULLs across BOTH of them — so neither may be
398        // pre-filtered. Missing this would be a wrong answer that only shows
399        // up in three-relation queries.
400        let sel = parse(
401            "SELECT 1 FROM a JOIN b ON a.x = b.x RIGHT JOIN c ON b.y = c.y \
402             WHERE a.v > 1 AND b.w > 1 AND c.z > 1",
403        )
404        .expect("parses");
405        let nullable = nullable_bindings(&sel);
406        assert!(nullable.contains(&"a".to_string()), "{nullable:?}");
407        assert!(nullable.contains(&"b".to_string()), "{nullable:?}");
408        assert!(!nullable.contains(&"c".to_string()), "c is never synthesised");
409
410        let p = plan_for(
411            "SELECT 1 FROM a JOIN b ON a.x = b.x RIGHT JOIN c ON b.y = c.y \
412             WHERE a.v > 1 AND b.w > 1 AND c.z > 1",
413        );
414        assert_eq!(p.pushed_count(), 1, "only c");
415        assert_eq!(p.for_binding("c").map(|v| v.len()), Some(1));
416        assert_eq!(p.refusals.len(), 2);
417    }
418
419    #[test]
420    fn an_all_inner_query_can_push_everything() {
421        let p = plan_for(
422            "SELECT 1 FROM a JOIN b ON a.x = b.x JOIN c ON b.y = c.y \
423             WHERE a.v > 1 AND b.w > 1 AND c.z > 1",
424        );
425        assert_eq!(p.pushed_count(), 3);
426        assert!(p.refusals.is_empty());
427        assert!(nullable_bindings(&parse(
428            "SELECT 1 FROM a JOIN b ON a.x = b.x JOIN c ON b.y = c.y"
429        ).unwrap()).is_empty());
430    }
431
432    #[test]
433    fn a_predicate_spanning_two_relations_is_refused_with_a_reason() {
434        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE a.v > b.w");
435        assert_eq!(p.pushed_count(), 0);
436        assert_eq!(p.refusals.len(), 1);
437        assert!(p.refusals[0].contains("spans more than one relation"), "{:?}", p.refusals);
438    }
439
440    #[test]
441    fn or_is_never_split() {
442        // `a.v > 5 OR b.w < 2` accepts a row when EITHER holds, so filtering
443        // `a` by the left half alone would drop rows the predicate accepts.
444        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE a.v > 5 OR b.w < 2");
445        assert_eq!(p.pushed_count(), 0);
446        assert_eq!(p.refusals.len(), 1);
447    }
448
449    #[test]
450    fn an_or_of_one_relation_is_also_refused_today() {
451        // `a.v > 5 OR a.v < 1` COULD be pushed, since it reads only `a`. It is
452        // allowed, because `reads` looks at the whole conjunct rather than
453        // splitting the OR.
454        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE a.v > 5 OR a.v < 1");
455        assert_eq!(p.pushed_count(), 1, "one conjunct, one relation");
456    }
457
458    #[test]
459    fn an_unqualified_column_is_refused() {
460        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE v > 5");
461        assert_eq!(p.pushed_count(), 0);
462        assert!(p.refusals[0].contains("unqualified"), "{:?}", p.refusals);
463    }
464
465    #[test]
466    fn a_constant_predicate_is_refused_as_pointless() {
467        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE 1 = 1");
468        assert_eq!(p.pushed_count(), 0);
469        assert!(p.refusals[0].contains("reads no column"), "{:?}", p.refusals);
470    }
471
472    #[test]
473    fn a_volatile_function_is_refused_because_the_allowlist_is_fail_safe() {
474        let sel = parse("SELECT 1 FROM a JOIN b ON a.x = b.x").expect("parses");
475        let _ = sel;
476        let pred = Expr::Binary {
477            op: "=".into(),
478            left: Box::new(Expr::Func {
479                name: "random".into(),
480                args: vec![Expr::Column { qual: Some("a".into()), name: "v".into() }],
481            }),
482            right: Box::new(Expr::Literal(serde_json::json!(1))),
483        };
484        let p = plan(Some(&pred), &["a".into(), "b".into()], &[]);
485        assert_eq!(p.pushed_count(), 0);
486        assert!(p.refusals[0].contains("not known to be pure"), "{:?}", p.refusals);
487    }
488
489    #[test]
490    fn pure_functions_and_postfix_operators_are_pushable() {
491        // `BETWEEN` desugars to `>= AND <=`, so it legitimately yields TWO
492        // pushable conjuncts. Stating the real count rather than rounding it
493        // to one — the parser's shape is part of what is being asserted.
494        for (w, want) in [
495            ("lower(a.name) = 'x'", 1),
496            ("a.v IS NULL", 1),
497            ("a.v IS NOT NULL", 1),
498            ("a.v IN (1, 2, 3)", 1),
499            ("a.v NOT IN (1, 2)", 1),
500            ("a.v BETWEEN 1 AND 9", 2),
501            ("a.v NOT BETWEEN 1 AND 9", 1),
502            ("coalesce(a.v, 0) > 1", 1),
503            ("a.v::text = '5'", 1),
504            ("NOT (a.v = 3)", 1),
505            ("CASE WHEN a.v > 1 THEN true ELSE false END", 1),
506        ] {
507            let p = plan_for(&format!("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE {w}"));
508            assert_eq!(
509                p.pushed_count(), want,
510                "{w} should push {want}: {:?}", p.refusals
511            );
512            assert!(p.refusals.is_empty(), "{w}: {:?}", p.refusals);
513        }
514    }
515
516    #[test]
517    fn nothing_is_pushed_without_a_join_because_there_is_nothing_to_push_below() {
518        let p = plan_for("SELECT 1 FROM a WHERE a.v > 5");
519        assert_eq!(p.pushed_count(), 0);
520        // Not a refusal either — there is simply no join.
521        assert!(p.refusals.is_empty());
522    }
523
524    #[test]
525    fn an_unknown_relation_is_left_to_the_evaluator_to_report() {
526        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE zz.v > 5");
527        assert_eq!(p.pushed_count(), 0);
528        assert!(p.refusals[0].contains("unknown relation"), "{:?}", p.refusals);
529    }
530
531    #[test]
532    fn a_binding_is_matched_case_insensitively() {
533        // Binding resolution ignores case, so the planner must too or it would
534        // attribute `A.v` to no relation and refuse a pushable predicate.
535        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE A.v > 5");
536        assert_eq!(p.for_binding("a").map(|v| v.len()), Some(1));
537        assert_eq!(p.for_binding("A").map(|v| v.len()), Some(1));
538    }
539}