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 serde_json::Value;
86use std::collections::HashMap;
87
88/// Functions safe to evaluate while pre-filtering.
89///
90/// An allowlist, for the same fail-safe reason as the hash-join key planner:
91/// a volatile function added to the evaluator and not added here is REFUSED
92/// rather than silently evaluated twice with different answers.
93const PURE_FUNCS: &[&str] = &[
94    "lower", "upper", "length", "char_length", "character_length", "coalesce",
95    "nullif", "int2", "int4", "int8", "text", "quote_ident", "format_type",
96    "array_to_string", "current_schema", "current_database", "current_catalog",
97    "current_user", "session_user", "user", "version", "pg_get_userbyid",
98    "pg_table_is_visible", "pg_type_is_visible", "pg_function_is_visible",
99    "pg_encoding_to_char", "pg_get_expr", "pg_get_indexdef",
100    "pg_get_constraintdef",
101];
102
103/// What the planner decided, per relation, plus why it declined the rest.
104#[derive(Debug, Clone, Default)]
105pub struct Pushdown {
106    /// binding (lowercased) -> conjuncts to pre-filter that relation with.
107    pub per_binding: HashMap<String, Vec<Expr>>,
108    /// Human-readable refusal reasons, in the order the conjuncts appeared.
109    pub refusals: Vec<String>,
110}
111
112impl Pushdown {
113    pub fn for_binding(&self, binding: &str) -> Option<&Vec<Expr>> {
114        self.per_binding.get(&binding.to_ascii_lowercase())
115    }
116
117    pub fn pushed_count(&self) -> usize {
118        self.per_binding.values().map(|v| v.len()).sum()
119    }
120}
121
122/// Split an expression into top-level `AND` conjuncts.
123///
124/// Only `AND` may be split. An `OR` branch constrains the row as a whole, so
125/// pre-filtering on one side of it would drop rows the predicate accepts.
126fn conjuncts<'a>(e: &'a Expr, out: &mut Vec<&'a Expr>) {
127    match e {
128        Expr::Binary { op, left, right } if op == "AND" => {
129            conjuncts(left, out);
130            conjuncts(right, out);
131        }
132        other => out.push(other),
133    }
134}
135
136/// Which bindings a predicate reads, and whether it is safe to evaluate early.
137enum Reads {
138    /// Exactly one binding, and nothing that prevents early evaluation.
139    One(String),
140    /// Reads no column at all — a constant. Pre-filtering on it would be
141    /// pointless (it is the same answer for every row) so it is left alone.
142    Constant,
143    Refused(&'static str),
144}
145
146fn reads(e: &Expr, known: &[String]) -> Reads {
147    let mut seen: Vec<String> = vec![];
148    let mut why: Option<&'static str> = None;
149    walk(e, known, &mut seen, &mut why);
150    if let Some(w) = why {
151        return Reads::Refused(w);
152    }
153    match seen.len() {
154        0 => Reads::Constant,
155        1 => Reads::One(seen.pop().expect("one")),
156        _ => Reads::Refused("spans more than one relation"),
157    }
158}
159
160fn walk(e: &Expr, known: &[String], seen: &mut Vec<String>, why: &mut Option<&'static str>) {
161    match e {
162        Expr::Column { qual, .. } => match qual {
163            Some(q) => {
164                let lower = q.to_ascii_lowercase();
165                if !known.iter().any(|b| b.eq_ignore_ascii_case(q)) {
166                    // An unknown binding is a query error, reported with a
167                    // better message by the evaluator than by the planner.
168                    *why = Some("references an unknown relation");
169                } else if !seen.contains(&lower) {
170                    seen.push(lower);
171                }
172            }
173            // A bare column resolves by scanning bindings in order AT
174            // EVALUATION TIME, so it cannot be attributed to one relation
175            // here. Guessing would pre-filter the wrong relation.
176            None => *why = Some("unqualified column cannot be attributed to a relation"),
177        },
178        Expr::Literal(_) => {}
179        Expr::Star | Expr::QualifiedStar(_) => *why = Some("contains `*`"),
180        Expr::Func { name, args } => {
181            if !PURE_FUNCS.iter().any(|f| f.eq_ignore_ascii_case(name)) {
182                *why = Some("calls a function not known to be pure");
183            }
184            for a in args {
185                walk(a, known, seen, why);
186            }
187        }
188        // An aggregate is reduced over a GROUP, so it has no value for the
189        // single row a pre-filter sees. Pushing one below the join would
190        // evaluate it against the wrong set of rows entirely.
191        Expr::Agg { .. } => *why = Some("contains an aggregate"),
192        Expr::Case { operand, whens, else_ } => {
193            if let Some(o) = operand {
194                walk(o, known, seen, why);
195            }
196            for (w, t) in whens {
197                walk(w, known, seen, why);
198                walk(t, known, seen, why);
199            }
200            if let Some(x) = else_ {
201                walk(x, known, seen, why);
202            }
203        }
204        Expr::Binary { left, right, .. } => {
205            walk(left, known, seen, why);
206            walk(right, known, seen, why);
207        }
208        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
209            walk(expr, known, seen, why)
210        }
211        Expr::InList { expr, list, .. } => {
212            walk(expr, known, seen, why);
213            for i in list {
214                walk(i, known, seen, why);
215            }
216        }
217        Expr::Index { expr, index } => {
218            walk(expr, known, seen, why);
219            walk(index, known, seen, why);
220        }
221        Expr::ArrayLit(items) => {
222            for i in items {
223                walk(i, known, seen, why);
224            }
225        }
226        // A subquery may read ANY binding of the enclosing query through
227        // correlation, and which ones cannot be told without running it. So a
228        // predicate containing one is never pushed below a join.
229        Expr::Subquery(_) | Expr::Exists { .. } | Expr::ArrayQuery(_) | Expr::InSubquery { .. } => {
230            *why = Some("contains a subquery")
231        }
232        Expr::Quantified { left, right, .. } => {
233            walk(left, known, seen, why);
234            walk(right, known, seen, why);
235        }
236    }
237}
238
239/// The bindings this query can NULL-synthesise.
240///
241/// Pre-filtering any of these is refused: removing a row can manufacture an
242/// outer row carrying NULLs the pre-filter never examined, and whether that
243/// row survives the retained `WHERE` depends on the predicate.
244pub fn nullable_bindings(sel: &crate::sqlselect::Select) -> Vec<String> {
245    use crate::sqlselect::JoinKind;
246    let mut out: Vec<String> = vec![];
247    let mut accumulated: Vec<String> = sel
248        .from
249        .iter()
250        .map(|t| t.binding().to_ascii_lowercase())
251        .collect();
252
253    for j in &sel.joins {
254        let rb = j.table.binding().to_ascii_lowercase();
255        // LEFT/FULL: the RIGHT side is synthesised when a left row has no
256        // partner.
257        if matches!(j.kind, JoinKind::Left | JoinKind::Full) && !out.contains(&rb) {
258            out.push(rb.clone());
259        }
260        // RIGHT/FULL: the whole accumulated LEFT side is synthesised when a
261        // right row has no partner — which retroactively makes every earlier
262        // binding nullable, the `FROM` relation included.
263        if matches!(j.kind, JoinKind::Right | JoinKind::Full) {
264            for a in &accumulated {
265                if !out.contains(a) {
266                    out.push(a.clone());
267                }
268            }
269        }
270        accumulated.push(rb);
271    }
272    out
273}
274
275/// Decide which `WHERE` conjuncts may be pre-applied to which relation.
276///
277/// `bindings` must list every relation in the query. The returned predicates
278/// are COPIES — the caller keeps evaluating the original `WHERE` after the
279/// join, which is what makes this safe.
280pub fn plan(
281    where_: Option<&Expr>,
282    bindings: &[String],
283    nullable: &[String],
284) -> Pushdown {
285    let mut out = Pushdown::default();
286    let Some(w) = where_ else { return out };
287
288    // With a single relation there is no join to push below, and the filter
289    // already runs directly over it. Pushing would only duplicate the work.
290    if bindings.len() < 2 {
291        return out;
292    }
293
294    let mut parts = vec![];
295    conjuncts(w, &mut parts);
296    for p in parts {
297        match reads(p, bindings) {
298            Reads::One(b) if nullable.iter().any(|n| n.eq_ignore_ascii_case(&b)) => {
299                // Oracle's wording, because it names the actual hazard rather
300                // than restating the rule.
301                out.refusals.push(format!(
302                    "Filter retained above join: predicate references nullable \
303                     side of an outer join ({b})"
304                ));
305            }
306            Reads::One(b) => out.per_binding.entry(b).or_default().push(p.clone()),
307            Reads::Constant => out
308                .refusals
309                .push("Filter retained above join: predicate reads no column".into()),
310            Reads::Refused(why) => out
311                .refusals
312                .push(format!("Filter retained above join: {why}")),
313        }
314    }
315    out
316}
317
318// ── storage-side pre-filter ─────────────────────────────────────────────────
319//
320// `plan()` above pushes a filter below a JOIN, which is a question about the
321// shape of the plan. This is a different question: what can be pushed all the
322// way into STORAGE, so the scan never materialises rows the query cannot want.
323//
324// It matters because the resolver that feeds this evaluator asks NQL for
325// `FROM <collection>` — the WHOLE collection, every row, before a single
326// predicate runs. On a catalogue relation that is free (a few dozen
327// synthesised rows). On a user collection it is the difference between reading
328// one document and reading all of them.
329//
330// # Why this is safe, precisely
331//
332// The pushed predicate is a PRE-filter and the full `WHERE` still runs
333// afterwards, untouched. So the only failure mode that matters is a FALSE
334// NEGATIVE: dropping a row the real `WHERE` would have kept. A false positive
335// costs a wasted row and nothing else.
336//
337// That asymmetry is the whole design. Everything below is chosen so a false
338// negative cannot happen:
339//
340//   * `NOT`, `IS NULL` and a negated `IN` are all REFUSED. Negation turns a
341//     benign false positive into a false negative — exactly the direction that
342//     is not survivable — because the two languages disagree about a missing
343//     field. SQL evaluates `NULL != 'x'` to UNKNOWN and drops the row; NQL has
344//     no NULL at all, it has an ABSENT FIELD, and a negated match over an
345//     absent field is the one case where it may keep what SQL drops. Under a
346//     `NOT` that inverts into dropping what SQL keeps.
347//   * A `NULL` literal is refused for the same reason.
348//   * `OR` requires BOTH sides to render. Half an `OR` is not a weaker filter,
349//     it is a different one.
350//   * Arithmetic, casts, functions and subqueries are refused: not because
351//     they are necessarily unsafe, but because their NQL semantics have not
352//     been verified pair-for-pair, and an unverified rewrite is how the
353//     qualified-`WHERE` bug happened.
354//
355// When nothing renders, the answer is `None` and the scan stays as it was:
356// slower, and correct.
357
358/// Render `e` as an NQL predicate over one relation, or `None` when any part
359/// of it cannot be rendered with semantics NQL is known to match.
360///
361/// `strict_qual` demands that every column name carry this relation's
362/// qualifier. With more than one relation in the query an unqualified name may
363/// belong to the other one, and pushing another relation's predicate into this
364/// scan is a false negative.
365pub fn to_nql_predicate(e: &Expr, binding: &str, strict_qual: bool) -> Option<String> {
366    match e {
367        Expr::Column { qual, name } => match qual {
368            Some(q) if q.eq_ignore_ascii_case(binding) => Some(name.clone()),
369            Some(_) => None,
370            None if strict_qual => None,
371            None => Some(name.clone()),
372        },
373        Expr::Literal(v) => nql_literal(v),
374        Expr::Binary { op, left, right } => {
375            let o = op.to_ascii_uppercase();
376            let l = to_nql_predicate(left, binding, strict_qual)?;
377            let r = to_nql_predicate(right, binding, strict_qual)?;
378            match o.as_str() {
379                // Comparisons. `<>` is spelled `!=` in NQL.
380                "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" => Some(format!("{} {} {}", l, o, r)),
381                "<>" => Some(format!("{} != {}", l, r)),
382                // Both sides of a boolean connective must render, or the
383                // result is a different predicate rather than a looser one.
384                "AND" => Some(format!("({} AND {})", l, r)),
385                "OR" => Some(format!("({} OR {})", l, r)),
386                _ => None,
387            }
388        }
389        Expr::InList { expr, list, negated: false } => {
390            let l = to_nql_predicate(expr, binding, strict_qual)?;
391            let mut items = Vec::with_capacity(list.len());
392            for it in list {
393                items.push(to_nql_predicate(it, binding, strict_qual)?);
394            }
395            if items.is_empty() {
396                return None;
397            }
398            Some(format!("{} IN ({})", l, items.join(", ")))
399        }
400        // Everything else, refused on purpose. See the module note above.
401        _ => None,
402    }
403}
404
405/// A literal in NQL's spelling, or `None` when it must not be pushed.
406fn nql_literal(v: &Value) -> Option<String> {
407    match v {
408        // NQL quotes strings with `"`, and a `"` inside one is escaped.
409        Value::String(s) => Some(format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))),
410        Value::Number(n) => Some(n.to_string()),
411        Value::Bool(b) => Some(if *b { "TRUE".into() } else { "FALSE".into() }),
412        // A NULL comparison is the one place the two languages genuinely
413        // disagree, so it never travels into the scan.
414        _ => None,
415    }
416}
417
418/// The NQL predicate to pre-filter one relation's scan with, or `None`.
419///
420/// Splits the `WHERE` into `AND` conjuncts and keeps the ones that render,
421/// which is what makes partial pushdown safe: a subset of a conjunction is a
422/// weaker filter, and a weaker pre-filter only costs rows, never answers.
423/// (A subset of a DISJUNCTION would not be, which is why `OR` is handled
424/// whole inside `to_nql_predicate` and never split here.)
425pub fn nql_prefilter(
426    where_: Option<&Expr>,
427    binding: &str,
428    bindings: &[String],
429    nullable: &[String],
430) -> Option<String> {
431    // The nullable side of an outer join must not be pre-filtered: dropping a
432    // row there changes which rows get NULL-synthesised, which changes the
433    // answer. Same hazard `plan()` refuses, for the same reason.
434    if nullable.iter().any(|n| n.eq_ignore_ascii_case(binding)) {
435        return None;
436    }
437    let w = where_?;
438    let strict = bindings.len() > 1;
439    let mut parts = vec![];
440    conjuncts(w, &mut parts);
441    let kept: Vec<String> = parts
442        .iter()
443        .filter_map(|p| to_nql_predicate(p, binding, strict))
444        .collect();
445    if kept.is_empty() {
446        None
447    } else {
448        Some(kept.join(" AND "))
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use crate::sqlselect::parse;
456
457    fn plan_for(sql: &str) -> Pushdown {
458        let sel = parse(sql).expect("parses");
459        let mut b = vec![];
460        if let Some(f) = &sel.from {
461            b.push(f.binding());
462        }
463        for j in &sel.joins {
464            b.push(j.table.binding());
465        }
466        let nullable = nullable_bindings(&sel);
467        plan(sel.where_.as_ref(), &b, &nullable)
468    }
469
470    #[test]
471    fn a_single_relation_predicate_is_pushed_to_that_relation() {
472        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE a.v > 5");
473        assert_eq!(p.pushed_count(), 1);
474        assert_eq!(p.for_binding("a").map(|v| v.len()), Some(1));
475        assert!(p.for_binding("b").is_none());
476        assert!(p.refusals.is_empty(), "{:?}", p.refusals);
477    }
478
479    #[test]
480    fn conjuncts_are_pushed_to_their_own_relations_independently() {
481        let p = plan_for(
482            "SELECT 1 FROM a JOIN b ON a.x = b.x WHERE a.v > 5 AND b.w < 2 AND a.z = 'q'",
483        );
484        assert_eq!(p.pushed_count(), 3);
485        assert_eq!(p.for_binding("a").map(|v| v.len()), Some(2));
486        assert_eq!(p.for_binding("b").map(|v| v.len()), Some(1));
487    }
488
489    #[test]
490    fn a_predicate_on_the_nullable_side_of_a_left_join_is_REFUSED() {
491        // This test asserted the opposite in the first version of this module,
492        // and it was wrong. `WHERE d.dname IS NULL` over a LEFT JOIN is
493        // SATISFIED by the synthesised NULL, so emptying the right relation
494        // manufactures outer rows that pass the retained WHERE — 1 row became
495        // 5. The semantic corpus caught it.
496        let p = plan_for("SELECT 1 FROM a LEFT JOIN b ON a.x = b.x WHERE b.w = 5");
497        assert_eq!(p.pushed_count(), 0);
498        assert!(p.refusals[0].contains("nullable side"), "{:?}", p.refusals);
499    }
500
501    #[test]
502    fn the_non_nullable_side_of_a_left_join_is_still_pushed() {
503        // `a` is never synthesised by a LEFT JOIN, so its own predicates are
504        // safe. This is the case that matters in practice — a selective filter
505        // on the driving relation.
506        let p = plan_for("SELECT 1 FROM a LEFT JOIN b ON a.x = b.x WHERE a.v > 5");
507        assert_eq!(p.for_binding("a").map(|v| v.len()), Some(1));
508        assert!(p.refusals.is_empty(), "{:?}", p.refusals);
509    }
510
511    #[test]
512    fn a_right_join_makes_the_LEFT_side_nullable_including_the_from_relation() {
513        let p = plan_for("SELECT 1 FROM a RIGHT JOIN b ON a.x = b.x WHERE a.v > 5");
514        assert_eq!(p.pushed_count(), 0, "a is synthesised by the RIGHT join");
515        assert!(p.refusals[0].contains("nullable side"), "{:?}", p.refusals);
516        // The right side of a RIGHT join is never synthesised.
517        let p = plan_for("SELECT 1 FROM a RIGHT JOIN b ON a.x = b.x WHERE b.w > 5");
518        assert_eq!(p.for_binding("b").map(|v| v.len()), Some(1));
519    }
520
521    #[test]
522    fn a_full_join_makes_both_sides_nullable() {
523        for w in ["a.v > 5", "b.w > 5"] {
524            let p = plan_for(&format!("SELECT 1 FROM a FULL JOIN b ON a.x = b.x WHERE {w}"));
525            assert_eq!(p.pushed_count(), 0, "{w}");
526        }
527    }
528
529    #[test]
530    fn a_later_right_join_retroactively_protects_earlier_relations() {
531        // `a` and `b` are fine on their own, but the RIGHT join to `c`
532        // synthesises NULLs across BOTH of them — so neither may be
533        // pre-filtered. Missing this would be a wrong answer that only shows
534        // up in three-relation queries.
535        let sel = parse(
536            "SELECT 1 FROM a JOIN b ON a.x = b.x RIGHT JOIN c ON b.y = c.y \
537             WHERE a.v > 1 AND b.w > 1 AND c.z > 1",
538        )
539        .expect("parses");
540        let nullable = nullable_bindings(&sel);
541        assert!(nullable.contains(&"a".to_string()), "{nullable:?}");
542        assert!(nullable.contains(&"b".to_string()), "{nullable:?}");
543        assert!(!nullable.contains(&"c".to_string()), "c is never synthesised");
544
545        let p = plan_for(
546            "SELECT 1 FROM a JOIN b ON a.x = b.x RIGHT JOIN c ON b.y = c.y \
547             WHERE a.v > 1 AND b.w > 1 AND c.z > 1",
548        );
549        assert_eq!(p.pushed_count(), 1, "only c");
550        assert_eq!(p.for_binding("c").map(|v| v.len()), Some(1));
551        assert_eq!(p.refusals.len(), 2);
552    }
553
554    #[test]
555    fn an_all_inner_query_can_push_everything() {
556        let p = plan_for(
557            "SELECT 1 FROM a JOIN b ON a.x = b.x JOIN c ON b.y = c.y \
558             WHERE a.v > 1 AND b.w > 1 AND c.z > 1",
559        );
560        assert_eq!(p.pushed_count(), 3);
561        assert!(p.refusals.is_empty());
562        assert!(nullable_bindings(&parse(
563            "SELECT 1 FROM a JOIN b ON a.x = b.x JOIN c ON b.y = c.y"
564        ).unwrap()).is_empty());
565    }
566
567    #[test]
568    fn a_predicate_spanning_two_relations_is_refused_with_a_reason() {
569        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE a.v > b.w");
570        assert_eq!(p.pushed_count(), 0);
571        assert_eq!(p.refusals.len(), 1);
572        assert!(p.refusals[0].contains("spans more than one relation"), "{:?}", p.refusals);
573    }
574
575    #[test]
576    fn or_is_never_split() {
577        // `a.v > 5 OR b.w < 2` accepts a row when EITHER holds, so filtering
578        // `a` by the left half alone would drop rows the predicate accepts.
579        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE a.v > 5 OR b.w < 2");
580        assert_eq!(p.pushed_count(), 0);
581        assert_eq!(p.refusals.len(), 1);
582    }
583
584    #[test]
585    fn an_or_of_one_relation_is_also_refused_today() {
586        // `a.v > 5 OR a.v < 1` COULD be pushed, since it reads only `a`. It is
587        // allowed, because `reads` looks at the whole conjunct rather than
588        // splitting the OR.
589        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE a.v > 5 OR a.v < 1");
590        assert_eq!(p.pushed_count(), 1, "one conjunct, one relation");
591    }
592
593    #[test]
594    fn an_unqualified_column_is_refused() {
595        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE v > 5");
596        assert_eq!(p.pushed_count(), 0);
597        assert!(p.refusals[0].contains("unqualified"), "{:?}", p.refusals);
598    }
599
600    #[test]
601    fn a_constant_predicate_is_refused_as_pointless() {
602        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE 1 = 1");
603        assert_eq!(p.pushed_count(), 0);
604        assert!(p.refusals[0].contains("reads no column"), "{:?}", p.refusals);
605    }
606
607    #[test]
608    fn a_volatile_function_is_refused_because_the_allowlist_is_fail_safe() {
609        let sel = parse("SELECT 1 FROM a JOIN b ON a.x = b.x").expect("parses");
610        let _ = sel;
611        let pred = Expr::Binary {
612            op: "=".into(),
613            left: Box::new(Expr::Func {
614                name: "random".into(),
615                args: vec![Expr::Column { qual: Some("a".into()), name: "v".into() }],
616            }),
617            right: Box::new(Expr::Literal(serde_json::json!(1))),
618        };
619        let p = plan(Some(&pred), &["a".into(), "b".into()], &[]);
620        assert_eq!(p.pushed_count(), 0);
621        assert!(p.refusals[0].contains("not known to be pure"), "{:?}", p.refusals);
622    }
623
624    #[test]
625    fn pure_functions_and_postfix_operators_are_pushable() {
626        // `BETWEEN` desugars to `>= AND <=`, so it legitimately yields TWO
627        // pushable conjuncts. Stating the real count rather than rounding it
628        // to one — the parser's shape is part of what is being asserted.
629        for (w, want) in [
630            ("lower(a.name) = 'x'", 1),
631            ("a.v IS NULL", 1),
632            ("a.v IS NOT NULL", 1),
633            ("a.v IN (1, 2, 3)", 1),
634            ("a.v NOT IN (1, 2)", 1),
635            ("a.v BETWEEN 1 AND 9", 2),
636            ("a.v NOT BETWEEN 1 AND 9", 1),
637            ("coalesce(a.v, 0) > 1", 1),
638            ("a.v::text = '5'", 1),
639            ("NOT (a.v = 3)", 1),
640            ("CASE WHEN a.v > 1 THEN true ELSE false END", 1),
641        ] {
642            let p = plan_for(&format!("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE {w}"));
643            assert_eq!(
644                p.pushed_count(), want,
645                "{w} should push {want}: {:?}", p.refusals
646            );
647            assert!(p.refusals.is_empty(), "{w}: {:?}", p.refusals);
648        }
649    }
650
651    #[test]
652    fn nothing_is_pushed_without_a_join_because_there_is_nothing_to_push_below() {
653        let p = plan_for("SELECT 1 FROM a WHERE a.v > 5");
654        assert_eq!(p.pushed_count(), 0);
655        // Not a refusal either — there is simply no join.
656        assert!(p.refusals.is_empty());
657    }
658
659    #[test]
660    fn an_unknown_relation_is_left_to_the_evaluator_to_report() {
661        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE zz.v > 5");
662        assert_eq!(p.pushed_count(), 0);
663        assert!(p.refusals[0].contains("unknown relation"), "{:?}", p.refusals);
664    }
665
666    #[test]
667    fn a_binding_is_matched_case_insensitively() {
668        // Binding resolution ignores case, so the planner must too or it would
669        // attribute `A.v` to no relation and refuse a pushable predicate.
670        let p = plan_for("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE A.v > 5");
671        assert_eq!(p.for_binding("a").map(|v| v.len()), Some(1));
672        assert_eq!(p.for_binding("A").map(|v| v.len()), Some(1));
673    }
674
675    // ── storage-side pre-filter ─────────────────────────────────────────────
676
677    /// Render the WHERE of `sql` as a pre-filter for `binding`.
678    fn pre(sql: &str, binding: &str) -> Option<String> {
679        let sel = parse(sql).expect("parses");
680        let bindings: Vec<String> = sel
681            .from
682            .iter()
683            .map(|t| t.binding())
684            .chain(sel.joins.iter().map(|j| j.table.binding()))
685            .collect();
686        let nullable = super::nullable_bindings(&sel);
687        super::nql_prefilter(sel.where_.as_ref(), binding, &bindings, &nullable)
688    }
689
690    #[test]
691    fn the_predicate_reaches_the_scan_in_nqls_spelling() {
692        assert_eq!(pre("SELECT 1 FROM orders WHERE status = 'paid'", "orders").as_deref(),
693                   Some("status = \"paid\""));
694        // `<>` is spelled `!=`.
695        assert_eq!(pre("SELECT 1 FROM orders WHERE total <> 5", "orders").as_deref(),
696                   Some("total != 5"));
697        assert_eq!(pre("SELECT 1 FROM orders WHERE total >= 100", "orders").as_deref(),
698                   Some("total >= 100"));
699        assert_eq!(pre("SELECT 1 FROM orders WHERE status LIKE 'pa%'", "orders").as_deref(),
700                   Some("status LIKE \"pa%\""));
701        assert_eq!(pre("SELECT 1 FROM orders WHERE status IN ('paid','open')", "orders").as_deref(),
702                   Some("status IN (\"paid\", \"open\")"));
703        assert_eq!(pre("SELECT 1 FROM orders WHERE a = 1 OR b = 2", "orders").as_deref(),
704                   Some("(a = 1 OR b = 2)"));
705        // A quote inside a literal survives into NQL's spelling.
706        assert_eq!(pre("SELECT 1 FROM orders WHERE s = 'a\"b'", "orders").as_deref(),
707                   Some("s = \"a\\\"b\""));
708    }
709
710    /// The contract: a pre-filter may cost a wasted row, never an answer. So
711    /// every construct whose NQL semantics could DROP a row SQL keeps has to
712    /// come back `None` and leave the scan alone.
713    #[test]
714    fn anything_that_could_drop_a_row_sql_keeps_is_refused() {
715        for sql in [
716            // Negation over an absent field is where the two languages part.
717            "SELECT 1 FROM orders WHERE NOT (status = 'paid')",
718            "SELECT 1 FROM orders WHERE status IS NULL",
719            "SELECT 1 FROM orders WHERE status IS NOT NULL",
720            "SELECT 1 FROM orders WHERE status NOT IN ('paid')",
721            "SELECT 1 FROM orders WHERE status = NULL",
722            // Unverified semantics: not necessarily wrong, just not proven.
723            "SELECT 1 FROM orders WHERE total + 1 > 5",
724            "SELECT 1 FROM orders WHERE lower(status) = 'paid'",
725            "SELECT 1 FROM orders WHERE total::text = '5'",
726        ] {
727            assert_eq!(pre(sql, "orders"), None, "{}", sql);
728        }
729    }
730
731    #[test]
732    fn a_conjunction_pushes_the_part_it_can_and_keeps_the_rest_above() {
733        // `lower(...)` does not render; `status = 'paid'` does. A SUBSET of a
734        // conjunction is a weaker filter, so keeping the renderable half is
735        // safe -- the full WHERE still runs above the scan.
736        assert_eq!(pre("SELECT 1 FROM orders WHERE status = 'paid' AND lower(x) = 'y'", "orders")
737                       .as_deref(),
738                   Some("status = \"paid\""));
739        // But half an OR is a DIFFERENT predicate, not a weaker one, so the
740        // whole disjunction is refused when either side cannot render.
741        assert_eq!(pre("SELECT 1 FROM orders WHERE status = 'paid' OR lower(x) = 'y'", "orders"),
742                   None);
743    }
744
745    #[test]
746    fn another_relations_predicate_never_reaches_this_scan() {
747        let sql = "SELECT 1 FROM orders o JOIN drivers d ON o.driver = d._id \
748                   WHERE o.status = 'paid' AND d.name = 'Bob'";
749        assert_eq!(pre(sql, "o").as_deref(), Some("status = \"paid\""));
750        assert_eq!(pre(sql, "d").as_deref(), Some("name = \"Bob\""));
751        // With two relations an UNQUALIFIED name could belong to either, and
752        // guessing would push one relation's filter into the other's scan.
753        assert_eq!(pre("SELECT 1 FROM a JOIN b ON a.x = b.x WHERE v > 5", "a"), None);
754        // With one relation there is nothing to confuse it with.
755        assert_eq!(pre("SELECT 1 FROM orders WHERE v > 5", "orders").as_deref(), Some("v > 5"));
756    }
757
758    #[test]
759    fn the_nullable_side_of_an_outer_join_is_never_pre_filtered() {
760        // Pre-filtering here would change which rows get NULL-synthesised,
761        // which changes the answer -- the same hazard `plan()` refuses.
762        let sql = "SELECT 1 FROM a LEFT JOIN b ON a.x = b.x WHERE b.v = 5";
763        assert_eq!(pre(sql, "b"), None);
764    }
765}