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 `$it` 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, Flatten, 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::dates;
24use crate::error::{Result, SurfaceError};
25use crate::names::{UnknownNames, VocabularyCache, check_names};
26use crate::paths::reference_path;
27use crate::planner::{SqlitePlanner, root_target};
28
29/// The default page size.
30pub const DEFAULT_LIMIT: usize = 50;
31
32/// Row-scoped domain functions: authored as free calls that implicitly
33/// reference the current row; rewritten to `$it.fn(…)` (`$it` is the
34/// language's name for the scope's row, `spec/oqx` SEMANTICS §2). `refs(x)` is not
35/// one: it reads no row, so it stays a free function of the context. The
36/// planner treats every call alike (declined by the translator, flagged by
37/// `expr_may_raise`), `has_edge` and `refs` included.
38const ROW_FNS: [&str; 12] = [
39    "text",
40    "semantic",
41    "under",
42    "under_heading",
43    "within",
44    "under_kind",
45    "yaml_path",
46    "json_pointer",
47    "has_edge",
48    "has_anchor",
49    "child_count",
50    "parent_type",
51];
52
53const ID_KEY: &str = "__oqx_id";
54const PATH_KEY: &str = "__oqx_path";
55/// The hit envelope's keys (§1.4, 3.0): the row's identity under the same
56/// bare names the expression language reads, so a key copied from a hit is
57/// valid in the next `where` with no translation; `select id, path` is simply
58/// the envelope and any other projection whose output key would be one of
59/// these must be aliased ([`envelope_collision`]).
60pub(crate) const HIT_ID: &str = "id";
61pub(crate) const HIT_PATH: &str = "path";
62/// A hit is a store row (§1.4, 1.5). A bare root scan's rows are store rows by
63/// construction; a `follow` destination (`follow before` over a frontmatter
64/// list of paths), a `from E` re-projection or any other source can reach a
65/// scalar, which the injected `id`/`path` reads would render as the junk hit
66/// `{ id: "undefined", path: "" }`. For those queries the row itself is
67/// projected too (`$it`) under this key and [`to_hit`] fails the query when it
68/// is not a row. Only then: the clone of every projected row is a real cost
69/// here, and a root scan cannot need it. A `values` projection returns no
70/// hits, so it is exempt. Same rule as the reference's `SELF_ITEM`.
71const SELF_KEY: &str = "__oqx_self";
72/// The reserved key a top-level `values` projection's single item is renamed
73/// to, so it rides through id/path injection, paging and distinct as an
74/// ordinary field and is peeled off at the end.
75const VALUE_KEY: &str = "__oqx_value";
76
77/// `query`'s options.
78#[derive(Clone, Copy, Default)]
79pub struct QueryOptions<'a> {
80    /// The page cap (default 50).
81    pub limit: Option<usize>,
82    /// Resume after a truncated page's cursor.
83    pub cursor: Option<&'a str>,
84    /// The provider behind `semantic(...)`; `None` → `semantic_unavailable`
85    /// when the query names a phrase.
86    pub provider: Option<&'a dyn EmbeddingProvider>,
87    /// Force the pure in-memory engine (skip the tier-3 pushdown planner).
88    /// The default plans; the differential gate runs both and compares.
89    pub in_memory: bool,
90    /// `bare: true` (§1.4, 4.2): every hit is its projection only — the
91    /// automatic `id`/`path` envelope members are stripped when the result is
92    /// shaped, AFTER paging and distinct (identity is kept internally: the
93    /// keyset cursor and the dedup key read the envelope). Needs a `select`
94    /// (without a projection a hit is only its envelope) and is refused with
95    /// `values` (already the bare-values form); a projected `id`/`path` is the
96    /// row's intrinsic, kept as any projected key is.
97    pub bare: bool,
98    /// What a tier-3 name on an open kind does (`spec/surface` §1.5, 4.6; a
99    /// tier-3 name on `links` is `filter_invalid` either way): `Hint` records
100    /// it in `hints` and runs the query — the default of the source entry
101    /// ([`query`], every read); `Refuse` fails with `filter_invalid` before
102    /// any row is read — the default of the AST entry ([`query_ast`]), whose
103    /// caller is the write selector compiler, where a name no row carries
104    /// would select every row. `None` takes the entry's default.
105    pub unknown_names: Option<UnknownNames>,
106    /// The vocabulary cache of the store (§1.5 "Caching"), kept by the
107    /// `Surface` beside its store; `None` judges with a cache of the run's own.
108    pub vocabulary: Option<&'a VocabularyCache>,
109    /// The instant the named points of `date()` resolve against
110    /// (`spec/surface` §1.3, 4.8): the host clock, UTC, in milliseconds —
111    /// the `Surface` passes the clock it stamps commits with (pinned by
112    /// `OMGBASE_SPEC_CLOCK`); `None` reads the wall clock. One instant per
113    /// run: the fold and the context share it.
114    pub now_ms: Option<i64>,
115    /// The `query` call as the caller received it (`spec/surface` 4.11): the
116    /// arguments a corrected call is spelled from when a parse error can be
117    /// fixed (`data.fix`, the bracket-list rule — [`bracket_list_as_tuple`]).
118    /// Absent, the message still gains its hint and no `fix` is composed.
119    pub request: Option<&'a Json>,
120}
121
122/// §1.4 `OqxResult`.
123#[derive(Clone, Debug, PartialEq)]
124pub struct OqxResult {
125    pub hits: Vec<Json>,
126    pub truncated: bool,
127    pub cursor: Option<String>,
128    pub consumer: Consumer,
129    pub count: Option<f64>,
130    pub exists: Option<bool>,
131    pub none: Option<bool>,
132    /// A top-level `values` projection's bare values, in place of `hits`.
133    pub values: Option<Vec<Json>>,
134    /// Unknown names (`spec/surface` §1.5, 4.6): one line per name the query
135    /// reads that is neither a field of its row kind nor a property any object
136    /// of that kind in the repository carries — the rows are still returned.
137    /// First-reference order, deduplicated; empty = absent on the wire (never
138    /// `[]`). The last member of the result.
139    pub hints: Vec<String>,
140}
141
142impl OqxResult {
143    fn scalar(consumer: Consumer) -> Self {
144        Self {
145            hits: Vec::new(),
146            truncated: false,
147            cursor: None,
148            consumer,
149            count: None,
150            exists: None,
151            none: None,
152            values: None,
153            hints: Vec::new(),
154        }
155    }
156
157    /// The wire shape: `{ hits, truncated, cursor, consumer, count?, exists?,
158    /// none?, values?, hints? }` — `hints` last, only when there is one.
159    #[must_use]
160    pub fn to_json(&self) -> Json {
161        let mut m = Map::new();
162        m.insert("hits".to_owned(), Json::Array(self.hits.clone()));
163        m.insert("truncated".to_owned(), Json::Bool(self.truncated));
164        m.insert(
165            "cursor".to_owned(),
166            self.cursor.clone().map_or(Json::Null, Json::String),
167        );
168        m.insert(
169            "consumer".to_owned(),
170            Json::String(self.consumer.as_str().to_owned()),
171        );
172        if let Some(n) = self.count {
173            m.insert("count".to_owned(), Value::Number(n).to_canonical_json());
174        }
175        if let Some(b) = self.exists {
176            m.insert("exists".to_owned(), Json::Bool(b));
177        }
178        if let Some(b) = self.none {
179            m.insert("none".to_owned(), Json::Bool(b));
180        }
181        if let Some(v) = &self.values {
182            m.insert("values".to_owned(), Json::Array(v.clone()));
183        }
184        if !self.hints.is_empty() {
185            m.insert(
186                "hints".to_owned(),
187                Json::Array(self.hints.iter().cloned().map(Json::String).collect()),
188            );
189        }
190        Json::Object(m)
191    }
192}
193
194// ---- the `$it` rewrite -----------------------------------------------------------
195
196/// Paths on the surface are the reference form (`spec/surface` §1 "Paths",
197/// 2.0): `path` and the edge field `dst_path` read `/a/b.md`. A string
198/// literal a query compares with one of them — `path == "a.md"`, `"a.md" !=
199/// dst_path`,
200/// `path.startsWith("lab/")` — is rooted first, so both spellings match; a
201/// property or binding compared with a path is never touched (it is the
202/// author's value). The planner sees the rewritten tree too, so the pushed
203/// SQL and the in-memory engine agree by construction.
204const PATH_ATTRIBUTES: [&str; 2] = ["path", "dst_path"];
205/// The handles through which a member read reaches a ROW's `path` (`doc.path`,
206/// `block.path`, a link's `dst.path`, `$it.path`); a member read through a
207/// source handle (`data.path`, `frontmatter.path`) is the author's value and
208/// is never rooted.
209const ROW_HANDLES: [&str; 4] = ["doc", "block", "dst", "$it"];
210
211fn is_path_read(e: &Expr) -> bool {
212    match e {
213        Expr::Ident { name, .. } | Expr::Outer { name, .. } => {
214            PATH_ATTRIBUTES.contains(&name.as_str())
215        }
216        Expr::Member { recv, name, .. } if PATH_ATTRIBUTES.contains(&name.as_str()) => {
217            matches!(&**recv, Expr::Ident { name, .. } | Expr::Outer { name, .. } if ROW_HANDLES.contains(&name.as_str()))
218        }
219        _ => false,
220    }
221}
222
223/// Result projections may not overwrite the envelope (§1.4, 3.0): a projection
224/// whose output key is `id` or `path` is allowed only when it IS the envelope's
225/// own selection — the bare attribute of the same name, which collapses onto
226/// the envelope key (`select id, path` is simply the envelope) — and is
227/// otherwise a loud error asking for an alias: `select data.path` must be
228/// `select p: data.path`, `select path: doc.layer` likewise. Checked over the
229/// resolved items (an alias is just a name here), before the engine runs, so
230/// the error never depends on the rows. A `values` projection returns no hits,
231/// so the envelope — and the rule — do not apply. Same rule as the reference's
232/// `envelopeCollision`.
233fn envelope_collision(items: &[SelectItem]) -> Option<SurfaceError> {
234    for item in items {
235        let name = match item {
236            SelectItem::Field { name, .. } | SelectItem::Collect { name, .. } => name.as_str(),
237        };
238        if name != HIT_ID && name != HIT_PATH {
239            continue;
240        }
241        if let SelectItem::Field { expr, .. } = item
242            && matches!(expr, Expr::Ident { name: n, .. } if n == name)
243        {
244            continue;
245        }
246        let hint = if name == HIT_PATH {
247            "p: data.path"
248        } else {
249            "i: data.id"
250        };
251        return Some(SurfaceError::filter_invalid(
252            format!("projection `{name}` collides with the hit envelope — alias it (`{hint}`)"),
253            "OQX",
254        ));
255    }
256    None
257}
258
259/// A `flatten … as id` / `as path` (oqx 0.25) would shadow the envelope's own
260/// reads on the flattened row (the bound name shadows a same-named row
261/// property, SEMANTICS §25): refused, like a projection that collides with the
262/// envelope. Same rule as the reference's `runResolved`.
263fn flatten_collision(flatten: &[Flatten]) -> Option<SurfaceError> {
264    let f = flatten
265        .iter()
266        .find(|f| f.name == HIT_ID || f.name == HIT_PATH)?;
267    Some(SurfaceError::filter_invalid(
268        format!(
269            "flatten `as {0}` collides with the hit envelope — a flattened row is still the row, whose `{0}` the envelope reads; bind another name",
270            f.name
271        ),
272        "OQX",
273    ))
274}
275
276fn root_literal(e: Expr) -> Expr {
277    match e {
278        Expr::Lit {
279            value: Value::Str(s),
280            span,
281        } => Expr::Lit {
282            value: Value::Str(reference_path(&s)),
283            span,
284        },
285        other => other,
286    }
287}
288
289/// The literal-rooting rewrite of one expression (see [`rewrite_query`]).
290#[must_use]
291pub fn root_path_literals(e: Expr) -> Expr {
292    match e {
293        Expr::Binary {
294            op: op @ (BinaryOp::Eq | BinaryOp::Ne),
295            left,
296            right,
297            span,
298        } => {
299            if is_path_read(&left) {
300                Expr::Binary {
301                    op,
302                    left,
303                    right: Box::new(root_literal(*right)),
304                    span,
305                }
306            } else if is_path_read(&right) {
307                Expr::Binary {
308                    op,
309                    left: Box::new(root_literal(*left)),
310                    right,
311                    span,
312                }
313            } else {
314                Expr::Binary {
315                    op,
316                    left,
317                    right,
318                    span,
319                }
320            }
321        }
322        Expr::Call {
323            recv: Some(recv),
324            name,
325            mut args,
326            span,
327        } if name == "startsWith" && args.len() == 1 && is_path_read(&recv) => {
328            let arg = root_literal(args.remove(0));
329            Expr::Call {
330                recv: Some(recv),
331                name,
332                args: vec![arg],
333                span,
334            }
335        }
336        other => other,
337    }
338}
339
340/// Dates over literals fold to literals (`spec/surface` §1.3 "Dates and
341/// durations", 4.8): `date("today")`, `dur("30 days")`, and a date method
342/// whose receiver and arguments are literals (`date("today").minus(dur("30
343/// days"))`, folded inside-out since `transform` maps children first) become
344/// the string / number / null they evaluate to — by the SAME functions the
345/// context runs, so nothing changes in meaning — before the planner sees the
346/// tree. So `where updated_at < date("today")` is a text-vs-text comparison
347/// the translator pushes down (a call is otherwise an unconditional decline),
348/// and a named point is resolved ONCE per query, at `now_ms`. A call with the
349/// wrong arity is left in place: it raises when evaluated, and an empty source
350/// never evaluates it (`spec/oqx` §11).
351#[must_use]
352pub fn fold_dates(e: Expr, now_ms: i64) -> Expr {
353    let Expr::Call {
354        recv, name, args, ..
355    } = &e
356    else {
357        return e;
358    };
359    let mut vals = Vec::with_capacity(args.len());
360    for a in args {
361        let Expr::Lit { value, .. } = a else { return e };
362        vals.push(value.clone());
363    }
364    let folded = match recv {
365        None => {
366            if dates::date_function_arity(name) != Some(vals.len()) {
367                return e;
368            }
369            dates::date_function(name, &vals, now_ms)
370        }
371        Some(r) => {
372            let Expr::Lit { value, .. } = &**r else {
373                return e;
374            };
375            if dates::date_method_arity(name) != Some(vals.len()) {
376                return e;
377            }
378            dates::date_method(name, value, &vals)
379        }
380    };
381    match folded {
382        Some(Ok(v)) => build::lit(v),
383        _ => e,
384    }
385}
386
387/// Rewrite every row function in a parsed query to a `$it` method call,
388/// root every string literal compared with a path intrinsic, and fold the
389/// dates over literals ([`fold_dates`], at `now_ms`) — one `transform` over
390/// the AST (`spec/oqx/AST.md` §5), every block included.
391#[must_use]
392pub fn rewrite_query(q: &Query, now_ms: i64) -> Query {
393    transform(q, &mut |e, _| match e {
394        Expr::Call {
395            recv: None,
396            name,
397            args,
398            span,
399        } if ROW_FNS.contains(&name.as_str()) => Expr::Call {
400            recv: Some(Box::new(build::ident("$it"))),
401            name,
402            args,
403            span,
404        },
405        other => fold_dates(root_path_literals(other), now_ms),
406    })
407}
408
409// ---- the root row (spec/surface §1.1, 2.0) ----------------------------------------------
410
411/// The `$repo` error (removed in surface 2.0): the same text in both engines.
412pub 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`";
413
414/// The bare-target error. `depth` is the scope depth the name was read at when
415/// known statically (the carets in the hint count it); `None` when raised at
416/// read time by the context, where the hint spells out the rule instead.
417#[must_use]
418pub fn bare_target_message(t: Target, depth: Option<usize>) -> String {
419    let name = t.as_str();
420    let noun = t.noun();
421    let carets = "^".repeat(depth.unwrap_or(1));
422    let how = if depth.is_none() {
423        format!("; one caret per enclosing block, or the absolute `0^{name}`")
424    } else {
425        String::new()
426    };
427    format!(
428        "`{name}` inside a block reads a property of the current row, which has none — did you mean `{carets}{name}` (the repository's {noun}{how})?"
429    )
430}
431
432/// The unknown-target error (3.0 name pass): a top-level `from <name>` whose
433/// name is not one of the five collections would read an absent root and
434/// return an empty result — refused instead, naming the collections (in the
435/// root object's order, `revisions` the fifth since 4.7); `edges`, the links
436/// target's name before 3.0, gets its new name spelled out.
437#[must_use]
438pub fn unknown_target_message(name: &str) -> String {
439    let was = if name == "edges" {
440        " (`edges` was renamed `links` in surface 3.0)"
441    } else {
442        ""
443    };
444    let roots: Vec<String> = crate::context::ALL_TARGETS
445        .iter()
446        .map(|t| format!("`{}`", t.as_str()))
447        .collect();
448    format!(
449        "`{name}` is not a target — the repository's collections are {}{was}",
450        roots.join(", ")
451    )
452}
453
454/// The past-the-root error: `^docs` at the root scope (more carets than there
455/// are enclosing scopes) would read absent and count 0 — refused, naming the
456/// bare spelling. `levels` is the caret count written.
457#[must_use]
458pub fn past_root_message(t: Target, levels: usize) -> String {
459    let name = t.as_str();
460    let noun = t.noun();
461    let carets = "^".repeat(levels);
462    format!(
463        "`{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})`)"
464    )
465}
466
467/// The repository is OQX's root row (`spec/oqx` 0.18): from a top-level row
468/// `^docs` reaches the documents (one caret per enclosing block, or the
469/// absolute `0^docs`) and `^id` the repository id. Two spellings are refused
470/// before evaluation, by one walk over the parsed tree, so the error does not
471/// depend on which rows the data holds and the planned and in-memory paths
472/// agree trivially: `$repo` anywhere (bare, as a member head, `^$repo`,
473/// `0^$repo`), and a bare `docs`/`links` at scope depth ≥ 1 — a property read
474/// of a row that has none. `blocks`/`nodes` are relations of some rows
475/// (`docs.blocks`, `docs.nodes`, `blocks.nodes`, `section.blocks`) and are
476/// judged by the context at read time with the same message. A `^<target>`
477/// with more carets than enclosing scopes (`^docs count { }` at the top level)
478/// would read absent and count 0: refused too, naming the bare spelling. Port
479/// of the reference's `checkRootSpellings`.
480pub fn check_root_spellings(q: &Query) -> Result<()> {
481    // A top-level `from <name>` that is no collection (`from edges`, the links
482    // target's name before 3.0) would scan an absent root and come back empty:
483    // refused, naming the five collections.
484    if let Expr::Ident { name, .. } = &q.source {
485        if Target::parse(name).is_none() {
486            return Err(SurfaceError::filter_invalid(
487                unknown_target_message(name),
488                "OQX",
489            ));
490        }
491    }
492    struct Check(Option<SurfaceError>);
493    impl Visitor<'_> for Check {
494        fn enter(&mut self, node: Node<'_>, ctx: &VisitContext<'_>) -> bool {
495            if self.0.is_some() {
496                return false;
497            }
498            match node {
499                Node::Expr(Expr::Ident { name, .. } | Expr::Outer { name, .. })
500                    if name == "$repo" =>
501                {
502                    self.0 = Some(SurfaceError::filter_invalid(REPO_REMOVED_MESSAGE, "OQX"));
503                }
504                Node::Expr(Expr::Ident { name, .. })
505                    if ctx.depth >= 1 && matches!(name.as_str(), "docs" | "links") =>
506                {
507                    let t = Target::parse(name).expect("a target name");
508                    self.0 = Some(SurfaceError::filter_invalid(
509                        bare_target_message(t, Some(ctx.depth)),
510                        "OQX",
511                    ));
512                }
513                Node::Expr(Expr::Outer { name, levels, .. }) if *levels > ctx.depth => {
514                    if let Some(t) = Target::parse(name) {
515                        self.0 = Some(SurfaceError::filter_invalid(
516                            past_root_message(t, *levels),
517                            "OQX",
518                        ));
519                    }
520                }
521                _ => {}
522            }
523            self.0.is_none()
524        }
525    }
526    let mut check = Check(None);
527    visit(Node::Query(q), &mut check);
528    check.0.map_or(Ok(()), Err)
529}
530
531// ---- semantic phrases -----------------------------------------------------------------
532
533/// Collects the distinct `semantic("…")` phrases of a tree.
534struct Phrases(Vec<String>);
535
536impl Visitor<'_> for Phrases {
537    fn enter(&mut self, node: Node<'_>, _ctx: &VisitContext<'_>) -> bool {
538        if let Node::Expr(Expr::Call {
539            recv: None,
540            name,
541            args,
542            ..
543        }) = node
544            && name == "semantic"
545            && let Some(Expr::Lit {
546                value: Value::Str(s),
547                ..
548            }) = args.first()
549            && !self.0.contains(s)
550        {
551            self.0.push(s.clone());
552        }
553        true
554    }
555}
556
557/// The distinct phrases `semantic("…")` names (free calls in the raw parse);
558/// empty when the source does not parse.
559#[must_use]
560pub fn collect_semantic_phrases(source: &str) -> Vec<String> {
561    let Ok(q) = oqx::parse_string(source) else {
562        return Vec::new();
563    };
564    semantic_phrases_of(&q)
565}
566
567/// [`collect_semantic_phrases`] over a parsed tree.
568#[must_use]
569pub fn semantic_phrases_of(q: &Query) -> Vec<String> {
570    let mut phrases = Phrases(Vec::new());
571    visit(Node::Query(q), &mut phrases);
572    phrases.0
573}
574
575/// Embed the query's phrases through the provider: the vectors
576/// `semantic(...)` ranks by. Without a provider the phrases stay unembedded
577/// and the context reports `filter_invalid` ("needs an embedding provider")
578/// when one is reached — after its target check, so `semantic()` on nodes
579/// names the targets (§9; the `query` tool's pre-check is what reports
580/// `semantic_unavailable`).
581pub fn embed_phrases(
582    provider: Option<&dyn EmbeddingProvider>,
583    phrases: Vec<String>,
584) -> Result<HashMap<String, SemanticVec>> {
585    let mut semantic: HashMap<String, SemanticVec> = HashMap::new();
586    if let Some(provider) = provider.filter(|_| !phrases.is_empty()) {
587        for phrase in phrases {
588            let vec = provider
589                .embed_query(&phrase)
590                .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?;
591            semantic.insert(
592                phrase,
593                SemanticVec {
594                    model: provider.model().to_owned(),
595                    vec: f32_to_blob(&vec),
596                },
597            );
598        }
599    }
600    Ok(semantic)
601}
602
603// ---- reserved projection names ----------------------------------------------------------
604
605/// The runner's own projection names (`__oqx_id`, `__oqx_path`,
606/// `__oqx_self`, `__oqx_value`) and the selector compiler's slots
607/// (`__omg_sel_<t>_<slot>`, [`crate::select`]) share two reserved prefixes.
608/// A user alias under either would shadow an injected item (`select
609/// __oqx_id: 1` overwrote the hit's `id` through 4.0) or collide with a
610/// slot, so it is refused before anything runs (4.1) — over the query's own
611/// top-level items, the only ones the runner injects beside. The reference's
612/// `reservedAlias`.
613pub const RESERVED_ALIAS_PREFIXES: [&str; 2] = ["__oqx_", "__omg_"];
614
615/// Refuse a top-level `select` alias under a reserved prefix.
616pub fn check_reserved_aliases(q: &Query) -> Result<()> {
617    for item in &q.select {
618        if let Some(prefix) = RESERVED_ALIAS_PREFIXES
619            .iter()
620            .find(|p| item.name().starts_with(*p))
621        {
622            return Err(SurfaceError::filter_invalid(
623                format!(
624                    "projection `{}` uses the reserved prefix `{prefix}` — choose another alias",
625                    item.name()
626                ),
627                "OQX",
628            ));
629        }
630    }
631    Ok(())
632}
633
634// ---- name mentions --------------------------------------------------------------------
635
636/// Whether `name` is read anywhere in `q` other than as its source: a bare
637/// identifier or a `^`-escaped one in the `from` steps, `where`, `select`,
638/// `order by`, `follow`, `limit`/`offset`, or any nested block.
639fn mentions_outside_source(q: &Query, name: &str) -> bool {
640    struct Mentions<'n> {
641        name: &'n str,
642        found: bool,
643    }
644    impl Visitor<'_> for Mentions<'_> {
645        fn enter(&mut self, node: Node<'_>, ctx: &VisitContext<'_>) -> bool {
646            if self.found {
647                return false;
648            }
649            // The source itself (the root's `source` slot) is not a mention.
650            if ctx.clause == Some(Clause::Source) && ctx.path.len() == 1 {
651                return false;
652            }
653            if let Node::Expr(Expr::Ident { name, .. } | Expr::Outer { name, .. }) = node
654                && name == self.name
655            {
656                self.found = true;
657            }
658            !self.found
659        }
660    }
661    let mut m = Mentions { name, found: false };
662    visit(Node::Query(q), &mut m);
663    m.found
664}
665
666// ---- hits ----------------------------------------------------------------------------
667
668/// Whether the engine's top-level rows can be anything but store rows: a
669/// `follow` (a destination may be a property's value), a `from E`
670/// re-projection, or a source that is not a bare root scan (one of the five
671/// collections, `revisions` included — the planner's `compile` is narrower:
672/// the four it can push down).
673fn may_reach_non_rows(q: &Query) -> bool {
674    q.follow.is_some() || !q.from.is_empty() || root_target(&q.source).is_none()
675}
676
677/// The reference's `describeValue`: what a non-row hit was, for the error.
678fn describe_value(v: &Value) -> String {
679    match v {
680        Value::Undefined | Value::Null => "an absent value".to_owned(),
681        Value::Str(s) => format!(
682            "a string ({})",
683            serde_json::to_string(s).unwrap_or_default()
684        ),
685        Value::Number(_) => format!("a number ({v})"),
686        Value::Bool(b) => format!("a boolean ({b})"),
687        Value::Array(_) => "an array".to_owned(),
688        Value::Range(_) => "a range".to_owned(),
689        Value::Object(_) => "an object".to_owned(),
690    }
691}
692
693fn not_a_store_row(v: &Value) -> SurfaceError {
694    SurfaceError::filter_invalid(
695        format!(
696            "a hit must be a document, block, node, link or revision row — the query reached {}; to follow document references held in a property use refs(<field>)",
697            describe_value(v)
698        ),
699        "OQX",
700    )
701}
702
703/// A shaped row (the reference's `Shaped`, 4.2): the identity the runner
704/// injected, kept APART from the user projection until the very end, so
705/// `distinct`, the keyset page and the cursor run on identity whether or not
706/// the hits carry it (`bare`). `id` is JavaScript's `String()` on the injected
707/// id, `path` `""` for an absent path; `proj` is the user projection with its
708/// values rendered per §1.4 "rows as values" (a store row nested in the result
709/// — an empty-projection `collect { }` and friends — becomes `{ id, path }`).
710/// When the row itself was projected under [`SELF_KEY`] and is not a store
711/// row, the query fails (§1.4: a hit is a store row).
712#[derive(Debug)]
713struct Shaped {
714    id: String,
715    path: String,
716    proj: oqx::Object,
717}
718
719fn path_string(path: &Value) -> String {
720    if path.is_absent() {
721        String::new()
722    } else {
723        path.to_string()
724    }
725}
726
727fn shape_row(row: Value) -> Result<Shaped> {
728    let mut shaped = Shaped {
729        id: Value::Undefined.to_string(),
730        path: String::new(),
731        proj: oqx::Object::new(),
732    };
733    let Value::Object(mut o) = row else {
734        return Ok(shaped);
735    };
736    if let Some(me) = o.remove(SELF_KEY)
737        && target_of(&me).is_none()
738    {
739        return Err(not_a_store_row(&me));
740    }
741    let Value::Object(o) = render_row_values(Value::Object(o)) else {
742        return Ok(shaped);
743    };
744    for (k, v) in o {
745        match k.as_str() {
746            ID_KEY => shaped.id = v.to_string(),
747            PATH_KEY => shaped.path = path_string(&v),
748            _ => {
749                shaped.proj.insert(k, v);
750            }
751        }
752    }
753    Ok(shaped)
754}
755
756/// The identity of a row the engine produced, read off the injected columns
757/// without shaping it — exactly the strings [`shape_row`] would carry — so an
758/// unshaped page can be cut by the keyset cursor before shaping (a scan that
759/// projects thousands of rows shapes fifty).
760fn raw_identity(row: &Value) -> (String, String) {
761    let get = |k: &str| {
762        row.as_object()
763            .and_then(|o| o.get(k))
764            .cloned()
765            .unwrap_or(Value::Undefined)
766    };
767    (path_string(&get(PATH_KEY)), get(ID_KEY).to_string())
768}
769
770/// A hit: `{ id, path, ...proj }` (§1.4, 3.0: the envelope keys are the
771/// attributes that read them). The envelope keys lead; a user projection of
772/// `id`/`path` collapses onto them in place (the same value, read by the
773/// same attribute).
774fn to_hit(s: Shaped) -> Value {
775    let mut hit = oqx::Object::with_capacity(s.proj.len() + 2);
776    hit.insert(HIT_ID, Value::Str(s.id));
777    hit.insert(HIT_PATH, Value::Str(s.path));
778    for (k, v) in s.proj {
779        hit.insert(k, v);
780    }
781    Value::Object(hit)
782}
783
784/// `bare` (§1.4, 4.2): the projection alone, in the query's own order. A
785/// projected `id`/`path` is an ordinary column here — the intrinsic the
786/// attribute reads, under the key the query gave it — and nothing is injected
787/// beside it.
788fn to_bare_hit(s: Shaped) -> Value {
789    Value::Object(s.proj)
790}
791
792/// The wire shape of one shaped row: the hit, or the bare hit.
793fn render_hit(s: Shaped, bare: bool) -> Json {
794    if bare {
795        to_bare_hit(s).to_canonical_json()
796    } else {
797        to_hit(s).to_canonical_json()
798    }
799}
800
801/// Top-level `select distinct`: dedup by the USER projection — every field but
802/// the envelope's `id`/`path`, which a projection of the attribute collapses
803/// onto (so `select distinct id` dedups on nothing but the empty projection,
804/// as it always has); under `bare` there is no envelope, so a projected
805/// `id`/`path` IS part of the projection and counts. Keeps the first (so the
806/// retained row carries a real identity), order-preserving. The key is the
807/// canonical JSON of the sorted `[key, value]` pairs (`JSON.stringify` in the
808/// reference).
809fn dedup_by_projection(rows: Vec<Shaped>, bare: bool) -> Vec<Shaped> {
810    let mut seen: Vec<String> = Vec::new();
811    let mut out = Vec::new();
812    for s in rows {
813        let mut pairs: Vec<(String, Value)> = s
814            .proj
815            .iter()
816            .filter(|(k, _)| bare || (*k != HIT_ID && *k != HIT_PATH))
817            .map(|(k, v)| (k.to_owned(), v.clone()))
818            .collect();
819        pairs.sort_by(|a, b| a.0.cmp(&b.0));
820        let key = Value::Array(
821            pairs
822                .into_iter()
823                .map(|(k, v)| Value::Array(vec![Value::Str(k), v]))
824                .collect(),
825        )
826        .to_canonical_json()
827        .to_string();
828        if seen.contains(&key) {
829            continue;
830        }
831        seen.push(key);
832        out.push(s);
833    }
834    out
835}
836
837/// A top-level `limit`/`offset` on the collect path is applied by the runner,
838/// so it must be a plain non-negative integer literal.
839fn const_bound(e: Option<&Expr>, word: &str) -> Result<Option<usize>> {
840    match e {
841        None => Ok(None),
842        Some(Expr::Lit {
843            value: Value::Number(n),
844            ..
845        }) if n.fract() == 0.0 && *n >= 0.0 && n.is_finite() => Ok(Some(*n as usize)),
846        Some(_) => Err(SurfaceError::filter_invalid(
847            format!("top-level {word} must be a non-negative integer literal"),
848            "OQX",
849        )),
850    }
851}
852
853/// The collect page over `rows`, whatever their representation — `ident`
854/// yields a row's `(path, id)`: the query's own `offset`/`limit` define the
855/// result SET, the keyset `cursor` (when given) cuts it, `cap` pages it.
856/// Returns the page, whether more rows followed, and the next cursor (none
857/// under a custom order, §1.4).
858fn page_rows<T>(
859    mut rows: Vec<T>,
860    ident: impl Fn(&T) -> (String, String),
861    offset: usize,
862    limit: Option<usize>,
863    cursor: Option<&str>,
864    cap: usize,
865    custom: bool,
866) -> Result<(Vec<T>, bool, Option<String>)> {
867    if offset > 0 || limit.is_some() {
868        let end = limit.map_or(rows.len(), |l| (offset + l).min(rows.len()));
869        if offset >= rows.len() {
870            rows.clear();
871        } else {
872            rows.truncate(end);
873            rows.drain(..offset);
874        }
875    }
876    if let Some(cursor) = cursor {
877        // The cursor carries the hit's (rooted) path; a 1.x cursor is refused (§1.4).
878        let parts = decode_path_cursor(cursor, "query", 2)?;
879        let (path, id) = (&parts[0], &parts[1]);
880        rows.retain(|r| {
881            let (rp, ri) = ident(r);
882            rp > *path || (rp == *path && ri > *id)
883        });
884    }
885    let truncated = rows.len() > cap;
886    rows.truncate(cap);
887    let next = if truncated && !custom {
888        rows.last().map(|last| {
889            let (p, i) = ident(last);
890            encode_cursor(&[&p, &i])
891        })
892    } else {
893        None
894    };
895    Ok((rows, truncated, next))
896}
897
898fn value_of(s: &Shaped) -> Json {
899    s.proj
900        .get(VALUE_KEY)
901        .cloned()
902        .unwrap_or(Value::Undefined)
903        .to_canonical_json()
904}
905
906/// `bare: true` needs a projection and is not the `values` form (§1.4, 4.2):
907/// without a projection a hit is only its envelope, and `values` is already
908/// the bare-values form. Checked over the resolved query where hits are built
909/// (the scalar consumers have no hits and ignore `bare`), before the engine
910/// runs, so the error never depends on the rows and the planned and in-memory
911/// paths agree trivially. The reference's `bareMisuse`.
912pub fn check_bare(q: &Query) -> Result<()> {
913    if q.values {
914        return Err(SurfaceError::filter_invalid(
915            BARE_WITH_VALUES_MESSAGE,
916            "OQX",
917        ));
918    }
919    if q.select.is_empty() {
920        return Err(SurfaceError::filter_invalid(
921            BARE_NEEDS_SELECT_MESSAGE,
922            "OQX",
923        ));
924    }
925    Ok(())
926}
927
928/// The `bare` refusals (4.2): the same text in both engines.
929pub const BARE_NEEDS_SELECT_MESSAGE: &str =
930    "`bare` needs a `select` — without a projection a hit is only its envelope";
931pub const BARE_WITH_VALUES_MESSAGE: &str =
932    "`bare` is redundant with `values` — `values` is already the bare-values form; drop one";
933
934// ---- the run --------------------------------------------------------------------------
935
936/// Run an OQX query against `repo_id` (§1.4).
937pub fn query(
938    store: &Store,
939    repo_id: &str,
940    source: &str,
941    opts: QueryOptions<'_>,
942) -> Result<OqxResult> {
943    let semantic = embed_phrases(opts.provider, collect_semantic_phrases(source))?;
944    let runner = Runner {
945        store,
946        repo_id,
947        semantic,
948        planned: !opts.in_memory,
949        now_ms: opts.now_ms.unwrap_or_else(dates::wall_clock_ms),
950    };
951    let raw = oqx::parse_string(source).map_err(|e| parse_invalid(source, &e, opts.request))?;
952    check_reserved_aliases(&raw)?;
953    // The source entry is every read: a tier-3 name on an open kind is a hint.
954    run_parsed(
955        &runner,
956        &raw,
957        opts,
958        opts.unknown_names.unwrap_or(UnknownNames::Hint),
959    )
960}
961
962// ---- the bracket-list fix (`spec/surface` §4 "Error data", 4.11) ----------------------
963//
964// OQX has no list literal; a tuple `("a", "b")` is the collection (`spec/oqx`
965// SEMANTICS §3b), so `id in ["a", "b"]` — the SQL habit — is the parse error
966// `unexpected '[' — expected a value (at offset N)`. When the source at N opens
967// a bracketed comma list of LITERALS closed by `]` (two or more members, each a
968// string, number, `true`, `false` or `null`; nothing nested), the message
969// gains `; a list is written as a tuple: (…)` and, when the request is known,
970// the error carries `fix`: the same request with that `[…]` rewritten to
971// `(…)`. Anything else at N (an identifier member, a nested bracket, one
972// member, an unclosed list) is the parser's error as it stands.
973
974const BRACKET_ERROR_HEAD: &str = "unexpected '[' — expected a value (at offset ";
975/// The suffix the bracket-list message gains (`TUPLE_HINT`).
976pub const TUPLE_HINT: &str = "; a list is written as a tuple: (…)";
977
978/// The offset `N` of `unexpected '[' — expected a value (at offset N)`, or
979/// `None` for any other message.
980fn bracket_error_offset(message: &str) -> Option<usize> {
981    let digits = message
982        .strip_prefix(BRACKET_ERROR_HEAD)?
983        .strip_suffix(')')?;
984    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
985        return None;
986    }
987    digits.parse().ok()
988}
989
990/// The source with the bracket list at the error's offset (in code points,
991/// as the parser counts) rewritten to a tuple, or `None` when the rule does
992/// not apply (the reference's `bracketListAsTuple`).
993#[must_use]
994pub fn bracket_list_as_tuple(source: &str, message: &str) -> Option<String> {
995    let open = bracket_error_offset(message)?;
996    let mut cps: Vec<char> = source.chars().collect();
997    if cps.get(open) != Some(&'[') {
998        return None;
999    }
1000    let mut i = open + 1;
1001    let mut members = 0;
1002    let close = loop {
1003        i = skip_spaces(&cps, i);
1004        let end = literal_end(&cps, i)?;
1005        members += 1;
1006        i = skip_spaces(&cps, end);
1007        match cps.get(i) {
1008            Some(',') => i += 1,
1009            Some(']') => break i,
1010            _ => return None,
1011        }
1012    };
1013    if members < 2 {
1014        return None;
1015    }
1016    cps[open] = '(';
1017    cps[close] = ')';
1018    Some(cps.into_iter().collect())
1019}
1020
1021fn skip_spaces(cps: &[char], mut i: usize) -> usize {
1022    while i < cps.len() && cps[i].is_whitespace() {
1023        i += 1;
1024    }
1025    i
1026}
1027
1028/// The index just past one OQX literal starting at `i` (a string in either
1029/// quote with `\<c>` escapes, an unsigned number, `true`/`false`/`null`), or
1030/// `None`.
1031fn literal_end(cps: &[char], i: usize) -> Option<usize> {
1032    let c = *cps.get(i)?;
1033    if c == '"' || c == '\'' {
1034        let mut j = i + 1;
1035        while j < cps.len() {
1036            if cps[j] == '\\' {
1037                j += 2;
1038            } else if cps[j] == c {
1039                return Some(j + 1);
1040            } else {
1041                j += 1;
1042            }
1043        }
1044        return None;
1045    }
1046    if c.is_ascii_digit() {
1047        let digits = |mut j: usize| {
1048            while j < cps.len() && cps[j].is_ascii_digit() {
1049                j += 1;
1050            }
1051            j
1052        };
1053        let mut j = digits(i);
1054        if cps.get(j) == Some(&'.') && cps.get(j + 1).is_some_and(char::is_ascii_digit) {
1055            j = digits(j + 1);
1056        }
1057        if matches!(cps.get(j), Some('e' | 'E')) {
1058            let mut k = j + 1;
1059            if matches!(cps.get(k), Some('+' | '-')) {
1060                k += 1;
1061            }
1062            if cps.get(k).is_some_and(char::is_ascii_digit) {
1063                j = digits(k);
1064            }
1065        }
1066        return Some(j);
1067    }
1068    for word in ["true", "false", "null"] {
1069        let n = word.len();
1070        let matches_word = cps.len() >= i + n && cps[i..i + n].iter().copied().eq(word.chars());
1071        let boundary = !cps
1072            .get(i + n)
1073            .is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '$');
1074        if matches_word && boundary {
1075            return Some(i + n);
1076        }
1077    }
1078    None
1079}
1080
1081/// The surface's `filter_invalid` for a parse error: the engine's message,
1082/// plus the tuple hint and `fix` when the bracket rule applies (the
1083/// reference's `parseInvalid`).
1084fn parse_invalid(source: &str, e: &oqx::OqxError, request: Option<&Json>) -> SurfaceError {
1085    let Some(rewritten) = bracket_list_as_tuple(source, &e.message) else {
1086        return SurfaceError::filter_invalid(e.message.clone(), "OQX");
1087    };
1088    let mut err = SurfaceError::filter_invalid(format!("{}{TUPLE_HINT}", e.message), "OQX");
1089    if let Some(Json::Object(args)) = request
1090        && let Some(d) = err.data.as_mut().and_then(Json::as_object_mut)
1091    {
1092        let mut fixed = args.clone();
1093        fixed.insert("query".to_owned(), Json::String(rewritten));
1094        d.insert(
1095            "fix".to_owned(),
1096            Json::String(omgbase_mutate::canonical_json(&Json::Object(fixed))),
1097        );
1098    }
1099    err
1100}
1101
1102/// Run an already-parsed query (§1.4) — the AST entry the selector compile
1103/// uses (`spec/mutate` §11.8: the selector's tree with the templates' slot
1104/// projections appended runs as any query does; the reserved-alias check is
1105/// the source entry's, so the appended `__omg_sel_*` items pass). `semantic`
1106/// holds the embedded phrases ([`embed_phrases`]); `opts.provider` is not
1107/// read here. Unknown names (4.6) default to [`UnknownNames::Refuse`] here:
1108/// this entry's caller is the write selector, where a name no row carries
1109/// would select every row; the source entry passes `Hint`.
1110pub fn query_ast(
1111    store: &Store,
1112    repo_id: &str,
1113    raw: &Query,
1114    opts: QueryOptions<'_>,
1115    semantic: HashMap<String, SemanticVec>,
1116) -> Result<OqxResult> {
1117    let runner = Runner {
1118        store,
1119        repo_id,
1120        semantic,
1121        planned: !opts.in_memory,
1122        now_ms: opts.now_ms.unwrap_or_else(dates::wall_clock_ms),
1123    };
1124    run_parsed(
1125        &runner,
1126        raw,
1127        opts,
1128        opts.unknown_names.unwrap_or(UnknownNames::Refuse),
1129    )
1130}
1131
1132/// One query's engine: a fresh store context per run (the planned path gives
1133/// the residual a context serving the produced rows as its root). A failure
1134/// inside a property read or row function is the engine's own error (the
1135/// context's `get` / `call_method` return `Err`); only a failed root scan,
1136/// which the `root` seam cannot raise, is read back after the run.
1137struct Runner<'a> {
1138    store: &'a Store,
1139    repo_id: &'a str,
1140    semantic: HashMap<String, SemanticVec>,
1141    planned: bool,
1142    /// The one instant of this run (§1.3): the fold and the context share it.
1143    now_ms: i64,
1144}
1145
1146impl Runner<'_> {
1147    /// Tier-3 pushdown reduces the scan in SQL and the in-memory engine
1148    /// finishes the residual over the produced rows (a declined plan, or
1149    /// `in_memory`, runs the whole query in memory over a full scan), so
1150    /// results match a pure scan. This is `oqx::PlannedEngine::run` inlined:
1151    /// the store context borrows the connection, so it cannot be the
1152    /// `'static` context a `Plan` carries.
1153    fn run(&self, q: &Query) -> Result<oqx::OqxResult> {
1154        let conn = self.store.conn();
1155        let ctx =
1156            StoreContext::new(conn, self.repo_id, self.semantic.clone()).with_now_ms(self.now_ms);
1157        let plan = if self.planned {
1158            SqlitePlanner::new(conn, self.repo_id)
1159                .try_plan(q, &[])
1160                .map_err(|e| SurfaceError::other(format!("sqlite: {e}")))?
1161        } else {
1162            None
1163        };
1164        let (ctx, residual) = match plan {
1165            // The residual's source is its one read of the rows root unless
1166            // the query text itself names `__oqx_rows__` somewhere else (a
1167            // `^`-reach, a `limit`, a nested block…): then every read must
1168            // see the rows, so they are cloned out instead of moved.
1169            Some(plan) => {
1170                let once = !mentions_outside_source(&plan.residual, oqx::ROWS_ROOT);
1171                let ctx = if once {
1172                    ctx.with_rows_root_once(plan.rows)
1173                } else {
1174                    ctx.with_rows_root(plan.rows)
1175                };
1176                (ctx, Some(plan.residual))
1177            }
1178            None => (ctx, None),
1179        };
1180        let engine = InMemoryEngine::new(ctx);
1181        let out = engine.run(residual.as_ref().unwrap_or(q), &[]);
1182        // `root` has no error channel: a store failure during a root scan was
1183        // served as an empty scan and wins over whatever the run made of it.
1184        if let Some(failed) = engine.context().take_root_failure() {
1185            return Err(failed.into());
1186        }
1187        // A root scan projected as a VALUE (`select all: ^docs`) was
1188        // materialized into its rows by the engine (`DataContext::materialize`).
1189        Ok(out?)
1190    }
1191}
1192
1193fn run_parsed(
1194    engine: &Runner<'_>,
1195    raw: &Query,
1196    opts: QueryOptions<'_>,
1197    mode: UnknownNames,
1198) -> Result<OqxResult> {
1199    check_root_spellings(raw)?;
1200    // Unknown names (spec/surface §1.5): judged over the RAW tree — before
1201    // alias resolution and the runner's own items — so an alias is never
1202    // mistaken for a row property and the verdict never depends on the rows.
1203    // The selector compiler's slot items (`__omg_sel_*`, the templates'
1204    // expressions) are not the selector: an expression reading an absent
1205    // property there is spec/mutate's `target_missing` / absent value, not an
1206    // unknown name.
1207    let own = Query {
1208        select: raw
1209            .select
1210            .iter()
1211            .filter(|it| {
1212                !RESERVED_ALIAS_PREFIXES
1213                    .iter()
1214                    .any(|p| it.name().starts_with(p))
1215            })
1216            .cloned()
1217            .collect(),
1218        ..raw.clone()
1219    };
1220    let local = VocabularyCache::default();
1221    let cache = opts.vocabulary.unwrap_or(&local);
1222    let hints = check_names(engine.store, engine.repo_id, &own, mode, cache)?;
1223    let mut result = run_resolved(engine, raw, opts)?;
1224    // `hints` is the last member, present only when there is something to say.
1225    result.hints = hints;
1226    Ok(result)
1227}
1228
1229fn run_resolved(engine: &Runner<'_>, raw: &Query, opts: QueryOptions<'_>) -> Result<OqxResult> {
1230    // The query's `select` aliases are resolved HERE, once, before the runner
1231    // renames/injects items (an engine evaluates the query it is given).
1232    let parsed = rewrite_query(&resolve_aliases(raw)?, engine.now_ms);
1233    let consumer = parsed.consumer;
1234
1235    match consumer {
1236        Consumer::Exists => {
1237            let res = engine.run(&parsed)?;
1238            let mut r = OqxResult::scalar(consumer);
1239            r.exists = Some(matches!(res, oqx::OqxResult::Exists(true)));
1240            return Ok(r);
1241        }
1242        Consumer::Count => {
1243            let res = engine.run(&parsed)?;
1244            let mut r = OqxResult::scalar(consumer);
1245            r.count = Some(match res {
1246                oqx::OqxResult::Count(n) => n,
1247                _ => 0.0,
1248            });
1249            return Ok(r);
1250        }
1251        Consumer::None => {
1252            let res = engine.run(&parsed)?;
1253            let mut r = OqxResult::scalar(consumer);
1254            r.none = Some(match res {
1255                oqx::OqxResult::None(b) => b,
1256                _ => true,
1257            });
1258            return Ok(r);
1259        }
1260        Consumer::Collect | Consumer::First | Consumer::Single => {}
1261    }
1262
1263    // A grouped query (oqx 0.24, SEMANTICS §24) yields GROUPS, not store rows:
1264    // the envelope does not apply — there is no `id`/`path` to inject and no
1265    // keyset to page by — so a hit is the group's projection as the query
1266    // wrote it (`{ layer, n }`), exactly what `bare` yields for a row query;
1267    // `bare` is accepted and changes nothing. Rows that surface as values
1268    // (`rows collect { }` with an empty projection, `collect(doc)`) render as
1269    // `{ id, path }` refs like every row-as-value. Paging is by position: the
1270    // query's own `limit`/`offset` define the set, the tool's `limit` takes
1271    // the first page, `truncated` says whether more groups follow, the cursor
1272    // is always null (as under a custom `order by`). `distinct` cannot occur
1273    // (a parse error).
1274    if parsed.group_by.is_some() {
1275        return run_grouped(engine, &parsed, consumer, opts);
1276    }
1277
1278    // A flattened query (oqx 0.25, SEMANTICS §25) yields the parent rows, one
1279    // per element: a hit is still a store row, so the envelope is injected as
1280    // usual and repeats per element (Dataview's `TABLE … FLATTEN`). What
1281    // changes is uniqueness — `(path, id)` no longer names one hit — so the
1282    // keyset cursor cannot land inside a document's elements: a flattened
1283    // query pages positionally with a null cursor, as a grouped query and a
1284    // custom `order by` do (a keyset extended with the flatten ordinal is a
1285    // later round). A bound name of `id` or `path` would shadow the envelope's
1286    // own reads (the name shadows a same-named row property on the flattened
1287    // row): refused, like a projection that collides with the envelope.
1288    let flattened = !parsed.flatten.is_empty();
1289    if let Some(e) = flatten_collision(&parsed.flatten) {
1290        return Err(e);
1291    }
1292
1293    // collect / first / single: inject id + path so every hit carries them. A
1294    // top-level `select distinct` is applied HERE, not in the engine (the
1295    // injected id/path are unique per row and would defeat the engine's
1296    // projection dedup). A top-level `values` projection runs as a RECORD
1297    // projection whose single item is renamed to VALUE_KEY.
1298    let top_distinct = parsed.distinct;
1299    let top_values = parsed.values;
1300    let user_select: Vec<SelectItem> = if top_values {
1301        parsed
1302            .select
1303            .first()
1304            .map(|it| match it {
1305                SelectItem::Field {
1306                    expr, lift, span, ..
1307                } => SelectItem::Field {
1308                    name: VALUE_KEY.to_owned(),
1309                    expr: expr.clone(),
1310                    lift: *lift,
1311                    span: *span,
1312                },
1313                SelectItem::Collect { op, span, .. } => SelectItem::Collect {
1314                    name: VALUE_KEY.to_owned(),
1315                    op: op.clone(),
1316                    span: *span,
1317                },
1318            })
1319            .into_iter()
1320            .collect()
1321    } else {
1322        parsed.select.clone()
1323    };
1324    // `bare` (4.2): the hits are the projection alone — refused without one
1325    // and with `values`; with no envelope there is no collision to refuse
1326    // either, a projected `id`/`path` is simply a column.
1327    let bare = opts.bare;
1328    if bare {
1329        check_bare(&parsed)?;
1330    } else if !top_values && let Some(e) = envelope_collision(&user_select) {
1331        return Err(e);
1332    }
1333    let id_item = build::field(ID_KEY, build::ident("id"));
1334    let path_item = build::field(PATH_KEY, build::ident("path"));
1335    let mut select = vec![id_item, path_item];
1336    if !top_values && may_reach_non_rows(&parsed) {
1337        select.push(build::field(SELF_KEY, build::ident("$it")));
1338    }
1339    select.extend(user_select);
1340    // On the collect path the query's own limit/offset is taken out of the
1341    // engine query and applied after the runner's distinct; first/single keep
1342    // theirs (the engine's offset-aware cap is exactly right for them).
1343    let (top_limit, top_offset) = (parsed.limit.clone(), parsed.offset.clone());
1344    let q = Query {
1345        distinct: false,
1346        values: false,
1347        select,
1348        limit: if consumer == Consumer::Collect {
1349            None
1350        } else {
1351            parsed.limit.clone()
1352        },
1353        offset: if consumer == Consumer::Collect {
1354            None
1355        } else {
1356            parsed.offset.clone()
1357        },
1358        ..parsed.clone()
1359    };
1360    let res = engine.run(&q)?;
1361
1362    if matches!(consumer, Consumer::First | Consumer::Single) {
1363        let row = match res {
1364            oqx::OqxResult::First(r) | oqx::OqxResult::Single(r) => r,
1365            _ => None,
1366        };
1367        let mut out = OqxResult::scalar(consumer);
1368        match row {
1369            None => {
1370                if top_values {
1371                    out.values = Some(Vec::new());
1372                }
1373            }
1374            Some(r) => {
1375                let shaped = shape_row(r)?;
1376                if top_values {
1377                    out.values = Some(vec![value_of(&shaped)]);
1378                } else {
1379                    out.hits = vec![render_hit(shaped, bare)];
1380                }
1381            }
1382        }
1383        return Ok(out);
1384    }
1385
1386    // collect: keyset pagination on (path, id) when the order is the default.
1387    let rows: Vec<Value> = match res {
1388        oqx::OqxResult::Collect(rows) => rows,
1389        _ => Vec::new(),
1390    };
1391    let custom = parsed.order_by.as_ref().is_some_and(|o| !o.is_empty()) || flattened;
1392    let cursor = opts.cursor.filter(|c| !c.is_empty() && !custom);
1393    let offset = const_bound(top_offset.as_ref(), "offset")?.unwrap_or(0);
1394    let limit = const_bound(top_limit.as_ref(), "limit")?;
1395    let cap = opts.limit.unwrap_or(DEFAULT_LIMIT);
1396    // The page runs on IDENTITY, never on the hit: a `distinct` needs every
1397    // row shaped first (its key is the projection), else the plain rows are
1398    // paged by their injected columns and only the page is shaped. The
1399    // envelope is stripped LAST (`bare`), so a bare result pages exactly like
1400    // the enveloped one.
1401    let (page, truncated, next) = if top_distinct {
1402        let shaped = rows
1403            .into_iter()
1404            .map(shape_row)
1405            .collect::<Result<Vec<_>>>()?;
1406        let shaped = dedup_by_projection(shaped, bare);
1407        page_rows(
1408            shaped,
1409            |s| (s.path.clone(), s.id.clone()),
1410            offset,
1411            limit,
1412            cursor,
1413            cap,
1414            custom,
1415        )?
1416    } else {
1417        let (page, truncated, next) =
1418            page_rows(rows, raw_identity, offset, limit, cursor, cap, custom)?;
1419        let page = page
1420            .into_iter()
1421            .map(shape_row)
1422            .collect::<Result<Vec<_>>>()?;
1423        (page, truncated, next)
1424    };
1425    let mut out = OqxResult::scalar(Consumer::Collect);
1426    out.truncated = truncated;
1427    out.cursor = next;
1428    if top_values {
1429        out.values = Some(page.iter().map(value_of).collect());
1430    } else {
1431        out.hits = page.into_iter().map(|s| render_hit(s, bare)).collect();
1432    }
1433    Ok(out)
1434}
1435
1436/// The collect/first/single path of a grouped query (see [`run_resolved`]).
1437fn run_grouped(
1438    engine: &Runner<'_>,
1439    parsed: &Query,
1440    consumer: Consumer,
1441    opts: QueryOptions<'_>,
1442) -> Result<OqxResult> {
1443    let top_values = parsed.values;
1444    // A `values` projection rides as a record under VALUE_KEY and is peeled off
1445    // the page, as on the row path.
1446    let select: Vec<SelectItem> = if top_values {
1447        parsed
1448            .select
1449            .first()
1450            .map(|it| match it {
1451                SelectItem::Field {
1452                    expr, lift, span, ..
1453                } => SelectItem::Field {
1454                    name: VALUE_KEY.to_owned(),
1455                    expr: expr.clone(),
1456                    lift: *lift,
1457                    span: *span,
1458                },
1459                SelectItem::Collect { op, span, .. } => SelectItem::Collect {
1460                    name: VALUE_KEY.to_owned(),
1461                    op: op.clone(),
1462                    span: *span,
1463                },
1464            })
1465            .into_iter()
1466            .collect()
1467    } else {
1468        parsed.select.clone()
1469    };
1470    let (top_limit, top_offset) = (parsed.limit.clone(), parsed.offset.clone());
1471    let q = Query {
1472        values: false,
1473        select,
1474        limit: if consumer == Consumer::Collect {
1475            None
1476        } else {
1477            parsed.limit.clone()
1478        },
1479        offset: if consumer == Consumer::Collect {
1480            None
1481        } else {
1482            parsed.offset.clone()
1483        },
1484        ..parsed.clone()
1485    };
1486    let res = engine.run(&q)?;
1487    let project = |row: Value| -> oqx::Object {
1488        match render_row_values(row) {
1489            Value::Object(o) => o,
1490            _ => oqx::Object::new(),
1491        }
1492    };
1493    let value_of = |g: &oqx::Object| -> Json {
1494        g.get(VALUE_KEY)
1495            .cloned()
1496            .unwrap_or(Value::Undefined)
1497            .to_canonical_json()
1498    };
1499    if matches!(consumer, Consumer::First | Consumer::Single) {
1500        let row = match res {
1501            oqx::OqxResult::First(r) | oqx::OqxResult::Single(r) => r,
1502            _ => None,
1503        };
1504        let mut out = OqxResult::scalar(consumer);
1505        match row {
1506            None => {
1507                if top_values {
1508                    out.values = Some(Vec::new());
1509                }
1510            }
1511            Some(r) => {
1512                let g = project(r);
1513                if top_values {
1514                    out.values = Some(vec![value_of(&g)]);
1515                } else {
1516                    out.hits = vec![Value::Object(g).to_canonical_json()];
1517                }
1518            }
1519        }
1520        return Ok(out);
1521    }
1522    let mut groups: Vec<oqx::Object> = match res {
1523        oqx::OqxResult::Collect(rows) => rows.into_iter().map(project).collect(),
1524        _ => Vec::new(),
1525    };
1526    let offset = const_bound(top_offset.as_ref(), "offset")?.unwrap_or(0);
1527    let limit = const_bound(top_limit.as_ref(), "limit")?;
1528    if offset > 0 || limit.is_some() {
1529        let end = limit.map_or(groups.len(), |l| (offset + l).min(groups.len()));
1530        if offset >= groups.len() {
1531            groups.clear();
1532        } else {
1533            groups.truncate(end);
1534            groups.drain(..offset);
1535        }
1536    }
1537    let cap = opts.limit.unwrap_or(DEFAULT_LIMIT);
1538    let truncated = groups.len() > cap;
1539    groups.truncate(cap);
1540    let mut out = OqxResult::scalar(Consumer::Collect);
1541    out.truncated = truncated;
1542    if top_values {
1543        out.values = Some(groups.iter().map(value_of).collect());
1544    } else {
1545        out.hits = groups
1546            .into_iter()
1547            .map(|g| Value::Object(g).to_canonical_json())
1548            .collect();
1549    }
1550    Ok(out)
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555    use super::*;
1556    use oqx::ast::Where;
1557    use serde_json::json;
1558
1559    // ---- the bracket-list fix (spec/surface §4 "Error data", 4.11) ----
1560
1561    fn parse_err(src: &str) -> String {
1562        oqx::parse_string(src).unwrap_err().message
1563    }
1564
1565    #[test]
1566    fn a_bracket_list_of_literals_is_rewritten_to_a_tuple() {
1567        let cases = [
1568            (
1569                "from docs where stage in [\"nigredo\", \"albedo\"]",
1570                "from docs where stage in (\"nigredo\", \"albedo\")",
1571            ),
1572            (
1573                "from blocks where depth in [0, 1] and path == \"/index.md\"",
1574                "from blocks where depth in (0, 1) and path == \"/index.md\"",
1575            ),
1576            (
1577                "from docs where verified in [ true, 'yes', null ]",
1578                "from docs where verified in ( true, 'yes', null )",
1579            ),
1580            (
1581                "from docs where x in [1.5e3,2E-1,\"a\\\"b\"]",
1582                "from docs where x in (1.5e3,2E-1,\"a\\\"b\")",
1583            ),
1584            // the offset counts code points, as the parser does
1585            (
1586                "from docs where title in [\"é\", \"ü\"] and stage in [\"a\", \"b\"]",
1587                "from docs where title in (\"é\", \"ü\") and stage in [\"a\", \"b\"]",
1588            ),
1589        ];
1590        for (src, want) in cases {
1591            let msg = parse_err(src);
1592            assert!(msg.starts_with(BRACKET_ERROR_HEAD), "{src}: {msg}");
1593            assert_eq!(
1594                bracket_list_as_tuple(src, &msg).as_deref(),
1595                Some(want),
1596                "{src}"
1597            );
1598        }
1599    }
1600
1601    #[test]
1602    fn a_bracket_list_that_is_not_two_or_more_literals_has_no_rewrite() {
1603        for src in [
1604            "from docs where stage in [[\"nigredo\"], \"albedo\"]",
1605            "from docs where stage in [stage, \"albedo\"]",
1606            "from docs where stage in [\"nigredo\"]",
1607            "from docs where stage in [\"nigredo\", \"albedo\"",
1608            "from docs where stage in [\"nigredo\" \"albedo\"]",
1609            "from docs where stage in [truex, 1]",
1610            "from docs where stage in [-1, 2]",
1611            "from docs where stage in []",
1612        ] {
1613            let msg = parse_err(src);
1614            assert_eq!(bracket_list_as_tuple(src, &msg), None, "{src}: {msg}");
1615        }
1616        // another message, or a message whose offset is not a `[`
1617        assert_eq!(bracket_list_as_tuple("from docs", "unexpected end"), None);
1618        assert_eq!(
1619            bracket_list_as_tuple(
1620                "from docs",
1621                "unexpected '[' — expected a value (at offset 2)"
1622            ),
1623            None
1624        );
1625        assert_eq!(
1626            bracket_error_offset("unexpected '[' — expected a value (at offset x)"),
1627            None
1628        );
1629        assert_eq!(
1630            bracket_error_offset("unexpected '[' — expected a value (at offset 7)"),
1631            Some(7)
1632        );
1633    }
1634
1635    #[test]
1636    fn a_parse_error_carries_the_hint_and_the_corrected_call() {
1637        let src = "select path from docs where stage in [\"nigredo\", \"albedo\"]";
1638        let e = oqx::parse_string(src).unwrap_err();
1639        let request = json!({ "query": src, "limit": 5, "bare": true });
1640        let err = parse_invalid(src, &e, Some(&request));
1641        assert_eq!(err.code, "filter_invalid");
1642        assert_eq!(
1643            err.message,
1644            "unexpected '[' — expected a value (at offset 37); a list is written as a tuple: (…)"
1645        );
1646        let d = err.data.unwrap();
1647        assert_eq!(d["hint"], "OQX");
1648        assert_eq!(d["reason"], err.message);
1649        assert_eq!(
1650            d["fix"],
1651            r#"{"bare":true,"limit":5,"query":"select path from docs where stage in (\"nigredo\", \"albedo\")"}"#
1652        );
1653        // no request: the hint alone
1654        let err = parse_invalid(src, &e, None);
1655        assert!(err.message.ends_with(TUPLE_HINT));
1656        assert!(err.data.unwrap().get("fix").is_none());
1657        // the rule does not apply: the parser's error as it stands
1658        let src = "from docs where stage in [\"nigredo\"]";
1659        let e = oqx::parse_string(src).unwrap_err();
1660        let err = parse_invalid(src, &e, Some(&json!({ "query": src })));
1661        assert_eq!(
1662            err.message,
1663            "unexpected '[' — expected a value (at offset 25)"
1664        );
1665        assert!(err.data.unwrap().get("fix").is_none());
1666    }
1667
1668    #[test]
1669    fn row_functions_become_self_methods() {
1670        let q = oqx::parse_string(
1671            "from blocks where text(\"x\") && under_heading(\"h\") && size(attrs) > 0 && doc.path.startsWith(\"a\")",
1672        )
1673        .unwrap();
1674        let r = rewrite_query(&q, 0);
1675        let Some(Where::And { parts, .. }) = &r.r#where else {
1676            panic!("and")
1677        };
1678        let Where::Scalar { expr, .. } = &parts[0] else {
1679            panic!("scalar")
1680        };
1681        assert!(
1682            matches!(expr, Expr::Call { recv: Some(r), name, .. } if name == "text" && **r == build::ident("$it"))
1683        );
1684        let Where::Scalar { expr, .. } = &parts[2] else {
1685            panic!("scalar")
1686        };
1687        assert!(
1688            matches!(expr, Expr::Binary { left, .. } if matches!(&**left, Expr::Call { recv: None, name, .. } if name == "size"))
1689        );
1690    }
1691
1692    // ---- the constant fold (spec/surface §1.3, 4.8; the reference's dates.test.ts) ----
1693
1694    /// 2026-09-27T00:00:00.000Z — the corpus clock.
1695    const NOW: i64 = 1_790_467_200_000;
1696
1697    fn where_expr(src: &str) -> Expr {
1698        let q = oqx::parse_string(&format!("from docs where {src}")).unwrap();
1699        let Some(Where::Scalar { expr, .. }) = q.r#where else {
1700            panic!("not a scalar where")
1701        };
1702        expr
1703    }
1704
1705    /// Fold inside-out as `transform` does: children first.
1706    fn fold(src: &str) -> Expr {
1707        fn go(e: Expr) -> Expr {
1708            match e {
1709                Expr::Call {
1710                    recv,
1711                    name,
1712                    args,
1713                    span,
1714                } => fold_dates(
1715                    Expr::Call {
1716                        recv: recv.map(|r| Box::new(go(*r))),
1717                        name,
1718                        args: args.into_iter().map(go).collect(),
1719                        span,
1720                    },
1721                    NOW,
1722                ),
1723                other => other,
1724            }
1725        }
1726        go(where_expr(src))
1727    }
1728
1729    fn lit(e: &Expr) -> Option<&Value> {
1730        match e {
1731            Expr::Lit { value, .. } => Some(value),
1732            _ => None,
1733        }
1734    }
1735
1736    #[test]
1737    fn folds_date_dur_over_literals_and_date_methods_over_literal_receivers_and_arguments() {
1738        assert_eq!(
1739            lit(&fold("date(\"2026-01-05\")")),
1740            Some(&Value::from("2026-01-05"))
1741        );
1742        assert_eq!(
1743            lit(&fold("date(\"today\")")),
1744            Some(&Value::from("2026-09-27"))
1745        );
1746        assert_eq!(
1747            lit(&fold("dur(\"30 days\")")),
1748            Some(&Value::from(30.0 * 86400.0))
1749        );
1750        assert_eq!(
1751            lit(&fold("date(\"today\").minus(dur(\"30 days\"))")),
1752            Some(&Value::from("2026-08-28"))
1753        );
1754        assert_eq!(lit(&fold("date(\"x\")")), Some(&Value::Null));
1755        assert_eq!(lit(&fold("date(5)")), Some(&Value::Null));
1756        assert_eq!(lit(&fold("dur(60).dur_text()")), Some(&Value::from("1m")));
1757    }
1758
1759    #[test]
1760    fn leaves_a_non_literal_argument_an_unknown_name_and_a_wrong_arity_in_place() {
1761        for src in [
1762            "date(when)",
1763            "date(\"today\").until(when)",
1764            "size(\"x\")",
1765            "date()",
1766            "date(\"2026-01-01\").plus()",
1767            "\"x\".lower()",
1768        ] {
1769            assert!(matches!(fold(src), Expr::Call { .. }), "{src}");
1770        }
1771    }
1772
1773    #[test]
1774    fn the_runner_folds_at_its_clock_so_a_named_point_pushes_down() {
1775        let q = oqx::parse_string("from docs where updated_at < date(\"today\") && date(when) < date(\"today\").minus(dur(\"30 days\"))").unwrap();
1776        let printed = oqx::print_query(&rewrite_query(&q, NOW)).unwrap();
1777        assert!(printed.contains("updated_at < \"2026-09-27\""), "{printed}");
1778        assert!(printed.contains("date(when) < \"2026-08-28\""), "{printed}");
1779    }
1780
1781    #[test]
1782    fn path_literals_are_rooted_only_against_path_reads() {
1783        let q = oqx::parse_string(
1784            "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\"",
1785        )
1786        .unwrap();
1787        let printed = oqx::print_query(&rewrite_query(&q, 0)).unwrap();
1788        assert!(printed.contains("path.startsWith(\"/lab/\")"), "{printed}");
1789        assert!(printed.contains("doc.path == \"/a.md\""), "{printed}");
1790        assert!(printed.contains("type == \"a.md\""), "{printed}");
1791        assert!(printed.contains("^path == \"/b.md\""), "{printed}");
1792        assert!(printed.contains("\"/x.md\" != dst_path"), "{printed}");
1793        // a property compared with a path is the author's value: untouched
1794        assert!(printed.contains("path == ^after"), "{printed}");
1795        // `contains`/`endsWith`/`matches` and the empty string are literal
1796        let q = oqx::parse_string(
1797            "from docs where path.contains(\"a/\") && path.endsWith(\".md\") && path == \"\"",
1798        )
1799        .unwrap();
1800        let printed = oqx::print_query(&rewrite_query(&q, 0)).unwrap();
1801        assert!(printed.contains("path.contains(\"a/\")"), "{printed}");
1802        assert!(printed.contains("path.endsWith(\".md\")"), "{printed}");
1803        assert!(printed.contains("path == \"/\""), "{printed}");
1804    }
1805
1806    #[test]
1807    fn repo_and_bare_targets_are_refused_statically() {
1808        let parse = |s: &str| oqx::parse_string(s).unwrap();
1809        for q in [
1810            "from docs where $repo",
1811            "select r: size($repo.docs) from docs",
1812            "select r: $repo.id from docs",
1813            "select r: nodes first { select x: ^$repo.id values } from docs",
1814            "select r: nodes first { select x: 0^$repo values } from docs",
1815            "$repo.docs count { }",
1816        ] {
1817            let e = check_root_spellings(&parse(q)).unwrap_err();
1818            assert_eq!(e.code, "filter_invalid", "{q}");
1819            assert_eq!(e.message, REPO_REMOVED_MESSAGE, "{q}");
1820        }
1821        // a bare `docs`/`links` at depth ≥ 1; the hint counts the carets
1822        let e = check_root_spellings(&parse("select x: docs collect { } from docs")).unwrap_err();
1823        assert_eq!(e.message, bare_target_message(Target::Docs, Some(1)));
1824        assert!(
1825            e.message
1826                .contains("did you mean `^docs` (the repository's documents)?")
1827        );
1828        let e = check_root_spellings(&parse(
1829            "select x: nodes collect { select y: size(links) } from docs",
1830        ))
1831        .unwrap_err();
1832        assert!(
1833            e.message
1834                .contains("did you mean `^^links` (the repository's links)?")
1835        );
1836        let e = check_root_spellings(&parse("docs collect { from docs }")).unwrap_err();
1837        assert!(e.message.contains("`^docs`"));
1838        // the root scope is depth 0: a bare target there is the scan
1839        for q in [
1840            "docs count { }",
1841            "from docs where nodes exists { }",
1842            "entries(docs) first { }",
1843            "select n: size(^docs), id: ^id, k: size(0^links) from docs",
1844            "from blocks where blocks exists { }", // judged by the context at read time
1845        ] {
1846            check_root_spellings(&parse(q)).unwrap_or_else(|e| panic!("{q}: {e}"));
1847        }
1848        // a `^target` reaching past the root
1849        let e = check_root_spellings(&parse("^docs count { }")).unwrap_err();
1850        assert_eq!(e.message, past_root_message(Target::Docs, 1));
1851        assert_eq!(
1852            e.message,
1853            "`^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)`)"
1854        );
1855        let e = check_root_spellings(&parse("select path from docs where ^^links exists { }"))
1856            .unwrap_err();
1857        assert!(
1858            e.message.contains("`^^links` reaches past the root"),
1859            "{}",
1860            e.message
1861        );
1862        // the read-time message spells out the rule
1863        let m = bare_target_message(Target::Blocks, None);
1864        assert!(m.contains("did you mean `^blocks` (the repository's blocks; one caret per enclosing block, or the absolute `0^blocks`)?"), "{m}");
1865    }
1866
1867    #[test]
1868    fn reserved_projection_names_are_refused() {
1869        let parse = |s: &str| oqx::parse_string(s).unwrap();
1870        let e = check_reserved_aliases(&parse("select __oqx_id: 1 from docs")).unwrap_err();
1871        assert_eq!(e.code, "filter_invalid");
1872        assert_eq!(
1873            e.message,
1874            "projection `__oqx_id` uses the reserved prefix `__oqx_` — choose another alias"
1875        );
1876        assert_eq!(e.data.as_ref().unwrap()["hint"], "OQX");
1877        let e = check_reserved_aliases(&parse("select __omg_sel_0_x: 1 from docs")).unwrap_err();
1878        assert!(
1879            e.message.contains("reserved prefix `__omg_`"),
1880            "{}",
1881            e.message
1882        );
1883        // only the top level: the runner injects beside those items alone
1884        assert!(
1885            check_reserved_aliases(&parse(
1886                "select __oqx: 1, x: nodes collect { select __oqx_y: 1 } from docs"
1887            ))
1888            .is_ok()
1889        );
1890    }
1891
1892    #[test]
1893    fn semantic_phrases_are_collected_distinct() {
1894        let phrases = collect_semantic_phrases(
1895            "select s: semantic(\"alpha\") from docs where semantic(\"alpha\") > 0.5 || nodes exists { where semantic(\"beta\") > 0 } order by semantic(\"gamma\") desc",
1896        );
1897        assert_eq!(phrases, ["alpha", "beta", "gamma"]);
1898        assert!(collect_semantic_phrases("not a query {{").is_empty());
1899        assert!(collect_semantic_phrases("from docs").is_empty());
1900    }
1901
1902    #[test]
1903    fn hits_peel_the_injected_columns() {
1904        let mut o = oqx::Object::new();
1905        o.insert(ID_KEY, Value::Str("d_1".into()));
1906        o.insert(PATH_KEY, Value::Null);
1907        o.insert("layer", Value::Str("canon".into()));
1908        let shaped = shape_row(Value::Object(o)).unwrap();
1909        assert_eq!((shaped.id.as_str(), shaped.path.as_str()), ("d_1", ""));
1910        let hit = to_hit(shaped);
1911        let ho = hit.as_object().unwrap();
1912        assert_eq!(ho.keys().collect::<Vec<_>>(), ["id", "path", "layer"]);
1913        assert_eq!(ho.get("path"), Some(&Value::Str(String::new())));
1914        // A user projection of `id` collapses onto the envelope key in place
1915        // (3.0): same position, the projected value.
1916        let mut o = oqx::Object::new();
1917        o.insert(ID_KEY, Value::Str("d_1".into()));
1918        o.insert(PATH_KEY, Value::Str("a.md".into()));
1919        o.insert("id", Value::Number(7.0));
1920        let hit = to_hit(shape_row(Value::Object(o)).unwrap());
1921        let ho = hit.as_object().unwrap();
1922        assert_eq!(ho.keys().collect::<Vec<_>>(), ["id", "path"]);
1923        assert_eq!(ho.get("id"), Some(&Value::Number(7.0)));
1924        // The unshaped row's identity reads the same strings.
1925        let mut o = oqx::Object::new();
1926        o.insert(ID_KEY, Value::Str("d_1".into()));
1927        o.insert(PATH_KEY, Value::Undefined);
1928        let row = Value::Object(o);
1929        assert_eq!(raw_identity(&row), (String::new(), "d_1".to_owned()));
1930        let shaped = shape_row(row).unwrap();
1931        assert_eq!((shaped.path, shaped.id), (String::new(), "d_1".to_owned()));
1932    }
1933
1934    #[test]
1935    fn bare_hits_are_the_projection_in_its_own_order() {
1936        // `select title, id`: the projected intrinsic under the query's key, in
1937        // the query's order — nothing injected beside it.
1938        let mut o = oqx::Object::new();
1939        o.insert(ID_KEY, Value::Str("d_1".into()));
1940        o.insert(PATH_KEY, Value::Str("/a.md".into()));
1941        o.insert("title", Value::Str("T".into()));
1942        o.insert("id", Value::Str("d_1".into()));
1943        let shaped = shape_row(Value::Object(o)).unwrap();
1944        let bare = to_bare_hit(shaped);
1945        let bo = bare.as_object().unwrap();
1946        assert_eq!(bo.keys().collect::<Vec<_>>(), ["title", "id"]);
1947        // `select path: layer` is fine bare (no envelope to collide with) and
1948        // the identity the page runs on is still the row's own path.
1949        let mut o = oqx::Object::new();
1950        o.insert(ID_KEY, Value::Str("d_1".into()));
1951        o.insert(PATH_KEY, Value::Str("/a.md".into()));
1952        o.insert("path", Value::Str("canon".into()));
1953        let shaped = shape_row(Value::Object(o)).unwrap();
1954        assert_eq!(shaped.path, "/a.md");
1955        assert_eq!(
1956            render_hit(shaped, true),
1957            serde_json::json!({ "path": "canon" })
1958        );
1959        // the same row, enveloped: the user's value collapses onto the key
1960        let mut o = oqx::Object::new();
1961        o.insert(ID_KEY, Value::Str("d_1".into()));
1962        o.insert(PATH_KEY, Value::Str("/a.md".into()));
1963        o.insert("path", Value::Str("/a.md".into()));
1964        assert_eq!(
1965            render_hit(shape_row(Value::Object(o)).unwrap(), false),
1966            serde_json::json!({ "id": "d_1", "path": "/a.md" })
1967        );
1968    }
1969
1970    #[test]
1971    fn bare_needs_a_select_and_is_not_the_values_form() {
1972        let parse = |s: &str| oqx::parse_string(s).unwrap();
1973        for q in ["from docs", "from docs where layer == \"canon\""] {
1974            let e = check_bare(&parse(q)).unwrap_err();
1975            assert_eq!(e.code, "filter_invalid", "{q}");
1976            assert_eq!(e.message, BARE_NEEDS_SELECT_MESSAGE, "{q}");
1977            assert_eq!(e.data.as_ref().unwrap()["hint"], "OQX");
1978        }
1979        let e = check_bare(&parse("select path values from docs")).unwrap_err();
1980        assert_eq!(e.code, "filter_invalid");
1981        assert_eq!(e.message, BARE_WITH_VALUES_MESSAGE);
1982        for q in [
1983            "select path from docs",
1984            "select id, title from docs",
1985            "docs first { select t: title }",
1986        ] {
1987            check_bare(&parse(q)).unwrap_or_else(|e| panic!("{q}: {e}"));
1988        }
1989    }
1990
1991    #[test]
1992    fn a_projection_onto_the_envelope_must_be_aliased() {
1993        let items = |src: &str| oqx::parse_string(src).unwrap().select;
1994        // the attribute itself collapses (allowed)
1995        assert!(envelope_collision(&items("select id, path from docs")).is_none());
1996        assert!(
1997            envelope_collision(&items("select id: id, path: path, p: path from docs")).is_none()
1998        );
1999        // anything else landing on `id`/`path` is the error, first offender first
2000        let e = envelope_collision(&items("select data.path from docs")).unwrap();
2001        assert_eq!(e.code, "filter_invalid");
2002        assert_eq!(
2003            e.message,
2004            "projection `path` collides with the hit envelope — alias it (`p: data.path`)"
2005        );
2006        let e = envelope_collision(&items("select id: layer from docs")).unwrap();
2007        assert_eq!(
2008            e.message,
2009            "projection `id` collides with the hit envelope — alias it (`i: data.id`)"
2010        );
2011        assert!(envelope_collision(&items("select path: doc.path from blocks")).is_some());
2012        assert!(envelope_collision(&items("select path: nodes collect { } from docs")).is_some());
2013    }
2014
2015    #[test]
2016    fn a_flatten_bound_onto_the_envelope_is_refused() {
2017        let flatten = |src: &str| oqx::parse_string(src).unwrap().flatten;
2018        // a bound name beside the envelope is fine, however many flattens
2019        assert!(
2020            flatten_collision(&flatten("select path, t from docs flatten tags as t")).is_none()
2021        );
2022        assert!(
2023            flatten_collision(&flatten(
2024                "select path, b from docs flatten blocks { where type == \"task\" } as b flatten b.children as c"
2025            ))
2026            .is_none()
2027        );
2028        assert!(flatten_collision(&flatten("select path from docs")).is_none());
2029        // `as id` / `as path` would shadow the envelope's reads
2030        let e = flatten_collision(&flatten("select path from docs flatten tags as id")).unwrap();
2031        assert_eq!(e.code, "filter_invalid");
2032        assert_eq!(
2033            e.message,
2034            "flatten `as id` collides with the hit envelope — a flattened row is still the row, whose `id` the envelope reads; bind another name"
2035        );
2036        assert_eq!(e.data.as_ref().unwrap()["hint"], "OQX");
2037        let e = flatten_collision(&flatten(
2038            "select title from docs flatten tags as t flatten data.paths as path",
2039        ))
2040        .unwrap();
2041        assert_eq!(
2042            e.message,
2043            "flatten `as path` collides with the hit envelope — a flattened row is still the row, whose `path` the envelope reads; bind another name"
2044        );
2045    }
2046
2047    #[test]
2048    fn a_path_read_is_the_attribute_or_a_row_handle_never_a_source_handle() {
2049        let pred = |src: &str| match oqx::parse_string(src).unwrap().r#where.unwrap() {
2050            oqx::ast::Where::Scalar { expr, .. } => expr,
2051            other => panic!("{other:?}"),
2052        };
2053        let left = |e: Expr| match e {
2054            Expr::Binary { left, .. } => *left,
2055            other => panic!("{other:?}"),
2056        };
2057        assert!(is_path_read(&left(pred("from docs where path == \"x\""))));
2058        assert!(is_path_read(&left(pred(
2059            "from blocks where doc.path == \"x\""
2060        ))));
2061        assert!(is_path_read(&left(pred("from docs where ^path == \"x\""))));
2062        assert!(is_path_read(&left(pred(
2063            "from links where dst_path == \"x\""
2064        ))));
2065        assert!(!is_path_read(&left(pred(
2066            "from docs where data.path == \"x\""
2067        ))));
2068        assert!(!is_path_read(&left(pred(
2069            "from docs where frontmatter.path == \"x\""
2070        ))));
2071    }
2072
2073    #[test]
2074    fn a_hit_that_is_not_a_store_row_fails_the_query() {
2075        // The row itself rides under SELF_KEY when the query can reach a
2076        // non-row; a string there is the `follow before` shape (§1.4, 1.5).
2077        let mut o = oqx::Object::new();
2078        o.insert(ID_KEY, Value::Undefined);
2079        o.insert(PATH_KEY, Value::Undefined);
2080        o.insert(SELF_KEY, Value::Str("/timeline/kickoff.md".into()));
2081        let e = shape_row(Value::Object(o)).unwrap_err();
2082        assert_eq!(e.code, "filter_invalid");
2083        assert_eq!(
2084            e.message,
2085            "a hit must be a document, block, node, link or revision row — the query reached a string (\"/timeline/kickoff.md\"); to follow document references held in a property use refs(<field>)"
2086        );
2087        // A tagged row under SELF_KEY passes and the key is peeled off.
2088        let mut row = oqx::Object::new();
2089        row.insert("doc_id", Value::Str("d_1".into()));
2090        row.insert("path", Value::Str("a.md".into()));
2091        let row = crate::context::tag_row(row, crate::context::Target::Docs);
2092        let mut o = oqx::Object::new();
2093        o.insert(ID_KEY, Value::Str("d_1".into()));
2094        o.insert(PATH_KEY, Value::Str("a.md".into()));
2095        o.insert(SELF_KEY, row);
2096        let hit = to_hit(shape_row(Value::Object(o)).unwrap());
2097        assert_eq!(
2098            hit.as_object().unwrap().keys().collect::<Vec<_>>(),
2099            ["id", "path"]
2100        );
2101        // Only queries that can reach a non-row project the row itself.
2102        let parse = |s: &str| oqx::parse_string(s).unwrap();
2103        assert!(!may_reach_non_rows(&parse(
2104            "from docs where layer == \"canon\""
2105        )));
2106        assert!(may_reach_non_rows(&parse("from docs follow before")));
2107        assert!(may_reach_non_rows(&parse("refs(\"/index.md\") first { }")));
2108    }
2109
2110    #[test]
2111    fn distinct_dedups_by_user_projection_first_wins() {
2112        let mk = |id: &str, t: &str| {
2113            let mut proj = oqx::Object::new();
2114            proj.insert("type", Value::Str(t.into()));
2115            Shaped {
2116                id: id.to_owned(),
2117                path: "p".to_owned(),
2118                proj,
2119            }
2120        };
2121        let out = dedup_by_projection(vec![mk("1", "a"), mk("2", "b"), mk("3", "a")], false);
2122        assert_eq!(out.len(), 2);
2123        assert_eq!(out[0].id, "1");
2124        assert_eq!(out[1].id, "2");
2125        // A projected `id` is the envelope's own selection: not part of the key
2126        // enveloped (`select distinct id` dedups on the empty projection), part
2127        // of it bare (there is no envelope — it is the projection).
2128        let with_id = |id: &str| {
2129            let mut proj = oqx::Object::new();
2130            proj.insert("id", Value::Str(id.into()));
2131            Shaped {
2132                id: id.to_owned(),
2133                path: "p".to_owned(),
2134                proj,
2135            }
2136        };
2137        assert_eq!(
2138            dedup_by_projection(vec![with_id("1"), with_id("2")], false).len(),
2139            1
2140        );
2141        assert_eq!(
2142            dedup_by_projection(vec![with_id("1"), with_id("2")], true).len(),
2143            2
2144        );
2145    }
2146
2147    #[test]
2148    fn the_page_runs_on_identity() {
2149        let rows: Vec<(String, String)> = [("/a.md", "d_1"), ("/a.md", "d_2"), ("/b.md", "d_3")]
2150            .iter()
2151            .map(|(p, i)| ((*p).to_owned(), (*i).to_owned()))
2152            .collect();
2153        let ident = |r: &(String, String)| (r.0.clone(), r.1.clone());
2154        // cap 2: truncated, the cursor is the last row's (path, id)
2155        let (page, truncated, next) =
2156            page_rows(rows.clone(), ident, 0, None, None, 2, false).unwrap();
2157        assert_eq!(page.len(), 2);
2158        assert!(truncated);
2159        let next = next.expect("a cursor");
2160        assert_eq!(next, encode_cursor(&["/a.md", "d_2"]));
2161        // the cursor resumes after it
2162        let (page, truncated, next) =
2163            page_rows(rows.clone(), ident, 0, None, Some(&next), 2, false).unwrap();
2164        assert_eq!(page, vec![("/b.md".to_owned(), "d_3".to_owned())]);
2165        assert!(!truncated && next.is_none());
2166        // the query's own offset/limit define the set; a custom order has no cursor
2167        let (page, truncated, next) =
2168            page_rows(rows.clone(), ident, 1, Some(1), None, 50, true).unwrap();
2169        assert_eq!(page, vec![("/a.md".to_owned(), "d_2".to_owned())]);
2170        assert!(!truncated && next.is_none());
2171        let (page, truncated, next) = page_rows(rows, ident, 0, None, None, 1, true).unwrap();
2172        assert_eq!(page.len(), 1);
2173        assert!(truncated && next.is_none());
2174    }
2175
2176    #[test]
2177    fn top_level_bounds_must_be_literals() {
2178        assert_eq!(const_bound(None, "limit").unwrap(), None);
2179        assert_eq!(
2180            const_bound(
2181                Some(&Expr::Lit {
2182                    value: Value::Number(3.0),
2183                    span: oqx::Span::EMPTY
2184                }),
2185                "limit"
2186            )
2187            .unwrap(),
2188            Some(3)
2189        );
2190        let e = const_bound(
2191            Some(&Expr::Lit {
2192                value: Value::Number(-1.0),
2193                span: oqx::Span::EMPTY,
2194            }),
2195            "offset",
2196        )
2197        .unwrap_err();
2198        assert_eq!(e.code, "filter_invalid");
2199        assert!(e.message.contains("top-level offset"));
2200    }
2201}