1use std::collections::HashMap;
13
14use omgbase_search::{EmbeddingProvider, f32_to_blob};
15use omgbase_store::Store;
16use oqx::ast::{BinaryOp, Expr, Query, SelectItem};
17use oqx::walk::{Clause, Node, VisitContext, Visitor, transform, visit};
18use oqx::{Consumer, Engine, InMemoryEngine, Value, build, resolve_aliases};
19use serde_json::{Map, Value as Json};
20
21use crate::context::{SemanticVec, StoreContext, Target, render_row_values, target_of};
22use crate::cursor::{decode_path_cursor, encode_cursor};
23use crate::error::{Result, SurfaceError};
24use crate::paths::reference_path;
25use crate::planner::{SqlitePlanner, root_target};
26
27pub const DEFAULT_LIMIT: usize = 50;
29
30const ROW_FNS: [&str; 12] = [
36 "text",
37 "semantic",
38 "under",
39 "under_heading",
40 "within",
41 "under_kind",
42 "yaml_path",
43 "json_pointer",
44 "has_edge",
45 "has_anchor",
46 "child_count",
47 "parent_type",
48];
49
50const ID_KEY: &str = "__oqx_id";
51const PATH_KEY: &str = "__oqx_path";
52pub(crate) const HIT_ID: &str = "id";
58pub(crate) const HIT_PATH: &str = "path";
59const SELF_KEY: &str = "__oqx_self";
69const VALUE_KEY: &str = "__oqx_value";
73
74#[derive(Clone, Copy, Default)]
76pub struct QueryOptions<'a> {
77 pub limit: Option<usize>,
79 pub cursor: Option<&'a str>,
81 pub provider: Option<&'a dyn EmbeddingProvider>,
84 pub in_memory: bool,
87}
88
89#[derive(Clone, Debug, PartialEq)]
91pub struct OqxResult {
92 pub hits: Vec<Json>,
93 pub truncated: bool,
94 pub cursor: Option<String>,
95 pub consumer: Consumer,
96 pub count: Option<f64>,
97 pub exists: Option<bool>,
98 pub none: Option<bool>,
99 pub values: Option<Vec<Json>>,
101}
102
103impl OqxResult {
104 fn scalar(consumer: Consumer) -> Self {
105 Self {
106 hits: Vec::new(),
107 truncated: false,
108 cursor: None,
109 consumer,
110 count: None,
111 exists: None,
112 none: None,
113 values: None,
114 }
115 }
116
117 #[must_use]
120 pub fn to_json(&self) -> Json {
121 let mut m = Map::new();
122 m.insert("hits".to_owned(), Json::Array(self.hits.clone()));
123 m.insert("truncated".to_owned(), Json::Bool(self.truncated));
124 m.insert(
125 "cursor".to_owned(),
126 self.cursor.clone().map_or(Json::Null, Json::String),
127 );
128 m.insert(
129 "consumer".to_owned(),
130 Json::String(self.consumer.as_str().to_owned()),
131 );
132 if let Some(n) = self.count {
133 m.insert("count".to_owned(), Value::Number(n).to_canonical_json());
134 }
135 if let Some(b) = self.exists {
136 m.insert("exists".to_owned(), Json::Bool(b));
137 }
138 if let Some(b) = self.none {
139 m.insert("none".to_owned(), Json::Bool(b));
140 }
141 if let Some(v) = &self.values {
142 m.insert("values".to_owned(), Json::Array(v.clone()));
143 }
144 Json::Object(m)
145 }
146}
147
148const PATH_ATTRIBUTES: [&str; 2] = ["path", "dst_path"];
159const ROW_HANDLES: [&str; 4] = ["doc", "block", "dst", "$it"];
164
165fn is_path_read(e: &Expr) -> bool {
166 match e {
167 Expr::Ident { name, .. } | Expr::Outer { name, .. } => {
168 PATH_ATTRIBUTES.contains(&name.as_str())
169 }
170 Expr::Member { recv, name, .. } if PATH_ATTRIBUTES.contains(&name.as_str()) => {
171 matches!(&**recv, Expr::Ident { name, .. } | Expr::Outer { name, .. } if ROW_HANDLES.contains(&name.as_str()))
172 }
173 _ => false,
174 }
175}
176
177fn envelope_collision(items: &[SelectItem]) -> Option<SurfaceError> {
188 for item in items {
189 let name = match item {
190 SelectItem::Field { name, .. } | SelectItem::Collect { name, .. } => name.as_str(),
191 };
192 if name != HIT_ID && name != HIT_PATH {
193 continue;
194 }
195 if let SelectItem::Field { expr, .. } = item
196 && matches!(expr, Expr::Ident { name: n, .. } if n == name)
197 {
198 continue;
199 }
200 let hint = if name == HIT_PATH {
201 "p: data.path"
202 } else {
203 "i: data.id"
204 };
205 return Some(SurfaceError::filter_invalid(
206 format!("projection `{name}` collides with the hit envelope — alias it (`{hint}`)"),
207 "OQX",
208 ));
209 }
210 None
211}
212
213fn root_literal(e: Expr) -> Expr {
214 match e {
215 Expr::Lit {
216 value: Value::Str(s),
217 span,
218 } => Expr::Lit {
219 value: Value::Str(reference_path(&s)),
220 span,
221 },
222 other => other,
223 }
224}
225
226#[must_use]
228pub fn root_path_literals(e: Expr) -> Expr {
229 match e {
230 Expr::Binary {
231 op: op @ (BinaryOp::Eq | BinaryOp::Ne),
232 left,
233 right,
234 span,
235 } => {
236 if is_path_read(&left) {
237 Expr::Binary {
238 op,
239 left,
240 right: Box::new(root_literal(*right)),
241 span,
242 }
243 } else if is_path_read(&right) {
244 Expr::Binary {
245 op,
246 left: Box::new(root_literal(*left)),
247 right,
248 span,
249 }
250 } else {
251 Expr::Binary {
252 op,
253 left,
254 right,
255 span,
256 }
257 }
258 }
259 Expr::Call {
260 recv: Some(recv),
261 name,
262 mut args,
263 span,
264 } if name == "startsWith" && args.len() == 1 && is_path_read(&recv) => {
265 let arg = root_literal(args.remove(0));
266 Expr::Call {
267 recv: Some(recv),
268 name,
269 args: vec![arg],
270 span,
271 }
272 }
273 other => other,
274 }
275}
276
277#[must_use]
281pub fn rewrite_query(q: &Query) -> Query {
282 transform(q, &mut |e, _| match e {
283 Expr::Call {
284 recv: None,
285 name,
286 args,
287 span,
288 } if ROW_FNS.contains(&name.as_str()) => Expr::Call {
289 recv: Some(Box::new(build::ident("$self"))),
290 name,
291 args,
292 span,
293 },
294 other => root_path_literals(other),
295 })
296}
297
298pub const REPO_REMOVED_MESSAGE: &str = "`$repo` was removed in surface 2.0 — reach the repository's collections through the root row: `^docs` from a top-level row (one caret per enclosing block, or the absolute `0^docs`), and the repository id as `^id` / `0^id`";
302
303#[must_use]
307pub fn bare_target_message(t: Target, depth: Option<usize>) -> String {
308 let name = t.as_str();
309 let noun = match t {
310 Target::Docs => "documents",
311 Target::Blocks => "blocks",
312 Target::Nodes => "nodes",
313 Target::Links => "links",
314 };
315 let carets = "^".repeat(depth.unwrap_or(1));
316 let how = if depth.is_none() {
317 format!("; one caret per enclosing block, or the absolute `0^{name}`")
318 } else {
319 String::new()
320 };
321 format!(
322 "`{name}` inside a block reads a property of the current row, which has none — did you mean `{carets}{name}` (the repository's {noun}{how})?"
323 )
324}
325
326#[must_use]
331pub fn unknown_target_message(name: &str) -> String {
332 let was = if name == "edges" {
333 " (`edges` was renamed `links` in surface 3.0)"
334 } else {
335 ""
336 };
337 format!(
338 "`{name}` is not a target — the repository's collections are `docs`, `blocks`, `nodes`, `links`{was}"
339 )
340}
341
342#[must_use]
346pub fn past_root_message(t: Target, levels: usize) -> String {
347 let name = t.as_str();
348 let noun = match t {
349 Target::Docs => "documents",
350 Target::Blocks => "blocks",
351 Target::Nodes => "nodes",
352 Target::Links => "links",
353 };
354 let carets = "^".repeat(levels);
355 format!(
356 "`{carets}{name}` reaches past the root — there is no enclosing row at this depth; at the top level the repository's {noun} are the bare `{name}` (`{name} count {{ … }}`, `from {name}`, `entries({name})`)"
357 )
358}
359
360pub fn check_root_spellings(q: &Query) -> Result<()> {
374 if let Expr::Ident { name, .. } = &q.source {
378 if Target::parse(name).is_none() {
379 return Err(SurfaceError::filter_invalid(
380 unknown_target_message(name),
381 "OQX",
382 ));
383 }
384 }
385 struct Check(Option<SurfaceError>);
386 impl Visitor for Check {
387 fn enter(&mut self, node: Node<'_>, ctx: &VisitContext<'_>) -> bool {
388 if self.0.is_some() {
389 return false;
390 }
391 match node {
392 Node::Expr(Expr::Ident { name, .. } | Expr::Outer { name, .. })
393 if name == "$repo" =>
394 {
395 self.0 = Some(SurfaceError::filter_invalid(REPO_REMOVED_MESSAGE, "OQX"));
396 }
397 Node::Expr(Expr::Ident { name, .. })
398 if ctx.depth >= 1 && matches!(name.as_str(), "docs" | "links") =>
399 {
400 let t = Target::parse(name).expect("a target name");
401 self.0 = Some(SurfaceError::filter_invalid(
402 bare_target_message(t, Some(ctx.depth)),
403 "OQX",
404 ));
405 }
406 Node::Expr(Expr::Outer { name, levels, .. }) if *levels > ctx.depth => {
407 if let Some(t) = Target::parse(name) {
408 self.0 = Some(SurfaceError::filter_invalid(
409 past_root_message(t, *levels),
410 "OQX",
411 ));
412 }
413 }
414 _ => {}
415 }
416 self.0.is_none()
417 }
418 }
419 let mut check = Check(None);
420 visit(Node::Query(q), &mut check);
421 check.0.map_or(Ok(()), Err)
422}
423
424struct Phrases(Vec<String>);
428
429impl Visitor for Phrases {
430 fn enter(&mut self, node: Node<'_>, _ctx: &VisitContext<'_>) -> bool {
431 if let Node::Expr(Expr::Call {
432 recv: None,
433 name,
434 args,
435 ..
436 }) = node
437 && name == "semantic"
438 && let Some(Expr::Lit {
439 value: Value::Str(s),
440 ..
441 }) = args.first()
442 && !self.0.contains(s)
443 {
444 self.0.push(s.clone());
445 }
446 true
447 }
448}
449
450#[must_use]
453pub fn collect_semantic_phrases(source: &str) -> Vec<String> {
454 let Ok(q) = oqx::parse_string(source) else {
455 return Vec::new();
456 };
457 let mut phrases = Phrases(Vec::new());
458 visit(Node::Query(&q), &mut phrases);
459 phrases.0
460}
461
462fn mentions_outside_source(q: &Query, name: &str) -> bool {
468 struct Mentions<'n> {
469 name: &'n str,
470 found: bool,
471 }
472 impl Visitor for Mentions<'_> {
473 fn enter(&mut self, node: Node<'_>, ctx: &VisitContext<'_>) -> bool {
474 if self.found {
475 return false;
476 }
477 if ctx.clause == Some(Clause::Source) && ctx.path.len() == 1 {
479 return false;
480 }
481 if let Node::Expr(Expr::Ident { name, .. } | Expr::Outer { name, .. }) = node
482 && name == self.name
483 {
484 self.found = true;
485 }
486 !self.found
487 }
488 }
489 let mut m = Mentions { name, found: false };
490 visit(Node::Query(q), &mut m);
491 m.found
492}
493
494fn may_reach_non_rows(q: &Query) -> bool {
500 q.follow.is_some() || !q.from.is_empty() || root_target(&q.source).is_none()
501}
502
503fn describe_value(v: &Value) -> String {
505 match v {
506 Value::Undefined | Value::Null => "an absent value".to_owned(),
507 Value::Str(s) => format!(
508 "a string ({})",
509 serde_json::to_string(s).unwrap_or_default()
510 ),
511 Value::Number(_) => format!("a number ({v})"),
512 Value::Bool(b) => format!("a boolean ({b})"),
513 Value::Array(_) => "an array".to_owned(),
514 Value::Range(_) => "a range".to_owned(),
515 Value::Object(_) => "an object".to_owned(),
516 }
517}
518
519fn not_a_store_row(v: &Value) -> SurfaceError {
520 SurfaceError::filter_invalid(
521 format!(
522 "a hit must be a document, block, node or link row — the query reached {}; to follow document references held in a property use refs(<field>)",
523 describe_value(v)
524 ),
525 "OQX",
526 )
527}
528
529fn to_hit(row: Value) -> Result<Value> {
539 let Value::Object(mut o) = row else {
540 return Ok(Value::Object(oqx::Object::new()));
541 };
542 if let Some(me) = o.remove(SELF_KEY) {
543 if target_of(&me).is_none() {
544 return Err(not_a_store_row(&me));
545 }
546 }
547 let Value::Object(o) = render_row_values(Value::Object(o)) else {
548 return Ok(Value::Object(oqx::Object::new()));
549 };
550 let mut id = Value::Undefined;
551 let mut path = Value::Undefined;
552 let mut rest = Vec::new();
553 for (k, v) in o {
554 match k.as_str() {
555 ID_KEY => id = v,
556 PATH_KEY => path = v,
557 _ => rest.push((k, v)),
558 }
559 }
560 let mut hit = oqx::Object::with_capacity(rest.len() + 2);
561 hit.insert(HIT_ID, Value::Str(id.to_string()));
562 hit.insert(
563 HIT_PATH,
564 Value::Str(if path.is_absent() {
565 String::new()
566 } else {
567 path.to_string()
568 }),
569 );
570 for (k, v) in rest {
571 hit.insert(k, v);
572 }
573 Ok(Value::Object(hit))
574}
575
576fn hit_str(hit: &Value, key: &str) -> String {
577 hit.as_object()
578 .and_then(|o| o.get(key))
579 .map(|v| v.to_string())
580 .unwrap_or_default()
581}
582
583fn dedup_hits_by_projection(hits: Vec<Value>) -> Vec<Value> {
587 let mut seen: Vec<String> = Vec::new();
588 let mut out = Vec::new();
589 for h in hits {
590 let mut pairs: Vec<(String, Value)> = h
591 .as_object()
592 .map(|o| {
593 o.iter()
594 .filter(|(k, _)| *k != HIT_ID && *k != HIT_PATH)
595 .map(|(k, v)| (k.to_owned(), v.clone()))
596 .collect()
597 })
598 .unwrap_or_default();
599 pairs.sort_by(|a, b| a.0.cmp(&b.0));
600 let key = Value::Array(
601 pairs
602 .into_iter()
603 .map(|(k, v)| Value::Array(vec![Value::Str(k), v]))
604 .collect(),
605 )
606 .to_canonical_json()
607 .to_string();
608 if seen.contains(&key) {
609 continue;
610 }
611 seen.push(key);
612 out.push(h);
613 }
614 out
615}
616
617fn const_bound(e: Option<&Expr>, word: &str) -> Result<Option<usize>> {
620 match e {
621 None => Ok(None),
622 Some(Expr::Lit {
623 value: Value::Number(n),
624 ..
625 }) if n.fract() == 0.0 && *n >= 0.0 && n.is_finite() => Ok(Some(*n as usize)),
626 Some(_) => Err(SurfaceError::filter_invalid(
627 format!("top-level {word} must be a non-negative integer literal"),
628 "OQX",
629 )),
630 }
631}
632
633fn value_of(hit: &Value) -> Value {
634 hit.as_object()
635 .and_then(|o| o.get(VALUE_KEY))
636 .cloned()
637 .unwrap_or(Value::Undefined)
638}
639
640fn without_value_key(hit: Value) -> Json {
641 hit.to_canonical_json()
642}
643
644pub fn query(
648 store: &Store,
649 repo_id: &str,
650 source: &str,
651 opts: QueryOptions<'_>,
652) -> Result<OqxResult> {
653 let phrases = collect_semantic_phrases(source);
658 let mut semantic: HashMap<String, SemanticVec> = HashMap::new();
659 if let Some(provider) = opts.provider.filter(|_| !phrases.is_empty()) {
660 for phrase in phrases {
661 let vec = provider
662 .embed_query(&phrase)
663 .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?;
664 semantic.insert(
665 phrase,
666 SemanticVec {
667 model: provider.model().to_owned(),
668 vec: f32_to_blob(&vec),
669 },
670 );
671 }
672 }
673 let runner = Runner {
674 store,
675 repo_id,
676 semantic,
677 planned: !opts.in_memory,
678 };
679 run_inner(&runner, source, opts)
680}
681
682struct Runner<'a> {
688 store: &'a Store,
689 repo_id: &'a str,
690 semantic: HashMap<String, SemanticVec>,
691 planned: bool,
692}
693
694impl Runner<'_> {
695 fn run(&self, q: &Query) -> Result<oqx::OqxResult> {
702 let conn = self.store.conn();
703 let ctx = StoreContext::new(conn, self.repo_id, self.semantic.clone());
704 let plan = if self.planned {
705 SqlitePlanner::new(conn, self.repo_id)
706 .try_plan(q, &[])
707 .map_err(|e| SurfaceError::other(format!("sqlite: {e}")))?
708 } else {
709 None
710 };
711 let (ctx, residual) = match plan {
712 Some(plan) => {
717 let once = !mentions_outside_source(&plan.residual, oqx::ROWS_ROOT);
718 let ctx = if once {
719 ctx.with_rows_root_once(plan.rows)
720 } else {
721 ctx.with_rows_root(plan.rows)
722 };
723 (ctx, Some(plan.residual))
724 }
725 None => (ctx, None),
726 };
727 let engine = InMemoryEngine::new(ctx);
728 let out = engine.run(residual.as_ref().unwrap_or(q), &[]);
729 if let Some(failed) = engine.context().take_root_failure() {
732 return Err(failed.into());
733 }
734 Ok(out?)
737 }
738}
739
740fn run_inner(engine: &Runner<'_>, source: &str, opts: QueryOptions<'_>) -> Result<OqxResult> {
741 let raw = oqx::parse_string(source)?;
744 check_root_spellings(&raw)?;
745 let parsed = rewrite_query(&resolve_aliases(&raw)?);
746 let consumer = parsed.consumer;
747
748 match consumer {
749 Consumer::Exists => {
750 let res = engine.run(&parsed)?;
751 let mut r = OqxResult::scalar(consumer);
752 r.exists = Some(matches!(res, oqx::OqxResult::Exists(true)));
753 return Ok(r);
754 }
755 Consumer::Count => {
756 let res = engine.run(&parsed)?;
757 let mut r = OqxResult::scalar(consumer);
758 r.count = Some(match res {
759 oqx::OqxResult::Count(n) => n,
760 _ => 0.0,
761 });
762 return Ok(r);
763 }
764 Consumer::None => {
765 let res = engine.run(&parsed)?;
766 let mut r = OqxResult::scalar(consumer);
767 r.none = Some(match res {
768 oqx::OqxResult::None(b) => b,
769 _ => true,
770 });
771 return Ok(r);
772 }
773 Consumer::Collect | Consumer::First | Consumer::Single => {}
774 }
775
776 let top_distinct = parsed.distinct;
782 let top_values = parsed.values;
783 let user_select: Vec<SelectItem> = if top_values {
784 parsed
785 .select
786 .first()
787 .map(|it| match it {
788 SelectItem::Field {
789 expr, lift, span, ..
790 } => SelectItem::Field {
791 name: VALUE_KEY.to_owned(),
792 expr: expr.clone(),
793 lift: *lift,
794 span: *span,
795 },
796 SelectItem::Collect { op, span, .. } => SelectItem::Collect {
797 name: VALUE_KEY.to_owned(),
798 op: op.clone(),
799 span: *span,
800 },
801 })
802 .into_iter()
803 .collect()
804 } else {
805 parsed.select.clone()
806 };
807 if !top_values && let Some(e) = envelope_collision(&user_select) {
808 return Err(e);
809 }
810 let id_item = build::field(ID_KEY, build::ident("id"));
811 let path_item = build::field(PATH_KEY, build::ident("path"));
812 let mut select = vec![id_item, path_item];
813 if !top_values && may_reach_non_rows(&parsed) {
814 select.push(build::field(SELF_KEY, build::ident("$it")));
815 }
816 select.extend(user_select);
817 let (top_limit, top_offset) = (parsed.limit.clone(), parsed.offset.clone());
821 let q = Query {
822 distinct: false,
823 values: false,
824 select,
825 limit: if consumer == Consumer::Collect {
826 None
827 } else {
828 parsed.limit.clone()
829 },
830 offset: if consumer == Consumer::Collect {
831 None
832 } else {
833 parsed.offset.clone()
834 },
835 ..parsed.clone()
836 };
837 let res = engine.run(&q)?;
838
839 if matches!(consumer, Consumer::First | Consumer::Single) {
840 let row = match res {
841 oqx::OqxResult::First(r) | oqx::OqxResult::Single(r) => r,
842 _ => None,
843 };
844 let mut out = OqxResult::scalar(consumer);
845 match row {
846 None => {
847 if top_values {
848 out.values = Some(Vec::new());
849 }
850 }
851 Some(r) => {
852 let hit = to_hit(r)?;
853 if top_values {
854 out.values = Some(vec![value_of(&hit).to_canonical_json()]);
855 } else {
856 out.hits = vec![without_value_key(hit)];
857 }
858 }
859 }
860 return Ok(out);
861 }
862
863 let mut rows: Vec<Value> = match res {
865 oqx::OqxResult::Collect(rows) => rows,
866 _ => Vec::new(),
867 };
868 let custom = parsed.order_by.as_ref().is_some_and(|o| !o.is_empty());
869 let cursor = opts.cursor.filter(|c| !c.is_empty() && !custom);
870 let eager = top_distinct || cursor.is_some();
875 if eager {
876 rows = rows.into_iter().map(to_hit).collect::<Result<_>>()?;
877 }
878 if top_distinct {
879 rows = dedup_hits_by_projection(rows);
880 }
881 let offset = const_bound(top_offset.as_ref(), "offset")?.unwrap_or(0);
882 let limit = const_bound(top_limit.as_ref(), "limit")?;
883 if offset > 0 || limit.is_some() {
884 let end = limit.map_or(rows.len(), |l| (offset + l).min(rows.len()));
885 if offset >= rows.len() {
886 rows.clear();
887 } else {
888 rows.truncate(end);
889 rows.drain(..offset);
890 }
891 }
892 let cap = opts.limit.unwrap_or(DEFAULT_LIMIT);
893 let mut page = rows;
894 if let Some(cursor) = cursor {
895 let parts = decode_path_cursor(cursor, "query", 2)?;
897 let (path, id) = (&parts[0], &parts[1]);
898 page.retain(|h| {
899 let hp = hit_str(h, HIT_PATH);
900 let hi = hit_str(h, HIT_ID);
901 hp > *path || (hp == *path && hi > *id)
902 });
903 }
904 let truncated = page.len() > cap;
905 page.truncate(cap);
906 if !eager {
907 page = page.into_iter().map(to_hit).collect::<Result<_>>()?;
908 }
909 let cursor = if truncated && !custom {
910 page.last()
911 .map(|last| encode_cursor(&[&hit_str(last, HIT_PATH), &hit_str(last, HIT_ID)]))
912 } else {
913 None
914 };
915 let mut out = OqxResult::scalar(Consumer::Collect);
916 out.truncated = truncated;
917 out.cursor = cursor;
918 if top_values {
919 out.values = Some(
920 page.iter()
921 .map(|h| value_of(h).to_canonical_json())
922 .collect(),
923 );
924 } else {
925 out.hits = page.into_iter().map(without_value_key).collect();
926 }
927 Ok(out)
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933 use oqx::ast::Where;
934
935 #[test]
936 fn row_functions_become_self_methods() {
937 let q = oqx::parse_string(
938 "from blocks where text(\"x\") && under_heading(\"h\") && size(attrs) > 0 && doc.path.startsWith(\"a\")",
939 )
940 .unwrap();
941 let r = rewrite_query(&q);
942 let Some(Where::And { parts, .. }) = &r.r#where else {
943 panic!("and")
944 };
945 let Where::Scalar { expr, .. } = &parts[0] else {
946 panic!("scalar")
947 };
948 assert!(
949 matches!(expr, Expr::Call { recv: Some(r), name, .. } if name == "text" && **r == build::ident("$self"))
950 );
951 let Where::Scalar { expr, .. } = &parts[2] else {
952 panic!("scalar")
953 };
954 assert!(
955 matches!(expr, Expr::Binary { left, .. } if matches!(&**left, Expr::Call { recv: None, name, .. } if name == "size"))
956 );
957 }
958
959 #[test]
960 fn path_literals_are_rooted_only_against_path_reads() {
961 let q = oqx::parse_string(
962 "select n: ^docs collect { path where path == ^after || \"x.md\" != dst_path } from docs where path.startsWith(\"lab/\") && doc.path == \"a.md\" && type == \"a.md\" && ^path == \"b.md\"",
963 )
964 .unwrap();
965 let printed = oqx::print_query(&rewrite_query(&q)).unwrap();
966 assert!(printed.contains("path.startsWith(\"/lab/\")"), "{printed}");
967 assert!(printed.contains("doc.path == \"/a.md\""), "{printed}");
968 assert!(printed.contains("type == \"a.md\""), "{printed}");
969 assert!(printed.contains("^path == \"/b.md\""), "{printed}");
970 assert!(printed.contains("\"/x.md\" != dst_path"), "{printed}");
971 assert!(printed.contains("path == ^after"), "{printed}");
973 let q = oqx::parse_string(
975 "from docs where path.contains(\"a/\") && path.endsWith(\".md\") && path == \"\"",
976 )
977 .unwrap();
978 let printed = oqx::print_query(&rewrite_query(&q)).unwrap();
979 assert!(printed.contains("path.contains(\"a/\")"), "{printed}");
980 assert!(printed.contains("path.endsWith(\".md\")"), "{printed}");
981 assert!(printed.contains("path == \"/\""), "{printed}");
982 }
983
984 #[test]
985 fn repo_and_bare_targets_are_refused_statically() {
986 let parse = |s: &str| oqx::parse_string(s).unwrap();
987 for q in [
988 "from docs where $repo",
989 "select r: size($repo.docs) from docs",
990 "select r: $repo.id from docs",
991 "select r: nodes first { select x: ^$repo.id values } from docs",
992 "select r: nodes first { select x: 0^$repo values } from docs",
993 "$repo.docs count { }",
994 ] {
995 let e = check_root_spellings(&parse(q)).unwrap_err();
996 assert_eq!(e.code, "filter_invalid", "{q}");
997 assert_eq!(e.message, REPO_REMOVED_MESSAGE, "{q}");
998 }
999 let e = check_root_spellings(&parse("select x: docs collect { } from docs")).unwrap_err();
1001 assert_eq!(e.message, bare_target_message(Target::Docs, Some(1)));
1002 assert!(
1003 e.message
1004 .contains("did you mean `^docs` (the repository's documents)?")
1005 );
1006 let e = check_root_spellings(&parse(
1007 "select x: nodes collect { select y: size(links) } from docs",
1008 ))
1009 .unwrap_err();
1010 assert!(
1011 e.message
1012 .contains("did you mean `^^links` (the repository's links)?")
1013 );
1014 let e = check_root_spellings(&parse("docs collect { from docs }")).unwrap_err();
1015 assert!(e.message.contains("`^docs`"));
1016 for q in [
1018 "docs count { }",
1019 "from docs where nodes exists { }",
1020 "entries(docs) first { }",
1021 "select n: size(^docs), id: ^id, k: size(0^links) from docs",
1022 "from blocks where blocks exists { }", ] {
1024 check_root_spellings(&parse(q)).unwrap_or_else(|e| panic!("{q}: {e}"));
1025 }
1026 let e = check_root_spellings(&parse("^docs count { }")).unwrap_err();
1028 assert_eq!(e.message, past_root_message(Target::Docs, 1));
1029 assert_eq!(
1030 e.message,
1031 "`^docs` reaches past the root — there is no enclosing row at this depth; at the top level the repository's documents are the bare `docs` (`docs count { … }`, `from docs`, `entries(docs)`)"
1032 );
1033 let e = check_root_spellings(&parse("select path from docs where ^^links exists { }"))
1034 .unwrap_err();
1035 assert!(
1036 e.message.contains("`^^links` reaches past the root"),
1037 "{}",
1038 e.message
1039 );
1040 let m = bare_target_message(Target::Blocks, None);
1042 assert!(m.contains("did you mean `^blocks` (the repository's blocks; one caret per enclosing block, or the absolute `0^blocks`)?"), "{m}");
1043 }
1044
1045 #[test]
1046 fn semantic_phrases_are_collected_distinct() {
1047 let phrases = collect_semantic_phrases(
1048 "select s: semantic(\"alpha\") from docs where semantic(\"alpha\") > 0.5 || nodes exists { where semantic(\"beta\") > 0 } order by semantic(\"gamma\") desc",
1049 );
1050 assert_eq!(phrases, ["alpha", "beta", "gamma"]);
1051 assert!(collect_semantic_phrases("not a query {{").is_empty());
1052 assert!(collect_semantic_phrases("from docs").is_empty());
1053 }
1054
1055 #[test]
1056 fn hits_peel_the_injected_columns() {
1057 let mut o = oqx::Object::new();
1058 o.insert(ID_KEY, Value::Str("d_1".into()));
1059 o.insert(PATH_KEY, Value::Null);
1060 o.insert("layer", Value::Str("canon".into()));
1061 let hit = to_hit(Value::Object(o)).unwrap();
1062 let ho = hit.as_object().unwrap();
1063 assert_eq!(ho.keys().collect::<Vec<_>>(), ["id", "path", "layer"]);
1064 assert_eq!(ho.get("path"), Some(&Value::Str(String::new())));
1065 let mut o = oqx::Object::new();
1068 o.insert(ID_KEY, Value::Str("d_1".into()));
1069 o.insert(PATH_KEY, Value::Str("a.md".into()));
1070 o.insert("id", Value::Number(7.0));
1071 let hit = to_hit(Value::Object(o)).unwrap();
1072 let ho = hit.as_object().unwrap();
1073 assert_eq!(ho.keys().collect::<Vec<_>>(), ["id", "path"]);
1074 assert_eq!(ho.get("id"), Some(&Value::Number(7.0)));
1075 }
1076
1077 #[test]
1078 fn a_projection_onto_the_envelope_must_be_aliased() {
1079 let items = |src: &str| oqx::parse_string(src).unwrap().select;
1080 assert!(envelope_collision(&items("select id, path from docs")).is_none());
1082 assert!(
1083 envelope_collision(&items("select id: id, path: path, p: path from docs")).is_none()
1084 );
1085 let e = envelope_collision(&items("select data.path from docs")).unwrap();
1087 assert_eq!(e.code, "filter_invalid");
1088 assert_eq!(
1089 e.message,
1090 "projection `path` collides with the hit envelope — alias it (`p: data.path`)"
1091 );
1092 let e = envelope_collision(&items("select id: layer from docs")).unwrap();
1093 assert_eq!(
1094 e.message,
1095 "projection `id` collides with the hit envelope — alias it (`i: data.id`)"
1096 );
1097 assert!(envelope_collision(&items("select path: doc.path from blocks")).is_some());
1098 assert!(envelope_collision(&items("select path: nodes collect { } from docs")).is_some());
1099 }
1100
1101 #[test]
1102 fn a_path_read_is_the_attribute_or_a_row_handle_never_a_source_handle() {
1103 let pred = |src: &str| match oqx::parse_string(src).unwrap().r#where.unwrap() {
1104 oqx::ast::Where::Scalar { expr, .. } => expr,
1105 other => panic!("{other:?}"),
1106 };
1107 let left = |e: Expr| match e {
1108 Expr::Binary { left, .. } => *left,
1109 other => panic!("{other:?}"),
1110 };
1111 assert!(is_path_read(&left(pred("from docs where path == \"x\""))));
1112 assert!(is_path_read(&left(pred(
1113 "from blocks where doc.path == \"x\""
1114 ))));
1115 assert!(is_path_read(&left(pred("from docs where ^path == \"x\""))));
1116 assert!(is_path_read(&left(pred(
1117 "from links where dst_path == \"x\""
1118 ))));
1119 assert!(!is_path_read(&left(pred(
1120 "from docs where data.path == \"x\""
1121 ))));
1122 assert!(!is_path_read(&left(pred(
1123 "from docs where frontmatter.path == \"x\""
1124 ))));
1125 }
1126
1127 #[test]
1128 fn a_hit_that_is_not_a_store_row_fails_the_query() {
1129 let mut o = oqx::Object::new();
1132 o.insert(ID_KEY, Value::Undefined);
1133 o.insert(PATH_KEY, Value::Undefined);
1134 o.insert(SELF_KEY, Value::Str("/timeline/kickoff.md".into()));
1135 let e = to_hit(Value::Object(o)).unwrap_err();
1136 assert_eq!(e.code, "filter_invalid");
1137 assert_eq!(
1138 e.message,
1139 "a hit must be a document, block, node or link row — the query reached a string (\"/timeline/kickoff.md\"); to follow document references held in a property use refs(<field>)"
1140 );
1141 let mut row = oqx::Object::new();
1143 row.insert("doc_id", Value::Str("d_1".into()));
1144 row.insert("path", Value::Str("a.md".into()));
1145 let row = crate::context::tag_row(row, crate::context::Target::Docs);
1146 let mut o = oqx::Object::new();
1147 o.insert(ID_KEY, Value::Str("d_1".into()));
1148 o.insert(PATH_KEY, Value::Str("a.md".into()));
1149 o.insert(SELF_KEY, row);
1150 let hit = to_hit(Value::Object(o)).unwrap();
1151 assert_eq!(
1152 hit.as_object().unwrap().keys().collect::<Vec<_>>(),
1153 ["id", "path"]
1154 );
1155 let parse = |s: &str| oqx::parse_string(s).unwrap();
1157 assert!(!may_reach_non_rows(&parse(
1158 "from docs where layer == \"canon\""
1159 )));
1160 assert!(may_reach_non_rows(&parse("from docs follow before")));
1161 assert!(may_reach_non_rows(&parse("refs(\"/index.md\") first { }")));
1162 }
1163
1164 #[test]
1165 fn distinct_dedups_by_user_projection_first_wins() {
1166 let mk = |id: &str, t: &str| {
1167 let mut o = oqx::Object::new();
1168 o.insert(HIT_ID, Value::Str(id.into()));
1169 o.insert(HIT_PATH, Value::Str("p".into()));
1170 o.insert("type", Value::Str(t.into()));
1171 Value::Object(o)
1172 };
1173 let out = dedup_hits_by_projection(vec![mk("1", "a"), mk("2", "b"), mk("3", "a")]);
1174 assert_eq!(out.len(), 2);
1175 assert_eq!(hit_str(&out[0], HIT_ID), "1");
1176 assert_eq!(hit_str(&out[1], HIT_ID), "2");
1177 }
1178
1179 #[test]
1180 fn top_level_bounds_must_be_literals() {
1181 assert_eq!(const_bound(None, "limit").unwrap(), None);
1182 assert_eq!(
1183 const_bound(
1184 Some(&Expr::Lit {
1185 value: Value::Number(3.0),
1186 span: oqx::Span::EMPTY
1187 }),
1188 "limit"
1189 )
1190 .unwrap(),
1191 Some(3)
1192 );
1193 let e = const_bound(
1194 Some(&Expr::Lit {
1195 value: Value::Number(-1.0),
1196 span: oqx::Span::EMPTY,
1197 }),
1198 "offset",
1199 )
1200 .unwrap_err();
1201 assert_eq!(e.code, "filter_invalid");
1202 assert!(e.message.contains("top-level offset"));
1203 }
1204}