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