Skip to main content

omgbase_surface/
query.rs

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