Skip to main content

omgbase_surface/
context.rs

1//! The query binding (`spec/surface/README.md` §1): a [`DataContext`] over
2//! the store, so the `oqx` in-memory engine reproduces the whole OQX surface
3//! — roots, intrinsics, reach-through, structural relations, the edge graph,
4//! the row functions — without a bespoke compiler. Port of
5//! `packages/core/src/oqx-js/context.ts`.
6//!
7//! Rows are the store's raw column objects (blobs as hex) carrying a hidden
8//! tag column ([`TAG_KEY`]) naming their target; `get` routes field /
9//! intrinsic / relation resolution per target, lazily querying the store.
10//! Row functions (`text`, `under`, …) arrive as methods on the `$self`
11//! receiver (see the runner's AST rewrite), since a free function sees no row.
12//!
13//! The tier-3 planner ([`crate::planner`]) hands the rows its SQL produced
14//! back through [`StoreContext::with_rows_root`]: the context then serves them
15//! as the residual query's [`oqx::ROWS_ROOT`] scan, while every other root,
16//! relation, intrinsic and row function still reaches the store — the
17//! reference's `rowsRoot` context option.
18//!
19//! Errors travel the engine's channel: a failure inside a property read or a
20//! row function — the reserved-basename guard, a store failure — is the
21//! `Err` of `get` / `call_method` (an eval-stage [`OqxError`], since `oqx`
22//! 0.13), which aborts the run exactly like a throw from the reference's
23//! `get`; the runner maps it to `filter_invalid` with the same message. The
24//! one seam still without a channel is `root` (the engine reads a named root
25//! for the top-level source and for a caret that reaches the root scope), so
26//! a store failure during a root scan is kept in [`StoreContext::take_root_failure`]
27//! and the runner reports it after the run.
28//!
29//! One seam differs from the reference and is bridged here:
30//!
31//! * the Rust engine expands `entries(x)` in row position itself (never via
32//!   `call_function`), so the `frontmatter` / `inline` source handles are
33//!   materialized eagerly as plain objects — one key per top-level property
34//!   in key order, valued by the scalar-vs-list rule — instead of the
35//!   reference's lazy handle. `frontmatter.<k>` and `entries(frontmatter)`
36//!   read the same values either way.
37
38use std::cell::{Cell, RefCell};
39use std::collections::HashMap;
40use std::rc::Rc;
41
42use omgbase_properties::Bound;
43use omgbase_search::{cosine_bytes, sanitize_fts_query};
44use oqx::semantics::{builtin_function, builtin_method_with, make_range, string_form};
45use oqx::{
46    CompiledRegex, DataContext, Object, OqxError, RegexDialect, RowIndex, Value, compile_regex,
47};
48use rusqlite::types::{Value as SqlValue, ValueRef};
49use rusqlite::{Connection, OptionalExtension, params_from_iter};
50
51use crate::planner;
52
53/// The hidden column tagging a store row with its target.
54pub const TAG_KEY: &str = "__oqx_target";
55const REPO_TAG: &str = "$repo";
56/// The key of a lazy ROOT SCAN marker: `{ "__oqx_scan": "docs" }` stands for
57/// `$repo.docs` / a bare `docs` until the engine reads it in row position
58/// (`to_rows`) or is about to observe it as a value (`materialize` — an
59/// operand, an argument, a projected item, a key), or probes it through
60/// `index_for`, in which case the scan never runs. The marker never reaches
61/// the language: the engine materializes every value it observes, so `==`,
62/// `in`, `entries(…)`, `size(…)`, truthiness, `distinct` and `order by` see
63/// the rows exactly as the reference's `Proxy` array shows them. A `Value` has
64/// no identity or laziness of its own, so this is the port of that handle.
65pub const SCAN_KEY: &str = "__oqx_scan";
66
67/// The four scan targets.
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
69pub enum Target {
70    Docs,
71    Blocks,
72    Nodes,
73    Edges,
74}
75
76impl Target {
77    /// The root name: `docs` | `blocks` | `nodes` | `edges`.
78    #[must_use]
79    pub fn as_str(self) -> &'static str {
80        match self {
81            Target::Docs => "docs",
82            Target::Blocks => "blocks",
83            Target::Nodes => "nodes",
84            Target::Edges => "edges",
85        }
86    }
87
88    /// The target a root name denotes, if any.
89    #[must_use]
90    pub fn parse(s: &str) -> Option<Self> {
91        Some(match s {
92            "docs" => Target::Docs,
93            "blocks" => Target::Blocks,
94            "nodes" => Target::Nodes,
95            "edges" => Target::Edges,
96            _ => return None,
97        })
98    }
99}
100
101/// docs intrinsics whose BARE form is almost always a typo: a loud error
102/// ("did you mean the intrinsic").
103const RESERVED_DOC_BASENAMES: [&str; 5] = ["id", "path", "updated_at", "content_hash", "body"];
104
105/// A query phrase's embedding: the model whose cache to read and the vector
106/// as a float32 little-endian blob.
107#[derive(Clone, Debug, PartialEq)]
108pub struct SemanticVec {
109    pub model: String,
110    pub vec: Vec<u8>,
111}
112
113/// The store-backed context for one repo.
114pub struct StoreContext<'a> {
115    conn: &'a Connection,
116    repo_id: String,
117    semantic: HashMap<String, SemanticVec>,
118    /// A store failure inside [`DataContext::root`], the one read the
119    /// engine's seam cannot fail through (see the module doc).
120    root_failure: RefCell<Option<OqxError>>,
121    /// The rows a tier-3 plan produced, served as [`oqx::ROWS_ROOT`].
122    rows_root: Option<RowsRoot>,
123    /// `matches()` patterns compiled during this run, by `(String(pattern),
124    /// flags)`; see [`Self::matches_memoized`].
125    regexes: RefCell<HashMap<(String, Option<String>), CompiledRegex>>,
126    /// Root scans read during this run, by target: a context lives for one
127    /// run, during which the store does not change, so `$repo.docs` read from
128    /// every outer row is one SELECT, not one per row.
129    scans: RefCell<HashMap<Target, Rc<Vec<Value>>>>,
130    /// How many root scans actually ran (tests prove a probe runs none).
131    scans_run: Cell<usize>,
132}
133
134/// The planned rows behind [`oqx::ROWS_ROOT`]: cloned out on every read, or
135/// — when the runner has proven the residual reads the root exactly once —
136/// moved out on the first read (`once`), which spares one deep copy and one
137/// drop of every produced row.
138struct RowsRoot {
139    rows: RefCell<Option<Vec<Value>>>,
140    once: bool,
141}
142
143fn sql_value(v: ValueRef<'_>) -> Value {
144    match v {
145        ValueRef::Null => Value::Null,
146        ValueRef::Integer(i) => Value::Number(i as f64),
147        ValueRef::Real(f) => Value::Number(f),
148        ValueRef::Text(t) => Value::Str(String::from_utf8_lossy(t).into_owned()),
149        ValueRef::Blob(b) => Value::Str(omgbase_format::hash::hex(b)),
150    }
151}
152
153/// A [`Value`] as a SQL parameter (`has_edge`'s destination, the planner's
154/// bound operands): booleans as 1/0 — how `json_extract` surfaces JSON
155/// booleans, so `attrs.b == true` compares against `1` — numbers as REAL
156/// (a JavaScript number binds as a double), absent as NULL.
157pub(crate) fn to_sql(v: &Value) -> SqlValue {
158    match v {
159        Value::Undefined | Value::Null | Value::Range(_) => SqlValue::Null,
160        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
161        Value::Number(n) => SqlValue::Real(*n),
162        Value::Str(s) => SqlValue::Text(s.clone()),
163        Value::Array(_) | Value::Object(_) => SqlValue::Text(v.to_string()),
164    }
165}
166
167/// JavaScript `String(v)` of an argument.
168fn js_string(v: &Value) -> String {
169    v.to_string()
170}
171
172/// `String(args[0] ?? "")`.
173fn arg_or_empty(args: &[Value], i: usize) -> String {
174    match args.get(i) {
175        None | Some(Value::Undefined) | Some(Value::Null) => String::new(),
176        Some(v) => js_string(v),
177    }
178}
179
180/// `JSON.parse` when a string, else the value (`null` → absent).
181fn parse_json(v: &Value) -> Value {
182    match v {
183        Value::Str(s) => {
184            serde_json::from_str::<serde_json::Value>(s).map_or_else(|_| v.clone(), Value::from)
185        }
186        Value::Null | Value::Undefined => Value::Undefined,
187        other => other.clone(),
188    }
189}
190
191/// A store failure as the engine's eval error (the message the reference's
192/// raw exception would carry).
193fn sql_err(e: rusqlite::Error) -> OqxError {
194    OqxError::eval(format!("sqlite: {e}"))
195}
196
197/// The tag of a store row, if it is one.
198pub fn target_of(row: &Value) -> Option<Target> {
199    row.as_object()
200        .and_then(|o| o.get(TAG_KEY))
201        .and_then(Value::as_str)
202        .and_then(Target::parse)
203}
204
205/// The target of a lazy root-scan marker, if `v` is one.
206#[must_use]
207pub fn scan_of(v: &Value) -> Option<Target> {
208    v.as_object()
209        .and_then(|o| o.get(SCAN_KEY))
210        .and_then(Value::as_str)
211        .and_then(Target::parse)
212}
213
214fn scan_marker(t: Target) -> Value {
215    let mut o = Object::with_capacity(1);
216    o.insert(SCAN_KEY, Value::Str(t.as_str().to_owned()));
217    Value::Object(o)
218}
219
220fn is_repo_root(row: &Value) -> bool {
221    row.as_object()
222        .and_then(|o| o.get(TAG_KEY))
223        .and_then(Value::as_str)
224        == Some(REPO_TAG)
225}
226
227fn col<'v>(row: &'v Value, key: &str) -> &'v Value {
228    row.as_object()
229        .and_then(|o| o.get(key))
230        .unwrap_or(&Value::Undefined)
231}
232
233fn col_str(row: &Value, key: &str) -> String {
234    match col(row, key) {
235        Value::Undefined | Value::Null => String::new(),
236        v => js_string(v),
237    }
238}
239
240/// Strip the hidden tag from a value tree (the wire form never carries it).
241#[must_use]
242pub fn strip_tags(v: Value) -> Value {
243    match v {
244        Value::Object(o) => Value::Object(
245            o.into_iter()
246                .filter(|(k, _)| k != TAG_KEY)
247                .map(|(k, x)| (k, strip_tags(x)))
248                .collect(),
249        ),
250        Value::Array(a) => Value::Array(a.into_iter().map(strip_tags).collect()),
251        other => other,
252    }
253}
254
255/// §1.4 rows as values (1.2): a store row that surfaces as a VALUE in a result
256/// tree — a nested `collect { }` / `first { }` / `single { }` with an empty
257/// projection, or a `values` item that is a row — renders as `{ id, path }`
258/// (the target's id column as a string; the owning document's path: a docs
259/// row's `path`, every other row's `__path` join column), never the store
260/// row. Everything else recurses, dropping the hidden tag as [`strip_tags`].
261#[must_use]
262pub fn render_row_values(v: Value) -> Value {
263    match v {
264        Value::Object(o) => {
265            let row = Value::Object(o);
266            if let Some(t) = target_of(&row) {
267                let (id_col, path_col) = match t {
268                    Target::Docs => ("doc_id", "path"),
269                    Target::Blocks => ("block_id", "__path"),
270                    Target::Nodes => ("node_id", "__path"),
271                    Target::Edges => ("edge_id", "__path"),
272                };
273                let mut out = Object::with_capacity(2);
274                out.insert("id", Value::Str(col_str(&row, id_col)));
275                out.insert("path", Value::Str(col_str(&row, path_col)));
276                return Value::Object(out);
277            }
278            let Value::Object(o) = row else {
279                unreachable!()
280            };
281            Value::Object(
282                o.into_iter()
283                    .filter(|(k, _)| k != TAG_KEY)
284                    .map(|(k, x)| (k, render_row_values(x)))
285                    .collect(),
286            )
287        }
288        Value::Array(a) => Value::Array(a.into_iter().map(render_row_values).collect()),
289        other => other,
290    }
291}
292
293impl<'a> StoreContext<'a> {
294    /// A context over `conn` scoped to `repo_id`, with the query phrases'
295    /// vectors for `semantic(...)` (empty when no provider ran).
296    #[must_use]
297    pub fn new(
298        conn: &'a Connection,
299        repo_id: &str,
300        semantic: HashMap<String, SemanticVec>,
301    ) -> Self {
302        Self {
303            conn,
304            repo_id: repo_id.to_owned(),
305            semantic,
306            root_failure: RefCell::new(None),
307            rows_root: None,
308            regexes: RefCell::new(HashMap::new()),
309            scans: RefCell::new(HashMap::new()),
310            scans_run: Cell::new(0),
311        }
312    }
313
314    /// The repository this context is scoped to.
315    #[must_use]
316    pub fn repo_id(&self) -> &str {
317        &self.repo_id
318    }
319
320    /// How many root scans (`SELECT … FROM <target>` whole) this context has
321    /// run — at most one per target per run; none for a block whose probes
322    /// the store indexes answered.
323    #[must_use]
324    pub fn scans_run(&self) -> usize {
325        self.scans_run.get()
326    }
327
328    /// The rows of a root scan, read once per run and shared.
329    pub(crate) fn scan_rows(&self, t: Target) -> oqx::Result<Rc<Vec<Value>>> {
330        if let Some(rows) = self.scans.borrow().get(&t) {
331            return Ok(Rc::clone(rows));
332        }
333        self.scans_run.set(self.scans_run.get() + 1);
334        let rows = match self.root_scan(t)? {
335            Value::Array(rows) => Rc::new(rows),
336            _ => Rc::new(Vec::new()),
337        };
338        self.scans.borrow_mut().insert(t, Rc::clone(&rows));
339        Ok(rows)
340    }
341
342    /// `refs(x)` (`spec/surface` §1.3, 1.5): the live documents the document
343    /// references held in a property name. `x` is a string, a list, or absent;
344    /// every string element that names a live document of this repo — a doc
345    /// id (`d_…`), a repo-root-absolute path (`/a/b.md`) or a bare
346    /// repo-relative path (`a/b.md`) — resolves to that document's row;
347    /// anything else (a dangling reference, a non-string element) is dropped.
348    /// Order preserved, duplicates kept. Port of the reference's `refs`.
349    fn refs(&self, x: &Value) -> oqx::Result<Value> {
350        let items: Vec<&Value> = match x {
351            Value::Undefined | Value::Null => Vec::new(),
352            Value::Array(a) => a.iter().collect(),
353            other => vec![other],
354        };
355        let mut out = Vec::new();
356        for item in items {
357            if let Value::Str(s) = item
358                && let Some(row) = self.ref_doc(s)?
359            {
360                out.push(row);
361            }
362        }
363        Ok(Value::Array(out))
364    }
365
366    /// One document reference resolved: by `path` after stripping one leading
367    /// `/`, else — when it begins with `d_` — by `doc_id`. Each is one indexed
368    /// lookup under the root scan's guards, columns and order
369    /// ([`crate::planner`]), so the row is indistinguishable from a scanned one
370    /// and the docs root is never read.
371    fn ref_doc(&self, r: &str) -> oqx::Result<Option<Value>> {
372        let t = Target::Docs;
373        let lookup = |column: &str, value: &str| -> oqx::Result<Option<Value>> {
374            let sql = format!(
375                "SELECT {} FROM {} WHERE {} AND d.{column} = ? ORDER BY {}",
376                planner::columns(t),
377                planner::from_clause(t),
378                planner::guards(t),
379                planner::order_clause(t)
380            );
381            let params = [
382                SqlValue::Text(self.repo_id.clone()),
383                SqlValue::Text(value.to_owned()),
384            ];
385            Ok(self.one(&sql, &params)?.map(|row| tag_row(row, t)))
386        };
387        if let Some(row) = lookup("path", r.strip_prefix('/').unwrap_or(r))? {
388            return Ok(Some(row));
389        }
390        if r.starts_with("d_") {
391            return lookup("doc_id", r);
392        }
393        Ok(None)
394    }
395
396    /// The tagged rows of one store-index probe statement (`store_index`).
397    pub(crate) fn probe_rows(
398        &self,
399        t: Target,
400        sql: &str,
401        params: &[SqlValue],
402    ) -> oqx::Result<Vec<Value>> {
403        Ok(tag_rows(self.all(sql, params)?, t))
404    }
405
406    /// A lazy root-scan marker expanded to its rows (`Err` → recorded as the
407    /// run's root failure, served empty — the `root` discipline).
408    fn expand_scan(&self, t: Target) -> Vec<Value> {
409        match self.scan_rows(t) {
410            Ok(rows) => rows.as_ref().clone(),
411            Err(e) => {
412                let mut slot = self.root_failure.borrow_mut();
413                if slot.is_none() {
414                    *slot = Some(e);
415                }
416                Vec::new()
417            }
418        }
419    }
420
421    /// Serve `rows` — target-tagged store rows a plan produced — as the
422    /// [`oqx::ROWS_ROOT`] scan (the residual query's source). Every other
423    /// root and every relation, intrinsic and row function still hits the
424    /// store, so the residual sees exactly what a full scan would.
425    #[must_use]
426    pub fn with_rows_root(mut self, rows: Vec<Value>) -> Self {
427        self.rows_root = Some(RowsRoot {
428            rows: RefCell::new(Some(rows)),
429            once: false,
430        });
431        self
432    }
433
434    /// [`Self::with_rows_root`] for a residual whose ONLY read of
435    /// [`oqx::ROWS_ROOT`] is its source scan (the runner checks the AST: the
436    /// name appears nowhere else — no `^`-reach, no `limit`/`offset`, no
437    /// nested mention): the rows are moved out on that first read instead
438    /// of deep-copied, and a second read — which cannot happen — would see
439    /// an empty scan. Same results as [`Self::with_rows_root`], one row copy
440    /// and one drop fewer.
441    #[must_use]
442    pub fn with_rows_root_once(mut self, rows: Vec<Value>) -> Self {
443        self.rows_root = Some(RowsRoot {
444            rows: RefCell::new(Some(rows)),
445            once: true,
446        });
447        self
448    }
449
450    /// The store failure a root scan hit during the run, if any. Every other
451    /// read fails through the engine's channel (`get` / `call_method` return
452    /// `Err`); `root` has none, so it serves an empty scan and leaves the
453    /// failure here for the runner to report.
454    pub fn take_root_failure(&self) -> Option<OqxError> {
455        self.root_failure.borrow_mut().take()
456    }
457
458    // ---- SQL helpers ------------------------------------------------------------------
459
460    fn all(&self, sql: &str, params: &[SqlValue]) -> oqx::Result<Vec<Object>> {
461        fetch_rows(self.conn, sql, params).map_err(sql_err)
462    }
463
464    fn one(&self, sql: &str, params: &[SqlValue]) -> oqx::Result<Option<Object>> {
465        Ok(self.all(sql, params)?.into_iter().next())
466    }
467
468    fn scalar(&self, sql: &str, params: &[SqlValue]) -> oqx::Result<Value> {
469        Ok(self
470            .one(sql, params)?
471            .and_then(|o| o.values().next().cloned())
472            .unwrap_or(Value::Undefined))
473    }
474
475    fn exists(&self, sql: &str, params: &[SqlValue]) -> oqx::Result<bool> {
476        let mut stmt = self.conn.prepare_cached(sql).map_err(sql_err)?;
477        stmt.exists(params_from_iter(params.iter()))
478            .map_err(sql_err)
479    }
480
481    fn tag_all(rows: Vec<Object>, t: Target) -> Value {
482        Value::Array(tag_rows(rows, t))
483    }
484
485    fn tag(row: Object, t: Target) -> Value {
486        tag_row(row, t)
487    }
488
489    fn repo_root(&self) -> Value {
490        let mut o = Object::with_capacity(1);
491        o.insert(TAG_KEY, Value::Str(REPO_TAG.to_owned()));
492        Value::Object(o)
493    }
494
495    // ---- roots (ordered for a stable (path, id) default) ------------------------------
496
497    /// The joined roots drive from `docs` in `(repo_id, path)` index order
498    /// (`CROSS JOIN` fixes the loop order) and reach the rows of each doc
499    /// through their `doc_id` index, so the `ORDER BY d.path, <id>` sorts one
500    /// document's rows at a time instead of every full row of the repo in a
501    /// temp b-tree. The `+` on the inner `repo_id` term keeps it a filter
502    /// (SQLite's unary-plus idiom: the term is not used for index selection,
503    /// which — without `ANALYZE` stats — would otherwise pick the
504    /// `(repo_id, type)` index and rescan the repo per document). Same rows,
505    /// same order: the key `(path, id)` is total (`UNIQUE (repo_id, path)`;
506    /// the id is a primary key), so no plan can order them differently.
507    fn root_scan(&self, t: Target) -> oqx::Result<Value> {
508        let repo = [SqlValue::Text(self.repo_id.clone())];
509        let sql = match t {
510            Target::Docs => {
511                "SELECT * FROM docs WHERE repo_id = ?1 AND deleted_commit IS NULL ORDER BY path, doc_id"
512            }
513            Target::Blocks => {
514                "SELECT b.*, d.path AS __path FROM docs d CROSS JOIN blocks b ON b.doc_id = d.doc_id
515                 WHERE d.repo_id = ?1 AND +b.repo_id = ?1 AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL
516                 ORDER BY d.path, b.block_id"
517            }
518            Target::Nodes => {
519                "SELECT n.*, d.path AS __path FROM docs d CROSS JOIN nodes n ON n.doc_id = d.doc_id
520                 WHERE d.repo_id = ?1 AND +n.repo_id = ?1 AND d.deleted_commit IS NULL ORDER BY d.path, n.node_id"
521            }
522            Target::Edges => {
523                "SELECT e.*, d.path AS __path FROM docs d CROSS JOIN edges e ON e.src_doc = d.doc_id
524                 WHERE d.repo_id = ?1 AND +e.repo_id = ?1 AND e.to_commit IS NULL AND d.deleted_commit IS NULL
525                 ORDER BY d.path, e.edge_id"
526            }
527        };
528        Ok(Self::tag_all(self.all(sql, &repo)?, t))
529    }
530
531    // ---- properties ---------------------------------------------------------------------
532
533    /// A property row decoded to a plain scalar; a range-shaped string stays a
534    /// string (`range(prop)` is the opt-in).
535    fn decode_prop(r: &Object) -> Value {
536        let get = |k: &str| r.get(k).cloned().unwrap_or(Value::Undefined);
537        match get("type").as_str().unwrap_or("") {
538            "number" => get("val_num"),
539            "bool" => Value::Bool(get("val_bool").truthy()),
540            "null" => Value::Null,
541            "json" => parse_json(&get("val_json")),
542            _ => get("val_text"),
543        }
544    }
545
546    /// The scalar-vs-list rule: exactly one `card = scalar` row → the scalar;
547    /// otherwise the array; no row → the nested object under `key.`.
548    fn doc_prop(&self, doc_id: &str, key: &str, source: Option<&str>) -> oqx::Result<Value> {
549        let rows = match source {
550            Some(s) => self.all(
551                "SELECT * FROM properties WHERE doc_id = ?1 AND key = ?2 AND source = ?3 AND deleted_commit IS NULL ORDER BY ord",
552                &[SqlValue::Text(doc_id.to_owned()), SqlValue::Text(key.to_owned()), SqlValue::Text(s.to_owned())],
553            )?,
554            None => self.all(
555                "SELECT * FROM properties WHERE doc_id = ?1 AND key = ?2 AND deleted_commit IS NULL ORDER BY ord",
556                &[SqlValue::Text(doc_id.to_owned()), SqlValue::Text(key.to_owned())],
557            )?,
558        };
559        if rows.is_empty() {
560            return self.doc_prop_object(doc_id, key, source);
561        }
562        if rows.len() == 1 && rows[0].get("card").and_then(Value::as_str) == Some("scalar") {
563            return Ok(Self::decode_prop(&rows[0]));
564        }
565        Ok(Value::Array(rows.iter().map(Self::decode_prop).collect()))
566    }
567
568    /// The nested object rebuilt from flattened dotted keys under `prefix.`
569    /// (`Undefined` when none); leaves decoded.
570    fn doc_prop_object(
571        &self,
572        doc_id: &str,
573        prefix: &str,
574        source: Option<&str>,
575    ) -> oqx::Result<Value> {
576        let like = SqlValue::Text(format!("{prefix}.%"));
577        let rows = match source {
578            Some(s) => self.all(
579                "SELECT * FROM properties WHERE doc_id = ?1 AND key LIKE ?2 AND source = ?3 AND deleted_commit IS NULL ORDER BY ord",
580                &[SqlValue::Text(doc_id.to_owned()), like, SqlValue::Text(s.to_owned())],
581            )?,
582            None => self.all(
583                "SELECT * FROM properties WHERE doc_id = ?1 AND key LIKE ?2 AND deleted_commit IS NULL ORDER BY ord",
584                &[SqlValue::Text(doc_id.to_owned()), like],
585            )?,
586        };
587        if rows.is_empty() {
588            return Ok(Value::Undefined);
589        }
590        let mut out = Object::new();
591        for r in &rows {
592            let key = r.get("key").and_then(Value::as_str).unwrap_or("");
593            let rest: Vec<&str> = key[(prefix.len() + 1).min(key.len())..]
594                .split('.')
595                .collect();
596            set_nested(&mut out, &rest, Self::decode_prop(r));
597        }
598        Ok(Value::Object(out))
599    }
600
601    /// The `frontmatter` / `inline` bag as a plain object: one entry per
602    /// top-level key in key order, each valued by [`Self::doc_prop`].
603    fn doc_prop_bag(&self, doc_id: &str, source: &str) -> oqx::Result<Value> {
604        let keys = self.all(
605            "SELECT DISTINCT key FROM properties WHERE doc_id = ?1 AND source = ?2 AND deleted_commit IS NULL ORDER BY key",
606            &[SqlValue::Text(doc_id.to_owned()), SqlValue::Text(source.to_owned())],
607        )?;
608        let mut out = Object::new();
609        for k in keys {
610            let key = k.get("key").and_then(Value::as_str).unwrap_or("");
611            let top = key.split('.').next().unwrap_or("");
612            if !out.contains_key(top) {
613                let v = self.doc_prop(doc_id, top, Some(source))?;
614                out.insert(top, v);
615            }
616        }
617        Ok(Value::Object(out))
618    }
619
620    // ---- structure ----------------------------------------------------------------------
621
622    /// The ordinal of a block's top-level ancestor (section ranges are in
623    /// top-level ordinals).
624    fn top_ordinal(&self, block: &Value) -> oqx::Result<Value> {
625        let ordinal = col(block, "ordinal").clone();
626        if col(block, "parent_block").is_absent() {
627            return Ok(ordinal);
628        }
629        let ap = col_str(block, "ancestor_path");
630        let Some(first) = ap.split('/').find(|s| !s.is_empty()) else {
631            return Ok(ordinal);
632        };
633        let r = self.scalar(
634            "SELECT ordinal FROM blocks WHERE doc_id = ?1 AND block_id = ?2",
635            &[
636                SqlValue::Text(col_str(block, "doc_id")),
637                SqlValue::Text(first.to_owned()),
638            ],
639        )?;
640        Ok(if r.is_absent() { ordinal } else { r })
641    }
642
643    /// A document's live blocks in document order — pre-order over the
644    /// containment tree (children by `ordinal` under their parent; a row whose
645    /// parent is not live is a root) — tagged, with `__path` = `path`.
646    fn doc_blocks_preorder(&self, doc_id: &str, path: &str) -> oqx::Result<Vec<Value>> {
647        let rows = self.all(
648            "SELECT b.*, ?1 AS __path FROM blocks b WHERE b.doc_id = ?2 AND b.deleted_commit IS NULL ORDER BY b.ordinal, b.block_id",
649            &[SqlValue::Text(path.to_owned()), SqlValue::Text(doc_id.to_owned())],
650        )?;
651        let ids: Vec<String> = rows
652            .iter()
653            .map(|r| {
654                r.get("block_id")
655                    .and_then(Value::as_str)
656                    .unwrap_or("")
657                    .to_owned()
658            })
659            .collect();
660        let parent_index: Vec<Option<usize>> = rows
661            .iter()
662            .map(|r| {
663                r.get("parent_block")
664                    .and_then(Value::as_str)
665                    .and_then(|p| ids.iter().position(|id| id == p))
666            })
667            .collect();
668        let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
669        let mut roots = Vec::new();
670        for (i, p) in parent_index.iter().enumerate() {
671            match p {
672                Some(p) => children[*p].push(i),
673                None => roots.push(i),
674            }
675        }
676        fn walk(i: usize, children: &[Vec<usize>], order: &mut Vec<usize>) {
677            order.push(i);
678            for &c in &children[i] {
679                walk(c, children, order);
680            }
681        }
682        let mut order = Vec::with_capacity(rows.len());
683        for r in roots {
684            walk(r, &children, &mut order);
685        }
686        let mut slots: Vec<Option<Object>> = rows.into_iter().map(Some).collect();
687        Ok(order
688            .into_iter()
689            .map(|i| Self::tag(slots[i].take().expect("visited once"), Target::Blocks))
690            .collect())
691    }
692
693    fn jattr(row: &Value, k: &str) -> Value {
694        match parse_json(col(row, "attrs")) {
695            Value::Object(o) => o.get(k).cloned().unwrap_or(Value::Undefined),
696            _ => Value::Undefined,
697        }
698    }
699
700    /// `Ok(None)` when `key` is not a relation of `t`.
701    fn relation(&self, row: &Value, t: Target, key: &str) -> oqx::Result<Option<Value>> {
702        let path = || SqlValue::Text(col_str(row, "__path"));
703        let doc_id = || SqlValue::Text(col_str(row, "doc_id"));
704        let doc_path = || SqlValue::Text(col_str(row, "path"));
705        let block_id = || SqlValue::Text(col_str(row, "block_id"));
706        let repo = || SqlValue::Text(self.repo_id.clone());
707        Ok(Some(match (t, key) {
708            (Target::Docs, "nodes") => {
709                // Document order: block-less nodes first, then by the owning
710                // block's pre-order rank, `span_start`, `node_id`.
711                let rows = self.all(
712                    "SELECT n.*, ?1 AS __path FROM nodes n WHERE n.doc_id = ?2 ORDER BY n.node_id",
713                    &[doc_path(), doc_id()],
714                )?;
715                let blocks = self.doc_blocks_preorder(&col_str(row, "doc_id"), &col_str(row, "path"))?;
716                let rank: HashMap<String, usize> = blocks
717                    .iter()
718                    .enumerate()
719                    .map(|(i, b)| (col_str(b, "block_id"), i))
720                    .collect();
721                let mut keyed: Vec<((usize, usize, f64, String), Object)> = rows
722                    .into_iter()
723                    .map(|r| {
724                        let block = r.get("block_id").and_then(Value::as_str);
725                        let (has_block, rk) = match block {
726                            None => (0, 0),
727                            Some(b) => (1, rank.get(b).copied().unwrap_or(usize::MAX)),
728                        };
729                        let span = r.get("span_start").and_then(Value::as_f64).unwrap_or(-1.0);
730                        let id = r.get("node_id").and_then(Value::as_str).unwrap_or("").to_owned();
731                        ((has_block, rk, span, id), r)
732                    })
733                    .collect();
734                keyed.sort_by(|a, b| {
735                    a.0.0
736                        .cmp(&b.0.0)
737                        .then(a.0.1.cmp(&b.0.1))
738                        .then(a.0.2.total_cmp(&b.0.2))
739                        .then(a.0.3.cmp(&b.0.3))
740                });
741                Value::Array(keyed.into_iter().map(|(_, r)| Self::tag(r, Target::Nodes)).collect())
742            }
743            (Target::Docs, "blocks") => {
744                Value::Array(self.doc_blocks_preorder(&col_str(row, "doc_id"), &col_str(row, "path"))?)
745            }
746            (Target::Docs, "out") => Self::tag_all(
747                self.all(
748                    "SELECT DISTINCT d2.* FROM docs d2 JOIN edges e ON e.dst_node = d2.doc_id
749                     WHERE e.src_doc = ?1 AND e.to_commit IS NULL AND d2.repo_id = ?2 AND d2.deleted_commit IS NULL ORDER BY d2.path, d2.doc_id",
750                    &[doc_id(), repo()],
751                )?,
752                Target::Docs,
753            ),
754            (Target::Docs, "in") => Self::tag_all(
755                self.all(
756                    "SELECT DISTINCT d2.* FROM docs d2 JOIN edges e ON e.src_doc = d2.doc_id
757                     WHERE e.dst_node = ?1 AND e.to_commit IS NULL AND d2.repo_id = ?2 AND d2.deleted_commit IS NULL ORDER BY d2.path, d2.doc_id",
758                    &[doc_id(), repo()],
759                )?,
760                Target::Docs,
761            ),
762            (Target::Docs, "out_edges") => Self::tag_all(
763                self.all(
764                    "SELECT e.*, ?1 AS __path FROM edges e WHERE e.src_doc = ?2 AND e.to_commit IS NULL ORDER BY e.predicate, e.edge_id",
765                    &[doc_path(), doc_id()],
766                )?,
767                Target::Edges,
768            ),
769            (Target::Docs, "in_edges") => Self::tag_all(
770                self.all(
771                    "SELECT e.*, d.path AS __path FROM edges e JOIN docs d ON d.doc_id = e.src_doc
772                     WHERE e.dst_node = ?1 AND e.to_commit IS NULL AND d.deleted_commit IS NULL ORDER BY e.predicate, e.edge_id",
773                    &[doc_id()],
774                )?,
775                Target::Edges,
776            ),
777            (Target::Blocks, "children") => Self::tag_all(
778                self.all(
779                    "SELECT b.*, ?1 AS __path FROM blocks b WHERE b.parent_block = ?2 AND b.deleted_commit IS NULL ORDER BY b.ordinal, b.block_id",
780                    &[path(), block_id()],
781                )?,
782                Target::Blocks,
783            ),
784            (Target::Blocks, "nodes") => Self::tag_all(
785                self.all(
786                    "SELECT n.*, ?1 AS __path FROM nodes n WHERE n.block_id = ?2 ORDER BY n.span_start, n.node_id",
787                    &[path(), block_id()],
788                )?,
789                Target::Nodes,
790            ),
791            (Target::Blocks, "out_edges") => Self::tag_all(
792                self.all(
793                    "SELECT e.*, ?1 AS __path FROM edges e WHERE e.src_block = ?2 AND e.to_commit IS NULL ORDER BY e.predicate, e.edge_id",
794                    &[path(), block_id()],
795                )?,
796                Target::Edges,
797            ),
798            (Target::Blocks, "section") => {
799                let top = to_sql(&self.top_ordinal(row)?);
800                Self::tag_all(
801                    self.all(
802                        "SELECT n.*, ?1 AS __path FROM nodes n WHERE n.doc_id = ?2 AND n.kind = 'md:section'
803                           AND json_extract(n.attrs,'$.first_ordinal') <= ?3 AND json_extract(n.attrs,'$.last_ordinal') >= ?4
804                         ORDER BY json_extract(n.attrs,'$.first_ordinal'), n.node_id",
805                        &[path(), doc_id(), top.clone(), top],
806                    )?,
807                    Target::Nodes,
808                )
809            }
810            (Target::Nodes, "blocks") => {
811                let (f, l) = (Self::jattr(row, "first_ordinal"), Self::jattr(row, "last_ordinal"));
812                if f.is_absent() || l.is_absent() {
813                    return Ok(Some(Value::Array(Vec::new())));
814                }
815                let (f, l) = (
816                    f.as_f64().unwrap_or(f64::NAN),
817                    l.as_f64().unwrap_or(f64::NAN),
818                );
819                let rows = self.doc_blocks_preorder(&col_str(row, "doc_id"), &col_str(row, "__path"))?;
820                let mut kept: Vec<Value> = Vec::new();
821                for b in rows {
822                    let t = self.top_ordinal(&b)?.as_f64().unwrap_or(f64::NAN);
823                    if t >= f && t <= l {
824                        kept.push(b);
825                    }
826                }
827                Value::Array(kept)
828            }
829            (Target::Nodes, "subsections") => {
830                let (f, l, lvl) = (
831                    Self::jattr(row, "first_ordinal"),
832                    Self::jattr(row, "last_ordinal"),
833                    Self::jattr(row, "level"),
834                );
835                if f.is_absent() {
836                    return Ok(Some(Value::Array(Vec::new())));
837                }
838                Self::tag_all(
839                    self.all(
840                        "SELECT n.*, ?1 AS __path FROM nodes n WHERE n.doc_id = ?2 AND n.kind = 'md:section'
841                           AND json_extract(n.attrs,'$.first_ordinal') >= ?3 AND json_extract(n.attrs,'$.last_ordinal') <= ?4
842                           AND json_extract(n.attrs,'$.level') > ?5 ORDER BY json_extract(n.attrs,'$.first_ordinal'), n.node_id",
843                        &[path(), doc_id(), to_sql(&f), to_sql(&l), to_sql(&lvl)],
844                    )?,
845                    Target::Nodes,
846                )
847            }
848            (Target::Nodes, "children") => {
849                let (f, l, lvl) = (
850                    Self::jattr(row, "first_ordinal"),
851                    Self::jattr(row, "last_ordinal"),
852                    Self::jattr(row, "level"),
853                );
854                if f.is_absent() {
855                    return Ok(Some(Value::Array(Vec::new())));
856                }
857                Self::tag_all(
858                    self.all(
859                        "SELECT i.*, ?1 AS __path FROM nodes i WHERE i.doc_id = ?2 AND i.kind = 'md:section'
860                           AND json_extract(i.attrs,'$.level') > ?3
861                           AND json_extract(i.attrs,'$.first_ordinal') >= ?4 AND json_extract(i.attrs,'$.last_ordinal') <= ?5
862                           AND NOT EXISTS (SELECT 1 FROM nodes m WHERE m.doc_id = i.doc_id AND m.kind = 'md:section'
863                             AND json_extract(m.attrs,'$.level') > ?6 AND json_extract(m.attrs,'$.level') < json_extract(i.attrs,'$.level')
864                             AND json_extract(m.attrs,'$.first_ordinal') <= json_extract(i.attrs,'$.first_ordinal')
865                             AND json_extract(m.attrs,'$.last_ordinal') >= json_extract(i.attrs,'$.last_ordinal'))
866                         ORDER BY json_extract(i.attrs,'$.first_ordinal'), i.node_id",
867                        &[path(), doc_id(), to_sql(&lvl), to_sql(&f), to_sql(&l), to_sql(&lvl)],
868                    )?,
869                    Target::Nodes,
870                )
871            }
872            _ => return Ok(None),
873        }))
874    }
875
876    fn owning_doc(&self, row: &Value) -> oqx::Result<Value> {
877        let id = match col(row, "doc_id") {
878            Value::Undefined | Value::Null => col(row, "src_doc").clone(),
879            v => v.clone(),
880        };
881        Ok(self
882            .one("SELECT * FROM docs WHERE doc_id = ?1", &[to_sql(&id)])?
883            .map_or(Value::Undefined, |o| Self::tag(o, Target::Docs)))
884    }
885
886    fn owning_block(&self, row: &Value) -> oqx::Result<Value> {
887        let id = col(row, "block_id");
888        if !id.truthy() {
889            return Ok(Value::Undefined);
890        }
891        Ok(self
892            .one(
893                "SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ?1",
894                &[to_sql(id)],
895            )?
896            .map_or(Value::Undefined, |o| Self::tag(o, Target::Blocks)))
897    }
898
899    // ---- intrinsics ---------------------------------------------------------------------
900
901    fn null_if_absent(v: Value) -> Value {
902        if v.is_absent() { Value::Null } else { v }
903    }
904
905    fn intrinsic(&self, row: &Value, t: Target, name: &str) -> oqx::Result<Value> {
906        if name == "$self" {
907            return Ok(row.clone());
908        }
909        let c = |k: &str| col(row, k).clone();
910        Ok(match (t, name) {
911            (Target::Docs, "$id") => c("doc_id"),
912            (Target::Docs, "$path") => c("path"),
913            (Target::Docs, "$content_hash") => Self::null_if_absent(c("file_hash")),
914            (Target::Docs, "$updated_at") => Self::null_if_absent(self.scalar(
915                "SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ?1",
916                &[to_sql(&c("current_rev"))],
917            )?),
918            (Target::Docs, "$body") => {
919                match omgbase_store::read::reconstruct(self.conn, &col_str(row, "doc_id")) {
920                    Ok(Some(s)) => Value::Str(s),
921                    Ok(None) => Value::Null,
922                    Err(e) => return Err(OqxError::eval(e.to_string())),
923                }
924            }
925            (Target::Docs, "$title") => {
926                Self::null_if_absent(self.doc_prop(&col_str(row, "doc_id"), "$title", Some("computed"))?)
927            }
928            (Target::Docs, "$tags") => {
929                Self::null_if_absent(self.doc_prop(&col_str(row, "doc_id"), "$tags", Some("computed"))?)
930            }
931            (Target::Blocks, "$id") => c("block_id"),
932            (Target::Blocks, "$doc") => c("doc_id"),
933            (Target::Blocks, "$path") => c("__path"),
934            (Target::Blocks, "$ordinal") => c("ordinal"),
935            (Target::Blocks, "$depth") => c("depth"),
936            (Target::Blocks, "$body") => c("text"),
937            (Target::Blocks, "$content_hash") => Self::null_if_absent(c("raw_hash")),
938            (Target::Blocks, "$updated_at") => Self::null_if_absent(self.scalar(
939                "SELECT MAX(c.ts) FROM block_changes bc JOIN commits c ON c.commit_id = bc.commit_id WHERE bc.block_id = ?1",
940                &[to_sql(&c("block_id"))],
941            )?),
942            (Target::Nodes, "$id" | "$node_id") => c("node_id"),
943            (Target::Nodes, "$doc_id") => c("doc_id"),
944            (Target::Nodes, "$block_id") => c("block_id"),
945            (Target::Nodes, "$path") => c("__path"),
946            (Target::Edges, "$id") => c("edge_id"),
947            (Target::Edges, "$src") => c("src_doc"),
948            (Target::Edges, "$dst") => c("dst_node"),
949            (Target::Edges, "$src_block") => c("src_block"),
950            (Target::Edges, "$via") => c("via_node"),
951            (Target::Edges, "$from_commit") => c("from_commit"),
952            (Target::Edges, "$path") => c("__path"),
953            (Target::Edges, "$dst_path") => Self::null_if_absent(self.scalar(
954                "SELECT path FROM docs WHERE doc_id = ?1",
955                &[to_sql(&c("dst_node"))],
956            )?),
957            (Target::Edges, "$dst_uri") => Self::null_if_absent(self.scalar(
958                "SELECT uri FROM external_nodes WHERE node_id = ?1",
959                &[to_sql(&c("dst_node"))],
960            )?),
961            _ => Value::Undefined,
962        })
963    }
964
965    // ---- row functions (methods on `$self`) ----------------------------------------------
966
967    fn filter_invalid(msg: String) -> Option<oqx::Result<Value>> {
968        Some(Err(OqxError::eval(msg)))
969    }
970
971    fn require_target(t: Target, want: Target, name: &str) -> Option<oqx::Result<Value>> {
972        (t != want).then(|| {
973            Err(OqxError::eval(format!(
974                "{name}() is only available on the {} target",
975                want.as_str()
976            )))
977        })
978    }
979
980    fn sql_result(r: oqx::Result<bool>) -> oqx::Result<Value> {
981        r.map(Value::Bool)
982    }
983
984    fn row_method(
985        &self,
986        name: &str,
987        row: &Value,
988        t: Target,
989        args: &[Value],
990    ) -> Option<oqx::Result<Value>> {
991        let c = |k: &str| col(row, k).clone();
992        match name {
993            "text" => Some(self.text_match(t, row, &arg_or_empty(args, 0))),
994            "semantic" => Some(self.semantic_score(t, row, &arg_or_empty(args, 0))),
995            "has_anchor" => Self::require_target(t, Target::Blocks, name).or_else(|| {
996                Some(Self::sql_result(self.exists(
997                    "SELECT 1 FROM edges WHERE src_block = ?1 AND anchor IS NOT NULL LIMIT 1",
998                    &[to_sql(&c("block_id"))],
999                )))
1000            }),
1001            "child_count" => Self::require_target(t, Target::Blocks, name).or_else(|| {
1002                Some(self.scalar(
1003                    "SELECT COUNT(*) FROM blocks WHERE parent_block = ?1 AND deleted_commit IS NULL",
1004                    &[to_sql(&c("block_id"))],
1005                ))
1006            }),
1007            "parent_type" => Self::require_target(t, Target::Blocks, name).or_else(|| {
1008                Some(
1009                    self.scalar(
1010                        "SELECT type FROM blocks WHERE block_id = ?1",
1011                        &[to_sql(&c("parent_block"))],
1012                    )
1013                    .map(Self::null_if_absent),
1014                )
1015            }),
1016            "has_edge" => {
1017                let pred = js_string(args.first().unwrap_or(&Value::Undefined));
1018                let (src_col, src_val) = if t == Target::Blocks {
1019                    ("src_block", c("block_id"))
1020                } else {
1021                    ("src_doc", c("doc_id"))
1022                };
1023                let r = if args.len() >= 2 {
1024                    self.exists(
1025                        &format!("SELECT 1 FROM edges WHERE {src_col} = ?1 AND predicate = ?2 AND to_commit IS NULL AND dst_node = ?3 LIMIT 1"),
1026                        &[to_sql(&src_val), SqlValue::Text(pred), to_sql(&args[1])],
1027                    )
1028                } else {
1029                    self.exists(
1030                        &format!("SELECT 1 FROM edges WHERE {src_col} = ?1 AND predicate = ?2 AND to_commit IS NULL LIMIT 1"),
1031                        &[to_sql(&src_val), SqlValue::Text(pred)],
1032                    )
1033                };
1034                Some(Self::sql_result(r))
1035            }
1036            "under" => Self::require_target(t, Target::Blocks, name).or_else(|| {
1037                let target = js_string(args.first().unwrap_or(&Value::Undefined));
1038                let ap = col_str(row, "ancestor_path");
1039                Some(Ok(Value::Bool(
1040                    ap.contains(&format!("/{target}/")) || col_str(row, "block_id") == target,
1041                )))
1042            }),
1043            "under_heading" => Self::require_target(t, Target::Blocks, name).or_else(|| {
1044                let text = js_string(args.first().unwrap_or(&Value::Undefined));
1045                let top = match self.top_ordinal(row) {
1046                    Ok(v) => to_sql(&v),
1047                    Err(e) => return Some(Err(e)),
1048                };
1049                Some(Self::sql_result(self.exists(
1050                    "SELECT 1 FROM sections s JOIN blocks hb ON hb.block_id = s.heading_block
1051                     WHERE s.doc_id = ?1 AND lower(hb.text) LIKE '%' || lower(?2) || '%' AND s.first_ordinal <= ?3 AND s.last_ordinal >= ?4 LIMIT 1",
1052                    &[to_sql(&c("doc_id")), SqlValue::Text(text), top.clone(), top],
1053                )))
1054            }),
1055            "within" => Self::require_target(t, Target::Blocks, name).or_else(|| {
1056                let target = js_string(args.first().unwrap_or(&Value::Undefined));
1057                if target.starts_with("d_") {
1058                    return Some(Ok(Value::Bool(col_str(row, "doc_id") == target)));
1059                }
1060                if target.contains('*') {
1061                    let like = glob_to_like(&target, false);
1062                    return Some(Self::sql_result(self.exists(
1063                        "SELECT 1 WHERE ?1 LIKE ?2 ESCAPE '\\'",
1064                        &[SqlValue::Text(col_str(row, "__path")), SqlValue::Text(like)],
1065                    )));
1066                }
1067                Some(Ok(Value::Bool(col_str(row, "__path") == target)))
1068            }),
1069            "under_kind" => Self::require_target(t, Target::Blocks, name).or_else(|| {
1070                let kind = js_string(args.first().unwrap_or(&Value::Undefined));
1071                let ap: Vec<String> = col_str(row, "ancestor_path")
1072                    .split('/')
1073                    .filter(|s| !s.is_empty())
1074                    .map(str::to_owned)
1075                    .collect();
1076                if ap.is_empty() {
1077                    return Some(Ok(Value::Bool(false)));
1078                }
1079                let placeholders: Vec<String> = (1..=ap.len()).map(|i| format!("?{i}")).collect();
1080                let placeholders = placeholders.join(",");
1081                let mut params: Vec<SqlValue> = ap.into_iter().map(SqlValue::Text).collect();
1082                let n = params.len();
1083                params.push(SqlValue::Text(kind));
1084                let r = match args.get(1) {
1085                    Some(v) if !v.is_absent() => {
1086                        let nm = js_string(v);
1087                        params.push(SqlValue::Text(nm.clone()));
1088                        params.push(SqlValue::Text(nm));
1089                        self.exists(
1090                            &format!(
1091                                "SELECT 1 FROM blocks WHERE block_id IN ({placeholders}) AND type = ?{} AND (lower(text) LIKE '%' || lower(?{}) || '%' OR json_extract(attrs,'$.key') = ?{}) LIMIT 1",
1092                                n + 1,
1093                                n + 2,
1094                                n + 3
1095                            ),
1096                            &params,
1097                        )
1098                    }
1099                    _ => self.exists(
1100                        &format!(
1101                            "SELECT 1 FROM blocks WHERE block_id IN ({placeholders}) AND type = ?{} LIMIT 1",
1102                            n + 1
1103                        ),
1104                        &params,
1105                    ),
1106                };
1107                Some(Self::sql_result(r))
1108            }),
1109            "yaml_path" => Self::require_target(t, Target::Blocks, name).or_else(|| {
1110                Some(Ok(Self::key_path(row, &js_string(args.first().unwrap_or(&Value::Undefined)), "yaml")))
1111            }),
1112            "json_pointer" => Self::require_target(t, Target::Blocks, name).or_else(|| {
1113                Some(Ok(Self::key_path(row, &js_string(args.first().unwrap_or(&Value::Undefined)), "json")))
1114            }),
1115            _ => None,
1116        }
1117    }
1118
1119    fn key_path(row: &Value, path: &str, kind: &str) -> Value {
1120        let key = if kind == "json" {
1121            let mut p = path;
1122            p = p.strip_prefix('#').unwrap_or(p);
1123            p = p.strip_prefix('/').unwrap_or(p);
1124            p.split('/').collect::<Vec<_>>().join(".")
1125        } else {
1126            path.to_owned()
1127        };
1128        let leaf = key.rsplit('.').next().unwrap_or("").to_owned();
1129        if !col_str(row, "type").starts_with(&format!("{kind}:")) {
1130            return Value::Bool(false);
1131        }
1132        let k = Self::jattr(row, "key");
1133        Value::Bool(k == Value::Str(leaf) || k == Value::Str(key))
1134    }
1135
1136    fn text_match(&self, t: Target, row: &Value, terms: &str) -> oqx::Result<Value> {
1137        if t == Target::Edges {
1138            return Err(OqxError::eval(
1139                "text(...) is not available on the edges target",
1140            ));
1141        }
1142        let m = sanitize_fts_query(terms);
1143        if m.is_empty() {
1144            return Ok(Value::Bool(false));
1145        }
1146        let r = match t {
1147            Target::Docs => self.exists(
1148                "SELECT 1 FROM blocks_fts JOIN blocks b ON b.rowid = blocks_fts.rowid WHERE b.doc_id = ?1 AND blocks_fts MATCH ?2 LIMIT 1",
1149                &[to_sql(col(row, "doc_id")), SqlValue::Text(m)],
1150            ),
1151            Target::Nodes => self.exists(
1152                "SELECT 1 FROM nodes_fts WHERE rowid = (SELECT rowid FROM nodes WHERE node_id = ?1) AND nodes_fts MATCH ?2",
1153                &[to_sql(col(row, "node_id")), SqlValue::Text(m)],
1154            ),
1155            _ => self.exists(
1156                "SELECT 1 FROM blocks_fts WHERE rowid = (SELECT rowid FROM blocks WHERE block_id = ?1) AND blocks_fts MATCH ?2",
1157                &[to_sql(col(row, "block_id")), SqlValue::Text(m)],
1158            ),
1159        };
1160        Self::sql_result(r)
1161    }
1162
1163    /// `recv.matches(pattern[, flags])` exactly as the builtin computes it —
1164    /// an absent receiver is `false` before the pattern is looked at, the
1165    /// pattern is `String(args[0])`, the flags are `args[1]`, then
1166    /// [`CompiledRegex::is_match`] on the receiver's string form — with the
1167    /// compiled pattern held for the run. The builtin's own cache hands out
1168    /// clones of the `regex::Regex`, and a clone starts with an empty
1169    /// search-cache pool, so a scan paid a lazy-DFA cache allocation (and its
1170    /// drop) per row. Flags that are not a string, and patterns that do not
1171    /// compile, are left to the builtin so its errors are reported verbatim.
1172    fn matches_memoized(&self, recv: &Value, args: &[Value]) -> Option<oqx::Result<Value>> {
1173        let flags = match args.get(1) {
1174            None | Some(Value::Undefined) | Some(Value::Null) => None,
1175            Some(Value::Str(s)) => Some(s.clone()),
1176            Some(_) => return None,
1177        };
1178        let Some(subject) = string_form(recv) else {
1179            return Some(Ok(Value::Bool(false)));
1180        };
1181        let pattern = args.first().unwrap_or(&Value::Undefined).to_string();
1182        let key = (pattern, flags);
1183        let mut memo = self.regexes.borrow_mut();
1184        if !memo.contains_key(&key) {
1185            let flags_value = args.get(1).cloned().unwrap_or(Value::Undefined);
1186            match compile_regex(&key.0, &flags_value, RegexDialect::Oqx) {
1187                Ok(re) => {
1188                    memo.insert(key.clone(), re);
1189                }
1190                Err(_) => return None,
1191            }
1192        }
1193        Some(Ok(Value::Bool(memo[&key].is_match(&subject))))
1194    }
1195
1196    fn semantic_score(&self, t: Target, row: &Value, phrase: &str) -> oqx::Result<Value> {
1197        if matches!(t, Target::Nodes | Target::Edges) {
1198            return Err(OqxError::eval(
1199                "semantic(...) is available on the docs and blocks targets",
1200            ));
1201        }
1202        let Some(resolved) = self.semantic.get(phrase) else {
1203            return Err(OqxError::eval(format!(
1204                "semantic({}) needs an embedding provider; none is configured for this query",
1205                serde_json::Value::String(phrase.to_owned())
1206            )));
1207        };
1208        let vec: oqx::Result<Option<Vec<u8>>> = match t {
1209            Target::Docs => self
1210                .conn
1211                .query_row(
1212                    "SELECT vec FROM doc_embeddings WHERE doc_id = ?1 AND model = ?2",
1213                    rusqlite::params![col_str(row, "doc_id"), resolved.model],
1214                    |r| r.get(0),
1215                )
1216                .optional()
1217                .map_err(sql_err),
1218            // The row for the block's current `(raw_hash, ctx_hash)` only
1219            // (`spec/search` §3, 1.1): a stale context row is never read.
1220            _ => omgbase_store::block_vector(self.conn, &col_str(row, "block_id"), &resolved.model)
1221                .map_err(|e| OqxError::eval(e.to_string())),
1222        };
1223        match vec? {
1224            Some(v) => Ok(Value::Number(cosine_bytes(&v, &resolved.vec))),
1225            None => Ok(Value::Null),
1226        }
1227    }
1228}
1229
1230/// Run `sql` and read every row as a column object (blobs as hex, integers
1231/// and reals as numbers) — the one shape a store row ever has in a query.
1232pub(crate) fn fetch_rows(
1233    conn: &Connection,
1234    sql: &str,
1235    params: &[SqlValue],
1236) -> rusqlite::Result<Vec<Object>> {
1237    let mut stmt = conn.prepare_cached(sql)?;
1238    let names: Vec<String> = stmt
1239        .column_names()
1240        .iter()
1241        .map(|s| (*s).to_owned())
1242        .collect();
1243    let rows = stmt.query_map(params_from_iter(params.iter()), |r| {
1244        // One slot beyond the columns: every row read here is tagged next
1245        // ([`tag_row`]), and the tag must not regrow the entries per row.
1246        let mut o = Object::with_capacity(names.len() + 1);
1247        for (i, name) in names.iter().enumerate() {
1248            o.insert(name.as_str(), sql_value(r.get_ref(i)?));
1249        }
1250        Ok(o)
1251    })?;
1252    rows.collect()
1253}
1254
1255/// Tag a store row with its target so the context resolves it (the
1256/// reference's `tagRows`; the planner hands produced rows back this way).
1257pub(crate) fn tag_row(mut row: Object, t: Target) -> Value {
1258    row.insert(TAG_KEY, Value::Str(t.as_str().to_owned()));
1259    Value::Object(row)
1260}
1261
1262/// [`tag_row`] over a result set.
1263pub(crate) fn tag_rows(rows: Vec<Object>, t: Target) -> Vec<Value> {
1264    rows.into_iter().map(|r| tag_row(r, t)).collect()
1265}
1266
1267/// `cur[seg] = {}` down the path, then the leaf (a scalar in the way is
1268/// replaced by an object; an existing key keeps its position).
1269fn set_nested(out: &mut Object, path: &[&str], leaf: Value) {
1270    let Some((first, rest)) = path.split_first() else {
1271        return;
1272    };
1273    if rest.is_empty() {
1274        out.insert(*first, leaf);
1275        return;
1276    }
1277    let mut child = match out.get(first) {
1278        Some(Value::Object(o)) => o.clone(),
1279        _ => Object::new(),
1280    };
1281    set_nested(&mut child, rest, leaf);
1282    out.insert(*first, Value::Object(child));
1283}
1284
1285/// A `*` glob as a `LIKE` pattern with `ESCAPE '\'`; `escape_backslash`
1286/// also escapes `\` (the list surfaces do, `within` does not).
1287#[must_use]
1288pub fn glob_to_like(glob: &str, escape_backslash: bool) -> String {
1289    let mut out = String::with_capacity(glob.len() + 4);
1290    for ch in glob.chars() {
1291        match ch {
1292            '%' | '_' => {
1293                out.push('\\');
1294                out.push(ch);
1295            }
1296            '\\' if escape_backslash => out.push_str("\\\\"),
1297            '*' => out.push('%'),
1298            c => out.push(c),
1299        }
1300    }
1301    out
1302}
1303
1304impl DataContext for StoreContext<'_> {
1305    fn root(&self, name: &str) -> Value {
1306        if let Some(rr) = self.rows_root.as_ref().filter(|_| name == oqx::ROWS_ROOT) {
1307            let mut slot = rr.rows.borrow_mut();
1308            let rows = if rr.once { slot.take() } else { slot.clone() };
1309            return Value::Array(rows.unwrap_or_default());
1310        }
1311        if name == "$repo" {
1312            return self.repo_root();
1313        }
1314        // A root scan is handed out LAZILY (see `SCAN_KEY`): `to_rows` runs it
1315        // — once per run — and `index_for` probes it without running it.
1316        Target::parse(name).map_or(Value::Undefined, scan_marker)
1317    }
1318
1319    fn get(&self, row: &Value, key: &str) -> oqx::Result<Value> {
1320        if row.is_absent() {
1321            return Ok(Value::Undefined);
1322        }
1323        // `$repo` is an intrinsic of EVERY scope, so a correlated subquery at any
1324        // depth reaches the repository root without scope climbing.
1325        if key == "$repo" {
1326            return Ok(self.repo_root());
1327        }
1328        if is_repo_root(row) {
1329            if key == "$id" {
1330                return Ok(Value::Str(self.repo_id.clone()));
1331            }
1332            return Ok(Target::parse(key).map_or(Value::Undefined, scan_marker));
1333        }
1334        let Some(t) = target_of(row) else {
1335            // A plain value (parsed attrs, a property bag, a lifted element).
1336            return Ok(oqx::DefaultContext::read(row, key));
1337        };
1338        if key.starts_with('$') {
1339            return self.intrinsic(row, t, key);
1340        }
1341        // self-alias namespaces
1342        match (t, key) {
1343            (Target::Docs, "doc") | (Target::Blocks, "block") | (Target::Nodes, "section") => {
1344                return Ok(row.clone());
1345            }
1346            (_, "doc") => return self.owning_doc(row),
1347            (Target::Nodes, "block") => return self.owning_block(row),
1348            _ => {}
1349        }
1350        if let Some(v) = self.relation(row, t, key)? {
1351            return Ok(v);
1352        }
1353        let c = |k: &str| col(row, k).clone();
1354        Ok(match t {
1355            Target::Docs => {
1356                if key == "format" {
1357                    return Ok(c("format"));
1358                }
1359                let doc_id = col_str(row, "doc_id");
1360                if key == "frontmatter" || key == "inline" {
1361                    return self.doc_prop_bag(&doc_id, key);
1362                }
1363                if RESERVED_DOC_BASENAMES.contains(&key) {
1364                    // The reference's `FilterInvalid` thrown from `get`; the
1365                    // runner maps this eval error to `filter_invalid` with
1366                    // the same message.
1367                    return Err(OqxError::eval(format!(
1368                        "bare '{key}' reads a frontmatter key; did you mean the intrinsic ${key}? (use frontmatter.{key} to force the property)"
1369                    )));
1370                }
1371                return self.doc_prop(&doc_id, key, None);
1372            }
1373            Target::Blocks => match key {
1374                "type" => c("type"),
1375                "text" => c("text"),
1376                "attrs" => parse_json(&c("attrs")),
1377                _ => Self::jattr(row, key),
1378            },
1379            Target::Nodes => match key {
1380                "kind" => c("kind"),
1381                "name" => c("name"),
1382                "value" => c("value"),
1383                "attrs" => parse_json(&c("attrs")),
1384                _ => Self::jattr(row, key),
1385            },
1386            Target::Edges => match key {
1387                "predicate" | "provenance" | "dst_kind" | "anchor" | "src_field" => c(key),
1388                _ => Value::Undefined,
1389            },
1390        })
1391    }
1392
1393    fn to_rows(&self, value: &Value) -> Vec<Value> {
1394        match value {
1395            Value::Undefined | Value::Null => Vec::new(),
1396            Value::Array(a) => a.clone(),
1397            other => match scan_of(other) {
1398                Some(t) => self.expand_scan(t),
1399                None => vec![other.clone()],
1400            },
1401        }
1402    }
1403
1404    /// A lazy root-scan marker observed as a VALUE is its rows (read once per
1405    /// run); everything else is itself. A store row is an object with several
1406    /// columns and the marker has exactly one key, so the common case is one
1407    /// length check.
1408    fn materialize(&self, value: Value) -> Value {
1409        if value.as_object().is_some_and(|o| o.len() == 1)
1410            && let Some(t) = scan_of(&value)
1411        {
1412            return Value::Array(self.expand_scan(t));
1413        }
1414        value
1415    }
1416
1417    fn index_for(&self, collection: &Value, path: &[String]) -> Option<Rc<dyn RowIndex + '_>> {
1418        crate::store_index::index_for(self, scan_of(collection)?, path)
1419    }
1420
1421    fn identity(&self, row: &Value) -> Value {
1422        match target_of(row) {
1423            Some(Target::Docs) => col(row, "doc_id").clone(),
1424            Some(Target::Blocks) => col(row, "block_id").clone(),
1425            Some(Target::Nodes) => col(row, "node_id").clone(),
1426            Some(Target::Edges) => col(row, "edge_id").clone(),
1427            None => row.clone(),
1428        }
1429    }
1430
1431    fn call_function(&self, name: &str, args: &[Value]) -> Option<oqx::Result<Value>> {
1432        // `size($repo.docs)`, `list(…)`, `has(…)`: the engine has materialized a
1433        // lazy scan argument into its rows before the call.
1434        if name == "refs" {
1435            return Some(self.refs(args.first().unwrap_or(&Value::Undefined)));
1436        }
1437        if name == "range" {
1438            let x = args.first().unwrap_or(&Value::Undefined);
1439            return Some(Ok(match x {
1440                Value::Range(_) => x.clone(),
1441                Value::Str(s) => match omgbase_properties::detect_range(s) {
1442                    Some(r) => {
1443                        let b = |b: &Bound| match b {
1444                            Bound::Open => Value::Undefined,
1445                            Bound::Num(n) => Value::Number(*n),
1446                            Bound::Iso(s) => Value::Str(s.clone()),
1447                        };
1448                        Value::from(make_range(b(&r.lo), b(&r.hi), r.exclusive_end))
1449                    }
1450                    None => Value::Null,
1451                },
1452                _ => Value::Null,
1453            }));
1454        }
1455        builtin_function(name, args)
1456    }
1457
1458    fn call_method(&self, name: &str, recv: &Value, args: &[Value]) -> Option<oqx::Result<Value>> {
1459        // `$repo.docs.size()`: the receiver, a value, is materialized by the
1460        // engine before the call.
1461        if let Some(t) = target_of(recv) {
1462            if let Some(r) = self.row_method(name, recv, t, args) {
1463                return Some(r);
1464            }
1465        } else if matches!(
1466            name,
1467            "text"
1468                | "semantic"
1469                | "under"
1470                | "under_heading"
1471                | "within"
1472                | "under_kind"
1473                | "yaml_path"
1474                | "json_pointer"
1475                | "has_edge"
1476                | "has_anchor"
1477                | "child_count"
1478                | "parent_type"
1479        ) {
1480            return Self::filter_invalid(format!("{name}() needs a docs/blocks/nodes/edges row"));
1481        }
1482        if name == "matches" {
1483            if let Some(r) = self.matches_memoized(recv, args) {
1484                return Some(r);
1485            }
1486        }
1487        builtin_method_with(RegexDialect::Oqx, name, recv, args)
1488    }
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493    use super::*;
1494
1495    #[test]
1496    fn glob_to_like_escapes() {
1497        assert_eq!(glob_to_like("a*/b_%", true), "a%/b\\_\\%");
1498        assert_eq!(glob_to_like("a\\b*", true), "a\\\\b%");
1499        assert_eq!(glob_to_like("a\\b*", false), "a\\b%");
1500    }
1501
1502    #[test]
1503    fn rows_surfacing_as_values_render_id_and_path() {
1504        // §1.4 (1.2): a tagged store row anywhere in a value tree is
1505        // `{ id, path }`; untagged records keep their keys, minus the tag.
1506        let mut node = Object::new();
1507        node.insert("node_id", Value::Str("n_1".into()));
1508        node.insert("attrs", Value::Str("{\"checked\":true}".into()));
1509        node.insert("__path", Value::Str("a.md".into()));
1510        let mut doc = Object::new();
1511        doc.insert("doc_id", Value::Str("d_0".into()));
1512        doc.insert("path", Value::Str("a.md".into()));
1513        doc.insert("blob", Value::Str("ff".into()));
1514        let mut record = Object::new();
1515        record.insert(TAG_KEY, Value::Str("junk".into()));
1516        record.insert(
1517            "tasks",
1518            Value::Array(vec![
1519                tag_row(node, Target::Nodes),
1520                tag_row(doc, Target::Docs),
1521            ]),
1522        );
1523        let out = render_row_values(Value::Object(record));
1524        let o = out.as_object().unwrap();
1525        assert!(o.get(TAG_KEY).is_none());
1526        let tasks = o.get("tasks").unwrap().as_array().unwrap();
1527        let keys = |v: &Value| -> Vec<String> {
1528            v.as_object()
1529                .unwrap()
1530                .iter()
1531                .map(|(k, _)| k.to_owned())
1532                .collect()
1533        };
1534        assert_eq!(keys(&tasks[0]), ["id", "path"]);
1535        assert_eq!(
1536            tasks[0].as_object().unwrap().get("id"),
1537            Some(&Value::Str("n_1".into()))
1538        );
1539        assert_eq!(
1540            tasks[0].as_object().unwrap().get("path"),
1541            Some(&Value::Str("a.md".into()))
1542        );
1543        assert_eq!(keys(&tasks[1]), ["id", "path"]);
1544        assert_eq!(
1545            tasks[1].as_object().unwrap().get("id"),
1546            Some(&Value::Str("d_0".into()))
1547        );
1548        // An id column that is not a string still renders as its string form.
1549        let mut edge = Object::new();
1550        edge.insert("edge_id", Value::Number(7.0));
1551        let e = render_row_values(tag_row(edge, Target::Edges));
1552        assert_eq!(
1553            e.as_object().unwrap().get("id"),
1554            Some(&Value::Str("7".into()))
1555        );
1556        assert_eq!(
1557            e.as_object().unwrap().get("path"),
1558            Some(&Value::Str(String::new()))
1559        );
1560    }
1561
1562    #[test]
1563    fn nested_property_objects_rebuild() {
1564        let mut o = Object::new();
1565        set_nested(&mut o, &["a", "b"], Value::Number(1.0));
1566        set_nested(&mut o, &["a", "c"], Value::Number(2.0));
1567        set_nested(&mut o, &["d"], Value::Str("x".into()));
1568        let a = o.get("a").unwrap().as_object().unwrap();
1569        assert_eq!(a.get("b"), Some(&Value::Number(1.0)));
1570        assert_eq!(a.get("c"), Some(&Value::Number(2.0)));
1571        assert_eq!(o.get("d"), Some(&Value::Str("x".into())));
1572        // A scalar in the way is replaced by an object.
1573        set_nested(&mut o, &["d", "e"], Value::Bool(true));
1574        assert!(o.get("d").unwrap().as_object().is_some());
1575    }
1576
1577    #[test]
1578    fn json_and_sql_bridges() {
1579        assert_eq!(
1580            parse_json(&Value::Str("{\"a\":1}".into()))
1581                .as_object()
1582                .unwrap()
1583                .get("a"),
1584            Some(&Value::Number(1.0))
1585        );
1586        assert_eq!(
1587            parse_json(&Value::Str("nope".into())),
1588            Value::Str("nope".into())
1589        );
1590        assert_eq!(parse_json(&Value::Null), Value::Undefined);
1591        assert_eq!(arg_or_empty(&[], 0), "");
1592        assert_eq!(arg_or_empty(&[Value::Number(2.0)], 0), "2");
1593    }
1594}