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 in-memory engine over
3//! the 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//! The tier-3 pushdown planner is not ported yet: every query runs in memory,
8//! which is the semantics this spec pins (a planner must be invisible).
9
10use std::collections::HashMap;
11
12use omgbase_search::{EmbeddingProvider, f32_to_blob};
13use omgbase_store::Store;
14use oqx::ast::{Expr, Follow, OpNode, OrderSpec, Query, SelectItem, Subquery, Where};
15use oqx::{Consumer, Engine, InMemoryEngine, Value};
16use serde_json::{Map, Value as Json};
17
18use crate::context::{SemanticVec, StoreContext, strip_tags};
19use crate::cursor::{decode_cursor, encode_cursor};
20use crate::error::{Result, SurfaceError};
21
22/// The default page size.
23pub const DEFAULT_LIMIT: usize = 50;
24
25/// Row-scoped domain functions: authored as free calls that implicitly
26/// reference the current row; rewritten to `$self.fn(…)`.
27const ROW_FNS: [&str; 12] = [
28    "text",
29    "semantic",
30    "under",
31    "under_heading",
32    "within",
33    "under_kind",
34    "yaml_path",
35    "json_pointer",
36    "has_edge",
37    "has_anchor",
38    "child_count",
39    "parent_type",
40];
41
42const ID_KEY: &str = "__oqx_id";
43const PATH_KEY: &str = "__oqx_path";
44/// The reserved key a top-level `values` projection's single item is renamed
45/// to, so it rides through id/path injection, paging and distinct as an
46/// ordinary field and is peeled off at the end.
47const VALUE_KEY: &str = "__oqx_value";
48
49/// `query`'s options.
50#[derive(Clone, Copy, Default)]
51pub struct QueryOptions<'a> {
52    /// The page cap (default 50).
53    pub limit: Option<usize>,
54    /// Resume after a truncated page's cursor.
55    pub cursor: Option<&'a str>,
56    /// The provider behind `semantic(...)`; `None` → `semantic_unavailable`
57    /// when the query names a phrase.
58    pub provider: Option<&'a dyn EmbeddingProvider>,
59}
60
61/// §1.4 `OqxResult`.
62#[derive(Clone, Debug, PartialEq)]
63pub struct OqxResult {
64    pub hits: Vec<Json>,
65    pub truncated: bool,
66    pub cursor: Option<String>,
67    pub consumer: Consumer,
68    pub count: Option<f64>,
69    pub exists: Option<bool>,
70    pub none: Option<bool>,
71    /// A top-level `values` projection's bare values, in place of `hits`.
72    pub values: Option<Vec<Json>>,
73}
74
75impl OqxResult {
76    fn scalar(consumer: Consumer) -> Self {
77        Self {
78            hits: Vec::new(),
79            truncated: false,
80            cursor: None,
81            consumer,
82            count: None,
83            exists: None,
84            none: None,
85            values: None,
86        }
87    }
88
89    /// The wire shape: `{ hits, truncated, cursor, consumer, count?, exists?,
90    /// none?, values? }`.
91    #[must_use]
92    pub fn to_json(&self) -> Json {
93        let mut m = Map::new();
94        m.insert("hits".to_owned(), Json::Array(self.hits.clone()));
95        m.insert("truncated".to_owned(), Json::Bool(self.truncated));
96        m.insert(
97            "cursor".to_owned(),
98            self.cursor.clone().map_or(Json::Null, Json::String),
99        );
100        m.insert(
101            "consumer".to_owned(),
102            Json::String(self.consumer.as_str().to_owned()),
103        );
104        if let Some(n) = self.count {
105            m.insert("count".to_owned(), Value::Number(n).to_canonical_json());
106        }
107        if let Some(b) = self.exists {
108            m.insert("exists".to_owned(), Json::Bool(b));
109        }
110        if let Some(b) = self.none {
111            m.insert("none".to_owned(), Json::Bool(b));
112        }
113        if let Some(v) = &self.values {
114            m.insert("values".to_owned(), Json::Array(v.clone()));
115        }
116        Json::Object(m)
117    }
118}
119
120// ---- the `$self` rewrite -----------------------------------------------------------
121
122fn self_ref() -> Expr {
123    Expr::Ident {
124        name: "$self".to_owned(),
125    }
126}
127
128fn rewrite_expr(e: &Expr) -> Expr {
129    match e {
130        Expr::Member { recv, name } => Expr::Member {
131            recv: Box::new(rewrite_expr(recv)),
132            name: name.clone(),
133        },
134        Expr::Index { recv, index } => Expr::Index {
135            recv: Box::new(rewrite_expr(recv)),
136            index: Box::new(rewrite_expr(index)),
137        },
138        Expr::Unary { op, expr } => Expr::Unary {
139            op: *op,
140            expr: Box::new(rewrite_expr(expr)),
141        },
142        Expr::Binary { op, left, right } => Expr::Binary {
143            op: *op,
144            left: Box::new(rewrite_expr(left)),
145            right: Box::new(rewrite_expr(right)),
146        },
147        Expr::Logical { op, left, right } => Expr::Logical {
148            op: *op,
149            left: Box::new(rewrite_expr(left)),
150            right: Box::new(rewrite_expr(right)),
151        },
152        Expr::In { left, right } => Expr::In {
153            left: Box::new(rewrite_expr(left)),
154            right: Box::new(rewrite_expr(right)),
155        },
156        Expr::Range {
157            lo,
158            hi,
159            exclusive_end,
160        } => Expr::Range {
161            lo: lo.as_ref().map(|x| Box::new(rewrite_expr(x))),
162            hi: hi.as_ref().map(|x| Box::new(rewrite_expr(x))),
163            exclusive_end: *exclusive_end,
164        },
165        Expr::Call { recv, name, args } => {
166            let args = args.iter().map(rewrite_expr).collect();
167            match recv {
168                None if ROW_FNS.contains(&name.as_str()) => Expr::Call {
169                    recv: Some(Box::new(self_ref())),
170                    name: name.clone(),
171                    args,
172                },
173                None => Expr::Call {
174                    recv: None,
175                    name: name.clone(),
176                    args,
177                },
178                Some(r) => Expr::Call {
179                    recv: Some(Box::new(rewrite_expr(r))),
180                    name: name.clone(),
181                    args,
182                },
183            }
184        }
185        Expr::Lit(_) | Expr::Ident { .. } | Expr::Outer { .. } | Expr::Binding { .. } => e.clone(),
186    }
187}
188
189fn rewrite_where(w: &Where) -> Where {
190    match w {
191        Where::And { parts } => Where::And {
192            parts: parts.iter().map(rewrite_where).collect(),
193        },
194        Where::Or { parts } => Where::Or {
195            parts: parts.iter().map(rewrite_where).collect(),
196        },
197        Where::Not { expr } => Where::Not {
198            expr: Box::new(rewrite_where(expr)),
199        },
200        Where::Scalar { expr } => Where::Scalar {
201            expr: rewrite_expr(expr),
202        },
203        Where::Op(op) => Where::Op(Box::new(rewrite_op(op))),
204    }
205}
206
207fn rewrite_op(op: &OpNode) -> OpNode {
208    OpNode {
209        receiver: rewrite_expr(&op.receiver),
210        op: op.op,
211        sub: rewrite_sub(&op.sub),
212        count_cmp: op.count_cmp.clone(),
213        distinct: op.distinct,
214    }
215}
216
217fn rewrite_follow(f: &Follow) -> Follow {
218    Follow {
219        receiver: rewrite_expr(&f.receiver),
220        distinct: f.distinct,
221        r#where: f.r#where.as_ref().map(rewrite_expr),
222        frontier: f.frontier.as_ref().map(rewrite_expr),
223        depth: f.depth,
224        by: f.by.as_ref().map(rewrite_expr),
225    }
226}
227
228fn rewrite_select(items: &[SelectItem]) -> Vec<SelectItem> {
229    items
230        .iter()
231        .map(|it| match it {
232            SelectItem::Field { name, expr, lift } => SelectItem::Field {
233                name: name.clone(),
234                expr: rewrite_expr(expr),
235                lift: *lift,
236            },
237            SelectItem::Collect { name, op } => SelectItem::Collect {
238                name: name.clone(),
239                op: Box::new(rewrite_op(op)),
240            },
241        })
242        .collect()
243}
244
245fn rewrite_order(o: Option<&Vec<OrderSpec>>) -> Option<Vec<OrderSpec>> {
246    o.map(|specs| {
247        specs
248            .iter()
249            .map(|s| OrderSpec {
250                expr: rewrite_expr(&s.expr),
251                desc: s.desc,
252            })
253            .collect()
254    })
255}
256
257fn rewrite_sub(s: &Subquery) -> Subquery {
258    Subquery {
259        from: s.from.iter().map(rewrite_expr).collect(),
260        r#where: s.r#where.as_ref().map(rewrite_where),
261        select: rewrite_select(&s.select),
262        order_by: rewrite_order(s.order_by.as_ref()),
263        follow: s.follow.as_ref().map(rewrite_follow),
264        values: s.values,
265        limit: s.limit.as_ref().map(rewrite_expr),
266        offset: s.offset.as_ref().map(rewrite_expr),
267    }
268}
269
270/// Rewrite every row function in a parsed query to a `$self` method call.
271#[must_use]
272pub fn rewrite_query(q: &Query) -> Query {
273    Query {
274        source: rewrite_expr(&q.source),
275        from: q.from.iter().map(rewrite_expr).collect(),
276        r#where: q.r#where.as_ref().map(rewrite_where),
277        select: rewrite_select(&q.select),
278        order_by: rewrite_order(q.order_by.as_ref()),
279        consumer: q.consumer,
280        follow: q.follow.as_ref().map(rewrite_follow),
281        distinct: q.distinct,
282        values: q.values,
283        limit: q.limit.as_ref().map(rewrite_expr),
284        offset: q.offset.as_ref().map(rewrite_expr),
285    }
286}
287
288// ---- semantic phrases -----------------------------------------------------------------
289
290fn visit_expr(e: &Expr, out: &mut Vec<String>) {
291    match e {
292        Expr::Call { recv, name, args } => {
293            if recv.is_none() && name == "semantic" {
294                if let Some(Expr::Lit(Value::Str(s))) = args.first() {
295                    if !out.contains(s) {
296                        out.push(s.clone());
297                    }
298                }
299            }
300            if let Some(r) = recv {
301                visit_expr(r, out);
302            }
303            for a in args {
304                visit_expr(a, out);
305            }
306        }
307        Expr::Member { recv, .. } => visit_expr(recv, out),
308        Expr::Index { recv, index } => {
309            visit_expr(recv, out);
310            visit_expr(index, out);
311        }
312        Expr::Unary { expr, .. } => visit_expr(expr, out),
313        Expr::Binary { left, right, .. }
314        | Expr::Logical { left, right, .. }
315        | Expr::In { left, right } => {
316            visit_expr(left, out);
317            visit_expr(right, out);
318        }
319        Expr::Range { lo, hi, .. } => {
320            if let Some(l) = lo {
321                visit_expr(l, out);
322            }
323            if let Some(h) = hi {
324                visit_expr(h, out);
325            }
326        }
327        Expr::Lit(_) | Expr::Ident { .. } | Expr::Outer { .. } | Expr::Binding { .. } => {}
328    }
329}
330
331fn visit_where(w: &Where, out: &mut Vec<String>) {
332    match w {
333        Where::And { parts } | Where::Or { parts } => {
334            parts.iter().for_each(|p| visit_where(p, out))
335        }
336        Where::Not { expr } => visit_where(expr, out),
337        Where::Scalar { expr } => visit_expr(expr, out),
338        Where::Op(op) => visit_op(op, out),
339    }
340}
341
342fn visit_op(op: &OpNode, out: &mut Vec<String>) {
343    visit_expr(&op.receiver, out);
344    visit_sub(&op.sub, out);
345}
346
347fn visit_select(items: &[SelectItem], out: &mut Vec<String>) {
348    for it in items {
349        match it {
350            SelectItem::Field { expr, .. } => visit_expr(expr, out),
351            SelectItem::Collect { op, .. } => visit_op(op, out),
352        }
353    }
354}
355
356fn visit_follow(f: &Follow, out: &mut Vec<String>) {
357    visit_expr(&f.receiver, out);
358    for x in [&f.r#where, &f.frontier, &f.by].into_iter().flatten() {
359        visit_expr(x, out);
360    }
361}
362
363fn visit_sub(s: &Subquery, out: &mut Vec<String>) {
364    s.from.iter().for_each(|e| visit_expr(e, out));
365    if let Some(w) = &s.r#where {
366        visit_where(w, out);
367    }
368    visit_select(&s.select, out);
369    if let Some(o) = &s.order_by {
370        o.iter().for_each(|spec| visit_expr(&spec.expr, out));
371    }
372    if let Some(f) = &s.follow {
373        visit_follow(f, out);
374    }
375}
376
377/// The distinct phrases `semantic("…")` names (free calls in the raw parse);
378/// empty when the source does not parse.
379#[must_use]
380pub fn collect_semantic_phrases(source: &str) -> Vec<String> {
381    let Ok(q) = oqx::parse_string(source) else {
382        return Vec::new();
383    };
384    let mut out = Vec::new();
385    visit_expr(&q.source, &mut out);
386    q.from.iter().for_each(|e| visit_expr(e, &mut out));
387    if let Some(w) = &q.r#where {
388        visit_where(w, &mut out);
389    }
390    visit_select(&q.select, &mut out);
391    if let Some(o) = &q.order_by {
392        o.iter().for_each(|spec| visit_expr(&spec.expr, &mut out));
393    }
394    if let Some(f) = &q.follow {
395        visit_follow(f, &mut out);
396    }
397    out
398}
399
400// ---- hits ----------------------------------------------------------------------------
401
402/// A projected row as a hit: `{ id, path, ...rest }` with the injected
403/// columns peeled off (JavaScript's `String()` on the id, `""` for an absent
404/// path).
405fn to_hit(row: Value) -> Value {
406    let Value::Object(o) = strip_tags(row) else {
407        return Value::Object(oqx::Object::new());
408    };
409    let mut id = Value::Undefined;
410    let mut path = Value::Undefined;
411    let mut rest = Vec::new();
412    for (k, v) in o {
413        match k.as_str() {
414            ID_KEY => id = v,
415            PATH_KEY => path = v,
416            _ => rest.push((k, v)),
417        }
418    }
419    let mut hit = oqx::Object::with_capacity(rest.len() + 2);
420    hit.insert("id", Value::Str(id.to_string()));
421    hit.insert(
422        "path",
423        Value::Str(if path.is_absent() {
424            String::new()
425        } else {
426            path.to_string()
427        }),
428    );
429    for (k, v) in rest {
430        hit.insert(k, v);
431    }
432    Value::Object(hit)
433}
434
435fn hit_str(hit: &Value, key: &str) -> String {
436    hit.as_object()
437        .and_then(|o| o.get(key))
438        .map(|v| v.to_string())
439        .unwrap_or_default()
440}
441
442/// Top-level `select distinct`: dedup hits by their USER projection (every
443/// field but `id`/`path`), keeping the first. The key is the canonical JSON
444/// of the sorted `[key, value]` pairs (`JSON.stringify` in the reference).
445fn dedup_hits_by_projection(hits: Vec<Value>) -> Vec<Value> {
446    let mut seen: Vec<String> = Vec::new();
447    let mut out = Vec::new();
448    for h in hits {
449        let mut pairs: Vec<(String, Value)> = h
450            .as_object()
451            .map(|o| {
452                o.iter()
453                    .filter(|(k, _)| *k != "id" && *k != "path")
454                    .map(|(k, v)| (k.to_owned(), v.clone()))
455                    .collect()
456            })
457            .unwrap_or_default();
458        pairs.sort_by(|a, b| a.0.cmp(&b.0));
459        let key = Value::Array(
460            pairs
461                .into_iter()
462                .map(|(k, v)| Value::Array(vec![Value::Str(k), v]))
463                .collect(),
464        )
465        .to_canonical_json()
466        .to_string();
467        if seen.contains(&key) {
468            continue;
469        }
470        seen.push(key);
471        out.push(h);
472    }
473    out
474}
475
476/// A top-level `limit`/`offset` on the collect path is applied by the runner,
477/// so it must be a plain non-negative integer literal.
478fn const_bound(e: Option<&Expr>, word: &str) -> Result<Option<usize>> {
479    match e {
480        None => Ok(None),
481        Some(Expr::Lit(Value::Number(n))) if n.fract() == 0.0 && *n >= 0.0 && n.is_finite() => {
482            Ok(Some(*n as usize))
483        }
484        Some(_) => Err(SurfaceError::filter_invalid(
485            format!("top-level {word} must be a non-negative integer literal"),
486            "OQX",
487        )),
488    }
489}
490
491fn value_of(hit: &Value) -> Value {
492    hit.as_object()
493        .and_then(|o| o.get(VALUE_KEY))
494        .cloned()
495        .unwrap_or(Value::Undefined)
496}
497
498fn without_value_key(hit: Value) -> Json {
499    strip_tags(hit).to_canonical_json()
500}
501
502// ---- the run --------------------------------------------------------------------------
503
504/// Run an OQX query against `repo_id` (§1.4).
505pub fn query(
506    store: &Store,
507    repo_id: &str,
508    source: &str,
509    opts: QueryOptions<'_>,
510) -> Result<OqxResult> {
511    // Without a provider the phrases stay unembedded and the context reports
512    // `filter_invalid` ("needs an embedding provider") when one is reached —
513    // after its target check, so `semantic()` on nodes names the targets
514    // (§9; the `query` tool's pre-check is what reports `semantic_unavailable`).
515    let phrases = collect_semantic_phrases(source);
516    let mut semantic: HashMap<String, SemanticVec> = HashMap::new();
517    if let Some(provider) = opts.provider.filter(|_| !phrases.is_empty()) {
518        for phrase in phrases {
519            let vec = provider
520                .embed_query(&phrase)
521                .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?;
522            semantic.insert(
523                phrase,
524                SemanticVec {
525                    model: provider.model().to_owned(),
526                    vec: f32_to_blob(&vec),
527                },
528            );
529        }
530    }
531    let engine = InMemoryEngine::new(StoreContext::new(store.conn(), repo_id, semantic));
532    let out = run_inner(&engine, source, opts);
533    // A failure a property read could not raise through the engine's seam
534    // (the reserved-basename guard, a SQL error) wins over whatever the run
535    // made of the `Undefined` it returned instead.
536    if let Some(pending) = engine.context().take_pending() {
537        return Err(pending);
538    }
539    out
540}
541
542fn run_inner(
543    engine: &InMemoryEngine<StoreContext<'_>>,
544    source: &str,
545    opts: QueryOptions<'_>,
546) -> Result<OqxResult> {
547    let parsed = rewrite_query(&oqx::parse_string(source)?);
548    let consumer = parsed.consumer;
549
550    match consumer {
551        Consumer::Exists => {
552            let res = engine.run(&parsed, &[])?;
553            let mut r = OqxResult::scalar(consumer);
554            r.exists = Some(matches!(res, oqx::OqxResult::Exists(true)));
555            return Ok(r);
556        }
557        Consumer::Count => {
558            let res = engine.run(&parsed, &[])?;
559            let mut r = OqxResult::scalar(consumer);
560            r.count = Some(match res {
561                oqx::OqxResult::Count(n) => n,
562                _ => 0.0,
563            });
564            return Ok(r);
565        }
566        Consumer::None => {
567            let res = engine.run(&parsed, &[])?;
568            let mut r = OqxResult::scalar(consumer);
569            r.none = Some(match res {
570                oqx::OqxResult::None(b) => b,
571                _ => true,
572            });
573            return Ok(r);
574        }
575        Consumer::Collect | Consumer::First | Consumer::Single => {}
576    }
577
578    // collect / first / single: inject id + path so every hit carries them. A
579    // top-level `select distinct` is applied HERE, not in the engine (the
580    // injected id/path are unique per row and would defeat the engine's
581    // projection dedup). A top-level `values` projection runs as a RECORD
582    // projection whose single item is renamed to VALUE_KEY.
583    let top_distinct = parsed.distinct;
584    let top_values = parsed.values;
585    let user_select: Vec<SelectItem> = if top_values {
586        parsed
587            .select
588            .first()
589            .map(|it| match it {
590                SelectItem::Field { expr, lift, .. } => SelectItem::Field {
591                    name: VALUE_KEY.to_owned(),
592                    expr: expr.clone(),
593                    lift: *lift,
594                },
595                SelectItem::Collect { op, .. } => SelectItem::Collect {
596                    name: VALUE_KEY.to_owned(),
597                    op: op.clone(),
598                },
599            })
600            .into_iter()
601            .collect()
602    } else {
603        parsed.select.clone()
604    };
605    let id_item = SelectItem::Field {
606        name: ID_KEY.to_owned(),
607        expr: Expr::Ident {
608            name: "$id".to_owned(),
609        },
610        lift: 0,
611    };
612    let path_item = SelectItem::Field {
613        name: PATH_KEY.to_owned(),
614        expr: Expr::Ident {
615            name: "$path".to_owned(),
616        },
617        lift: 0,
618    };
619    let mut select = vec![id_item, path_item];
620    select.extend(user_select);
621    // On the collect path the query's own limit/offset is taken out of the
622    // engine query and applied after the runner's distinct; first/single keep
623    // theirs (the engine's offset-aware cap is exactly right for them).
624    let (top_limit, top_offset) = (parsed.limit.clone(), parsed.offset.clone());
625    let q = Query {
626        distinct: false,
627        values: false,
628        select,
629        limit: if consumer == Consumer::Collect {
630            None
631        } else {
632            parsed.limit.clone()
633        },
634        offset: if consumer == Consumer::Collect {
635            None
636        } else {
637            parsed.offset.clone()
638        },
639        ..parsed.clone()
640    };
641    let res = engine.run(&q, &[])?;
642
643    if matches!(consumer, Consumer::First | Consumer::Single) {
644        let row = match res {
645            oqx::OqxResult::First(r) | oqx::OqxResult::Single(r) => r,
646            _ => None,
647        };
648        let mut out = OqxResult::scalar(consumer);
649        match row {
650            None => {
651                if top_values {
652                    out.values = Some(Vec::new());
653                }
654            }
655            Some(r) => {
656                let hit = to_hit(r);
657                if top_values {
658                    out.values = Some(vec![strip_tags(value_of(&hit)).to_canonical_json()]);
659                } else {
660                    out.hits = vec![without_value_key(hit)];
661                }
662            }
663        }
664        return Ok(out);
665    }
666
667    // collect: keyset pagination on (path, id) when the order is the default.
668    let mut rows: Vec<Value> = match res {
669        oqx::OqxResult::Collect(rows) => rows.into_iter().map(to_hit).collect(),
670        _ => Vec::new(),
671    };
672    if top_distinct {
673        rows = dedup_hits_by_projection(rows);
674    }
675    let offset = const_bound(top_offset.as_ref(), "offset")?.unwrap_or(0);
676    let limit = const_bound(top_limit.as_ref(), "limit")?;
677    if offset > 0 || limit.is_some() {
678        let end = limit.map_or(rows.len(), |l| (offset + l).min(rows.len()));
679        rows = if offset >= rows.len() {
680            Vec::new()
681        } else {
682            rows[offset..end].to_vec()
683        };
684    }
685    let custom = parsed.order_by.as_ref().is_some_and(|o| !o.is_empty());
686    let cap = opts.limit.unwrap_or(DEFAULT_LIMIT);
687    let mut page = rows;
688    if !custom {
689        if let Some(cursor) = opts.cursor.filter(|c| !c.is_empty()) {
690            let parts = decode_cursor(cursor, "query", 2)?;
691            let (path, id) = (&parts[0], &parts[1]);
692            page.retain(|h| {
693                let hp = hit_str(h, "path");
694                let hi = hit_str(h, "id");
695                hp > *path || (hp == *path && hi > *id)
696            });
697        }
698    }
699    let truncated = page.len() > cap;
700    page.truncate(cap);
701    let cursor = if truncated && !custom {
702        page.last()
703            .map(|last| encode_cursor(&[&hit_str(last, "path"), &hit_str(last, "id")]))
704    } else {
705        None
706    };
707    let mut out = OqxResult::scalar(Consumer::Collect);
708    out.truncated = truncated;
709    out.cursor = cursor;
710    if top_values {
711        out.values = Some(
712            page.iter()
713                .map(|h| strip_tags(value_of(h)).to_canonical_json())
714                .collect(),
715        );
716    } else {
717        out.hits = page.into_iter().map(without_value_key).collect();
718    }
719    Ok(out)
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725
726    #[test]
727    fn row_functions_become_self_methods() {
728        let q = oqx::parse_string(
729            "from blocks where text(\"x\") && under_heading(\"h\") && size(attrs) > 0 && doc.$path.startsWith(\"a\")",
730        )
731        .unwrap();
732        let r = rewrite_query(&q);
733        let Some(Where::And { parts }) = &r.r#where else {
734            panic!("and")
735        };
736        let Where::Scalar { expr } = &parts[0] else {
737            panic!("scalar")
738        };
739        assert!(
740            matches!(expr, Expr::Call { recv: Some(r), name, .. } if name == "text" && **r == self_ref())
741        );
742        let Where::Scalar { expr } = &parts[2] else {
743            panic!("scalar")
744        };
745        assert!(
746            matches!(expr, Expr::Binary { left, .. } if matches!(&**left, Expr::Call { recv: None, name, .. } if name == "size"))
747        );
748    }
749
750    #[test]
751    fn semantic_phrases_are_collected_distinct() {
752        let phrases = collect_semantic_phrases(
753            "select s: semantic(\"alpha\") from docs where semantic(\"alpha\") > 0.5 || nodes exists { where semantic(\"beta\") > 0 } order by semantic(\"gamma\") desc",
754        );
755        assert_eq!(phrases, ["alpha", "beta", "gamma"]);
756        assert!(collect_semantic_phrases("not a query {{").is_empty());
757        assert!(collect_semantic_phrases("from docs").is_empty());
758    }
759
760    #[test]
761    fn hits_peel_the_injected_columns() {
762        let mut o = oqx::Object::new();
763        o.insert(ID_KEY, Value::Str("d_1".into()));
764        o.insert(PATH_KEY, Value::Null);
765        o.insert("layer", Value::Str("canon".into()));
766        let hit = to_hit(Value::Object(o));
767        let ho = hit.as_object().unwrap();
768        assert_eq!(ho.keys().collect::<Vec<_>>(), ["id", "path", "layer"]);
769        assert_eq!(ho.get("path"), Some(&Value::Str(String::new())));
770        // A user field named `id` overrides the injected one in place.
771        let mut o = oqx::Object::new();
772        o.insert(ID_KEY, Value::Str("d_1".into()));
773        o.insert(PATH_KEY, Value::Str("a.md".into()));
774        o.insert("id", Value::Number(7.0));
775        let hit = to_hit(Value::Object(o));
776        let ho = hit.as_object().unwrap();
777        assert_eq!(ho.keys().collect::<Vec<_>>(), ["id", "path"]);
778        assert_eq!(ho.get("id"), Some(&Value::Number(7.0)));
779    }
780
781    #[test]
782    fn distinct_dedups_by_user_projection_first_wins() {
783        let mk = |id: &str, t: &str| {
784            let mut o = oqx::Object::new();
785            o.insert("id", Value::Str(id.into()));
786            o.insert("path", Value::Str("p".into()));
787            o.insert("type", Value::Str(t.into()));
788            Value::Object(o)
789        };
790        let out = dedup_hits_by_projection(vec![mk("1", "a"), mk("2", "b"), mk("3", "a")]);
791        assert_eq!(out.len(), 2);
792        assert_eq!(hit_str(&out[0], "id"), "1");
793        assert_eq!(hit_str(&out[1], "id"), "2");
794    }
795
796    #[test]
797    fn top_level_bounds_must_be_literals() {
798        assert_eq!(const_bound(None, "limit").unwrap(), None);
799        assert_eq!(
800            const_bound(Some(&Expr::Lit(Value::Number(3.0))), "limit").unwrap(),
801            Some(3)
802        );
803        let e = const_bound(Some(&Expr::Lit(Value::Number(-1.0))), "offset").unwrap_err();
804        assert_eq!(e.code, "filter_invalid");
805        assert!(e.message.contains("top-level offset"));
806    }
807}