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 pub bare: bool,
95}
96
97#[derive(Clone, Debug, PartialEq)]
99pub struct OqxResult {
100 pub hits: Vec<Json>,
101 pub truncated: bool,
102 pub cursor: Option<String>,
103 pub consumer: Consumer,
104 pub count: Option<f64>,
105 pub exists: Option<bool>,
106 pub none: Option<bool>,
107 pub values: Option<Vec<Json>>,
109}
110
111impl OqxResult {
112 fn scalar(consumer: Consumer) -> Self {
113 Self {
114 hits: Vec::new(),
115 truncated: false,
116 cursor: None,
117 consumer,
118 count: None,
119 exists: None,
120 none: None,
121 values: None,
122 }
123 }
124
125 #[must_use]
128 pub fn to_json(&self) -> Json {
129 let mut m = Map::new();
130 m.insert("hits".to_owned(), Json::Array(self.hits.clone()));
131 m.insert("truncated".to_owned(), Json::Bool(self.truncated));
132 m.insert(
133 "cursor".to_owned(),
134 self.cursor.clone().map_or(Json::Null, Json::String),
135 );
136 m.insert(
137 "consumer".to_owned(),
138 Json::String(self.consumer.as_str().to_owned()),
139 );
140 if let Some(n) = self.count {
141 m.insert("count".to_owned(), Value::Number(n).to_canonical_json());
142 }
143 if let Some(b) = self.exists {
144 m.insert("exists".to_owned(), Json::Bool(b));
145 }
146 if let Some(b) = self.none {
147 m.insert("none".to_owned(), Json::Bool(b));
148 }
149 if let Some(v) = &self.values {
150 m.insert("values".to_owned(), Json::Array(v.clone()));
151 }
152 Json::Object(m)
153 }
154}
155
156const PATH_ATTRIBUTES: [&str; 2] = ["path", "dst_path"];
167const ROW_HANDLES: [&str; 4] = ["doc", "block", "dst", "$it"];
172
173fn is_path_read(e: &Expr) -> bool {
174 match e {
175 Expr::Ident { name, .. } | Expr::Outer { name, .. } => {
176 PATH_ATTRIBUTES.contains(&name.as_str())
177 }
178 Expr::Member { recv, name, .. } if PATH_ATTRIBUTES.contains(&name.as_str()) => {
179 matches!(&**recv, Expr::Ident { name, .. } | Expr::Outer { name, .. } if ROW_HANDLES.contains(&name.as_str()))
180 }
181 _ => false,
182 }
183}
184
185fn envelope_collision(items: &[SelectItem]) -> Option<SurfaceError> {
196 for item in items {
197 let name = match item {
198 SelectItem::Field { name, .. } | SelectItem::Collect { name, .. } => name.as_str(),
199 };
200 if name != HIT_ID && name != HIT_PATH {
201 continue;
202 }
203 if let SelectItem::Field { expr, .. } = item
204 && matches!(expr, Expr::Ident { name: n, .. } if n == name)
205 {
206 continue;
207 }
208 let hint = if name == HIT_PATH {
209 "p: data.path"
210 } else {
211 "i: data.id"
212 };
213 return Some(SurfaceError::filter_invalid(
214 format!("projection `{name}` collides with the hit envelope — alias it (`{hint}`)"),
215 "OQX",
216 ));
217 }
218 None
219}
220
221fn root_literal(e: Expr) -> Expr {
222 match e {
223 Expr::Lit {
224 value: Value::Str(s),
225 span,
226 } => Expr::Lit {
227 value: Value::Str(reference_path(&s)),
228 span,
229 },
230 other => other,
231 }
232}
233
234#[must_use]
236pub fn root_path_literals(e: Expr) -> Expr {
237 match e {
238 Expr::Binary {
239 op: op @ (BinaryOp::Eq | BinaryOp::Ne),
240 left,
241 right,
242 span,
243 } => {
244 if is_path_read(&left) {
245 Expr::Binary {
246 op,
247 left,
248 right: Box::new(root_literal(*right)),
249 span,
250 }
251 } else if is_path_read(&right) {
252 Expr::Binary {
253 op,
254 left: Box::new(root_literal(*left)),
255 right,
256 span,
257 }
258 } else {
259 Expr::Binary {
260 op,
261 left,
262 right,
263 span,
264 }
265 }
266 }
267 Expr::Call {
268 recv: Some(recv),
269 name,
270 mut args,
271 span,
272 } if name == "startsWith" && args.len() == 1 && is_path_read(&recv) => {
273 let arg = root_literal(args.remove(0));
274 Expr::Call {
275 recv: Some(recv),
276 name,
277 args: vec![arg],
278 span,
279 }
280 }
281 other => other,
282 }
283}
284
285#[must_use]
289pub fn rewrite_query(q: &Query) -> Query {
290 transform(q, &mut |e, _| match e {
291 Expr::Call {
292 recv: None,
293 name,
294 args,
295 span,
296 } if ROW_FNS.contains(&name.as_str()) => Expr::Call {
297 recv: Some(Box::new(build::ident("$self"))),
298 name,
299 args,
300 span,
301 },
302 other => root_path_literals(other),
303 })
304}
305
306pub const REPO_REMOVED_MESSAGE: &str = "`$repo` was removed in surface 2.0 — reach the repository's collections through the root row: `^docs` from a top-level row (one caret per enclosing block, or the absolute `0^docs`), and the repository id as `^id` / `0^id`";
310
311#[must_use]
315pub fn bare_target_message(t: Target, depth: Option<usize>) -> String {
316 let name = t.as_str();
317 let noun = match t {
318 Target::Docs => "documents",
319 Target::Blocks => "blocks",
320 Target::Nodes => "nodes",
321 Target::Links => "links",
322 };
323 let carets = "^".repeat(depth.unwrap_or(1));
324 let how = if depth.is_none() {
325 format!("; one caret per enclosing block, or the absolute `0^{name}`")
326 } else {
327 String::new()
328 };
329 format!(
330 "`{name}` inside a block reads a property of the current row, which has none — did you mean `{carets}{name}` (the repository's {noun}{how})?"
331 )
332}
333
334#[must_use]
339pub fn unknown_target_message(name: &str) -> String {
340 let was = if name == "edges" {
341 " (`edges` was renamed `links` in surface 3.0)"
342 } else {
343 ""
344 };
345 format!(
346 "`{name}` is not a target — the repository's collections are `docs`, `blocks`, `nodes`, `links`{was}"
347 )
348}
349
350#[must_use]
354pub fn past_root_message(t: Target, levels: usize) -> String {
355 let name = t.as_str();
356 let noun = match t {
357 Target::Docs => "documents",
358 Target::Blocks => "blocks",
359 Target::Nodes => "nodes",
360 Target::Links => "links",
361 };
362 let carets = "^".repeat(levels);
363 format!(
364 "`{carets}{name}` reaches past the root — there is no enclosing row at this depth; at the top level the repository's {noun} are the bare `{name}` (`{name} count {{ … }}`, `from {name}`, `entries({name})`)"
365 )
366}
367
368pub fn check_root_spellings(q: &Query) -> Result<()> {
382 if let Expr::Ident { name, .. } = &q.source {
386 if Target::parse(name).is_none() {
387 return Err(SurfaceError::filter_invalid(
388 unknown_target_message(name),
389 "OQX",
390 ));
391 }
392 }
393 struct Check(Option<SurfaceError>);
394 impl Visitor for Check {
395 fn enter(&mut self, node: Node<'_>, ctx: &VisitContext<'_>) -> bool {
396 if self.0.is_some() {
397 return false;
398 }
399 match node {
400 Node::Expr(Expr::Ident { name, .. } | Expr::Outer { name, .. })
401 if name == "$repo" =>
402 {
403 self.0 = Some(SurfaceError::filter_invalid(REPO_REMOVED_MESSAGE, "OQX"));
404 }
405 Node::Expr(Expr::Ident { name, .. })
406 if ctx.depth >= 1 && matches!(name.as_str(), "docs" | "links") =>
407 {
408 let t = Target::parse(name).expect("a target name");
409 self.0 = Some(SurfaceError::filter_invalid(
410 bare_target_message(t, Some(ctx.depth)),
411 "OQX",
412 ));
413 }
414 Node::Expr(Expr::Outer { name, levels, .. }) if *levels > ctx.depth => {
415 if let Some(t) = Target::parse(name) {
416 self.0 = Some(SurfaceError::filter_invalid(
417 past_root_message(t, *levels),
418 "OQX",
419 ));
420 }
421 }
422 _ => {}
423 }
424 self.0.is_none()
425 }
426 }
427 let mut check = Check(None);
428 visit(Node::Query(q), &mut check);
429 check.0.map_or(Ok(()), Err)
430}
431
432struct Phrases(Vec<String>);
436
437impl Visitor for Phrases {
438 fn enter(&mut self, node: Node<'_>, _ctx: &VisitContext<'_>) -> bool {
439 if let Node::Expr(Expr::Call {
440 recv: None,
441 name,
442 args,
443 ..
444 }) = node
445 && name == "semantic"
446 && let Some(Expr::Lit {
447 value: Value::Str(s),
448 ..
449 }) = args.first()
450 && !self.0.contains(s)
451 {
452 self.0.push(s.clone());
453 }
454 true
455 }
456}
457
458#[must_use]
461pub fn collect_semantic_phrases(source: &str) -> Vec<String> {
462 let Ok(q) = oqx::parse_string(source) else {
463 return Vec::new();
464 };
465 semantic_phrases_of(&q)
466}
467
468#[must_use]
470pub fn semantic_phrases_of(q: &Query) -> Vec<String> {
471 let mut phrases = Phrases(Vec::new());
472 visit(Node::Query(q), &mut phrases);
473 phrases.0
474}
475
476pub fn embed_phrases(
483 provider: Option<&dyn EmbeddingProvider>,
484 phrases: Vec<String>,
485) -> Result<HashMap<String, SemanticVec>> {
486 let mut semantic: HashMap<String, SemanticVec> = HashMap::new();
487 if let Some(provider) = provider.filter(|_| !phrases.is_empty()) {
488 for phrase in phrases {
489 let vec = provider
490 .embed_query(&phrase)
491 .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?;
492 semantic.insert(
493 phrase,
494 SemanticVec {
495 model: provider.model().to_owned(),
496 vec: f32_to_blob(&vec),
497 },
498 );
499 }
500 }
501 Ok(semantic)
502}
503
504pub const RESERVED_ALIAS_PREFIXES: [&str; 2] = ["__oqx_", "__omg_"];
515
516pub fn check_reserved_aliases(q: &Query) -> Result<()> {
518 for item in &q.select {
519 if let Some(prefix) = RESERVED_ALIAS_PREFIXES
520 .iter()
521 .find(|p| item.name().starts_with(*p))
522 {
523 return Err(SurfaceError::filter_invalid(
524 format!(
525 "projection `{}` uses the reserved prefix `{prefix}` — choose another alias",
526 item.name()
527 ),
528 "OQX",
529 ));
530 }
531 }
532 Ok(())
533}
534
535fn mentions_outside_source(q: &Query, name: &str) -> bool {
541 struct Mentions<'n> {
542 name: &'n str,
543 found: bool,
544 }
545 impl Visitor for Mentions<'_> {
546 fn enter(&mut self, node: Node<'_>, ctx: &VisitContext<'_>) -> bool {
547 if self.found {
548 return false;
549 }
550 if ctx.clause == Some(Clause::Source) && ctx.path.len() == 1 {
552 return false;
553 }
554 if let Node::Expr(Expr::Ident { name, .. } | Expr::Outer { name, .. }) = node
555 && name == self.name
556 {
557 self.found = true;
558 }
559 !self.found
560 }
561 }
562 let mut m = Mentions { name, found: false };
563 visit(Node::Query(q), &mut m);
564 m.found
565}
566
567fn may_reach_non_rows(q: &Query) -> bool {
573 q.follow.is_some() || !q.from.is_empty() || root_target(&q.source).is_none()
574}
575
576fn describe_value(v: &Value) -> String {
578 match v {
579 Value::Undefined | Value::Null => "an absent value".to_owned(),
580 Value::Str(s) => format!(
581 "a string ({})",
582 serde_json::to_string(s).unwrap_or_default()
583 ),
584 Value::Number(_) => format!("a number ({v})"),
585 Value::Bool(b) => format!("a boolean ({b})"),
586 Value::Array(_) => "an array".to_owned(),
587 Value::Range(_) => "a range".to_owned(),
588 Value::Object(_) => "an object".to_owned(),
589 }
590}
591
592fn not_a_store_row(v: &Value) -> SurfaceError {
593 SurfaceError::filter_invalid(
594 format!(
595 "a hit must be a document, block, node or link row — the query reached {}; to follow document references held in a property use refs(<field>)",
596 describe_value(v)
597 ),
598 "OQX",
599 )
600}
601
602#[derive(Debug)]
612struct Shaped {
613 id: String,
614 path: String,
615 proj: oqx::Object,
616}
617
618fn path_string(path: &Value) -> String {
619 if path.is_absent() {
620 String::new()
621 } else {
622 path.to_string()
623 }
624}
625
626fn shape_row(row: Value) -> Result<Shaped> {
627 let mut shaped = Shaped {
628 id: Value::Undefined.to_string(),
629 path: String::new(),
630 proj: oqx::Object::new(),
631 };
632 let Value::Object(mut o) = row else {
633 return Ok(shaped);
634 };
635 if let Some(me) = o.remove(SELF_KEY)
636 && target_of(&me).is_none()
637 {
638 return Err(not_a_store_row(&me));
639 }
640 let Value::Object(o) = render_row_values(Value::Object(o)) else {
641 return Ok(shaped);
642 };
643 for (k, v) in o {
644 match k.as_str() {
645 ID_KEY => shaped.id = v.to_string(),
646 PATH_KEY => shaped.path = path_string(&v),
647 _ => {
648 shaped.proj.insert(k, v);
649 }
650 }
651 }
652 Ok(shaped)
653}
654
655fn raw_identity(row: &Value) -> (String, String) {
660 let get = |k: &str| {
661 row.as_object()
662 .and_then(|o| o.get(k))
663 .cloned()
664 .unwrap_or(Value::Undefined)
665 };
666 (path_string(&get(PATH_KEY)), get(ID_KEY).to_string())
667}
668
669fn to_hit(s: Shaped) -> Value {
674 let mut hit = oqx::Object::with_capacity(s.proj.len() + 2);
675 hit.insert(HIT_ID, Value::Str(s.id));
676 hit.insert(HIT_PATH, Value::Str(s.path));
677 for (k, v) in s.proj {
678 hit.insert(k, v);
679 }
680 Value::Object(hit)
681}
682
683fn to_bare_hit(s: Shaped) -> Value {
688 Value::Object(s.proj)
689}
690
691fn render_hit(s: Shaped, bare: bool) -> Json {
693 if bare {
694 to_bare_hit(s).to_canonical_json()
695 } else {
696 to_hit(s).to_canonical_json()
697 }
698}
699
700fn dedup_by_projection(rows: Vec<Shaped>, bare: bool) -> Vec<Shaped> {
709 let mut seen: Vec<String> = Vec::new();
710 let mut out = Vec::new();
711 for s in rows {
712 let mut pairs: Vec<(String, Value)> = s
713 .proj
714 .iter()
715 .filter(|(k, _)| bare || (*k != HIT_ID && *k != HIT_PATH))
716 .map(|(k, v)| (k.to_owned(), v.clone()))
717 .collect();
718 pairs.sort_by(|a, b| a.0.cmp(&b.0));
719 let key = Value::Array(
720 pairs
721 .into_iter()
722 .map(|(k, v)| Value::Array(vec![Value::Str(k), v]))
723 .collect(),
724 )
725 .to_canonical_json()
726 .to_string();
727 if seen.contains(&key) {
728 continue;
729 }
730 seen.push(key);
731 out.push(s);
732 }
733 out
734}
735
736fn const_bound(e: Option<&Expr>, word: &str) -> Result<Option<usize>> {
739 match e {
740 None => Ok(None),
741 Some(Expr::Lit {
742 value: Value::Number(n),
743 ..
744 }) if n.fract() == 0.0 && *n >= 0.0 && n.is_finite() => Ok(Some(*n as usize)),
745 Some(_) => Err(SurfaceError::filter_invalid(
746 format!("top-level {word} must be a non-negative integer literal"),
747 "OQX",
748 )),
749 }
750}
751
752fn page_rows<T>(
758 mut rows: Vec<T>,
759 ident: impl Fn(&T) -> (String, String),
760 offset: usize,
761 limit: Option<usize>,
762 cursor: Option<&str>,
763 cap: usize,
764 custom: bool,
765) -> Result<(Vec<T>, bool, Option<String>)> {
766 if offset > 0 || limit.is_some() {
767 let end = limit.map_or(rows.len(), |l| (offset + l).min(rows.len()));
768 if offset >= rows.len() {
769 rows.clear();
770 } else {
771 rows.truncate(end);
772 rows.drain(..offset);
773 }
774 }
775 if let Some(cursor) = cursor {
776 let parts = decode_path_cursor(cursor, "query", 2)?;
778 let (path, id) = (&parts[0], &parts[1]);
779 rows.retain(|r| {
780 let (rp, ri) = ident(r);
781 rp > *path || (rp == *path && ri > *id)
782 });
783 }
784 let truncated = rows.len() > cap;
785 rows.truncate(cap);
786 let next = if truncated && !custom {
787 rows.last().map(|last| {
788 let (p, i) = ident(last);
789 encode_cursor(&[&p, &i])
790 })
791 } else {
792 None
793 };
794 Ok((rows, truncated, next))
795}
796
797fn value_of(s: &Shaped) -> Json {
798 s.proj
799 .get(VALUE_KEY)
800 .cloned()
801 .unwrap_or(Value::Undefined)
802 .to_canonical_json()
803}
804
805pub fn check_bare(q: &Query) -> Result<()> {
812 if q.values {
813 return Err(SurfaceError::filter_invalid(
814 BARE_WITH_VALUES_MESSAGE,
815 "OQX",
816 ));
817 }
818 if q.select.is_empty() {
819 return Err(SurfaceError::filter_invalid(
820 BARE_NEEDS_SELECT_MESSAGE,
821 "OQX",
822 ));
823 }
824 Ok(())
825}
826
827pub const BARE_NEEDS_SELECT_MESSAGE: &str =
829 "`bare` needs a `select` — without a projection a hit is only its envelope";
830pub const BARE_WITH_VALUES_MESSAGE: &str =
831 "`bare` is redundant with `values` — `values` is already the bare-values form; drop one";
832
833pub fn query(
837 store: &Store,
838 repo_id: &str,
839 source: &str,
840 opts: QueryOptions<'_>,
841) -> Result<OqxResult> {
842 let semantic = embed_phrases(opts.provider, collect_semantic_phrases(source))?;
843 let runner = Runner {
844 store,
845 repo_id,
846 semantic,
847 planned: !opts.in_memory,
848 };
849 let raw = oqx::parse_string(source)?;
850 check_reserved_aliases(&raw)?;
851 run_parsed(&runner, &raw, opts)
852}
853
854pub fn query_ast(
861 store: &Store,
862 repo_id: &str,
863 raw: &Query,
864 opts: QueryOptions<'_>,
865 semantic: HashMap<String, SemanticVec>,
866) -> Result<OqxResult> {
867 let runner = Runner {
868 store,
869 repo_id,
870 semantic,
871 planned: !opts.in_memory,
872 };
873 run_parsed(&runner, raw, opts)
874}
875
876struct Runner<'a> {
882 store: &'a Store,
883 repo_id: &'a str,
884 semantic: HashMap<String, SemanticVec>,
885 planned: bool,
886}
887
888impl Runner<'_> {
889 fn run(&self, q: &Query) -> Result<oqx::OqxResult> {
896 let conn = self.store.conn();
897 let ctx = StoreContext::new(conn, self.repo_id, self.semantic.clone());
898 let plan = if self.planned {
899 SqlitePlanner::new(conn, self.repo_id)
900 .try_plan(q, &[])
901 .map_err(|e| SurfaceError::other(format!("sqlite: {e}")))?
902 } else {
903 None
904 };
905 let (ctx, residual) = match plan {
906 Some(plan) => {
911 let once = !mentions_outside_source(&plan.residual, oqx::ROWS_ROOT);
912 let ctx = if once {
913 ctx.with_rows_root_once(plan.rows)
914 } else {
915 ctx.with_rows_root(plan.rows)
916 };
917 (ctx, Some(plan.residual))
918 }
919 None => (ctx, None),
920 };
921 let engine = InMemoryEngine::new(ctx);
922 let out = engine.run(residual.as_ref().unwrap_or(q), &[]);
923 if let Some(failed) = engine.context().take_root_failure() {
926 return Err(failed.into());
927 }
928 Ok(out?)
931 }
932}
933
934fn run_parsed(engine: &Runner<'_>, raw: &Query, opts: QueryOptions<'_>) -> Result<OqxResult> {
935 check_root_spellings(raw)?;
938 let parsed = rewrite_query(&resolve_aliases(raw)?);
939 let consumer = parsed.consumer;
940
941 match consumer {
942 Consumer::Exists => {
943 let res = engine.run(&parsed)?;
944 let mut r = OqxResult::scalar(consumer);
945 r.exists = Some(matches!(res, oqx::OqxResult::Exists(true)));
946 return Ok(r);
947 }
948 Consumer::Count => {
949 let res = engine.run(&parsed)?;
950 let mut r = OqxResult::scalar(consumer);
951 r.count = Some(match res {
952 oqx::OqxResult::Count(n) => n,
953 _ => 0.0,
954 });
955 return Ok(r);
956 }
957 Consumer::None => {
958 let res = engine.run(&parsed)?;
959 let mut r = OqxResult::scalar(consumer);
960 r.none = Some(match res {
961 oqx::OqxResult::None(b) => b,
962 _ => true,
963 });
964 return Ok(r);
965 }
966 Consumer::Collect | Consumer::First | Consumer::Single => {}
967 }
968
969 let top_distinct = parsed.distinct;
975 let top_values = parsed.values;
976 let user_select: Vec<SelectItem> = if top_values {
977 parsed
978 .select
979 .first()
980 .map(|it| match it {
981 SelectItem::Field {
982 expr, lift, span, ..
983 } => SelectItem::Field {
984 name: VALUE_KEY.to_owned(),
985 expr: expr.clone(),
986 lift: *lift,
987 span: *span,
988 },
989 SelectItem::Collect { op, span, .. } => SelectItem::Collect {
990 name: VALUE_KEY.to_owned(),
991 op: op.clone(),
992 span: *span,
993 },
994 })
995 .into_iter()
996 .collect()
997 } else {
998 parsed.select.clone()
999 };
1000 let bare = opts.bare;
1004 if bare {
1005 check_bare(&parsed)?;
1006 } else if !top_values && let Some(e) = envelope_collision(&user_select) {
1007 return Err(e);
1008 }
1009 let id_item = build::field(ID_KEY, build::ident("id"));
1010 let path_item = build::field(PATH_KEY, build::ident("path"));
1011 let mut select = vec![id_item, path_item];
1012 if !top_values && may_reach_non_rows(&parsed) {
1013 select.push(build::field(SELF_KEY, build::ident("$it")));
1014 }
1015 select.extend(user_select);
1016 let (top_limit, top_offset) = (parsed.limit.clone(), parsed.offset.clone());
1020 let q = Query {
1021 distinct: false,
1022 values: false,
1023 select,
1024 limit: if consumer == Consumer::Collect {
1025 None
1026 } else {
1027 parsed.limit.clone()
1028 },
1029 offset: if consumer == Consumer::Collect {
1030 None
1031 } else {
1032 parsed.offset.clone()
1033 },
1034 ..parsed.clone()
1035 };
1036 let res = engine.run(&q)?;
1037
1038 if matches!(consumer, Consumer::First | Consumer::Single) {
1039 let row = match res {
1040 oqx::OqxResult::First(r) | oqx::OqxResult::Single(r) => r,
1041 _ => None,
1042 };
1043 let mut out = OqxResult::scalar(consumer);
1044 match row {
1045 None => {
1046 if top_values {
1047 out.values = Some(Vec::new());
1048 }
1049 }
1050 Some(r) => {
1051 let shaped = shape_row(r)?;
1052 if top_values {
1053 out.values = Some(vec![value_of(&shaped)]);
1054 } else {
1055 out.hits = vec![render_hit(shaped, bare)];
1056 }
1057 }
1058 }
1059 return Ok(out);
1060 }
1061
1062 let rows: Vec<Value> = match res {
1064 oqx::OqxResult::Collect(rows) => rows,
1065 _ => Vec::new(),
1066 };
1067 let custom = parsed.order_by.as_ref().is_some_and(|o| !o.is_empty());
1068 let cursor = opts.cursor.filter(|c| !c.is_empty() && !custom);
1069 let offset = const_bound(top_offset.as_ref(), "offset")?.unwrap_or(0);
1070 let limit = const_bound(top_limit.as_ref(), "limit")?;
1071 let cap = opts.limit.unwrap_or(DEFAULT_LIMIT);
1072 let (page, truncated, next) = if top_distinct {
1078 let shaped = rows
1079 .into_iter()
1080 .map(shape_row)
1081 .collect::<Result<Vec<_>>>()?;
1082 let shaped = dedup_by_projection(shaped, bare);
1083 page_rows(
1084 shaped,
1085 |s| (s.path.clone(), s.id.clone()),
1086 offset,
1087 limit,
1088 cursor,
1089 cap,
1090 custom,
1091 )?
1092 } else {
1093 let (page, truncated, next) =
1094 page_rows(rows, raw_identity, offset, limit, cursor, cap, custom)?;
1095 let page = page
1096 .into_iter()
1097 .map(shape_row)
1098 .collect::<Result<Vec<_>>>()?;
1099 (page, truncated, next)
1100 };
1101 let mut out = OqxResult::scalar(Consumer::Collect);
1102 out.truncated = truncated;
1103 out.cursor = next;
1104 if top_values {
1105 out.values = Some(page.iter().map(value_of).collect());
1106 } else {
1107 out.hits = page.into_iter().map(|s| render_hit(s, bare)).collect();
1108 }
1109 Ok(out)
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115 use oqx::ast::Where;
1116
1117 #[test]
1118 fn row_functions_become_self_methods() {
1119 let q = oqx::parse_string(
1120 "from blocks where text(\"x\") && under_heading(\"h\") && size(attrs) > 0 && doc.path.startsWith(\"a\")",
1121 )
1122 .unwrap();
1123 let r = rewrite_query(&q);
1124 let Some(Where::And { parts, .. }) = &r.r#where else {
1125 panic!("and")
1126 };
1127 let Where::Scalar { expr, .. } = &parts[0] else {
1128 panic!("scalar")
1129 };
1130 assert!(
1131 matches!(expr, Expr::Call { recv: Some(r), name, .. } if name == "text" && **r == build::ident("$self"))
1132 );
1133 let Where::Scalar { expr, .. } = &parts[2] else {
1134 panic!("scalar")
1135 };
1136 assert!(
1137 matches!(expr, Expr::Binary { left, .. } if matches!(&**left, Expr::Call { recv: None, name, .. } if name == "size"))
1138 );
1139 }
1140
1141 #[test]
1142 fn path_literals_are_rooted_only_against_path_reads() {
1143 let q = oqx::parse_string(
1144 "select n: ^docs collect { path where path == ^after || \"x.md\" != dst_path } from docs where path.startsWith(\"lab/\") && doc.path == \"a.md\" && type == \"a.md\" && ^path == \"b.md\"",
1145 )
1146 .unwrap();
1147 let printed = oqx::print_query(&rewrite_query(&q)).unwrap();
1148 assert!(printed.contains("path.startsWith(\"/lab/\")"), "{printed}");
1149 assert!(printed.contains("doc.path == \"/a.md\""), "{printed}");
1150 assert!(printed.contains("type == \"a.md\""), "{printed}");
1151 assert!(printed.contains("^path == \"/b.md\""), "{printed}");
1152 assert!(printed.contains("\"/x.md\" != dst_path"), "{printed}");
1153 assert!(printed.contains("path == ^after"), "{printed}");
1155 let q = oqx::parse_string(
1157 "from docs where path.contains(\"a/\") && path.endsWith(\".md\") && path == \"\"",
1158 )
1159 .unwrap();
1160 let printed = oqx::print_query(&rewrite_query(&q)).unwrap();
1161 assert!(printed.contains("path.contains(\"a/\")"), "{printed}");
1162 assert!(printed.contains("path.endsWith(\".md\")"), "{printed}");
1163 assert!(printed.contains("path == \"/\""), "{printed}");
1164 }
1165
1166 #[test]
1167 fn repo_and_bare_targets_are_refused_statically() {
1168 let parse = |s: &str| oqx::parse_string(s).unwrap();
1169 for q in [
1170 "from docs where $repo",
1171 "select r: size($repo.docs) from docs",
1172 "select r: $repo.id from docs",
1173 "select r: nodes first { select x: ^$repo.id values } from docs",
1174 "select r: nodes first { select x: 0^$repo values } from docs",
1175 "$repo.docs count { }",
1176 ] {
1177 let e = check_root_spellings(&parse(q)).unwrap_err();
1178 assert_eq!(e.code, "filter_invalid", "{q}");
1179 assert_eq!(e.message, REPO_REMOVED_MESSAGE, "{q}");
1180 }
1181 let e = check_root_spellings(&parse("select x: docs collect { } from docs")).unwrap_err();
1183 assert_eq!(e.message, bare_target_message(Target::Docs, Some(1)));
1184 assert!(
1185 e.message
1186 .contains("did you mean `^docs` (the repository's documents)?")
1187 );
1188 let e = check_root_spellings(&parse(
1189 "select x: nodes collect { select y: size(links) } from docs",
1190 ))
1191 .unwrap_err();
1192 assert!(
1193 e.message
1194 .contains("did you mean `^^links` (the repository's links)?")
1195 );
1196 let e = check_root_spellings(&parse("docs collect { from docs }")).unwrap_err();
1197 assert!(e.message.contains("`^docs`"));
1198 for q in [
1200 "docs count { }",
1201 "from docs where nodes exists { }",
1202 "entries(docs) first { }",
1203 "select n: size(^docs), id: ^id, k: size(0^links) from docs",
1204 "from blocks where blocks exists { }", ] {
1206 check_root_spellings(&parse(q)).unwrap_or_else(|e| panic!("{q}: {e}"));
1207 }
1208 let e = check_root_spellings(&parse("^docs count { }")).unwrap_err();
1210 assert_eq!(e.message, past_root_message(Target::Docs, 1));
1211 assert_eq!(
1212 e.message,
1213 "`^docs` reaches past the root — there is no enclosing row at this depth; at the top level the repository's documents are the bare `docs` (`docs count { … }`, `from docs`, `entries(docs)`)"
1214 );
1215 let e = check_root_spellings(&parse("select path from docs where ^^links exists { }"))
1216 .unwrap_err();
1217 assert!(
1218 e.message.contains("`^^links` reaches past the root"),
1219 "{}",
1220 e.message
1221 );
1222 let m = bare_target_message(Target::Blocks, None);
1224 assert!(m.contains("did you mean `^blocks` (the repository's blocks; one caret per enclosing block, or the absolute `0^blocks`)?"), "{m}");
1225 }
1226
1227 #[test]
1228 fn reserved_projection_names_are_refused() {
1229 let parse = |s: &str| oqx::parse_string(s).unwrap();
1230 let e = check_reserved_aliases(&parse("select __oqx_id: 1 from docs")).unwrap_err();
1231 assert_eq!(e.code, "filter_invalid");
1232 assert_eq!(
1233 e.message,
1234 "projection `__oqx_id` uses the reserved prefix `__oqx_` — choose another alias"
1235 );
1236 assert_eq!(e.data.as_ref().unwrap()["hint"], "OQX");
1237 let e = check_reserved_aliases(&parse("select __omg_sel_0_x: 1 from docs")).unwrap_err();
1238 assert!(
1239 e.message.contains("reserved prefix `__omg_`"),
1240 "{}",
1241 e.message
1242 );
1243 assert!(
1245 check_reserved_aliases(&parse(
1246 "select __oqx: 1, x: nodes collect { select __oqx_y: 1 } from docs"
1247 ))
1248 .is_ok()
1249 );
1250 }
1251
1252 #[test]
1253 fn semantic_phrases_are_collected_distinct() {
1254 let phrases = collect_semantic_phrases(
1255 "select s: semantic(\"alpha\") from docs where semantic(\"alpha\") > 0.5 || nodes exists { where semantic(\"beta\") > 0 } order by semantic(\"gamma\") desc",
1256 );
1257 assert_eq!(phrases, ["alpha", "beta", "gamma"]);
1258 assert!(collect_semantic_phrases("not a query {{").is_empty());
1259 assert!(collect_semantic_phrases("from docs").is_empty());
1260 }
1261
1262 #[test]
1263 fn hits_peel_the_injected_columns() {
1264 let mut o = oqx::Object::new();
1265 o.insert(ID_KEY, Value::Str("d_1".into()));
1266 o.insert(PATH_KEY, Value::Null);
1267 o.insert("layer", Value::Str("canon".into()));
1268 let shaped = shape_row(Value::Object(o)).unwrap();
1269 assert_eq!((shaped.id.as_str(), shaped.path.as_str()), ("d_1", ""));
1270 let hit = to_hit(shaped);
1271 let ho = hit.as_object().unwrap();
1272 assert_eq!(ho.keys().collect::<Vec<_>>(), ["id", "path", "layer"]);
1273 assert_eq!(ho.get("path"), Some(&Value::Str(String::new())));
1274 let mut o = oqx::Object::new();
1277 o.insert(ID_KEY, Value::Str("d_1".into()));
1278 o.insert(PATH_KEY, Value::Str("a.md".into()));
1279 o.insert("id", Value::Number(7.0));
1280 let hit = to_hit(shape_row(Value::Object(o)).unwrap());
1281 let ho = hit.as_object().unwrap();
1282 assert_eq!(ho.keys().collect::<Vec<_>>(), ["id", "path"]);
1283 assert_eq!(ho.get("id"), Some(&Value::Number(7.0)));
1284 let mut o = oqx::Object::new();
1286 o.insert(ID_KEY, Value::Str("d_1".into()));
1287 o.insert(PATH_KEY, Value::Undefined);
1288 let row = Value::Object(o);
1289 assert_eq!(raw_identity(&row), (String::new(), "d_1".to_owned()));
1290 let shaped = shape_row(row).unwrap();
1291 assert_eq!((shaped.path, shaped.id), (String::new(), "d_1".to_owned()));
1292 }
1293
1294 #[test]
1295 fn bare_hits_are_the_projection_in_its_own_order() {
1296 let mut o = oqx::Object::new();
1299 o.insert(ID_KEY, Value::Str("d_1".into()));
1300 o.insert(PATH_KEY, Value::Str("/a.md".into()));
1301 o.insert("title", Value::Str("T".into()));
1302 o.insert("id", Value::Str("d_1".into()));
1303 let shaped = shape_row(Value::Object(o)).unwrap();
1304 let bare = to_bare_hit(shaped);
1305 let bo = bare.as_object().unwrap();
1306 assert_eq!(bo.keys().collect::<Vec<_>>(), ["title", "id"]);
1307 let mut o = oqx::Object::new();
1310 o.insert(ID_KEY, Value::Str("d_1".into()));
1311 o.insert(PATH_KEY, Value::Str("/a.md".into()));
1312 o.insert("path", Value::Str("canon".into()));
1313 let shaped = shape_row(Value::Object(o)).unwrap();
1314 assert_eq!(shaped.path, "/a.md");
1315 assert_eq!(
1316 render_hit(shaped, true),
1317 serde_json::json!({ "path": "canon" })
1318 );
1319 let mut o = oqx::Object::new();
1321 o.insert(ID_KEY, Value::Str("d_1".into()));
1322 o.insert(PATH_KEY, Value::Str("/a.md".into()));
1323 o.insert("path", Value::Str("/a.md".into()));
1324 assert_eq!(
1325 render_hit(shape_row(Value::Object(o)).unwrap(), false),
1326 serde_json::json!({ "id": "d_1", "path": "/a.md" })
1327 );
1328 }
1329
1330 #[test]
1331 fn bare_needs_a_select_and_is_not_the_values_form() {
1332 let parse = |s: &str| oqx::parse_string(s).unwrap();
1333 for q in ["from docs", "from docs where layer == \"canon\""] {
1334 let e = check_bare(&parse(q)).unwrap_err();
1335 assert_eq!(e.code, "filter_invalid", "{q}");
1336 assert_eq!(e.message, BARE_NEEDS_SELECT_MESSAGE, "{q}");
1337 assert_eq!(e.data.as_ref().unwrap()["hint"], "OQX");
1338 }
1339 let e = check_bare(&parse("select path values from docs")).unwrap_err();
1340 assert_eq!(e.code, "filter_invalid");
1341 assert_eq!(e.message, BARE_WITH_VALUES_MESSAGE);
1342 for q in [
1343 "select path from docs",
1344 "select id, title from docs",
1345 "docs first { select t: title }",
1346 ] {
1347 check_bare(&parse(q)).unwrap_or_else(|e| panic!("{q}: {e}"));
1348 }
1349 }
1350
1351 #[test]
1352 fn a_projection_onto_the_envelope_must_be_aliased() {
1353 let items = |src: &str| oqx::parse_string(src).unwrap().select;
1354 assert!(envelope_collision(&items("select id, path from docs")).is_none());
1356 assert!(
1357 envelope_collision(&items("select id: id, path: path, p: path from docs")).is_none()
1358 );
1359 let e = envelope_collision(&items("select data.path from docs")).unwrap();
1361 assert_eq!(e.code, "filter_invalid");
1362 assert_eq!(
1363 e.message,
1364 "projection `path` collides with the hit envelope — alias it (`p: data.path`)"
1365 );
1366 let e = envelope_collision(&items("select id: layer from docs")).unwrap();
1367 assert_eq!(
1368 e.message,
1369 "projection `id` collides with the hit envelope — alias it (`i: data.id`)"
1370 );
1371 assert!(envelope_collision(&items("select path: doc.path from blocks")).is_some());
1372 assert!(envelope_collision(&items("select path: nodes collect { } from docs")).is_some());
1373 }
1374
1375 #[test]
1376 fn a_path_read_is_the_attribute_or_a_row_handle_never_a_source_handle() {
1377 let pred = |src: &str| match oqx::parse_string(src).unwrap().r#where.unwrap() {
1378 oqx::ast::Where::Scalar { expr, .. } => expr,
1379 other => panic!("{other:?}"),
1380 };
1381 let left = |e: Expr| match e {
1382 Expr::Binary { left, .. } => *left,
1383 other => panic!("{other:?}"),
1384 };
1385 assert!(is_path_read(&left(pred("from docs where path == \"x\""))));
1386 assert!(is_path_read(&left(pred(
1387 "from blocks where doc.path == \"x\""
1388 ))));
1389 assert!(is_path_read(&left(pred("from docs where ^path == \"x\""))));
1390 assert!(is_path_read(&left(pred(
1391 "from links where dst_path == \"x\""
1392 ))));
1393 assert!(!is_path_read(&left(pred(
1394 "from docs where data.path == \"x\""
1395 ))));
1396 assert!(!is_path_read(&left(pred(
1397 "from docs where frontmatter.path == \"x\""
1398 ))));
1399 }
1400
1401 #[test]
1402 fn a_hit_that_is_not_a_store_row_fails_the_query() {
1403 let mut o = oqx::Object::new();
1406 o.insert(ID_KEY, Value::Undefined);
1407 o.insert(PATH_KEY, Value::Undefined);
1408 o.insert(SELF_KEY, Value::Str("/timeline/kickoff.md".into()));
1409 let e = shape_row(Value::Object(o)).unwrap_err();
1410 assert_eq!(e.code, "filter_invalid");
1411 assert_eq!(
1412 e.message,
1413 "a hit must be a document, block, node or link row — the query reached a string (\"/timeline/kickoff.md\"); to follow document references held in a property use refs(<field>)"
1414 );
1415 let mut row = oqx::Object::new();
1417 row.insert("doc_id", Value::Str("d_1".into()));
1418 row.insert("path", Value::Str("a.md".into()));
1419 let row = crate::context::tag_row(row, crate::context::Target::Docs);
1420 let mut o = oqx::Object::new();
1421 o.insert(ID_KEY, Value::Str("d_1".into()));
1422 o.insert(PATH_KEY, Value::Str("a.md".into()));
1423 o.insert(SELF_KEY, row);
1424 let hit = to_hit(shape_row(Value::Object(o)).unwrap());
1425 assert_eq!(
1426 hit.as_object().unwrap().keys().collect::<Vec<_>>(),
1427 ["id", "path"]
1428 );
1429 let parse = |s: &str| oqx::parse_string(s).unwrap();
1431 assert!(!may_reach_non_rows(&parse(
1432 "from docs where layer == \"canon\""
1433 )));
1434 assert!(may_reach_non_rows(&parse("from docs follow before")));
1435 assert!(may_reach_non_rows(&parse("refs(\"/index.md\") first { }")));
1436 }
1437
1438 #[test]
1439 fn distinct_dedups_by_user_projection_first_wins() {
1440 let mk = |id: &str, t: &str| {
1441 let mut proj = oqx::Object::new();
1442 proj.insert("type", Value::Str(t.into()));
1443 Shaped {
1444 id: id.to_owned(),
1445 path: "p".to_owned(),
1446 proj,
1447 }
1448 };
1449 let out = dedup_by_projection(vec![mk("1", "a"), mk("2", "b"), mk("3", "a")], false);
1450 assert_eq!(out.len(), 2);
1451 assert_eq!(out[0].id, "1");
1452 assert_eq!(out[1].id, "2");
1453 let with_id = |id: &str| {
1457 let mut proj = oqx::Object::new();
1458 proj.insert("id", Value::Str(id.into()));
1459 Shaped {
1460 id: id.to_owned(),
1461 path: "p".to_owned(),
1462 proj,
1463 }
1464 };
1465 assert_eq!(
1466 dedup_by_projection(vec![with_id("1"), with_id("2")], false).len(),
1467 1
1468 );
1469 assert_eq!(
1470 dedup_by_projection(vec![with_id("1"), with_id("2")], true).len(),
1471 2
1472 );
1473 }
1474
1475 #[test]
1476 fn the_page_runs_on_identity() {
1477 let rows: Vec<(String, String)> = [("/a.md", "d_1"), ("/a.md", "d_2"), ("/b.md", "d_3")]
1478 .iter()
1479 .map(|(p, i)| ((*p).to_owned(), (*i).to_owned()))
1480 .collect();
1481 let ident = |r: &(String, String)| (r.0.clone(), r.1.clone());
1482 let (page, truncated, next) =
1484 page_rows(rows.clone(), ident, 0, None, None, 2, false).unwrap();
1485 assert_eq!(page.len(), 2);
1486 assert!(truncated);
1487 let next = next.expect("a cursor");
1488 assert_eq!(next, encode_cursor(&["/a.md", "d_2"]));
1489 let (page, truncated, next) =
1491 page_rows(rows.clone(), ident, 0, None, Some(&next), 2, false).unwrap();
1492 assert_eq!(page, vec![("/b.md".to_owned(), "d_3".to_owned())]);
1493 assert!(!truncated && next.is_none());
1494 let (page, truncated, next) =
1496 page_rows(rows.clone(), ident, 1, Some(1), None, 50, true).unwrap();
1497 assert_eq!(page, vec![("/a.md".to_owned(), "d_2".to_owned())]);
1498 assert!(!truncated && next.is_none());
1499 let (page, truncated, next) = page_rows(rows, ident, 0, None, None, 1, true).unwrap();
1500 assert_eq!(page.len(), 1);
1501 assert!(truncated && next.is_none());
1502 }
1503
1504 #[test]
1505 fn top_level_bounds_must_be_literals() {
1506 assert_eq!(const_bound(None, "limit").unwrap(), None);
1507 assert_eq!(
1508 const_bound(
1509 Some(&Expr::Lit {
1510 value: Value::Number(3.0),
1511 span: oqx::Span::EMPTY
1512 }),
1513 "limit"
1514 )
1515 .unwrap(),
1516 Some(3)
1517 );
1518 let e = const_bound(
1519 Some(&Expr::Lit {
1520 value: Value::Number(-1.0),
1521 span: oqx::Span::EMPTY,
1522 }),
1523 "offset",
1524 )
1525 .unwrap_err();
1526 assert_eq!(e.code, "filter_invalid");
1527 assert!(e.message.contains("top-level offset"));
1528 }
1529}