Skip to main content

omgbase_surface/
planner.rs

1//! The tier-3 pushdown planner: reduces the row set a query scans by
2//! translating its pushable top-level `where` conjuncts into ONE SQL
3//! statement against the store, then handing the produced rows (plus the
4//! untranslatable residual) back to the in-memory engine to finish. Port of
5//! `packages/core/src/oqx-js/planner.ts` over the [`oqx`] seam
6//! ([`QueryPlanner`], [`Plan`], [`oqx::partition_pushable`],
7//! [`oqx::residual_query`], [`oqx::ROWS_ROOT`]).
8//!
9//! Correctness is guaranteed by the residual fallback — anything
10//! [`crate::translate`] declines stays in-memory — and verified by the
11//! differential gate (planned == in-memory over every corpus-backed query
12//! case and the conformance list). Deliberately conservative: only simple
13//! top-level target scans (no `follow`, no `from E` re-projection) whose
14//! source is a bare `docs|blocks|nodes|links` or `<target>`, with at
15//! least one pushable scalar conjunct, are planned; everything else declines
16//! to a full in-memory run.
17//!
18//! One shape difference from the reference, forced by ownership: the
19//! reference attaches a store context to its plan (`makeStoreContext(…,
20//! rowsRoot)`), but a [`crate::StoreContext`] borrows the store's connection
21//! and `Plan::context` must be `'static`, so the plan carries no context and
22//! the runner ([`mod@crate::query`]) builds the rows-root context itself
23//! ([`crate::StoreContext::with_rows_root`]) — the same six lines
24//! `oqx::PlannedEngine::run` would execute.
25
26use oqx::Consumer;
27use oqx::ast::{Expr, Follow, FollowDestination, OpNode, Query, SelectItem, Subquery, Where};
28use oqx::{Plan, QueryPlanner, Value, partition_pushable, residual_query};
29use rusqlite::Connection;
30use rusqlite::types::Value as SqlValue;
31
32use crate::context::{Target, fetch_rows, tag_rows};
33use crate::translate::{TranslateCtx, translate_predicate};
34
35/// The SQL aliases of the scanned row (`self`) and its owning doc (`doc`).
36fn aliases(t: Target) -> (&'static str, &'static str) {
37    match t {
38        Target::Docs => ("d", "d"),
39        Target::Blocks => ("b", "d"),
40        Target::Nodes => ("n", "d"),
41        Target::Links => ("e", "d"),
42    }
43}
44
45pub(crate) fn from_clause(t: Target) -> &'static str {
46    match t {
47        Target::Docs => "docs d",
48        Target::Blocks => "blocks b JOIN docs d ON d.doc_id = b.doc_id",
49        Target::Nodes => "nodes n JOIN docs d ON d.doc_id = n.doc_id",
50        Target::Links => "edges e JOIN docs d ON d.doc_id = e.src_doc",
51    }
52}
53
54/// Row columns + the owning-doc path as `__path` (matches the context's
55/// roots so produced rows are indistinguishable from a full scan's).
56pub(crate) fn columns(t: Target) -> &'static str {
57    match t {
58        Target::Docs => "d.*",
59        Target::Blocks => "b.*, d.path AS __path",
60        Target::Nodes => "n.*, d.path AS __path",
61        Target::Links => "e.*, d.path AS __path",
62    }
63}
64
65/// The root order (`spec/surface` §1.1).
66pub(crate) fn order_clause(t: Target) -> &'static str {
67    match t {
68        Target::Docs => "d.path, d.doc_id",
69        Target::Blocks => "d.path, b.block_id",
70        Target::Nodes => "d.path, n.node_id",
71        Target::Links => "d.path, e.edge_id",
72    }
73}
74
75/// The liveness guards of the root scan, with the repo id bound.
76pub(crate) fn guards(t: Target) -> &'static str {
77    match t {
78        Target::Docs => "d.repo_id = ? AND d.deleted_commit IS NULL",
79        Target::Blocks => "b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL",
80        Target::Nodes => "n.repo_id = ? AND d.deleted_commit IS NULL",
81        Target::Links => "e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL",
82    }
83}
84
85/// The joined targets driven FROM the document: the same rows and columns as
86/// [`from_clause`], with the loop order fixed by `CROSS JOIN` so a predicate on
87/// the document (`d.path = ?`) is the outer search and the target's rows are
88/// reached through their `doc_id` / `src_doc` index (the store indexes'
89/// `path` probes). [`guards_by_doc`] goes with it; `Docs` is `from_clause`.
90pub(crate) fn from_by_doc(t: Target) -> &'static str {
91    match t {
92        Target::Docs => from_clause(t),
93        Target::Blocks => "docs d CROSS JOIN blocks b ON b.doc_id = d.doc_id",
94        Target::Nodes => "docs d CROSS JOIN nodes n ON n.doc_id = d.doc_id",
95        Target::Links => "docs d CROSS JOIN edges e ON e.src_doc = d.doc_id",
96    }
97}
98
99/// [`guards`] for a [`from_by_doc`] statement: the same tests, with the
100/// document's repo first and the target's repo term behind SQLite's unary `+`
101/// so it stays a filter and never selects a `(repo_id, …)` index over the
102/// `doc_id` one. Two repo-id binds (`Docs`: one, as `guards`).
103pub(crate) fn guards_by_doc(t: Target) -> &'static str {
104    match t {
105        Target::Docs => guards(t),
106        Target::Blocks => {
107            "d.repo_id = ? AND +b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL"
108        }
109        Target::Nodes => "d.repo_id = ? AND +n.repo_id = ? AND d.deleted_commit IS NULL",
110        Target::Links => {
111            "d.repo_id = ? AND +e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL"
112        }
113    }
114}
115
116/// The root collection a query scans, if it is a bare `docs|blocks|nodes|links`
117/// source at the root scope (else `None` — not a pushable shape; a nested
118/// `^docs` is a block's receiver, never the query's source). The runner asks
119/// too: a bare root scan's rows are store rows by construction.
120pub(crate) fn root_target(source: &Expr) -> Option<Target> {
121    match source {
122        Expr::Ident { name, .. } => Target::parse(name),
123        _ => None,
124    }
125}
126
127/// Whether `name` is a relation of `target`'s rows that is spelled like a
128/// target (§1.2: `docs.nodes`, `docs.blocks`, `blocks.nodes`, `section.blocks`).
129fn is_target_relation_of(target: Target, name: &str) -> bool {
130    matches!(
131        (target, name),
132        (Target::Docs, "nodes" | "blocks") | (Target::Blocks, "nodes") | (Target::Nodes, "blocks")
133    )
134}
135
136/// The row a reach-through head reaches (`doc.x`, `block.x`, `section.x`,
137/// and a link's `dst.x`).
138fn reach_through(head: &str) -> Option<Target> {
139    match head {
140        "doc" | "dst" => Some(Target::Docs),
141        "block" => Some(Target::Blocks),
142        "section" => Some(Target::Nodes),
143        _ => None,
144    }
145}
146
147// ---- the residual-error decline (surface 1.1 patch, §1) ---------------------------------
148
149/// Whether a residual `where` could raise an OQX eval error the pushed
150/// conjuncts might hide by emptying the scan. In memory every conjunct of the
151/// original `&&` is evaluated for the first row and a throwing one aborts the
152/// run; planned, a pushed conjunct that matches no row means the residual
153/// never runs and the error vanishes. So a residual containing any function
154/// or method call (an unknown function, a bad regex, a wrong arity…), a
155/// nested block with the `single` consumer (more than one row raises), a
156/// `^`-escaped name (no enclosing scope at the top level) sends the whole
157/// query to the in-memory engine unplanned (before this batch of 3.0 a bare
158/// reserved docs basename did too; the attributes are bare now and never
159/// raise). Only comparisons, logical
160/// operators, `in`, `!`, literals, bindings and plain reads keep the push.
161///
162/// The walk is generic over the whole `oqx` AST — every `Where` node, every
163/// nested block (its `from`, `where`, `select`, `order by`, `follow`,
164/// `limit` / `offset`) and every `Expr`. A `^name:` lift in a nested select
165/// counts as a `^`-escaped name. Same decisions as the reference's
166/// `residualMayRaise`.
167fn residual_may_raise(w: &Where, target: Target) -> bool {
168    where_may_raise(w, target, true)
169}
170
171fn where_may_raise(w: &Where, target: Target, root: bool) -> bool {
172    match w {
173        Where::And { parts, .. } | Where::Or { parts, .. } => {
174            parts.iter().any(|p| where_may_raise(p, target, root))
175        }
176        Where::Not { expr, .. } => where_may_raise(expr, target, root),
177        Where::Scalar { expr, .. } => expr_may_raise(expr, target, root),
178        Where::Op(op) => op_may_raise(op, target, root),
179    }
180}
181
182fn op_may_raise(op: &OpNode, target: Target, root: bool) -> bool {
183    op.op == Consumer::Single
184        || expr_may_raise(&op.receiver, target, root)
185        || subquery_may_raise(&op.sub, target)
186}
187
188fn subquery_may_raise(sub: &Subquery, target: Target) -> bool {
189    let inner = |e: &Expr| expr_may_raise(e, target, false);
190    sub.from.iter().any(inner)
191        || sub
192            .r#where
193            .as_ref()
194            .is_some_and(|w| where_may_raise(w, target, false))
195        || sub.select.iter().any(|item| match item {
196            SelectItem::Field { expr, lift, .. } => *lift > 0 || inner(expr),
197            SelectItem::Collect { op, .. } => op_may_raise(op, target, false),
198        })
199        || sub.order_by.iter().flatten().any(|o| inner(&o.expr))
200        || sub
201            .follow
202            .as_ref()
203            .is_some_and(|f| follow_may_raise(f, target))
204        || sub.frontier.as_ref().is_some_and(inner)
205        || sub.limit.as_ref().is_some_and(inner)
206        || sub.offset.as_ref().is_some_and(inner)
207}
208
209// A destination is a relation read in the frontier row's scope, or a destination
210// block (whose receiver, body, `frontier` and `depth` are its own — oqx 0.20).
211fn follow_may_raise(f: &Follow, target: Target) -> bool {
212    let inner = |e: &Expr| expr_may_raise(e, target, false);
213    f.destinations.iter().any(|d| match d {
214        FollowDestination::Relation(e) => inner(e),
215        FollowDestination::Block(op) => op_may_raise(op, target, false),
216    })
217}
218
219fn expr_may_raise(e: &Expr, target: Target, root: bool) -> bool {
220    let again = |e: &Expr| expr_may_raise(e, target, root);
221    match e {
222        Expr::Lit { .. } | Expr::Binding { .. } => false,
223        // A bare target name read as a property raises (the root-row rule,
224        // `crate::query`) unless it is a relation of the rows at hand: known at
225        // the root scope, unknown below.
226        Expr::Ident { name, .. } if Target::parse(name).is_some() => {
227            !root || !is_target_relation_of(target, name)
228        }
229        // A bare attribute never raises (3.0: there is no loud-error set).
230        Expr::Ident { .. } => false,
231        Expr::Outer { .. } | Expr::Call { .. } => true,
232        // `<head>.<target>` raises unless the head is a reach-through whose row
233        // has that relation.
234        Expr::Member { recv, name, .. } => {
235            (Target::parse(name).is_some()
236                && !matches!(&**recv, Expr::Ident { name: head, .. }
237                        if reach_through(head).is_some_and(|t| is_target_relation_of(t, name))))
238                || again(recv)
239        }
240        Expr::Unary { expr, .. } => again(expr),
241        // `x!` raises when absent; a value-position directive may `single`-fail
242        // or read anything its block reads.
243        Expr::Required { .. } => true,
244        Expr::Op(op) => op_may_raise(op, target, root),
245        Expr::Binary { left, right, .. }
246        | Expr::Logical { left, right, .. }
247        | Expr::In { left, right, .. } => again(left) || again(right),
248        Expr::Range { lo, hi, .. } => {
249            lo.as_deref().is_some_and(again) || hi.as_deref().is_some_and(again)
250        }
251        // A tuple (oqx 0.20) is never pushed down; it raises iff an element does.
252        Expr::Tuple { items, .. } => items.iter().any(again),
253        // A template literal (oqx 0.21) is never pushed down; interpolating a
254        // list or a record is an eval error only the data decides, so with any
255        // interpolation it may raise.
256        Expr::Template { parts, .. } => oqx::walk::template_exprs(parts).next().is_some(),
257    }
258}
259
260/// A compiled plan before execution: the statement, its params and the
261/// residual query. Pure — what the planner would run, for inspection.
262#[derive(Clone, Debug, PartialEq)]
263pub struct Compiled {
264    pub target: Target,
265    pub sql: String,
266    pub params: Vec<SqlValue>,
267    pub residual: Query,
268}
269
270/// Compile `query` against `repo_id`, or `None` when the shape declines: a
271/// `follow`, a `from E` re-projection, a source that is not a root scan, or
272/// no pushable conjunct at all (the engine then does everything).
273#[must_use]
274pub fn compile(query: &Query, params: &[Value], repo_id: &str) -> Option<Compiled> {
275    if query.follow.is_some() || !query.from.is_empty() {
276        return None;
277    }
278    let target = root_target(&query.source)?;
279    let (self_alias, doc_alias) = aliases(target);
280    let ctx = TranslateCtx {
281        target,
282        self_alias,
283        doc_alias,
284        params,
285    };
286    let (pushed, residual) = partition_pushable(query.r#where.as_ref(), |e| {
287        translate_predicate(e, &ctx).is_some()
288    });
289    if pushed.is_empty() {
290        return None;
291    }
292    // Decline (c): a residual that could raise must not be hidden behind an
293    // emptied scan — the whole query runs in memory.
294    if residual
295        .as_ref()
296        .is_some_and(|w| residual_may_raise(w, target))
297    {
298        return None;
299    }
300    let mut where_sql = guards(target).to_owned();
301    let mut sql_params = vec![SqlValue::Text(repo_id.to_owned())];
302    for e in &pushed {
303        let frag = translate_predicate(e, &ctx).expect("accepted by partition_pushable");
304        where_sql.push_str(" AND (");
305        where_sql.push_str(&frag.sql);
306        where_sql.push(')');
307        sql_params.extend(frag.params);
308    }
309    let sql = format!(
310        "SELECT {} FROM {} WHERE {where_sql} ORDER BY {}",
311        columns(target),
312        from_clause(target),
313        order_clause(target)
314    );
315    Some(Compiled {
316        target,
317        sql,
318        params: sql_params,
319        residual: residual_query(query, residual),
320    })
321}
322
323/// The SQLite planner over one repo of the store.
324pub struct SqlitePlanner<'a> {
325    conn: &'a Connection,
326    repo_id: String,
327}
328
329impl<'a> SqlitePlanner<'a> {
330    #[must_use]
331    pub fn new(conn: &'a Connection, repo_id: &str) -> Self {
332        Self {
333            conn,
334            repo_id: repo_id.to_owned(),
335        }
336    }
337
338    /// Plan `query`: `Ok(None)` when the shape declines, `Ok(Some(plan))`
339    /// with the produced rows (tagged like the context's root rows) and the
340    /// residual, `Err` when the statement itself failed (a store error, not
341    /// a decline — the runner reports it rather than silently rescanning).
342    pub fn try_plan(&self, query: &Query, params: &[Value]) -> rusqlite::Result<Option<Plan>> {
343        let Some(compiled) = compile(query, params, &self.repo_id) else {
344            return Ok(None);
345        };
346        let rows = fetch_rows(self.conn, &compiled.sql, &compiled.params)?;
347        Ok(Some(Plan::new(
348            tag_rows(rows, compiled.target),
349            compiled.residual,
350        )))
351    }
352}
353
354impl QueryPlanner for SqlitePlanner<'_> {
355    /// The seam's shape: a failed statement declines (the engine falls back
356    /// to a full scan). The runner uses [`Self::try_plan`] to surface it.
357    fn plan(&self, query: &Query, params: &[Value]) -> Option<Plan> {
358        self.try_plan(query, params).ok().flatten()
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use oqx::ROWS_ROOT;
366    use oqx::ast::Where;
367
368    fn parse(src: &str) -> Query {
369        oqx::parse_string(src).expect("parses")
370    }
371
372    fn text(s: &str) -> SqlValue {
373        SqlValue::Text(s.to_owned())
374    }
375
376    #[test]
377    fn a_pushable_scan_compiles_to_one_statement_in_root_order() {
378        let c =
379            compile(&parse("from docs where path == \"index.md\""), &[], "r_1").expect("planned");
380        assert_eq!(c.target, Target::Docs);
381        assert_eq!(
382            c.sql,
383            "SELECT d.* FROM docs d WHERE d.repo_id = ? AND d.deleted_commit IS NULL AND ((('/' || d.path) IS ?)) ORDER BY d.path, d.doc_id"
384        );
385        assert_eq!(c.params, vec![text("r_1"), text("index.md")]);
386        assert_eq!(
387            c.residual.source,
388            Expr::Ident {
389                name: ROWS_ROOT.to_owned(),
390                span: oqx::Span::EMPTY,
391            }
392        );
393        assert_eq!(c.residual.r#where, None);
394    }
395
396    #[test]
397    fn every_target_has_its_join_columns_guards_and_order() {
398        let b = compile(
399            &parse("from blocks where path.startsWith(\"lab/\")"),
400            &[],
401            "r",
402        )
403        .unwrap();
404        assert_eq!(
405            b.sql,
406            "SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id \
407             WHERE b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL \
408             AND ((substr(('/' || d.path), 1, length(?)) = ?)) ORDER BY d.path, b.block_id"
409        );
410        assert_eq!(b.params, vec![text("r"), text("lab/"), text("lab/")]);
411        let n = compile(
412            &parse("nodes count { where kind == \"md:task\" }"),
413            &[],
414            "r",
415        )
416        .unwrap();
417        assert_eq!(n.target, Target::Nodes);
418        assert!(n.sql.starts_with(
419            "SELECT n.*, d.path AS __path FROM nodes n JOIN docs d ON d.doc_id = n.doc_id WHERE n.repo_id = ? AND d.deleted_commit IS NULL AND ((n.kind IS ?))"
420        ));
421        assert!(n.sql.ends_with("ORDER BY d.path, n.node_id"));
422        let e = compile(
423            &parse("from links where predicate == \"references\""),
424            &[],
425            "r",
426        )
427        .unwrap();
428        assert!(e.sql.starts_with(
429            "SELECT e.*, d.path AS __path FROM edges e JOIN docs d ON d.doc_id = e.src_doc WHERE e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL AND ((e.predicate IS ?))"
430        ));
431        assert!(e.sql.ends_with("ORDER BY d.path, e.edge_id"));
432    }
433
434    #[test]
435    fn mixed_conjunctions_push_the_translatable_parts_and_keep_the_rest() {
436        let q = parse(
437            "from docs where path.startsWith(\"processes/\") && nodes exists { where kind == \"md:task\" } && layer == \"canon\"",
438        );
439        let c = compile(&q, &[], "r").expect("planned");
440        assert!(
441            c.sql
442                .contains("(substr(('/' || d.path), 1, length(?)) = ?)"),
443            "{}",
444            c.sql
445        );
446        assert!(c.sql.contains("p.key = 'layer'"), "{}", c.sql);
447        // Guards first, then the fragments' params in statement order.
448        assert_eq!(
449            c.params,
450            vec![
451                text("r"),
452                text("processes/"),
453                text("processes/"),
454                text("canon")
455            ]
456        );
457        // One conjunct left → it stands alone, not wrapped in an `and`.
458        assert!(
459            matches!(c.residual.r#where, Some(Where::Op(_))),
460            "{:?}",
461            c.residual.r#where
462        );
463        assert!(c.residual.from.is_empty());
464        assert_eq!(c.residual.select, q.select);
465        assert_eq!(c.residual.consumer, q.consumer);
466    }
467
468    #[test]
469    fn declined_shapes_return_none() {
470        // nothing pushable → let the engine do it all
471        assert!(compile(&parse("from docs"), &[], "r").is_none());
472        assert!(compile(&parse("from docs where era in 800..1680"), &[], "r").is_none());
473        assert!(compile(&parse("from docs where !verified"), &[], "r").is_none());
474        assert!(
475            compile(
476                &parse("from docs where path == \"a\" || path == \"b\""),
477                &[],
478                "r"
479            )
480            .is_none()
481        );
482        assert!(
483            compile(
484                &parse("from docs where nodes exists { where kind == \"md:task\" }"),
485                &[],
486                "r"
487            )
488            .is_none()
489        );
490        // follow
491        assert!(
492            compile(
493                &parse("from docs where path == \"a.md\" follow distinct out_links collect { from dst }"),
494                &[],
495                "r"
496            )
497            .is_none()
498        );
499        // follow with several destinations: relations and a destination block
500        // decline exactly like a single relation (0.14)
501        assert!(
502            compile(
503                &parse("from docs where path == \"a.md\" follow out_links collect { from dst }, in_links collect { from doc }"),
504                &[],
505                "r"
506            )
507            .is_none()
508        );
509        assert!(
510            compile(
511                &parse(
512                    "from docs where path == \"a.md\" follow ^docs collect { where after.contains(^path) }"
513                ),
514                &[],
515                "r"
516            )
517            .is_none()
518        );
519        // a body-level `frontier` (oqx 0.20) plans like the `follow` it needs
520        assert!(
521            compile(
522                &parse("from docs where path == \"a.md\" frontier type == \"hub\" follow distinct out_links collect { from dst frontier type == \"practitioner\" }"),
523                &[],
524                "r"
525            )
526            .is_none()
527        );
528        // not a root scan
529        assert!(compile(&parse("from things where path == \"a.md\""), &[], "r").is_none());
530        assert!(compile(&parse("from nope where path == \"a.md\""), &[], "r").is_none());
531        assert!(compile(&parse("from docs.nodes where kind == \"x\""), &[], "r").is_none());
532        // a `from E` re-projection (an AST-level shape)
533        let mut q = parse("from docs where path == \"a.md\"");
534        q.from.push(Expr::Ident {
535            name: "nodes".to_owned(),
536            span: oqx::Span::EMPTY,
537        });
538        assert!(compile(&q, &[], "r").is_none());
539    }
540
541    // -- decline (c): a residual that could raise sends the whole query in memory --
542
543    #[test]
544    fn a_residual_that_could_raise_declines_the_whole_query() {
545        let declined = |src: &str| {
546            assert!(
547                compile(&parse(src), &[], "r").is_none(),
548                "should decline: {src}"
549            );
550        };
551        let planned = |src: &str| {
552            assert!(
553                compile(&parse(src), &[], "r").is_some(),
554                "should plan: {src}"
555            );
556        };
557        // a bare attribute is never a raise (3.0: no loud-error set) — `path`
558        // pushes, `raw`/`updated_at`/`tags` are plain residuals
559        planned("from docs where path == \"x\" && path == \"nope.md\"");
560        planned("from docs where path == \"nope.md\" && !raw");
561        planned("from docs where path == \"nope.md\" && updated_at");
562        planned("from docs where path == \"nope.md\" && tags");
563        // `doc.<attribute>` on any target, at any depth, is a plain read too
564        planned("from blocks where path == \"x\" && doc.path == \"y\"");
565        planned("from blocks where path == \"x\" && nodes exists { where doc.path == \"y\" }");
566        // a function or method call
567        declined("from docs where path.matches(\"[\") && path == \"nope.md\"");
568        declined("from docs where nope(\"x\") && path == \"nope.md\"");
569        declined("from docs where path == \"x\" && size(tags) > 1");
570        declined("from docs where path == \"x\" && nodes exists { where name.lower() == \"a\" }");
571        declined(
572            "from docs where path == \"x\" && nodes count { where kind == \"a\" order by size(name) } > 1",
573        );
574        // a `^`-escaped name: an outer reference or a lift
575        declined("from docs where path == \"x\" && ^slug == \"y\"");
576        declined("from docs where path == \"x\" && nodes exists { where name == ^title }");
577        declined("from docs where path == \"x\" && nodes collect { ^first_task: name }");
578        // a nested block with the `single` consumer
579        declined(
580            "from docs where path == \"x\" && blocks exists { select t: nodes single { where kind == \"md:task\" } }",
581        );
582        // the pushed side raising is impossible; only the residual matters
583        planned("from docs where path.startsWith(\"lab/\") && path == \"x\"");
584        // plain reads, comparisons, `in`, `!`, literals and bindings keep the push
585        planned("from docs where path == \"x\" && era in 800..1680");
586        planned("from docs where path == \"x\" && !verified");
587        planned("from docs where path == \"x\" && (layer == \"a\" || layer == \"b\")");
588        planned("from docs where path == \"x\" && verified == true");
589        planned("from docs where path == \"x\" && nodes exists { where kind == \"md:task\" }");
590        planned(
591            "from docs where path == \"x\" && nodes count { where kind == \"md:task\" limit 5 } > 1",
592        );
593        // on blocks a bare `path` is an attribute, and inside a block the rows
594        // are another scope — an ordinary read, not the guard
595        planned("from blocks where path == \"x\" && !path");
596        planned("from docs where path == \"x\" && nodes exists { where path == \"y\" }");
597        planned("from docs where path == \"x\" && frontmatter.path == \"y\"");
598        // a key read through a source handle is pushed (3.0), scoped to its
599        // source; a WHOLE handle reference and `entries(…)` stay residual (and
600        // the function call declines the query)
601        let fm = compile(
602            &parse("from docs where frontmatter.layer == \"canon\""),
603            &[],
604            "r",
605        )
606        .expect("planned");
607        assert!(fm.sql.contains("p.source = 'frontmatter'"), "{}", fm.sql);
608        let data =
609            compile(&parse("from docs where data.layer == \"canon\""), &[], "r").expect("planned");
610        assert!(!data.sql.contains("p.source"), "{}", data.sql);
611        planned("from blocks where doc.inline.element == \"fire\"");
612        planned("from docs where path == \"x\" && frontmatter == null");
613        declined("from docs where path == \"x\" && entries(frontmatter) exists { }");
614    }
615
616    #[test]
617    fn a_top_level_not_or_or_is_the_whole_residual_and_declines() {
618        // `!(…)` and `||` at the top are `Where::Not` / `Where::Or`, never scalar
619        // parts, so nothing is pushed even when their leaves would be.
620        assert!(compile(&parse("from docs where !(path == \"a\")"), &[], "r").is_none());
621        assert!(
622            compile(
623                &parse("from docs where (path == \"a\" || path == \"b\") && layer == \"canon\""),
624                &[],
625                "r"
626            )
627            .is_some()
628        );
629    }
630}