Skip to main content

omgbase_surface/
query.rs

1//! The runner (`spec/surface/README.md` §1.4): parse the source, rewrite the
2//! row functions to `$self` methods, run it through the engine over the
3//! store context, and shape the engine's result into the surface's
4//! `OqxResult` (lean `{ id, path, … }` hits, keyset paging, the consumer
5//! scalars). Port of `packages/core/src/oqx-js/run.ts`.
6//!
7//! By default the tier-3 planner ([`crate::planner`]) pre-filters the scan in
8//! SQL and the in-memory engine finishes the residual over the produced rows;
9//! [`QueryOptions::in_memory`] forces the pure in-memory engine (the
10//! differential gate runs every case both ways — a planner must be invisible).
11
12use std::collections::HashMap;
13
14use omgbase_search::{EmbeddingProvider, f32_to_blob};
15use omgbase_store::Store;
16use oqx::ast::{BinaryOp, Expr, Query, SelectItem};
17use oqx::walk::{Clause, Node, VisitContext, Visitor, transform, visit};
18use oqx::{Consumer, Engine, InMemoryEngine, Value, build, resolve_aliases};
19use serde_json::{Map, Value as Json};
20
21use crate::context::{SemanticVec, StoreContext, Target, render_row_values, target_of};
22use crate::cursor::{decode_path_cursor, encode_cursor};
23use crate::error::{Result, SurfaceError};
24use crate::paths::reference_path;
25use crate::planner::{SqlitePlanner, root_target};
26
27/// The default page size.
28pub const DEFAULT_LIMIT: usize = 50;
29
30/// Row-scoped domain functions: authored as free calls that implicitly
31/// reference the current row; rewritten to `$self.fn(…)`. `refs(x)` is not
32/// one: it reads no row, so it stays a free function of the context. The
33/// planner treats every call alike (declined by the translator, flagged by
34/// `expr_may_raise`), `has_edge` and `refs` included.
35const ROW_FNS: [&str; 12] = [
36    "text",
37    "semantic",
38    "under",
39    "under_heading",
40    "within",
41    "under_kind",
42    "yaml_path",
43    "json_pointer",
44    "has_edge",
45    "has_anchor",
46    "child_count",
47    "parent_type",
48];
49
50const ID_KEY: &str = "__oqx_id";
51const PATH_KEY: &str = "__oqx_path";
52/// The hit envelope's keys (§1.4, 3.0): the row's identity under the same
53/// bare names the expression language reads, so a key copied from a hit is
54/// valid in the next `where` with no translation; `select id, path` is simply
55/// the envelope and any other projection whose output key would be one of
56/// these must be aliased ([`envelope_collision`]).
57pub(crate) const HIT_ID: &str = "id";
58pub(crate) const HIT_PATH: &str = "path";
59/// A hit is a store row (§1.4, 1.5). A bare root scan's rows are store rows by
60/// construction; a `follow` destination (`follow before` over a frontmatter
61/// list of paths), a `from E` re-projection or any other source can reach a
62/// scalar, which the injected `id`/`path` reads would render as the junk hit
63/// `{ id: "undefined", path: "" }`. For those queries the row itself is
64/// projected too (`$it`) under this key and [`to_hit`] fails the query when it
65/// is not a row. Only then: the clone of every projected row is a real cost
66/// here, and a root scan cannot need it. A `values` projection returns no
67/// hits, so it is exempt. Same rule as the reference's `SELF_ITEM`.
68const SELF_KEY: &str = "__oqx_self";
69/// The reserved key a top-level `values` projection's single item is renamed
70/// to, so it rides through id/path injection, paging and distinct as an
71/// ordinary field and is peeled off at the end.
72const VALUE_KEY: &str = "__oqx_value";
73
74/// `query`'s options.
75#[derive(Clone, Copy, Default)]
76pub struct QueryOptions<'a> {
77    /// The page cap (default 50).
78    pub limit: Option<usize>,
79    /// Resume after a truncated page's cursor.
80    pub cursor: Option<&'a str>,
81    /// The provider behind `semantic(...)`; `None` → `semantic_unavailable`
82    /// when the query names a phrase.
83    pub provider: Option<&'a dyn EmbeddingProvider>,
84    /// Force the pure in-memory engine (skip the tier-3 pushdown planner).
85    /// The default plans; the differential gate runs both and compares.
86    pub in_memory: bool,
87}
88
89/// §1.4 `OqxResult`.
90#[derive(Clone, Debug, PartialEq)]
91pub struct OqxResult {
92    pub hits: Vec<Json>,
93    pub truncated: bool,
94    pub cursor: Option<String>,
95    pub consumer: Consumer,
96    pub count: Option<f64>,
97    pub exists: Option<bool>,
98    pub none: Option<bool>,
99    /// A top-level `values` projection's bare values, in place of `hits`.
100    pub values: Option<Vec<Json>>,
101}
102
103impl OqxResult {
104    fn scalar(consumer: Consumer) -> Self {
105        Self {
106            hits: Vec::new(),
107            truncated: false,
108            cursor: None,
109            consumer,
110            count: None,
111            exists: None,
112            none: None,
113            values: None,
114        }
115    }
116
117    /// The wire shape: `{ hits, truncated, cursor, consumer, count?, exists?,
118    /// none?, values? }`.
119    #[must_use]
120    pub fn to_json(&self) -> Json {
121        let mut m = Map::new();
122        m.insert("hits".to_owned(), Json::Array(self.hits.clone()));
123        m.insert("truncated".to_owned(), Json::Bool(self.truncated));
124        m.insert(
125            "cursor".to_owned(),
126            self.cursor.clone().map_or(Json::Null, Json::String),
127        );
128        m.insert(
129            "consumer".to_owned(),
130            Json::String(self.consumer.as_str().to_owned()),
131        );
132        if let Some(n) = self.count {
133            m.insert("count".to_owned(), Value::Number(n).to_canonical_json());
134        }
135        if let Some(b) = self.exists {
136            m.insert("exists".to_owned(), Json::Bool(b));
137        }
138        if let Some(b) = self.none {
139            m.insert("none".to_owned(), Json::Bool(b));
140        }
141        if let Some(v) = &self.values {
142            m.insert("values".to_owned(), Json::Array(v.clone()));
143        }
144        Json::Object(m)
145    }
146}
147
148// ---- the `$self` rewrite -----------------------------------------------------------
149
150/// Paths on the surface are the reference form (`spec/surface` §1 "Paths",
151/// 2.0): `path` and the edge field `dst_path` read `/a/b.md`. A string
152/// literal a query compares with one of them — `path == "a.md"`, `"a.md" !=
153/// dst_path`,
154/// `path.startsWith("lab/")` — is rooted first, so both spellings match; a
155/// property or binding compared with a path is never touched (it is the
156/// author's value). The planner sees the rewritten tree too, so the pushed
157/// SQL and the in-memory engine agree by construction.
158const PATH_ATTRIBUTES: [&str; 2] = ["path", "dst_path"];
159/// The handles through which a member read reaches a ROW's `path` (`doc.path`,
160/// `block.path`, a link's `dst.path`, `$it.path`); a member read through a
161/// source handle (`data.path`, `frontmatter.path`) is the author's value and
162/// is never rooted.
163const ROW_HANDLES: [&str; 4] = ["doc", "block", "dst", "$it"];
164
165fn is_path_read(e: &Expr) -> bool {
166    match e {
167        Expr::Ident { name, .. } | Expr::Outer { name, .. } => {
168            PATH_ATTRIBUTES.contains(&name.as_str())
169        }
170        Expr::Member { recv, name, .. } if PATH_ATTRIBUTES.contains(&name.as_str()) => {
171            matches!(&**recv, Expr::Ident { name, .. } | Expr::Outer { name, .. } if ROW_HANDLES.contains(&name.as_str()))
172        }
173        _ => false,
174    }
175}
176
177/// Result projections may not overwrite the envelope (§1.4, 3.0): a projection
178/// whose output key is `id` or `path` is allowed only when it IS the envelope's
179/// own selection — the bare attribute of the same name, which collapses onto
180/// the envelope key (`select id, path` is simply the envelope) — and is
181/// otherwise a loud error asking for an alias: `select data.path` must be
182/// `select p: data.path`, `select path: doc.layer` likewise. Checked over the
183/// resolved items (an alias is just a name here), before the engine runs, so
184/// the error never depends on the rows. A `values` projection returns no hits,
185/// so the envelope — and the rule — do not apply. Same rule as the reference's
186/// `envelopeCollision`.
187fn envelope_collision(items: &[SelectItem]) -> Option<SurfaceError> {
188    for item in items {
189        let name = match item {
190            SelectItem::Field { name, .. } | SelectItem::Collect { name, .. } => name.as_str(),
191        };
192        if name != HIT_ID && name != HIT_PATH {
193            continue;
194        }
195        if let SelectItem::Field { expr, .. } = item
196            && matches!(expr, Expr::Ident { name: n, .. } if n == name)
197        {
198            continue;
199        }
200        let hint = if name == HIT_PATH {
201            "p: data.path"
202        } else {
203            "i: data.id"
204        };
205        return Some(SurfaceError::filter_invalid(
206            format!("projection `{name}` collides with the hit envelope — alias it (`{hint}`)"),
207            "OQX",
208        ));
209    }
210    None
211}
212
213fn root_literal(e: Expr) -> Expr {
214    match e {
215        Expr::Lit {
216            value: Value::Str(s),
217            span,
218        } => Expr::Lit {
219            value: Value::Str(reference_path(&s)),
220            span,
221        },
222        other => other,
223    }
224}
225
226/// The literal-rooting rewrite of one expression (see [`rewrite_query`]).
227#[must_use]
228pub fn root_path_literals(e: Expr) -> Expr {
229    match e {
230        Expr::Binary {
231            op: op @ (BinaryOp::Eq | BinaryOp::Ne),
232            left,
233            right,
234            span,
235        } => {
236            if is_path_read(&left) {
237                Expr::Binary {
238                    op,
239                    left,
240                    right: Box::new(root_literal(*right)),
241                    span,
242                }
243            } else if is_path_read(&right) {
244                Expr::Binary {
245                    op,
246                    left: Box::new(root_literal(*left)),
247                    right,
248                    span,
249                }
250            } else {
251                Expr::Binary {
252                    op,
253                    left,
254                    right,
255                    span,
256                }
257            }
258        }
259        Expr::Call {
260            recv: Some(recv),
261            name,
262            mut args,
263            span,
264        } if name == "startsWith" && args.len() == 1 && is_path_read(&recv) => {
265            let arg = root_literal(args.remove(0));
266            Expr::Call {
267                recv: Some(recv),
268                name,
269                args: vec![arg],
270                span,
271            }
272        }
273        other => other,
274    }
275}
276
277/// Rewrite every row function in a parsed query to a `$self` method call, and
278/// root every string literal compared with a path intrinsic — one `transform`
279/// over the AST (`spec/oqx/AST.md` §5), every block included.
280#[must_use]
281pub fn rewrite_query(q: &Query) -> Query {
282    transform(q, &mut |e, _| match e {
283        Expr::Call {
284            recv: None,
285            name,
286            args,
287            span,
288        } if ROW_FNS.contains(&name.as_str()) => Expr::Call {
289            recv: Some(Box::new(build::ident("$self"))),
290            name,
291            args,
292            span,
293        },
294        other => root_path_literals(other),
295    })
296}
297
298// ---- the root row (spec/surface §1.1, 2.0) ----------------------------------------------
299
300/// The `$repo` error (removed in surface 2.0): the same text in both engines.
301pub const REPO_REMOVED_MESSAGE: &str = "`$repo` was removed in surface 2.0 — reach the repository's collections through the root row: `^docs` from a top-level row (one caret per enclosing block, or the absolute `0^docs`), and the repository id as `^id` / `0^id`";
302
303/// The bare-target error. `depth` is the scope depth the name was read at when
304/// known statically (the carets in the hint count it); `None` when raised at
305/// read time by the context, where the hint spells out the rule instead.
306#[must_use]
307pub fn bare_target_message(t: Target, depth: Option<usize>) -> String {
308    let name = t.as_str();
309    let noun = match t {
310        Target::Docs => "documents",
311        Target::Blocks => "blocks",
312        Target::Nodes => "nodes",
313        Target::Links => "links",
314    };
315    let carets = "^".repeat(depth.unwrap_or(1));
316    let how = if depth.is_none() {
317        format!("; one caret per enclosing block, or the absolute `0^{name}`")
318    } else {
319        String::new()
320    };
321    format!(
322        "`{name}` inside a block reads a property of the current row, which has none — did you mean `{carets}{name}` (the repository's {noun}{how})?"
323    )
324}
325
326/// The unknown-target error (3.0 name pass): a top-level `from <name>` whose
327/// name is not one of the four collections would read an absent root and
328/// return an empty result — refused instead, naming the collections; `edges`,
329/// the links target's name before 3.0, gets its new name spelled out.
330#[must_use]
331pub fn unknown_target_message(name: &str) -> String {
332    let was = if name == "edges" {
333        " (`edges` was renamed `links` in surface 3.0)"
334    } else {
335        ""
336    };
337    format!(
338        "`{name}` is not a target — the repository's collections are `docs`, `blocks`, `nodes`, `links`{was}"
339    )
340}
341
342/// The past-the-root error: `^docs` at the root scope (more carets than there
343/// are enclosing scopes) would read absent and count 0 — refused, naming the
344/// bare spelling. `levels` is the caret count written.
345#[must_use]
346pub fn past_root_message(t: Target, levels: usize) -> String {
347    let name = t.as_str();
348    let noun = match t {
349        Target::Docs => "documents",
350        Target::Blocks => "blocks",
351        Target::Nodes => "nodes",
352        Target::Links => "links",
353    };
354    let carets = "^".repeat(levels);
355    format!(
356        "`{carets}{name}` reaches past the root — there is no enclosing row at this depth; at the top level the repository's {noun} are the bare `{name}` (`{name} count {{ … }}`, `from {name}`, `entries({name})`)"
357    )
358}
359
360/// The repository is OQX's root row (`spec/oqx` 0.18): from a top-level row
361/// `^docs` reaches the documents (one caret per enclosing block, or the
362/// absolute `0^docs`) and `^id` the repository id. Two spellings are refused
363/// before evaluation, by one walk over the parsed tree, so the error does not
364/// depend on which rows the data holds and the planned and in-memory paths
365/// agree trivially: `$repo` anywhere (bare, as a member head, `^$repo`,
366/// `0^$repo`), and a bare `docs`/`links` at scope depth ≥ 1 — a property read
367/// of a row that has none. `blocks`/`nodes` are relations of some rows
368/// (`docs.blocks`, `docs.nodes`, `blocks.nodes`, `section.blocks`) and are
369/// judged by the context at read time with the same message. A `^<target>`
370/// with more carets than enclosing scopes (`^docs count { }` at the top level)
371/// would read absent and count 0: refused too, naming the bare spelling. Port
372/// of the reference's `checkRootSpellings`.
373pub fn check_root_spellings(q: &Query) -> Result<()> {
374    // A top-level `from <name>` that is no collection (`from edges`, the links
375    // target's name before 3.0) would scan an absent root and come back empty:
376    // refused, naming the four collections.
377    if let Expr::Ident { name, .. } = &q.source {
378        if Target::parse(name).is_none() {
379            return Err(SurfaceError::filter_invalid(
380                unknown_target_message(name),
381                "OQX",
382            ));
383        }
384    }
385    struct Check(Option<SurfaceError>);
386    impl Visitor for Check {
387        fn enter(&mut self, node: Node<'_>, ctx: &VisitContext<'_>) -> bool {
388            if self.0.is_some() {
389                return false;
390            }
391            match node {
392                Node::Expr(Expr::Ident { name, .. } | Expr::Outer { name, .. })
393                    if name == "$repo" =>
394                {
395                    self.0 = Some(SurfaceError::filter_invalid(REPO_REMOVED_MESSAGE, "OQX"));
396                }
397                Node::Expr(Expr::Ident { name, .. })
398                    if ctx.depth >= 1 && matches!(name.as_str(), "docs" | "links") =>
399                {
400                    let t = Target::parse(name).expect("a target name");
401                    self.0 = Some(SurfaceError::filter_invalid(
402                        bare_target_message(t, Some(ctx.depth)),
403                        "OQX",
404                    ));
405                }
406                Node::Expr(Expr::Outer { name, levels, .. }) if *levels > ctx.depth => {
407                    if let Some(t) = Target::parse(name) {
408                        self.0 = Some(SurfaceError::filter_invalid(
409                            past_root_message(t, *levels),
410                            "OQX",
411                        ));
412                    }
413                }
414                _ => {}
415            }
416            self.0.is_none()
417        }
418    }
419    let mut check = Check(None);
420    visit(Node::Query(q), &mut check);
421    check.0.map_or(Ok(()), Err)
422}
423
424// ---- semantic phrases -----------------------------------------------------------------
425
426/// Collects the distinct `semantic("…")` phrases of a tree.
427struct Phrases(Vec<String>);
428
429impl Visitor for Phrases {
430    fn enter(&mut self, node: Node<'_>, _ctx: &VisitContext<'_>) -> bool {
431        if let Node::Expr(Expr::Call {
432            recv: None,
433            name,
434            args,
435            ..
436        }) = node
437            && name == "semantic"
438            && let Some(Expr::Lit {
439                value: Value::Str(s),
440                ..
441            }) = args.first()
442            && !self.0.contains(s)
443        {
444            self.0.push(s.clone());
445        }
446        true
447    }
448}
449
450/// The distinct phrases `semantic("…")` names (free calls in the raw parse);
451/// empty when the source does not parse.
452#[must_use]
453pub fn collect_semantic_phrases(source: &str) -> Vec<String> {
454    let Ok(q) = oqx::parse_string(source) else {
455        return Vec::new();
456    };
457    let mut phrases = Phrases(Vec::new());
458    visit(Node::Query(&q), &mut phrases);
459    phrases.0
460}
461
462// ---- name mentions --------------------------------------------------------------------
463
464/// Whether `name` is read anywhere in `q` other than as its source: a bare
465/// identifier or a `^`-escaped one in the `from` steps, `where`, `select`,
466/// `order by`, `follow`, `limit`/`offset`, or any nested block.
467fn mentions_outside_source(q: &Query, name: &str) -> bool {
468    struct Mentions<'n> {
469        name: &'n str,
470        found: bool,
471    }
472    impl Visitor for Mentions<'_> {
473        fn enter(&mut self, node: Node<'_>, ctx: &VisitContext<'_>) -> bool {
474            if self.found {
475                return false;
476            }
477            // The source itself (the root's `source` slot) is not a mention.
478            if ctx.clause == Some(Clause::Source) && ctx.path.len() == 1 {
479                return false;
480            }
481            if let Node::Expr(Expr::Ident { name, .. } | Expr::Outer { name, .. }) = node
482                && name == self.name
483            {
484                self.found = true;
485            }
486            !self.found
487        }
488    }
489    let mut m = Mentions { name, found: false };
490    visit(Node::Query(q), &mut m);
491    m.found
492}
493
494// ---- hits ----------------------------------------------------------------------------
495
496/// Whether the engine's top-level rows can be anything but store rows: a
497/// `follow` (a destination may be a property's value), a `from E`
498/// re-projection, or a source that is not a bare root scan.
499fn may_reach_non_rows(q: &Query) -> bool {
500    q.follow.is_some() || !q.from.is_empty() || root_target(&q.source).is_none()
501}
502
503/// The reference's `describeValue`: what a non-row hit was, for the error.
504fn describe_value(v: &Value) -> String {
505    match v {
506        Value::Undefined | Value::Null => "an absent value".to_owned(),
507        Value::Str(s) => format!(
508            "a string ({})",
509            serde_json::to_string(s).unwrap_or_default()
510        ),
511        Value::Number(_) => format!("a number ({v})"),
512        Value::Bool(b) => format!("a boolean ({b})"),
513        Value::Array(_) => "an array".to_owned(),
514        Value::Range(_) => "a range".to_owned(),
515        Value::Object(_) => "an object".to_owned(),
516    }
517}
518
519fn not_a_store_row(v: &Value) -> SurfaceError {
520    SurfaceError::filter_invalid(
521        format!(
522            "a hit must be a document, block, node or link row — the query reached {}; to follow document references held in a property use refs(<field>)",
523            describe_value(v)
524        ),
525        "OQX",
526    )
527}
528
529/// A projected row as a hit: `{ id, path, ...rest }` (§1.4, 3.0: the
530/// envelope keys are the attributes that read them) with the injected columns
531/// peeled off (JavaScript's `String()` on the id, `""` for an absent path). A
532/// user projection of `id`/`path` collapses onto the envelope key in place.
533/// The projection's values are rendered per §1.4 "rows as values": a store row
534/// nested in the result (an empty-projection `collect { }` and friends)
535/// becomes `{ id, path }`. When the row itself was projected under
536/// [`SELF_KEY`] and is not a store row, the query fails (§1.4: a hit is a
537/// store row).
538fn to_hit(row: Value) -> Result<Value> {
539    let Value::Object(mut o) = row else {
540        return Ok(Value::Object(oqx::Object::new()));
541    };
542    if let Some(me) = o.remove(SELF_KEY) {
543        if target_of(&me).is_none() {
544            return Err(not_a_store_row(&me));
545        }
546    }
547    let Value::Object(o) = render_row_values(Value::Object(o)) else {
548        return Ok(Value::Object(oqx::Object::new()));
549    };
550    let mut id = Value::Undefined;
551    let mut path = Value::Undefined;
552    let mut rest = Vec::new();
553    for (k, v) in o {
554        match k.as_str() {
555            ID_KEY => id = v,
556            PATH_KEY => path = v,
557            _ => rest.push((k, v)),
558        }
559    }
560    let mut hit = oqx::Object::with_capacity(rest.len() + 2);
561    hit.insert(HIT_ID, Value::Str(id.to_string()));
562    hit.insert(
563        HIT_PATH,
564        Value::Str(if path.is_absent() {
565            String::new()
566        } else {
567            path.to_string()
568        }),
569    );
570    for (k, v) in rest {
571        hit.insert(k, v);
572    }
573    Ok(Value::Object(hit))
574}
575
576fn hit_str(hit: &Value, key: &str) -> String {
577    hit.as_object()
578        .and_then(|o| o.get(key))
579        .map(|v| v.to_string())
580        .unwrap_or_default()
581}
582
583/// Top-level `select distinct`: dedup hits by their USER projection (every
584/// field but the envelope's `id`/`path`), keeping the first. The key is the canonical JSON
585/// of the sorted `[key, value]` pairs (`JSON.stringify` in the reference).
586fn dedup_hits_by_projection(hits: Vec<Value>) -> Vec<Value> {
587    let mut seen: Vec<String> = Vec::new();
588    let mut out = Vec::new();
589    for h in hits {
590        let mut pairs: Vec<(String, Value)> = h
591            .as_object()
592            .map(|o| {
593                o.iter()
594                    .filter(|(k, _)| *k != HIT_ID && *k != HIT_PATH)
595                    .map(|(k, v)| (k.to_owned(), v.clone()))
596                    .collect()
597            })
598            .unwrap_or_default();
599        pairs.sort_by(|a, b| a.0.cmp(&b.0));
600        let key = Value::Array(
601            pairs
602                .into_iter()
603                .map(|(k, v)| Value::Array(vec![Value::Str(k), v]))
604                .collect(),
605        )
606        .to_canonical_json()
607        .to_string();
608        if seen.contains(&key) {
609            continue;
610        }
611        seen.push(key);
612        out.push(h);
613    }
614    out
615}
616
617/// A top-level `limit`/`offset` on the collect path is applied by the runner,
618/// so it must be a plain non-negative integer literal.
619fn const_bound(e: Option<&Expr>, word: &str) -> Result<Option<usize>> {
620    match e {
621        None => Ok(None),
622        Some(Expr::Lit {
623            value: Value::Number(n),
624            ..
625        }) if n.fract() == 0.0 && *n >= 0.0 && n.is_finite() => Ok(Some(*n as usize)),
626        Some(_) => Err(SurfaceError::filter_invalid(
627            format!("top-level {word} must be a non-negative integer literal"),
628            "OQX",
629        )),
630    }
631}
632
633fn value_of(hit: &Value) -> Value {
634    hit.as_object()
635        .and_then(|o| o.get(VALUE_KEY))
636        .cloned()
637        .unwrap_or(Value::Undefined)
638}
639
640fn without_value_key(hit: Value) -> Json {
641    hit.to_canonical_json()
642}
643
644// ---- the run --------------------------------------------------------------------------
645
646/// Run an OQX query against `repo_id` (§1.4).
647pub fn query(
648    store: &Store,
649    repo_id: &str,
650    source: &str,
651    opts: QueryOptions<'_>,
652) -> Result<OqxResult> {
653    // Without a provider the phrases stay unembedded and the context reports
654    // `filter_invalid` ("needs an embedding provider") when one is reached —
655    // after its target check, so `semantic()` on nodes names the targets
656    // (§9; the `query` tool's pre-check is what reports `semantic_unavailable`).
657    let phrases = collect_semantic_phrases(source);
658    let mut semantic: HashMap<String, SemanticVec> = HashMap::new();
659    if let Some(provider) = opts.provider.filter(|_| !phrases.is_empty()) {
660        for phrase in phrases {
661            let vec = provider
662                .embed_query(&phrase)
663                .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?;
664            semantic.insert(
665                phrase,
666                SemanticVec {
667                    model: provider.model().to_owned(),
668                    vec: f32_to_blob(&vec),
669                },
670            );
671        }
672    }
673    let runner = Runner {
674        store,
675        repo_id,
676        semantic,
677        planned: !opts.in_memory,
678    };
679    run_inner(&runner, source, opts)
680}
681
682/// One query's engine: a fresh store context per run (the planned path gives
683/// the residual a context serving the produced rows as its root). A failure
684/// inside a property read or row function is the engine's own error (the
685/// context's `get` / `call_method` return `Err`); only a failed root scan,
686/// which the `root` seam cannot raise, is read back after the run.
687struct Runner<'a> {
688    store: &'a Store,
689    repo_id: &'a str,
690    semantic: HashMap<String, SemanticVec>,
691    planned: bool,
692}
693
694impl Runner<'_> {
695    /// Tier-3 pushdown reduces the scan in SQL and the in-memory engine
696    /// finishes the residual over the produced rows (a declined plan, or
697    /// `in_memory`, runs the whole query in memory over a full scan), so
698    /// results match a pure scan. This is `oqx::PlannedEngine::run` inlined:
699    /// the store context borrows the connection, so it cannot be the
700    /// `'static` context a `Plan` carries.
701    fn run(&self, q: &Query) -> Result<oqx::OqxResult> {
702        let conn = self.store.conn();
703        let ctx = StoreContext::new(conn, self.repo_id, self.semantic.clone());
704        let plan = if self.planned {
705            SqlitePlanner::new(conn, self.repo_id)
706                .try_plan(q, &[])
707                .map_err(|e| SurfaceError::other(format!("sqlite: {e}")))?
708        } else {
709            None
710        };
711        let (ctx, residual) = match plan {
712            // The residual's source is its one read of the rows root unless
713            // the query text itself names `__oqx_rows__` somewhere else (a
714            // `^`-reach, a `limit`, a nested block…): then every read must
715            // see the rows, so they are cloned out instead of moved.
716            Some(plan) => {
717                let once = !mentions_outside_source(&plan.residual, oqx::ROWS_ROOT);
718                let ctx = if once {
719                    ctx.with_rows_root_once(plan.rows)
720                } else {
721                    ctx.with_rows_root(plan.rows)
722                };
723                (ctx, Some(plan.residual))
724            }
725            None => (ctx, None),
726        };
727        let engine = InMemoryEngine::new(ctx);
728        let out = engine.run(residual.as_ref().unwrap_or(q), &[]);
729        // `root` has no error channel: a store failure during a root scan was
730        // served as an empty scan and wins over whatever the run made of it.
731        if let Some(failed) = engine.context().take_root_failure() {
732            return Err(failed.into());
733        }
734        // A root scan projected as a VALUE (`select all: ^docs`) was
735        // materialized into its rows by the engine (`DataContext::materialize`).
736        Ok(out?)
737    }
738}
739
740fn run_inner(engine: &Runner<'_>, source: &str, opts: QueryOptions<'_>) -> Result<OqxResult> {
741    // The query's `select` aliases are resolved HERE, once, before the runner
742    // renames/injects items (an engine evaluates the query it is given).
743    let raw = oqx::parse_string(source)?;
744    check_root_spellings(&raw)?;
745    let parsed = rewrite_query(&resolve_aliases(&raw)?);
746    let consumer = parsed.consumer;
747
748    match consumer {
749        Consumer::Exists => {
750            let res = engine.run(&parsed)?;
751            let mut r = OqxResult::scalar(consumer);
752            r.exists = Some(matches!(res, oqx::OqxResult::Exists(true)));
753            return Ok(r);
754        }
755        Consumer::Count => {
756            let res = engine.run(&parsed)?;
757            let mut r = OqxResult::scalar(consumer);
758            r.count = Some(match res {
759                oqx::OqxResult::Count(n) => n,
760                _ => 0.0,
761            });
762            return Ok(r);
763        }
764        Consumer::None => {
765            let res = engine.run(&parsed)?;
766            let mut r = OqxResult::scalar(consumer);
767            r.none = Some(match res {
768                oqx::OqxResult::None(b) => b,
769                _ => true,
770            });
771            return Ok(r);
772        }
773        Consumer::Collect | Consumer::First | Consumer::Single => {}
774    }
775
776    // collect / first / single: inject id + path so every hit carries them. A
777    // top-level `select distinct` is applied HERE, not in the engine (the
778    // injected id/path are unique per row and would defeat the engine's
779    // projection dedup). A top-level `values` projection runs as a RECORD
780    // projection whose single item is renamed to VALUE_KEY.
781    let top_distinct = parsed.distinct;
782    let top_values = parsed.values;
783    let user_select: Vec<SelectItem> = if top_values {
784        parsed
785            .select
786            .first()
787            .map(|it| match it {
788                SelectItem::Field {
789                    expr, lift, span, ..
790                } => SelectItem::Field {
791                    name: VALUE_KEY.to_owned(),
792                    expr: expr.clone(),
793                    lift: *lift,
794                    span: *span,
795                },
796                SelectItem::Collect { op, span, .. } => SelectItem::Collect {
797                    name: VALUE_KEY.to_owned(),
798                    op: op.clone(),
799                    span: *span,
800                },
801            })
802            .into_iter()
803            .collect()
804    } else {
805        parsed.select.clone()
806    };
807    if !top_values && let Some(e) = envelope_collision(&user_select) {
808        return Err(e);
809    }
810    let id_item = build::field(ID_KEY, build::ident("id"));
811    let path_item = build::field(PATH_KEY, build::ident("path"));
812    let mut select = vec![id_item, path_item];
813    if !top_values && may_reach_non_rows(&parsed) {
814        select.push(build::field(SELF_KEY, build::ident("$it")));
815    }
816    select.extend(user_select);
817    // On the collect path the query's own limit/offset is taken out of the
818    // engine query and applied after the runner's distinct; first/single keep
819    // theirs (the engine's offset-aware cap is exactly right for them).
820    let (top_limit, top_offset) = (parsed.limit.clone(), parsed.offset.clone());
821    let q = Query {
822        distinct: false,
823        values: false,
824        select,
825        limit: if consumer == Consumer::Collect {
826            None
827        } else {
828            parsed.limit.clone()
829        },
830        offset: if consumer == Consumer::Collect {
831            None
832        } else {
833            parsed.offset.clone()
834        },
835        ..parsed.clone()
836    };
837    let res = engine.run(&q)?;
838
839    if matches!(consumer, Consumer::First | Consumer::Single) {
840        let row = match res {
841            oqx::OqxResult::First(r) | oqx::OqxResult::Single(r) => r,
842            _ => None,
843        };
844        let mut out = OqxResult::scalar(consumer);
845        match row {
846            None => {
847                if top_values {
848                    out.values = Some(Vec::new());
849                }
850            }
851            Some(r) => {
852                let hit = to_hit(r)?;
853                if top_values {
854                    out.values = Some(vec![value_of(&hit).to_canonical_json()]);
855                } else {
856                    out.hits = vec![without_value_key(hit)];
857                }
858            }
859        }
860        return Ok(out);
861    }
862
863    // collect: keyset pagination on (path, id) when the order is the default.
864    let mut rows: Vec<Value> = match res {
865        oqx::OqxResult::Collect(rows) => rows,
866        _ => Vec::new(),
867    };
868    let custom = parsed.order_by.as_ref().is_some_and(|o| !o.is_empty());
869    let cursor = opts.cursor.filter(|c| !c.is_empty() && !custom);
870    // Rows become hits ({ id, path, … }) before anything inspects them — the
871    // distinct key and the cursor's (path, id) read the HIT — else only the
872    // page does (the offset/limit slice and the cap see plain rows), so a
873    // scan that projects thousands of rows shapes fifty.
874    let eager = top_distinct || cursor.is_some();
875    if eager {
876        rows = rows.into_iter().map(to_hit).collect::<Result<_>>()?;
877    }
878    if top_distinct {
879        rows = dedup_hits_by_projection(rows);
880    }
881    let offset = const_bound(top_offset.as_ref(), "offset")?.unwrap_or(0);
882    let limit = const_bound(top_limit.as_ref(), "limit")?;
883    if offset > 0 || limit.is_some() {
884        let end = limit.map_or(rows.len(), |l| (offset + l).min(rows.len()));
885        if offset >= rows.len() {
886            rows.clear();
887        } else {
888            rows.truncate(end);
889            rows.drain(..offset);
890        }
891    }
892    let cap = opts.limit.unwrap_or(DEFAULT_LIMIT);
893    let mut page = rows;
894    if let Some(cursor) = cursor {
895        // The cursor carries the hit's (rooted) path; a 1.x cursor is refused (§1.4).
896        let parts = decode_path_cursor(cursor, "query", 2)?;
897        let (path, id) = (&parts[0], &parts[1]);
898        page.retain(|h| {
899            let hp = hit_str(h, HIT_PATH);
900            let hi = hit_str(h, HIT_ID);
901            hp > *path || (hp == *path && hi > *id)
902        });
903    }
904    let truncated = page.len() > cap;
905    page.truncate(cap);
906    if !eager {
907        page = page.into_iter().map(to_hit).collect::<Result<_>>()?;
908    }
909    let cursor = if truncated && !custom {
910        page.last()
911            .map(|last| encode_cursor(&[&hit_str(last, HIT_PATH), &hit_str(last, HIT_ID)]))
912    } else {
913        None
914    };
915    let mut out = OqxResult::scalar(Consumer::Collect);
916    out.truncated = truncated;
917    out.cursor = cursor;
918    if top_values {
919        out.values = Some(
920            page.iter()
921                .map(|h| value_of(h).to_canonical_json())
922                .collect(),
923        );
924    } else {
925        out.hits = page.into_iter().map(without_value_key).collect();
926    }
927    Ok(out)
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use oqx::ast::Where;
934
935    #[test]
936    fn row_functions_become_self_methods() {
937        let q = oqx::parse_string(
938            "from blocks where text(\"x\") && under_heading(\"h\") && size(attrs) > 0 && doc.path.startsWith(\"a\")",
939        )
940        .unwrap();
941        let r = rewrite_query(&q);
942        let Some(Where::And { parts, .. }) = &r.r#where else {
943            panic!("and")
944        };
945        let Where::Scalar { expr, .. } = &parts[0] else {
946            panic!("scalar")
947        };
948        assert!(
949            matches!(expr, Expr::Call { recv: Some(r), name, .. } if name == "text" && **r == build::ident("$self"))
950        );
951        let Where::Scalar { expr, .. } = &parts[2] else {
952            panic!("scalar")
953        };
954        assert!(
955            matches!(expr, Expr::Binary { left, .. } if matches!(&**left, Expr::Call { recv: None, name, .. } if name == "size"))
956        );
957    }
958
959    #[test]
960    fn path_literals_are_rooted_only_against_path_reads() {
961        let q = oqx::parse_string(
962            "select n: ^docs collect { path where path == ^after || \"x.md\" != dst_path } from docs where path.startsWith(\"lab/\") && doc.path == \"a.md\" && type == \"a.md\" && ^path == \"b.md\"",
963        )
964        .unwrap();
965        let printed = oqx::print_query(&rewrite_query(&q)).unwrap();
966        assert!(printed.contains("path.startsWith(\"/lab/\")"), "{printed}");
967        assert!(printed.contains("doc.path == \"/a.md\""), "{printed}");
968        assert!(printed.contains("type == \"a.md\""), "{printed}");
969        assert!(printed.contains("^path == \"/b.md\""), "{printed}");
970        assert!(printed.contains("\"/x.md\" != dst_path"), "{printed}");
971        // a property compared with a path is the author's value: untouched
972        assert!(printed.contains("path == ^after"), "{printed}");
973        // `contains`/`endsWith`/`matches` and the empty string are literal
974        let q = oqx::parse_string(
975            "from docs where path.contains(\"a/\") && path.endsWith(\".md\") && path == \"\"",
976        )
977        .unwrap();
978        let printed = oqx::print_query(&rewrite_query(&q)).unwrap();
979        assert!(printed.contains("path.contains(\"a/\")"), "{printed}");
980        assert!(printed.contains("path.endsWith(\".md\")"), "{printed}");
981        assert!(printed.contains("path == \"/\""), "{printed}");
982    }
983
984    #[test]
985    fn repo_and_bare_targets_are_refused_statically() {
986        let parse = |s: &str| oqx::parse_string(s).unwrap();
987        for q in [
988            "from docs where $repo",
989            "select r: size($repo.docs) from docs",
990            "select r: $repo.id from docs",
991            "select r: nodes first { select x: ^$repo.id values } from docs",
992            "select r: nodes first { select x: 0^$repo values } from docs",
993            "$repo.docs count { }",
994        ] {
995            let e = check_root_spellings(&parse(q)).unwrap_err();
996            assert_eq!(e.code, "filter_invalid", "{q}");
997            assert_eq!(e.message, REPO_REMOVED_MESSAGE, "{q}");
998        }
999        // a bare `docs`/`links` at depth ≥ 1; the hint counts the carets
1000        let e = check_root_spellings(&parse("select x: docs collect { } from docs")).unwrap_err();
1001        assert_eq!(e.message, bare_target_message(Target::Docs, Some(1)));
1002        assert!(
1003            e.message
1004                .contains("did you mean `^docs` (the repository's documents)?")
1005        );
1006        let e = check_root_spellings(&parse(
1007            "select x: nodes collect { select y: size(links) } from docs",
1008        ))
1009        .unwrap_err();
1010        assert!(
1011            e.message
1012                .contains("did you mean `^^links` (the repository's links)?")
1013        );
1014        let e = check_root_spellings(&parse("docs collect { from docs }")).unwrap_err();
1015        assert!(e.message.contains("`^docs`"));
1016        // the root scope is depth 0: a bare target there is the scan
1017        for q in [
1018            "docs count { }",
1019            "from docs where nodes exists { }",
1020            "entries(docs) first { }",
1021            "select n: size(^docs), id: ^id, k: size(0^links) from docs",
1022            "from blocks where blocks exists { }", // judged by the context at read time
1023        ] {
1024            check_root_spellings(&parse(q)).unwrap_or_else(|e| panic!("{q}: {e}"));
1025        }
1026        // a `^target` reaching past the root
1027        let e = check_root_spellings(&parse("^docs count { }")).unwrap_err();
1028        assert_eq!(e.message, past_root_message(Target::Docs, 1));
1029        assert_eq!(
1030            e.message,
1031            "`^docs` reaches past the root — there is no enclosing row at this depth; at the top level the repository's documents are the bare `docs` (`docs count { … }`, `from docs`, `entries(docs)`)"
1032        );
1033        let e = check_root_spellings(&parse("select path from docs where ^^links exists { }"))
1034            .unwrap_err();
1035        assert!(
1036            e.message.contains("`^^links` reaches past the root"),
1037            "{}",
1038            e.message
1039        );
1040        // the read-time message spells out the rule
1041        let m = bare_target_message(Target::Blocks, None);
1042        assert!(m.contains("did you mean `^blocks` (the repository's blocks; one caret per enclosing block, or the absolute `0^blocks`)?"), "{m}");
1043    }
1044
1045    #[test]
1046    fn semantic_phrases_are_collected_distinct() {
1047        let phrases = collect_semantic_phrases(
1048            "select s: semantic(\"alpha\") from docs where semantic(\"alpha\") > 0.5 || nodes exists { where semantic(\"beta\") > 0 } order by semantic(\"gamma\") desc",
1049        );
1050        assert_eq!(phrases, ["alpha", "beta", "gamma"]);
1051        assert!(collect_semantic_phrases("not a query {{").is_empty());
1052        assert!(collect_semantic_phrases("from docs").is_empty());
1053    }
1054
1055    #[test]
1056    fn hits_peel_the_injected_columns() {
1057        let mut o = oqx::Object::new();
1058        o.insert(ID_KEY, Value::Str("d_1".into()));
1059        o.insert(PATH_KEY, Value::Null);
1060        o.insert("layer", Value::Str("canon".into()));
1061        let hit = to_hit(Value::Object(o)).unwrap();
1062        let ho = hit.as_object().unwrap();
1063        assert_eq!(ho.keys().collect::<Vec<_>>(), ["id", "path", "layer"]);
1064        assert_eq!(ho.get("path"), Some(&Value::Str(String::new())));
1065        // A user projection of `id` collapses onto the envelope key in place
1066        // (3.0): same position, the projected value.
1067        let mut o = oqx::Object::new();
1068        o.insert(ID_KEY, Value::Str("d_1".into()));
1069        o.insert(PATH_KEY, Value::Str("a.md".into()));
1070        o.insert("id", Value::Number(7.0));
1071        let hit = to_hit(Value::Object(o)).unwrap();
1072        let ho = hit.as_object().unwrap();
1073        assert_eq!(ho.keys().collect::<Vec<_>>(), ["id", "path"]);
1074        assert_eq!(ho.get("id"), Some(&Value::Number(7.0)));
1075    }
1076
1077    #[test]
1078    fn a_projection_onto_the_envelope_must_be_aliased() {
1079        let items = |src: &str| oqx::parse_string(src).unwrap().select;
1080        // the attribute itself collapses (allowed)
1081        assert!(envelope_collision(&items("select id, path from docs")).is_none());
1082        assert!(
1083            envelope_collision(&items("select id: id, path: path, p: path from docs")).is_none()
1084        );
1085        // anything else landing on `id`/`path` is the error, first offender first
1086        let e = envelope_collision(&items("select data.path from docs")).unwrap();
1087        assert_eq!(e.code, "filter_invalid");
1088        assert_eq!(
1089            e.message,
1090            "projection `path` collides with the hit envelope — alias it (`p: data.path`)"
1091        );
1092        let e = envelope_collision(&items("select id: layer from docs")).unwrap();
1093        assert_eq!(
1094            e.message,
1095            "projection `id` collides with the hit envelope — alias it (`i: data.id`)"
1096        );
1097        assert!(envelope_collision(&items("select path: doc.path from blocks")).is_some());
1098        assert!(envelope_collision(&items("select path: nodes collect { } from docs")).is_some());
1099    }
1100
1101    #[test]
1102    fn a_path_read_is_the_attribute_or_a_row_handle_never_a_source_handle() {
1103        let pred = |src: &str| match oqx::parse_string(src).unwrap().r#where.unwrap() {
1104            oqx::ast::Where::Scalar { expr, .. } => expr,
1105            other => panic!("{other:?}"),
1106        };
1107        let left = |e: Expr| match e {
1108            Expr::Binary { left, .. } => *left,
1109            other => panic!("{other:?}"),
1110        };
1111        assert!(is_path_read(&left(pred("from docs where path == \"x\""))));
1112        assert!(is_path_read(&left(pred(
1113            "from blocks where doc.path == \"x\""
1114        ))));
1115        assert!(is_path_read(&left(pred("from docs where ^path == \"x\""))));
1116        assert!(is_path_read(&left(pred(
1117            "from links where dst_path == \"x\""
1118        ))));
1119        assert!(!is_path_read(&left(pred(
1120            "from docs where data.path == \"x\""
1121        ))));
1122        assert!(!is_path_read(&left(pred(
1123            "from docs where frontmatter.path == \"x\""
1124        ))));
1125    }
1126
1127    #[test]
1128    fn a_hit_that_is_not_a_store_row_fails_the_query() {
1129        // The row itself rides under SELF_KEY when the query can reach a
1130        // non-row; a string there is the `follow before` shape (§1.4, 1.5).
1131        let mut o = oqx::Object::new();
1132        o.insert(ID_KEY, Value::Undefined);
1133        o.insert(PATH_KEY, Value::Undefined);
1134        o.insert(SELF_KEY, Value::Str("/timeline/kickoff.md".into()));
1135        let e = to_hit(Value::Object(o)).unwrap_err();
1136        assert_eq!(e.code, "filter_invalid");
1137        assert_eq!(
1138            e.message,
1139            "a hit must be a document, block, node or link row — the query reached a string (\"/timeline/kickoff.md\"); to follow document references held in a property use refs(<field>)"
1140        );
1141        // A tagged row under SELF_KEY passes and the key is peeled off.
1142        let mut row = oqx::Object::new();
1143        row.insert("doc_id", Value::Str("d_1".into()));
1144        row.insert("path", Value::Str("a.md".into()));
1145        let row = crate::context::tag_row(row, crate::context::Target::Docs);
1146        let mut o = oqx::Object::new();
1147        o.insert(ID_KEY, Value::Str("d_1".into()));
1148        o.insert(PATH_KEY, Value::Str("a.md".into()));
1149        o.insert(SELF_KEY, row);
1150        let hit = to_hit(Value::Object(o)).unwrap();
1151        assert_eq!(
1152            hit.as_object().unwrap().keys().collect::<Vec<_>>(),
1153            ["id", "path"]
1154        );
1155        // Only queries that can reach a non-row project the row itself.
1156        let parse = |s: &str| oqx::parse_string(s).unwrap();
1157        assert!(!may_reach_non_rows(&parse(
1158            "from docs where layer == \"canon\""
1159        )));
1160        assert!(may_reach_non_rows(&parse("from docs follow before")));
1161        assert!(may_reach_non_rows(&parse("refs(\"/index.md\") first { }")));
1162    }
1163
1164    #[test]
1165    fn distinct_dedups_by_user_projection_first_wins() {
1166        let mk = |id: &str, t: &str| {
1167            let mut o = oqx::Object::new();
1168            o.insert(HIT_ID, Value::Str(id.into()));
1169            o.insert(HIT_PATH, Value::Str("p".into()));
1170            o.insert("type", Value::Str(t.into()));
1171            Value::Object(o)
1172        };
1173        let out = dedup_hits_by_projection(vec![mk("1", "a"), mk("2", "b"), mk("3", "a")]);
1174        assert_eq!(out.len(), 2);
1175        assert_eq!(hit_str(&out[0], HIT_ID), "1");
1176        assert_eq!(hit_str(&out[1], HIT_ID), "2");
1177    }
1178
1179    #[test]
1180    fn top_level_bounds_must_be_literals() {
1181        assert_eq!(const_bound(None, "limit").unwrap(), None);
1182        assert_eq!(
1183            const_bound(
1184                Some(&Expr::Lit {
1185                    value: Value::Number(3.0),
1186                    span: oqx::Span::EMPTY
1187                }),
1188                "limit"
1189            )
1190            .unwrap(),
1191            Some(3)
1192        );
1193        let e = const_bound(
1194            Some(&Expr::Lit {
1195                value: Value::Number(-1.0),
1196                span: oqx::Span::EMPTY,
1197            }),
1198            "offset",
1199        )
1200        .unwrap_err();
1201        assert_eq!(e.code, "filter_invalid");
1202        assert!(e.message.contains("top-level offset"));
1203    }
1204}