Skip to main content

omgbase_surface/
query.rs

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