1use oqx::Value;
47use oqx::ast::{BinaryOp, Expr, LogicalOp};
48use rusqlite::types::Value as SqlValue;
49
50use crate::context::{Target, to_sql};
51
52#[derive(Clone, Copy, Debug)]
56pub struct TranslateCtx<'a> {
57 pub target: Target,
58 pub self_alias: &'a str,
59 pub doc_alias: &'a str,
60 pub params: &'a [Value],
61}
62
63#[derive(Clone, Debug, PartialEq)]
65pub struct Frag {
66 pub sql: String,
67 pub params: Vec<SqlValue>,
68}
69
70impl Frag {
71 fn bare(sql: impl Into<String>) -> Self {
72 Self {
73 sql: sql.into(),
74 params: Vec::new(),
75 }
76 }
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum Ty {
86 Text,
89 Int,
91 Num,
93 Bool,
95 Json,
98 Prop,
102 Null,
104}
105
106#[must_use]
147pub fn comparable(op: BinaryOp, a: Ty, b: Ty) -> bool {
148 use Ty::{Int, Null, Num, Prop, Text};
149 let numeric = |t: Ty| matches!(t, Int | Num);
150 let both_numeric = numeric(a) && numeric(b);
151 if matches!(op, BinaryOp::Eq | BinaryOp::Ne) {
152 a == Text
153 || b == Text
154 || (a == Null && b != Prop)
155 || (b == Null && a != Prop)
156 || both_numeric
157 } else {
158 (a == Text && b == Text) || both_numeric
159 }
160}
161
162fn const_ty(v: &Value) -> Option<Ty> {
165 Some(match v {
166 Value::Str(_) => Ty::Text,
167 Value::Number(_) => Ty::Num,
168 Value::Bool(_) => Ty::Bool,
169 Value::Null | Value::Undefined => Ty::Null,
170 Value::Array(_) | Value::Object(_) | Value::Range(_) => return None,
171 })
172}
173
174#[must_use]
184pub fn non_property_handles(t: Target) -> &'static [&'static str] {
185 match t {
186 Target::Docs => &[
187 "doc",
188 "blocks",
189 "nodes",
190 "out",
191 "in",
192 "out_edges",
193 "in_edges",
194 "frontmatter",
195 "inline",
196 ],
197 Target::Blocks => &[
198 "block",
199 "doc",
200 "children",
201 "nodes",
202 "out_edges",
203 "section",
204 "attrs",
205 ],
206 Target::Nodes => &[
207 "section",
208 "doc",
209 "block",
210 "blocks",
211 "subsections",
212 "children",
213 "attrs",
214 ],
215 Target::Edges => &["doc"],
216 }
217}
218
219fn intrinsic_sql(name: &str, ctx: &TranslateCtx<'_>) -> Option<(String, Ty)> {
228 let (s, d) = (ctx.self_alias, ctx.doc_alias);
229 let sql = match (ctx.target, name) {
230 (Target::Docs, "$id") => format!("{s}.doc_id"),
231 (Target::Docs, "$path") => format!("{d}.path"),
232 (Target::Docs, "$content_hash") => format!("lower(hex({s}.file_hash))"),
233 (Target::Docs, "$updated_at") => format!(
234 "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = {s}.current_rev)"
235 ),
236 (Target::Blocks, "$id") => format!("{s}.block_id"),
237 (Target::Blocks, "$doc") => format!("{s}.doc_id"),
238 (Target::Blocks, "$path") => format!("{d}.path"),
239 (Target::Blocks, "$ordinal") => return Some((format!("{s}.ordinal"), Ty::Int)),
240 (Target::Blocks, "$depth") => return Some((format!("{s}.depth"), Ty::Int)),
241 (Target::Blocks, "$body") => format!("{s}.text"),
242 (Target::Blocks, "$content_hash") => format!("lower(hex({s}.raw_hash))"),
243 (Target::Nodes, "$id" | "$node_id") => format!("{s}.node_id"),
244 (Target::Nodes, "$doc_id") => format!("{s}.doc_id"),
245 (Target::Nodes, "$block_id") => format!("{s}.block_id"),
246 (Target::Nodes, "$path") => format!("{d}.path"),
247 (Target::Edges, "$id") => format!("{s}.edge_id"),
248 (Target::Edges, "$src") => format!("{s}.src_doc"),
249 (Target::Edges, "$dst") => format!("{s}.dst_node"),
250 (Target::Edges, "$src_block") => format!("{s}.src_block"),
251 (Target::Edges, "$via") => format!("{s}.via_node"),
252 (Target::Edges, "$from_commit") => format!("{s}.from_commit"),
253 (Target::Edges, "$path") => format!("{d}.path"),
254 (Target::Edges, "$dst_path") => {
255 format!("(SELECT dd.path FROM docs dd WHERE dd.doc_id = {s}.dst_node)")
256 }
257 (Target::Edges, "$dst_uri") => {
258 format!("(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = {s}.dst_node)")
259 }
260 _ => return None,
261 };
262 Some((sql, Ty::Text))
263}
264
265pub(crate) const RESERVED_DOC_BASENAMES: [&str; 5] =
269 ["id", "path", "updated_at", "content_hash", "body"];
270
271fn is_seg(s: &str) -> bool {
273 let mut chars = s.chars();
274 chars
275 .next()
276 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
277 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
278}
279
280fn prop_scalar(doc_alias: &str, key: &str) -> Option<String> {
285 if !is_seg(key) {
286 return None;
287 }
288 Some(format!(
289 "(SELECT COALESCE(p.val_text, p.val_num, p.val_bool) FROM properties p \
290 WHERE p.doc_id = {doc_alias}.doc_id AND p.key = '{key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL \
291 AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = {doc_alias}.doc_id AND p2.key = '{key}' AND p2.deleted_commit IS NULL) = 1 \
292 LIMIT 1)"
293 ))
294}
295
296fn json_extract(col: &str, segs: &[&str]) -> Option<String> {
299 if segs.iter().any(|s| !is_seg(s)) {
300 return None;
301 }
302 Some(format!("json_extract({col}, '$.{}')", segs.join(".")))
303}
304
305fn member_segments(e: &Expr) -> Option<Vec<&str>> {
308 match e {
309 Expr::Ident { name } => Some(vec![name.as_str()]),
310 Expr::Member { recv, name } => {
311 let mut base = member_segments(recv)?;
312 base.push(name.as_str());
313 Some(base)
314 }
315 _ => None,
316 }
317}
318
319pub fn translate_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
325 typed_value(e, ctx).map(|(frag, _)| frag)
326}
327
328pub fn typed_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<(Frag, Ty)> {
330 let (s, d, target) = (ctx.self_alias, ctx.doc_alias, ctx.target);
331 let text = |sql: String| Some((Frag::bare(sql), Ty::Text));
332 let constant = |v: &Value| {
333 Some((
334 Frag {
335 sql: "?".to_owned(),
336 params: vec![to_sql(v)],
337 },
338 const_ty(v)?,
339 ))
340 };
341 match e {
342 Expr::Lit(v) => constant(v),
343 Expr::Binding { index } => constant(ctx.params.get(*index).unwrap_or(&Value::Undefined)),
344 Expr::Ident { name } => {
345 if name.starts_with('$') {
346 return intrinsic_sql(name, ctx).map(|(sql, ty)| (Frag::bare(sql), ty));
347 }
348 let name = name.as_str();
349 if non_property_handles(target).contains(&name) {
352 return None;
353 }
354 match target {
355 Target::Docs => {
356 if name == "format" {
358 return text(format!("{s}.format"));
359 }
360 if RESERVED_DOC_BASENAMES.contains(&name) {
362 return None;
363 }
364 prop_scalar(d, name).map(|sql| (Frag::bare(sql), Ty::Prop))
365 }
366 Target::Blocks => {
367 if name == "type" || name == "text" {
368 return text(format!("{s}.{name}"));
369 }
370 json_extract(&format!("{s}.attrs"), &[name])
373 .map(|sql| (Frag::bare(sql), Ty::Json))
374 }
375 Target::Nodes => {
376 if matches!(name, "kind" | "name" | "value") {
377 return text(format!("{s}.{name}"));
378 }
379 json_extract(&format!("{s}.attrs"), &[name])
380 .map(|sql| (Frag::bare(sql), Ty::Json))
381 }
382 Target::Edges => {
383 if matches!(
384 name,
385 "predicate" | "provenance" | "dst_kind" | "anchor" | "src_field"
386 ) {
387 return text(format!("{s}.{name}"));
388 }
389 None
390 }
391 }
392 }
393 Expr::Member { .. } => {
394 let segs = member_segments(e)?;
395 let (head, rest) = segs.split_first()?;
396 if rest.is_empty() {
397 return None;
398 }
399 if *head == "attrs" && matches!(target, Target::Blocks | Target::Nodes) {
401 return json_extract(&format!("{s}.attrs"), rest)
402 .map(|sql| (Frag::bare(sql), Ty::Json));
403 }
404 if *head == "doc" {
407 if rest.len() != 1 {
408 return None;
409 }
410 let k = rest[0];
411 if k == "$path" {
412 return text(format!("{d}.path"));
413 }
414 if k == "format" {
415 return text(format!("{d}.format"));
416 }
417 if k.starts_with('$')
420 || RESERVED_DOC_BASENAMES.contains(&k)
421 || non_property_handles(Target::Docs).contains(&k)
422 {
423 return None;
424 }
425 return prop_scalar(d, k).map(|sql| (Frag::bare(sql), Ty::Prop));
426 }
427 if *head == "block"
429 && target == Target::Nodes
430 && rest.len() == 1
431 && matches!(rest[0], "type" | "text")
432 {
433 return text(format!(
434 "(SELECT bb.{} FROM blocks bb WHERE bb.block_id = {s}.block_id)",
435 rest[0]
436 ));
437 }
438 None
439 }
440 Expr::Call {
442 recv: Some(recv),
443 name,
444 args,
445 } if args.is_empty() && (name == "lower" || name == "upper") => {
446 let recv = translate_value(recv, ctx)?;
447 Some((
448 Frag {
449 sql: format!("{name}({})", recv.sql),
450 params: recv.params,
451 },
452 Ty::Text,
453 ))
454 }
455 _ => None,
456 }
457}
458
459fn is_op(op: BinaryOp) -> Option<&'static str> {
463 Some(match op {
464 BinaryOp::Eq => "IS",
465 BinaryOp::Ne => "IS NOT",
466 BinaryOp::Lt => "<",
467 BinaryOp::Le => "<=",
468 BinaryOp::Gt => ">",
469 BinaryOp::Ge => ">=",
470 BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => {
471 return None;
472 }
473 })
474}
475
476pub fn translate_predicate(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
481 match e {
482 Expr::Logical {
485 op: LogicalOp::And,
486 left,
487 right,
488 } => join2(
489 translate_predicate(left, ctx),
490 translate_predicate(right, ctx),
491 "AND",
492 ),
493 Expr::Binary { op, left, right } => {
494 let sql_op = is_op(*op)?;
496 let (l, lt) = typed_value(left, ctx)?;
497 let (r, rt) = typed_value(right, ctx)?;
498 if !comparable(*op, lt, rt) {
501 return None;
502 }
503 let op = sql_op;
504 let mut params = l.params;
509 params.extend(r.params);
510 Some(Frag {
511 sql: format!("({} {op} {})", l.sql, r.sql),
512 params,
513 })
514 }
515 Expr::Call {
516 recv: Some(recv),
517 name,
518 args,
519 } if args.len() == 1 => {
520 let recv = translate_value(recv, ctx)?;
523 let arg = translate_value(&args[0], ctx)?;
524 let mut params = recv.params;
525 let sql = match name.as_str() {
526 "startsWith" => {
527 params.extend(arg.params.iter().cloned());
529 params.extend(arg.params);
530 format!("(substr({}, 1, length({a})) = {a})", recv.sql, a = arg.sql)
531 }
532 "endsWith" => {
533 params.extend(arg.params.iter().cloned());
537 params.extend(arg.params);
538 format!("(substr({}, -length({a})) = {a})", recv.sql, a = arg.sql)
539 }
540 "contains" => {
541 params.extend(arg.params);
542 format!("(instr({}, {}) > 0)", recv.sql, arg.sql)
543 }
544 _ => return None,
545 };
546 Some(Frag { sql, params })
547 }
548 _ => None,
549 }
550}
551
552fn join2(a: Option<Frag>, b: Option<Frag>, connective: &str) -> Option<Frag> {
555 let (a, b) = (a?, b?);
556 let mut params = a.params;
557 params.extend(b.params);
558 Some(Frag {
559 sql: format!("({} {connective} {})", a.sql, b.sql),
560 params,
561 })
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use oqx::ast::Where;
568
569 const DOCS: TranslateCtx<'static> = TranslateCtx {
570 target: Target::Docs,
571 self_alias: "d",
572 doc_alias: "d",
573 params: &[],
574 };
575
576 fn text(s: &str) -> SqlValue {
577 SqlValue::Text(s.to_owned())
578 }
579
580 fn frag(sql: &str, params: &[SqlValue]) -> Option<Frag> {
581 Some(Frag {
582 sql: sql.to_owned(),
583 params: params.to_vec(),
584 })
585 }
586
587 fn pred(src: &str) -> Expr {
589 let q = oqx::parse_string(&format!("from docs where {src}")).expect("parses");
590 match q.r#where {
591 Some(Where::Scalar { expr }) => expr,
592 other => panic!("expected a single scalar predicate, got {other:?}"),
593 }
594 }
595
596 fn ident(name: &str) -> Box<Expr> {
597 Box::new(Expr::Ident {
598 name: name.to_owned(),
599 })
600 }
601
602 fn lit(s: &str) -> Box<Expr> {
603 Box::new(Expr::Lit(Value::from(s)))
604 }
605
606 fn eq(l: Box<Expr>, r: Box<Expr>) -> Box<Expr> {
607 Box::new(Expr::Binary {
608 op: BinaryOp::Eq,
609 left: l,
610 right: r,
611 })
612 }
613
614 #[test]
617 fn equality_is_null_safe_is() {
618 assert_eq!(
619 translate_predicate(&pred("$path == \"index.md\""), &DOCS),
620 frag("(d.path IS ?)", &[text("index.md")])
621 );
622 }
623
624 #[test]
625 fn inequality_is_null_safe_is_not() {
626 assert_eq!(
627 translate_predicate(&pred("$path != \"x\""), &DOCS),
628 frag("(d.path IS NOT ?)", &[text("x")])
629 );
630 }
631
632 #[test]
633 fn intrinsic_column_mapping() {
634 assert_eq!(
635 translate_predicate(&pred("$id == \"d_1\""), &DOCS),
636 frag("(d.doc_id IS ?)", &[text("d_1")])
637 );
638 }
639
640 #[test]
643 fn relational_ops_are_plain_comparisons() {
644 assert_eq!(
645 translate_predicate(&pred("$path < \"m\""), &DOCS),
646 frag("(d.path < ?)", &[text("m")])
647 );
648 assert_eq!(
649 translate_predicate(&pred("$path >= \"m\""), &DOCS),
650 frag("(d.path >= ?)", &[text("m")])
651 );
652 assert_eq!(translate_predicate(&pred("$path + 1"), &DOCS), None);
654 }
655
656 #[test]
659 fn starts_with_is_substr_equality() {
660 assert_eq!(
661 translate_predicate(&pred("$path.startsWith(\"lab/\")"), &DOCS),
662 frag(
663 "(substr(d.path, 1, length(?)) = ?)",
664 &[text("lab/"), text("lab/")]
665 )
666 );
667 }
668
669 #[test]
670 fn lower_then_starts_with_pushes_with_explicit_lower() {
671 assert_eq!(
672 translate_predicate(&pred("$path.lower().startsWith(\"lab/\")"), &DOCS),
673 frag(
674 "(substr(lower(d.path), 1, length(?)) = ?)",
675 &[text("lab/"), text("lab/")]
676 )
677 );
678 }
679
680 #[test]
681 fn contains_is_instr() {
682 assert_eq!(
683 translate_predicate(&pred("$path.contains(\"notes\")"), &DOCS),
684 frag("(instr(d.path, ?) > 0)", &[text("notes")])
685 );
686 }
687
688 #[test]
689 fn ends_with_is_negative_substr_equality() {
690 assert_eq!(
691 translate_predicate(&pred("$path.endsWith(\".md\")"), &DOCS),
692 frag(
693 "(substr(d.path, -length(?)) = ?)",
694 &[text(".md"), text(".md")]
695 )
696 );
697 }
698
699 #[test]
700 fn upper_wraps_the_receiver_in_value_position() {
701 assert_eq!(
702 translate_value(&pred("$path.upper()"), &DOCS),
703 frag("upper(d.path)", &[])
704 );
705 }
706
707 #[test]
710 fn bare_doc_property_is_the_scalar_in_scope_subquery() {
711 let f = translate_predicate(&pred("layer == \"canon\""), &DOCS).expect("pushable");
712 assert!(f.sql.contains("FROM properties p"), "{}", f.sql);
713 assert!(f.sql.contains("p.key = 'layer'"), "{}", f.sql);
714 assert!(f.sql.contains("p.card = 'scalar'"), "{}", f.sql);
715 assert!(
716 f.sql.starts_with('(') && f.sql.contains(" IS ?)"),
717 "{}",
718 f.sql
719 );
720 assert_eq!(f.params, vec![text("canon")]);
721 }
722
723 #[test]
724 fn updated_at_pushes_as_its_revisions_subquery() {
725 let f =
726 translate_predicate(&pred("$updated_at >= \"2026-01-01\""), &DOCS).expect("pushable");
727 assert!(
728 f.sql.contains("FROM revisions r JOIN commits c"),
729 "{}",
730 f.sql
731 );
732 }
733
734 #[test]
735 fn format_is_a_column_not_a_property() {
736 assert_eq!(
737 translate_predicate(&pred("format == \"markdown\""), &DOCS),
738 frag("(d.format IS ?)", &[text("markdown")])
739 );
740 }
741
742 #[test]
743 fn booleans_bind_as_one_and_zero() {
744 let blocks = TranslateCtx {
745 target: Target::Blocks,
746 self_alias: "b",
747 ..DOCS
748 };
749 assert_eq!(
752 translate_predicate(&pred("type == true"), &blocks).map(|f| f.params),
753 Some(vec![SqlValue::Integer(1)])
754 );
755 assert_eq!(
756 translate_predicate(&pred("type == false"), &blocks).map(|f| f.params),
757 Some(vec![SqlValue::Integer(0)])
758 );
759 assert_eq!(
760 translate_predicate(&pred("$ordinal < 1000"), &blocks).map(|f| f.params),
761 Some(vec![SqlValue::Real(1000.0)])
762 );
763 assert_eq!(
764 translate_predicate(&pred("$path == null"), &DOCS).map(|f| f.params),
765 Some(vec![SqlValue::Null])
766 );
767 }
768
769 const REPRESENTATIVES: [(Ty, &str); 7] = [
774 (Ty::Text, "$path"),
775 (Ty::Int, "$ordinal"),
776 (Ty::Num, "1"),
777 (Ty::Bool, "true"),
778 (Ty::Null, "null"),
779 (Ty::Json, "checked"),
780 (Ty::Prop, "doc.layer"),
781 ];
782
783 #[test]
784 fn representatives_carry_their_type() {
785 let blocks = TranslateCtx {
786 target: Target::Blocks,
787 self_alias: "b",
788 ..DOCS
789 };
790 for (ty, src) in REPRESENTATIVES {
791 let (_, got) = typed_value(&pred(src), &blocks).expect(src);
792 assert_eq!(got, ty, "{src}");
793 }
794 assert_eq!(
795 typed_value(&pred("attrs.a.b"), &blocks).map(|(_, t)| t),
796 Some(Ty::Json)
797 );
798 assert_eq!(
799 typed_value(&pred("$depth"), &blocks).map(|(_, t)| t),
800 Some(Ty::Int)
801 );
802 assert_eq!(
803 typed_value(&pred("type.lower()"), &blocks).map(|(_, t)| t),
804 Some(Ty::Text)
805 );
806 assert_eq!(
807 typed_value(&pred("checked.upper()"), &blocks).map(|(_, t)| t),
808 Some(Ty::Text)
809 );
810 assert_eq!(
811 typed_value(&pred("layer"), &DOCS).map(|(_, t)| t),
812 Some(Ty::Prop)
813 );
814 assert_eq!(
815 typed_value(&pred("format"), &DOCS).map(|(_, t)| t),
816 Some(Ty::Text)
817 );
818 }
819
820 #[test]
821 fn the_comparison_matrix_decides_every_cell() {
822 const P: bool = true;
824 const D: bool = false;
825 #[rustfmt::skip]
827 const EQUALITY: [[bool; 7]; 7] = [
828 [P, P, P, P, P, P, P],
829 [P, P, P, D, P, D, D],
830 [P, P, P, D, P, D, D],
831 [P, D, D, D, P, D, D],
832 [P, P, P, P, P, P, D],
833 [P, D, D, D, P, D, D],
834 [P, D, D, D, D, D, D],
835 ];
836 #[rustfmt::skip]
838 const RELATIONAL: [[bool; 7]; 7] = [
839 [P, D, D, D, D, D, D],
840 [D, P, P, D, D, D, D],
841 [D, P, P, D, D, D, D],
842 [D, D, D, D, D, D, D],
843 [D, D, D, D, D, D, D],
844 [D, D, D, D, D, D, D],
845 [D, D, D, D, D, D, D],
846 ];
847 let blocks = TranslateCtx {
848 target: Target::Blocks,
849 self_alias: "b",
850 ..DOCS
851 };
852 let ops = [
853 (BinaryOp::Eq, "==", &EQUALITY),
854 (BinaryOp::Ne, "!=", &EQUALITY),
855 (BinaryOp::Lt, "<", &RELATIONAL),
856 (BinaryOp::Le, "<=", &RELATIONAL),
857 (BinaryOp::Gt, ">", &RELATIONAL),
858 (BinaryOp::Ge, ">=", &RELATIONAL),
859 ];
860 for (i, (a, l)) in REPRESENTATIVES.iter().enumerate() {
861 for (j, (b, r)) in REPRESENTATIVES.iter().enumerate() {
862 for (op, spelled, matrix) in ops {
863 let want = matrix[i][j];
864 assert_eq!(matrix[j][i], want, "the matrix is symmetric ({a:?}, {b:?})");
865 assert_eq!(
866 comparable(op, *a, *b),
867 want,
868 "comparable({spelled}, {a:?}, {b:?})"
869 );
870 let src = format!("{l} {spelled} {r}");
871 assert_eq!(
872 translate_predicate(&pred(&src), &blocks).is_some(),
873 want,
874 "{src}"
875 );
876 }
877 }
878 }
879 }
880
881 #[test]
882 fn the_spec_shapes_of_decline_a() {
883 let blocks = TranslateCtx {
884 target: Target::Blocks,
885 self_alias: "b",
886 ..DOCS
887 };
888 assert_eq!(translate_predicate(&pred("checked == 1"), &blocks), None);
890 assert_eq!(translate_predicate(&pred("checked == true"), &blocks), None);
891 assert_eq!(
892 translate_predicate(&pred("attrs.checked == true"), &blocks),
893 None
894 );
895 assert_eq!(translate_predicate(&pred("verified == 1"), &DOCS), None);
897 assert_eq!(translate_predicate(&pred("verified == true"), &DOCS), None);
898 assert_eq!(translate_predicate(&pred("era < 1000"), &DOCS), None);
899 assert_eq!(translate_predicate(&pred("doc.era < 1000"), &blocks), None);
900 assert_eq!(
902 translate_predicate(&pred("$ordinal == true"), &blocks),
903 None
904 );
905 assert_eq!(
906 translate_predicate(&pred("$ordinal == 1"), &blocks),
907 frag("(b.ordinal IS ?)", &[SqlValue::Real(1.0)])
908 );
909 assert_eq!(translate_predicate(&pred("tags != null"), &DOCS), None);
911 assert_eq!(translate_predicate(&pred("tags == null"), &DOCS), None);
912 assert_eq!(
913 translate_predicate(&pred("doc.tags == null"), &blocks),
914 None
915 );
916 assert_eq!(
917 translate_predicate(&pred("checked == null"), &blocks),
918 frag(
919 "(json_extract(b.attrs, '$.checked') IS ?)",
920 &[SqlValue::Null]
921 )
922 );
923 assert_eq!(
924 translate_predicate(&pred("$ordinal != null"), &blocks),
925 frag("(b.ordinal IS NOT ?)", &[SqlValue::Null])
926 );
927 assert_eq!(
929 translate_predicate(&pred("checked == \"x\""), &blocks),
930 frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
931 );
932 assert!(translate_predicate(&pred("layer == \"canon\""), &DOCS).is_some());
933 assert!(translate_predicate(&pred("$ordinal == \"1\""), &blocks).is_some());
934 assert!(translate_predicate(&pred("$ordinal != \"1\""), &blocks).is_some());
935 assert_eq!(
938 translate_predicate(&pred("$ordinal < \"3\""), &blocks),
939 None
940 );
941 assert_eq!(
942 translate_predicate(&pred("\"3\" >= $ordinal"), &blocks),
943 None
944 );
945 assert_eq!(translate_predicate(&pred("$path > 5"), &DOCS), None);
946 assert_eq!(translate_predicate(&pred("type <= 1"), &blocks), None);
947 assert_eq!(
948 translate_predicate(&pred("$ordinal < 3"), &blocks),
949 frag("(b.ordinal < ?)", &[SqlValue::Real(3.0)])
950 );
951 assert_eq!(
952 translate_predicate(&pred("$path > \"m\""), &DOCS),
953 frag("(d.path > ?)", &[text("m")])
954 );
955 assert_eq!(
957 translate_predicate(&pred("$ordinal == checked"), &blocks),
958 None
959 );
960 assert_eq!(
961 translate_predicate(&pred("checked == level"), &blocks),
962 None
963 );
964 assert_eq!(
965 translate_predicate(&pred("doc.era == doc.year"), &blocks),
966 None
967 );
968 assert_eq!(translate_predicate(&pred("level < \"x\""), &blocks), None);
969 assert_eq!(translate_predicate(&pred("level < 3"), &blocks), None);
970 assert_eq!(translate_predicate(&pred("true == false"), &blocks), None);
971 assert_eq!(translate_predicate(&pred("$ordinal > null"), &blocks), None);
972 assert!(translate_predicate(&pred("$ordinal == $depth"), &blocks).is_some());
973 assert!(translate_predicate(&pred("$ordinal <= $depth"), &blocks).is_some());
974 assert!(translate_predicate(&pred("type == null"), &blocks).is_some());
975 }
976
977 #[test]
978 fn bindings_are_typed_by_their_value() {
979 let blocks = TranslateCtx {
980 target: Target::Blocks,
981 self_alias: "b",
982 ..DOCS
983 };
984 let params = [
985 Value::from("s"),
986 Value::from(1.0),
987 Value::Bool(true),
988 Value::Null,
989 Value::Array(vec![]),
990 ];
991 let ctx = TranslateCtx {
992 params: ¶ms,
993 ..blocks
994 };
995 let against = |i: usize, rhs: &str| {
996 let e = Expr::Binary {
997 op: BinaryOp::Eq,
998 left: Box::new(Expr::Binding { index: i }),
999 right: Box::new(pred(rhs)),
1000 };
1001 translate_predicate(&e, &ctx).is_some()
1002 };
1003 assert!(against(0, "checked"));
1005 assert!(!against(1, "checked"));
1006 assert!(!against(2, "checked"));
1007 assert!(!against(2, "$ordinal"));
1008 assert!(against(1, "$ordinal"));
1009 assert!(against(3, "checked"));
1011 assert!(!against(3, "doc.tags"));
1012 assert!(against(9, "checked"));
1014 assert!(!against(9, "doc.tags"));
1015 assert!(!against(4, "type"));
1017 }
1018
1019 #[test]
1022 fn relation_and_handle_names_are_not_property_reads() {
1023 let blocks = TranslateCtx {
1024 target: Target::Blocks,
1025 self_alias: "b",
1026 ..DOCS
1027 };
1028 let nodes = TranslateCtx {
1029 target: Target::Nodes,
1030 self_alias: "n",
1031 ..DOCS
1032 };
1033 let edges = TranslateCtx {
1034 target: Target::Edges,
1035 self_alias: "e",
1036 ..DOCS
1037 };
1038 for (ctx, target) in [
1039 (&DOCS, Target::Docs),
1040 (&blocks, Target::Blocks),
1041 (&nodes, Target::Nodes),
1042 (&edges, Target::Edges),
1043 ] {
1044 for name in non_property_handles(target) {
1045 let src = format!("{name} == null");
1046 assert_eq!(
1047 translate_predicate(&pred(&src), ctx),
1048 None,
1049 "{target:?}: {src}"
1050 );
1051 let src = format!("{name} == \"x\"");
1052 assert_eq!(
1053 translate_predicate(&pred(&src), ctx),
1054 None,
1055 "{target:?}: {src}"
1056 );
1057 }
1058 }
1059 assert_eq!(translate_predicate(&pred("nodes == null"), &DOCS), None);
1061 assert_eq!(
1062 translate_predicate(&pred("frontmatter == null"), &DOCS),
1063 None
1064 );
1065 assert_eq!(translate_predicate(&pred("nodes == null"), &blocks), None);
1066 assert_eq!(translate_predicate(&pred("attrs == null"), &blocks), None);
1067 assert_eq!(
1069 translate_predicate(&pred("doc.nodes == null"), &blocks),
1070 None
1071 );
1072 assert_eq!(
1073 translate_predicate(&pred("doc.frontmatter == null"), &blocks),
1074 None
1075 );
1076 assert_eq!(translate_predicate(&pred("doc.doc == null"), &DOCS), None);
1077 assert!(translate_predicate(&pred("doc.layer == \"canon\""), &blocks).is_some());
1078 assert!(translate_predicate(&pred("section == \"x\""), &DOCS).is_some());
1080 assert!(translate_predicate(&pred("frontmatter == \"x\""), &blocks).is_some());
1081 }
1082
1083 #[test]
1089 fn handle_sets_match_the_store_context() {
1090 use std::collections::HashMap;
1091
1092 use omgbase_reconcile::Config;
1093 use omgbase_store::{BatchItem, Store};
1094 use oqx::DataContext;
1095
1096 use crate::context::StoreContext;
1097
1098 let mut store = Store::open_in_memory().expect("store");
1099 let repo = store.create_repo("handles").expect("repo");
1100 let items = [
1101 BatchItem::observed(
1102 "a.md",
1103 "---\ntitle: A\nlayer: canon\nverified: true\n---\n# Heading\n\nSee [b](b.md) and [[b]].\n\n- [ ] task\n - nested\n\nkey:: value\n\n## Sub\n\ntext\n",
1104 ),
1105 BatchItem::observed("b.md", "# B\n\nBack to [a](a.md).\n"),
1106 ];
1107 store
1108 .observe_batch(
1109 &repo,
1110 &items,
1111 "2026-09-26T00:00:00.000Z",
1112 &Config::default(),
1113 )
1114 .expect("observe");
1115 let ctx = StoreContext::new(store.conn(), &repo, HashMap::new());
1116
1117 let mut universe: Vec<&str> = [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges]
1118 .into_iter()
1119 .flat_map(|t| non_property_handles(t).iter().copied())
1120 .collect();
1121 universe.extend([
1122 "layer",
1123 "title",
1124 "verified",
1125 "checked",
1126 "level",
1127 "format",
1128 "type",
1129 "text",
1130 "kind",
1131 "name",
1132 "value",
1133 "predicate",
1134 "key",
1135 "nope",
1136 ]);
1137 universe.sort_unstable();
1138 universe.dedup();
1139
1140 let is_shape = |v: &Value| matches!(v, Value::Array(_) | Value::Object(_));
1141 for target in [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges] {
1142 let Value::Array(rows) = ctx.root(target.as_str()) else {
1143 panic!("{target:?} root is not an array");
1144 };
1145 assert!(!rows.is_empty(), "{target:?} has rows");
1146 let set = non_property_handles(target);
1147 for name in &universe {
1148 let mut shaped = 0;
1149 for row in &rows {
1150 let v = ctx.get(row, name).expect("get");
1151 if set.contains(name) {
1152 assert!(
1153 v.is_absent() || is_shape(&v),
1154 "{target:?}.{name} read as a scalar: {v:?}"
1155 );
1156 } else {
1157 assert!(!is_shape(&v), "{target:?}.{name} is a handle: {v:?}");
1158 }
1159 shaped += usize::from(is_shape(&v));
1160 }
1161 if set.contains(name) {
1162 assert!(
1163 shaped > 0,
1164 "{target:?}.{name} never resolved to rows or a bag"
1165 );
1166 }
1167 }
1168 }
1169 }
1170
1171 #[test]
1174 fn reserved_bare_basename_is_not_pushed() {
1175 assert_eq!(translate_predicate(&pred("path == \"x\""), &DOCS), None);
1176 assert_eq!(translate_predicate(&pred("body == \"x\""), &DOCS), None);
1177 assert_eq!(translate_predicate(&pred("doc.path == \"x\""), &DOCS), None);
1178 }
1179
1180 #[test]
1181 fn docs_body_and_computed_intrinsics_are_not_columns() {
1182 assert_eq!(translate_predicate(&pred("$body == \"x\""), &DOCS), None);
1183 assert_eq!(translate_predicate(&pred("$title == \"x\""), &DOCS), None);
1184 assert_eq!(translate_predicate(&pred("$tags == \"x\""), &DOCS), None);
1185 }
1186
1187 #[test]
1188 fn matches_needs_a_regexp_udf() {
1189 assert_eq!(
1190 translate_predicate(&pred("$path.matches(\"^lab/\")"), &DOCS),
1191 None
1192 );
1193 }
1194
1195 #[test]
1196 fn negation_as_a_nested_expr_is_not_and_safe() {
1197 let e = Expr::Unary {
1198 op: oqx::ast::UnaryOp::Not,
1199 expr: ident("$path"),
1200 };
1201 assert_eq!(translate_predicate(&e, &DOCS), None);
1202 }
1203
1204 #[test]
1205 fn disjunction_as_a_nested_expr_is_declined() {
1206 let e = Expr::Logical {
1207 op: LogicalOp::Or,
1208 left: eq(ident("$path"), lit("a")),
1209 right: eq(ident("$path"), lit("b")),
1210 };
1211 assert_eq!(translate_predicate(&e, &DOCS), None);
1212 }
1213
1214 #[test]
1215 fn unmapped_node_intrinsic_is_not_pushed() {
1216 let nodes = TranslateCtx {
1217 target: Target::Nodes,
1218 self_alias: "n",
1219 ..DOCS
1220 };
1221 assert_eq!(
1222 translate_predicate(&pred("$locator == \"x\""), &nodes),
1223 None
1224 );
1225 let blocks = TranslateCtx {
1227 target: Target::Blocks,
1228 self_alias: "b",
1229 ..DOCS
1230 };
1231 assert_eq!(
1232 translate_predicate(&pred("$updated_at == \"x\""), &blocks),
1233 None
1234 );
1235 }
1236
1237 #[test]
1238 fn range_membership_in_and_bare_idents_are_declined() {
1239 assert_eq!(translate_predicate(&pred("era in 800..1680"), &DOCS), None);
1240 assert_eq!(
1241 translate_predicate(&pred("\"a\" in list(tags)"), &DOCS),
1242 None
1243 );
1244 assert_eq!(translate_predicate(&pred("verified"), &DOCS), None);
1245 assert_eq!(translate_predicate(&pred("doc.verified"), &DOCS), None);
1246 assert_eq!(translate_predicate(&pred("size(tags) > 1"), &DOCS), None);
1247 assert_eq!(translate_predicate(&pred("$self.text(\"x\")"), &DOCS), None);
1248 assert_eq!(translate_predicate(&pred("$value == \"x\""), &DOCS), None);
1249 assert_eq!(translate_predicate(&pred("^slug == \"x\""), &DOCS), None);
1250 assert_eq!(
1251 translate_predicate(&pred("frontmatter.era == 1"), &DOCS),
1252 None
1253 );
1254 }
1255
1256 #[test]
1259 fn and_composes_two_pushable_comparisons() {
1260 let e = Expr::Logical {
1261 op: LogicalOp::And,
1262 left: eq(ident("$path"), lit("a")),
1263 right: Box::new(Expr::Binary {
1264 op: BinaryOp::Ne,
1265 left: ident("$id"),
1266 right: lit("d_2"),
1267 }),
1268 };
1269 assert_eq!(
1270 translate_predicate(&e, &DOCS),
1271 frag(
1272 "((d.path IS ?) AND (d.doc_id IS NOT ?))",
1273 &[text("a"), text("d_2")]
1274 )
1275 );
1276 }
1277
1278 #[test]
1279 fn and_declines_wholesale_if_either_side_is_not_pushable() {
1280 let e = Expr::Logical {
1281 op: LogicalOp::And,
1282 left: eq(ident("$path"), lit("a")),
1283 right: eq(ident("$body"), lit("x")),
1285 };
1286 assert_eq!(translate_predicate(&e, &DOCS), None);
1287 }
1288
1289 #[test]
1290 fn resolves_a_binding_to_its_param_value() {
1291 let e = eq(ident("$path"), Box::new(Expr::Binding { index: 0 }));
1292 let params = [Value::from("from-binding.md")];
1293 let ctx = TranslateCtx {
1294 params: ¶ms,
1295 ..DOCS
1296 };
1297 assert_eq!(
1298 translate_predicate(&e, &ctx),
1299 frag("(d.path IS ?)", &[text("from-binding.md")])
1300 );
1301 assert_eq!(
1303 translate_predicate(&e, &DOCS),
1304 frag("(d.path IS ?)", &[SqlValue::Null])
1305 );
1306 }
1307
1308 #[test]
1311 fn blocks_and_nodes_flatten_bare_identifiers_into_attrs() {
1312 let blocks = TranslateCtx {
1313 target: Target::Blocks,
1314 self_alias: "b",
1315 ..DOCS
1316 };
1317 let both = Expr::Logical {
1320 op: LogicalOp::And,
1321 left: eq(ident("type"), lit("task")),
1322 right: eq(ident("marker"), lit("x")),
1323 };
1324 assert_eq!(
1325 translate_predicate(&both, &blocks),
1326 frag(
1327 "((b.type IS ?) AND (json_extract(b.attrs, '$.marker') IS ?))",
1328 &[text("task"), text("x")]
1329 )
1330 );
1331 assert_eq!(
1332 translate_predicate(&pred("attrs.marker == \"x\""), &blocks),
1333 frag("(json_extract(b.attrs, '$.marker') IS ?)", &[text("x")])
1334 );
1335 assert_eq!(
1337 translate_predicate(&pred("attrs.checked == true"), &blocks),
1338 None
1339 );
1340 let nodes = TranslateCtx {
1341 target: Target::Nodes,
1342 self_alias: "n",
1343 ..DOCS
1344 };
1345 assert_eq!(
1346 translate_predicate(&pred("kind == \"md:section\""), &nodes),
1347 frag("(n.kind IS ?)", &[text("md:section")])
1348 );
1349 assert_eq!(
1350 translate_predicate(&pred("level == \"1\""), &nodes),
1351 frag("(json_extract(n.attrs, '$.level') IS ?)", &[text("1")])
1352 );
1353 assert_eq!(translate_predicate(&pred("level == 1"), &nodes), None);
1354 assert_eq!(
1355 translate_predicate(&pred("attrs.a.b == \"c\""), &nodes),
1356 frag("(json_extract(n.attrs, '$.a.b') IS ?)", &[text("c")])
1357 );
1358 assert_eq!(
1360 translate_predicate(&pred("attrs.marker == \"x\""), &DOCS),
1361 None
1362 );
1363 }
1364
1365 #[test]
1366 fn doc_and_block_reach_through() {
1367 let blocks = TranslateCtx {
1368 target: Target::Blocks,
1369 self_alias: "b",
1370 ..DOCS
1371 };
1372 let f = translate_predicate(&pred("doc.type == \"lab-note\""), &blocks).expect("pushable");
1373 assert!(
1374 f.sql.contains("p.doc_id = d.doc_id AND p.key = 'type'"),
1375 "{}",
1376 f.sql
1377 );
1378 assert_eq!(
1379 translate_predicate(&pred("doc.$path == \"a.md\""), &blocks),
1380 frag("(d.path IS ?)", &[text("a.md")])
1381 );
1382 assert_eq!(
1383 translate_predicate(&pred("doc.format == \"markdown\""), &blocks),
1384 frag("(d.format IS ?)", &[text("markdown")])
1385 );
1386 assert_eq!(
1387 translate_predicate(&pred("doc.$id == \"d_1\""), &blocks),
1388 None
1389 );
1390 assert_eq!(translate_predicate(&pred("doc.a.b == 1"), &blocks), None);
1391 let nodes = TranslateCtx {
1392 target: Target::Nodes,
1393 self_alias: "n",
1394 ..DOCS
1395 };
1396 assert_eq!(
1397 translate_predicate(&pred("block.type == \"task\""), &nodes),
1398 frag(
1399 "((SELECT bb.type FROM blocks bb WHERE bb.block_id = n.block_id) IS ?)",
1400 &[text("task")]
1401 )
1402 );
1403 assert_eq!(
1404 translate_predicate(&pred("block.type == \"task\""), &blocks),
1405 None
1406 );
1407 assert_eq!(
1408 translate_predicate(&pred("section.level == 1"), &nodes),
1409 None
1410 );
1411 }
1412
1413 #[test]
1414 fn edges_push_their_five_fields_and_intrinsics() {
1415 let edges = TranslateCtx {
1416 target: Target::Edges,
1417 self_alias: "e",
1418 ..DOCS
1419 };
1420 assert_eq!(
1421 translate_predicate(&pred("predicate == \"references\""), &edges),
1422 frag("(e.predicate IS ?)", &[text("references")])
1423 );
1424 assert_eq!(
1425 translate_predicate(&pred("$dst_path == \"index.md\""), &edges),
1426 frag(
1427 "((SELECT dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
1428 &[text("index.md")]
1429 )
1430 );
1431 assert_eq!(translate_predicate(&pred("weight == 1"), &edges), None);
1432 }
1433
1434 #[test]
1435 fn unsafe_identifier_segments_are_never_inlined() {
1436 assert!(is_seg("layer") && is_seg("_x9"));
1437 assert!(!is_seg("") && !is_seg("9a") && !is_seg("a-b") && !is_seg("a'b"));
1438 assert_eq!(json_extract("n.attrs", &["ok", "no-pe"]), None);
1439 assert_eq!(prop_scalar("d", "x'y"), None);
1440 }
1441}