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|edges` or `$repo.<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::{RESERVED_DOC_BASENAMES, 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::Edges => ("e", "d"),
42    }
43}
44
45fn 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::Edges => "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).
56fn 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::Edges => "e.*, d.path AS __path",
62    }
63}
64
65/// The root order (`spec/surface` §1.1).
66fn 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::Edges => "d.path, e.edge_id",
72    }
73}
74
75/// The liveness guards of the root scan, with the repo id bound.
76fn 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::Edges => "e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL",
82    }
83}
84
85/// The root collection a query scans, if it is a bare `docs|blocks|nodes|edges`
86/// or `$repo.<target>` source (else `None` — not a pushable shape).
87fn root_target(source: &Expr) -> Option<Target> {
88    match source {
89        Expr::Ident { name } => Target::parse(name),
90        Expr::Member { recv, name } => match &**recv {
91            Expr::Ident { name: r } if r == "$repo" => Target::parse(name),
92            _ => None,
93        },
94        _ => None,
95    }
96}
97
98// ---- the residual-error decline (surface 1.1 patch, §1) ---------------------------------
99
100/// Whether a residual `where` could raise an OQX eval error the pushed
101/// conjuncts might hide by emptying the scan. In memory every conjunct of the
102/// original `&&` is evaluated for the first row and a throwing one aborts the
103/// run; planned, a pushed conjunct that matches no row means the residual
104/// never runs and the error vanishes. So a residual containing any function
105/// or method call (an unknown function, a bad regex, a wrong arity…), a
106/// nested block with the `single` consumer (more than one row raises), a
107/// `^`-escaped name (no enclosing scope at the top level) or a bare reserved
108/// docs basename (`path` for `$path`…, the guard in `get`) sends the whole
109/// query to the in-memory engine unplanned. Only comparisons, logical
110/// operators, `in`, `!`, literals, bindings and plain reads keep the push.
111///
112/// The walk is generic over the whole `oqx` AST — every `Where` node, every
113/// nested block (its `from`, `where`, `select`, `order by`, `follow`,
114/// `limit` / `offset`) and every `Expr`. A `^name:` lift in a nested select
115/// counts as a `^`-escaped name. Inside a block the rows are a different
116/// scope (a relation's rows), so a bare reserved name is an ordinary read
117/// there (`root` is false) — only `doc.<reserved>` still raises at any depth.
118/// Same decisions as the reference's `residualMayRaise`.
119fn residual_may_raise(w: &Where, target: Target) -> bool {
120    where_may_raise(w, target, true)
121}
122
123fn where_may_raise(w: &Where, target: Target, root: bool) -> bool {
124    match w {
125        Where::And { parts } | Where::Or { parts } => {
126            parts.iter().any(|p| where_may_raise(p, target, root))
127        }
128        Where::Not { expr } => where_may_raise(expr, target, root),
129        Where::Scalar { expr } => expr_may_raise(expr, target, root),
130        Where::Op(op) => op_may_raise(op, target, root),
131    }
132}
133
134fn op_may_raise(op: &OpNode, target: Target, root: bool) -> bool {
135    op.op == Consumer::Single
136        || expr_may_raise(&op.receiver, target, root)
137        || subquery_may_raise(&op.sub, target)
138}
139
140fn subquery_may_raise(sub: &Subquery, target: Target) -> bool {
141    let inner = |e: &Expr| expr_may_raise(e, target, false);
142    sub.from.iter().any(inner)
143        || sub
144            .r#where
145            .as_ref()
146            .is_some_and(|w| where_may_raise(w, target, false))
147        || sub.select.iter().any(|item| match item {
148            SelectItem::Field { expr, lift, .. } => *lift > 0 || inner(expr),
149            SelectItem::Collect { op, .. } => op_may_raise(op, target, false),
150        })
151        || sub.order_by.iter().flatten().any(|o| inner(&o.expr))
152        || sub
153            .follow
154            .as_ref()
155            .is_some_and(|f| follow_may_raise(f, target))
156        || sub.limit.as_ref().is_some_and(inner)
157        || sub.offset.as_ref().is_some_and(inner)
158}
159
160fn follow_may_raise(f: &Follow, target: Target) -> bool {
161    let inner = |e: &Expr| expr_may_raise(e, target, false);
162    f.destinations.iter().any(|d| match d {
163        FollowDestination::Relation(e) => inner(e),
164        FollowDestination::Block(op) => op_may_raise(op, target, false),
165    }) || f.r#where.as_ref().is_some_and(inner)
166        || f.frontier.as_ref().is_some_and(inner)
167        || f.by.as_ref().is_some_and(inner)
168}
169
170fn is_reserved(name: &str) -> bool {
171    RESERVED_DOC_BASENAMES.contains(&name)
172}
173
174fn expr_may_raise(e: &Expr, target: Target, root: bool) -> bool {
175    let again = |e: &Expr| expr_may_raise(e, target, root);
176    match e {
177        Expr::Lit(_) | Expr::Binding { .. } => false,
178        Expr::Ident { name } => root && target == Target::Docs && is_reserved(name),
179        Expr::Outer { .. } | Expr::Call { .. } => true,
180        // `doc.<reserved>`: the reach-through row is a doc (the row itself on
181        // docs), so the guard fires on any target at any depth.
182        Expr::Member { recv, name } => {
183            (is_reserved(name) && matches!(&**recv, Expr::Ident { name } if name == "doc"))
184                || again(recv)
185        }
186        Expr::Index { recv, index } => again(recv) || again(index),
187        Expr::Unary { expr, .. } => again(expr),
188        Expr::Binary { left, right, .. }
189        | Expr::Logical { left, right, .. }
190        | Expr::In { left, right } => again(left) || again(right),
191        Expr::Range { lo, hi, .. } => {
192            lo.as_deref().is_some_and(again) || hi.as_deref().is_some_and(again)
193        }
194    }
195}
196
197/// A compiled plan before execution: the statement, its params and the
198/// residual query. Pure — what the planner would run, for inspection.
199#[derive(Clone, Debug, PartialEq)]
200pub struct Compiled {
201    pub target: Target,
202    pub sql: String,
203    pub params: Vec<SqlValue>,
204    pub residual: Query,
205}
206
207/// Compile `query` against `repo_id`, or `None` when the shape declines: a
208/// `follow`, a `from E` re-projection, a source that is not a root scan, or
209/// no pushable conjunct at all (the engine then does everything).
210#[must_use]
211pub fn compile(query: &Query, params: &[Value], repo_id: &str) -> Option<Compiled> {
212    if query.follow.is_some() || !query.from.is_empty() {
213        return None;
214    }
215    let target = root_target(&query.source)?;
216    let (self_alias, doc_alias) = aliases(target);
217    let ctx = TranslateCtx {
218        target,
219        self_alias,
220        doc_alias,
221        params,
222    };
223    let (pushed, residual) = partition_pushable(query.r#where.as_ref(), |e| {
224        translate_predicate(e, &ctx).is_some()
225    });
226    if pushed.is_empty() {
227        return None;
228    }
229    // Decline (c): a residual that could raise must not be hidden behind an
230    // emptied scan — the whole query runs in memory.
231    if residual
232        .as_ref()
233        .is_some_and(|w| residual_may_raise(w, target))
234    {
235        return None;
236    }
237    let mut where_sql = guards(target).to_owned();
238    let mut sql_params = vec![SqlValue::Text(repo_id.to_owned())];
239    for e in &pushed {
240        let frag = translate_predicate(e, &ctx).expect("accepted by partition_pushable");
241        where_sql.push_str(" AND (");
242        where_sql.push_str(&frag.sql);
243        where_sql.push(')');
244        sql_params.extend(frag.params);
245    }
246    let sql = format!(
247        "SELECT {} FROM {} WHERE {where_sql} ORDER BY {}",
248        columns(target),
249        from_clause(target),
250        order_clause(target)
251    );
252    Some(Compiled {
253        target,
254        sql,
255        params: sql_params,
256        residual: residual_query(query, residual),
257    })
258}
259
260/// The SQLite planner over one repo of the store.
261pub struct SqlitePlanner<'a> {
262    conn: &'a Connection,
263    repo_id: String,
264}
265
266impl<'a> SqlitePlanner<'a> {
267    #[must_use]
268    pub fn new(conn: &'a Connection, repo_id: &str) -> Self {
269        Self {
270            conn,
271            repo_id: repo_id.to_owned(),
272        }
273    }
274
275    /// Plan `query`: `Ok(None)` when the shape declines, `Ok(Some(plan))`
276    /// with the produced rows (tagged like the context's root rows) and the
277    /// residual, `Err` when the statement itself failed (a store error, not
278    /// a decline — the runner reports it rather than silently rescanning).
279    pub fn try_plan(&self, query: &Query, params: &[Value]) -> rusqlite::Result<Option<Plan>> {
280        let Some(compiled) = compile(query, params, &self.repo_id) else {
281            return Ok(None);
282        };
283        let rows = fetch_rows(self.conn, &compiled.sql, &compiled.params)?;
284        Ok(Some(Plan::new(
285            tag_rows(rows, compiled.target),
286            compiled.residual,
287        )))
288    }
289}
290
291impl QueryPlanner for SqlitePlanner<'_> {
292    /// The seam's shape: a failed statement declines (the engine falls back
293    /// to a full scan). The runner uses [`Self::try_plan`] to surface it.
294    fn plan(&self, query: &Query, params: &[Value]) -> Option<Plan> {
295        self.try_plan(query, params).ok().flatten()
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use oqx::ROWS_ROOT;
303    use oqx::ast::Where;
304
305    fn parse(src: &str) -> Query {
306        oqx::parse_string(src).expect("parses")
307    }
308
309    fn text(s: &str) -> SqlValue {
310        SqlValue::Text(s.to_owned())
311    }
312
313    #[test]
314    fn a_pushable_scan_compiles_to_one_statement_in_root_order() {
315        let c =
316            compile(&parse("from docs where $path == \"index.md\""), &[], "r_1").expect("planned");
317        assert_eq!(c.target, Target::Docs);
318        assert_eq!(
319            c.sql,
320            "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"
321        );
322        assert_eq!(c.params, vec![text("r_1"), text("index.md")]);
323        assert_eq!(
324            c.residual.source,
325            Expr::Ident {
326                name: ROWS_ROOT.to_owned()
327            }
328        );
329        assert_eq!(c.residual.r#where, None);
330    }
331
332    #[test]
333    fn every_target_has_its_join_columns_guards_and_order() {
334        let b = compile(
335            &parse("from blocks where $path.startsWith(\"lab/\")"),
336            &[],
337            "r",
338        )
339        .unwrap();
340        assert_eq!(
341            b.sql,
342            "SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id \
343             WHERE b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL \
344             AND ((substr(d.path, 1, length(?)) = ?)) ORDER BY d.path, b.block_id"
345        );
346        assert_eq!(b.params, vec![text("r"), text("lab/"), text("lab/")]);
347        let n = compile(
348            &parse("$repo.nodes count { where kind == \"md:task\" }"),
349            &[],
350            "r",
351        )
352        .unwrap();
353        assert_eq!(n.target, Target::Nodes);
354        assert!(n.sql.starts_with(
355            "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 ?))"
356        ));
357        assert!(n.sql.ends_with("ORDER BY d.path, n.node_id"));
358        let e = compile(
359            &parse("from edges where predicate == \"references\""),
360            &[],
361            "r",
362        )
363        .unwrap();
364        assert!(e.sql.starts_with(
365            "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 ?))"
366        ));
367        assert!(e.sql.ends_with("ORDER BY d.path, e.edge_id"));
368    }
369
370    #[test]
371    fn mixed_conjunctions_push_the_translatable_parts_and_keep_the_rest() {
372        let q = parse(
373            "from docs where $path.startsWith(\"processes/\") && nodes exists { where kind == \"md:task\" } && layer == \"canon\"",
374        );
375        let c = compile(&q, &[], "r").expect("planned");
376        assert!(
377            c.sql.contains("(substr(d.path, 1, length(?)) = ?)"),
378            "{}",
379            c.sql
380        );
381        assert!(c.sql.contains("p.key = 'layer'"), "{}", c.sql);
382        // Guards first, then the fragments' params in statement order.
383        assert_eq!(
384            c.params,
385            vec![
386                text("r"),
387                text("processes/"),
388                text("processes/"),
389                text("canon")
390            ]
391        );
392        // One conjunct left → it stands alone, not wrapped in an `and`.
393        assert!(
394            matches!(c.residual.r#where, Some(Where::Op(_))),
395            "{:?}",
396            c.residual.r#where
397        );
398        assert!(c.residual.from.is_empty());
399        assert_eq!(c.residual.select, q.select);
400        assert_eq!(c.residual.consumer, q.consumer);
401    }
402
403    #[test]
404    fn declined_shapes_return_none() {
405        // nothing pushable → let the engine do it all
406        assert!(compile(&parse("from docs"), &[], "r").is_none());
407        assert!(compile(&parse("from docs where era in 800..1680"), &[], "r").is_none());
408        assert!(compile(&parse("from docs where !verified"), &[], "r").is_none());
409        assert!(
410            compile(
411                &parse("from docs where $path == \"a\" || $path == \"b\""),
412                &[],
413                "r"
414            )
415            .is_none()
416        );
417        assert!(
418            compile(
419                &parse("from docs where nodes exists { where kind == \"md:task\" }"),
420                &[],
421                "r"
422            )
423            .is_none()
424        );
425        // follow
426        assert!(
427            compile(
428                &parse("from docs where $path == \"a.md\" follow distinct doc.out"),
429                &[],
430                "r"
431            )
432            .is_none()
433        );
434        // follow with several destinations: relations and a destination block
435        // decline exactly like a single relation (0.14)
436        assert!(
437            compile(
438                &parse("from docs where $path == \"a.md\" follow doc.out, doc.in"),
439                &[],
440                "r"
441            )
442            .is_none()
443        );
444        assert!(
445            compile(
446                &parse(
447                    "from docs where $path == \"a.md\" follow $repo.docs collect { where after.contains(^$path) }"
448                ),
449                &[],
450                "r"
451            )
452            .is_none()
453        );
454        // not a root scan
455        assert!(compile(&parse("from things where $path == \"a.md\""), &[], "r").is_none());
456        assert!(compile(&parse("from $repo where $path == \"a.md\""), &[], "r").is_none());
457        assert!(compile(&parse("from docs.nodes where kind == \"x\""), &[], "r").is_none());
458        // a `from E` re-projection (an AST-level shape)
459        let mut q = parse("from docs where $path == \"a.md\"");
460        q.from.push(Expr::Ident {
461            name: "nodes".to_owned(),
462        });
463        assert!(compile(&q, &[], "r").is_none());
464    }
465
466    // -- decline (c): a residual that could raise sends the whole query in memory --
467
468    #[test]
469    fn a_residual_that_could_raise_declines_the_whole_query() {
470        let declined = |src: &str| {
471            assert!(
472                compile(&parse(src), &[], "r").is_none(),
473                "should decline: {src}"
474            );
475        };
476        let planned = |src: &str| {
477            assert!(
478                compile(&parse(src), &[], "r").is_some(),
479                "should plan: {src}"
480            );
481        };
482        // a bare reserved docs basename (the guard in `get`)
483        declined("from docs where path == \"x\" && $path == \"nope.md\"");
484        declined("from docs where $path == \"nope.md\" && !body");
485        // `doc.<reserved>` on any target, at any depth
486        declined("from blocks where $path == \"x\" && doc.path == \"y\"");
487        declined("from blocks where $path == \"x\" && nodes exists { where doc.path == \"y\" }");
488        // a function or method call
489        declined("from docs where $path.matches(\"[\") && $path == \"nope.md\"");
490        declined("from docs where nope(\"x\") && $path == \"nope.md\"");
491        declined("from docs where $path == \"x\" && size(tags) > 1");
492        declined("from docs where $path == \"x\" && nodes exists { where name.lower() == \"a\" }");
493        declined(
494            "from docs where $path == \"x\" && nodes count { where kind == \"a\" order by size(name) } > 1",
495        );
496        // a `^`-escaped name: an outer reference or a lift
497        declined("from docs where $path == \"x\" && ^slug == \"y\"");
498        declined("from docs where $path == \"x\" && nodes exists { where name == ^title }");
499        declined("from docs where $path == \"x\" && nodes collect { ^first_task: name }");
500        // a nested block with the `single` consumer
501        declined(
502            "from docs where $path == \"x\" && blocks exists { select t: nodes single { where kind == \"md:task\" } }",
503        );
504        // the pushed side raising is impossible; only the residual matters
505        planned("from docs where $path.startsWith(\"lab/\") && $path == \"x\"");
506        // plain reads, comparisons, `in`, `!`, literals and bindings keep the push
507        planned("from docs where $path == \"x\" && era in 800..1680");
508        planned("from docs where $path == \"x\" && !verified");
509        planned("from docs where $path == \"x\" && (layer == \"a\" || layer == \"b\")");
510        planned("from docs where $path == \"x\" && verified == true");
511        planned("from docs where $path == \"x\" && nodes exists { where kind == \"md:task\" }");
512        planned(
513            "from docs where $path == \"x\" && nodes count { where kind == \"md:task\" limit 5 } > 1",
514        );
515        // on blocks a bare `path` is an attribute, and inside a block the rows
516        // are another scope — an ordinary read, not the guard
517        planned("from blocks where $path == \"x\" && !path");
518        planned("from docs where $path == \"x\" && nodes exists { where path == \"y\" }");
519        planned("from docs where $path == \"x\" && frontmatter.path == \"y\"");
520    }
521
522    #[test]
523    fn a_top_level_not_or_or_is_the_whole_residual_and_declines() {
524        // `!(…)` and `||` at the top are `Where::Not` / `Where::Or`, never scalar
525        // parts, so nothing is pushed even when their leaves would be.
526        assert!(compile(&parse("from docs where !($path == \"a\")"), &[], "r").is_none());
527        assert!(
528            compile(
529                &parse("from docs where ($path == \"a\" || $path == \"b\") && layer == \"canon\""),
530                &[],
531                "r"
532            )
533            .is_some()
534        );
535    }
536}