1use oqx::Value;
54use oqx::ast::{BinaryOp, Expr, LogicalOp};
55use rusqlite::types::Value as SqlValue;
56
57use crate::context::{Target, to_sql};
58
59#[derive(Clone, Copy, Debug)]
63pub struct TranslateCtx<'a> {
64 pub target: Target,
65 pub self_alias: &'a str,
66 pub doc_alias: &'a str,
67 pub params: &'a [Value],
68}
69
70#[derive(Clone, Debug, PartialEq)]
72pub struct Frag {
73 pub sql: String,
74 pub params: Vec<SqlValue>,
75}
76
77impl Frag {
78 fn bare(sql: impl Into<String>) -> Self {
79 Self {
80 sql: sql.into(),
81 params: Vec::new(),
82 }
83 }
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum Ty {
93 Text,
96 Int,
98 Num,
100 Bool,
102 Json,
106 Prop,
111 Null,
113}
114
115#[must_use]
158pub fn comparable(op: BinaryOp, a: Ty, b: Ty) -> bool {
159 use Ty::{Bool, Int, Json, Null, Num, Prop, Text};
160 let numeric = |t: Ty| matches!(t, Int | Num);
161 let read = |t: Ty| matches!(t, Json | Prop);
162 let both_numeric = numeric(a) && numeric(b);
163 let typed = |konst: fn(Ty) -> bool| (read(a) && konst(b)) || (read(b) && konst(a));
165 if matches!(op, BinaryOp::Eq | BinaryOp::Ne) {
166 a == Text
167 || b == Text
168 || (a == Null && b != Prop)
169 || (b == Null && a != Prop)
170 || both_numeric
171 || typed(|t| matches!(t, Bool | Num))
172 } else {
173 (a == Text && b == Text) || both_numeric || typed(|t| t == Num)
174 }
175}
176
177fn const_ty(v: &Value) -> Option<Ty> {
180 Some(match v {
181 Value::Str(_) => Ty::Text,
182 Value::Number(_) => Ty::Num,
183 Value::Bool(_) => Ty::Bool,
184 Value::Null | Value::Undefined => Ty::Null,
185 Value::Array(_) | Value::Object(_) | Value::Range(_) => return None,
186 })
187}
188
189#[must_use]
199pub fn non_property_handles(t: Target) -> &'static [&'static str] {
200 match t {
201 Target::Docs => &[
202 "doc",
203 "blocks",
204 "nodes",
205 "out",
206 "in",
207 "out_edges",
208 "in_edges",
209 "frontmatter",
210 "inline",
211 ],
212 Target::Blocks => &[
213 "block",
214 "doc",
215 "children",
216 "nodes",
217 "out_edges",
218 "section",
219 "attrs",
220 ],
221 Target::Nodes => &[
222 "section",
223 "doc",
224 "block",
225 "blocks",
226 "subsections",
227 "children",
228 "attrs",
229 ],
230 Target::Edges => &["doc"],
231 }
232}
233
234fn intrinsic_sql(name: &str, ctx: &TranslateCtx<'_>) -> Option<(String, Ty)> {
243 let (s, d) = (ctx.self_alias, ctx.doc_alias);
244 let sql = match (ctx.target, name) {
245 (Target::Docs, "$id") => format!("{s}.doc_id"),
246 (Target::Docs, "$path") => format!("{d}.path"),
247 (Target::Docs, "$content_hash") => format!("lower(hex({s}.file_hash))"),
248 (Target::Docs, "$updated_at") => format!(
249 "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = {s}.current_rev)"
250 ),
251 (Target::Blocks, "$id") => format!("{s}.block_id"),
252 (Target::Blocks, "$doc") => format!("{s}.doc_id"),
253 (Target::Blocks, "$path") => format!("{d}.path"),
254 (Target::Blocks, "$ordinal") => return Some((format!("{s}.ordinal"), Ty::Int)),
255 (Target::Blocks, "$depth") => return Some((format!("{s}.depth"), Ty::Int)),
256 (Target::Blocks, "$body") => format!("{s}.text"),
257 (Target::Blocks, "$content_hash") => format!("lower(hex({s}.raw_hash))"),
258 (Target::Nodes, "$id" | "$node_id") => format!("{s}.node_id"),
259 (Target::Nodes, "$doc_id") => format!("{s}.doc_id"),
260 (Target::Nodes, "$block_id") => format!("{s}.block_id"),
261 (Target::Nodes, "$path") => format!("{d}.path"),
262 (Target::Edges, "$id") => format!("{s}.edge_id"),
263 (Target::Edges, "$src") => format!("{s}.src_doc"),
264 (Target::Edges, "$dst") => format!("{s}.dst_node"),
265 (Target::Edges, "$src_block") => format!("{s}.src_block"),
266 (Target::Edges, "$via") => format!("{s}.via_node"),
267 (Target::Edges, "$from_commit") => format!("{s}.from_commit"),
268 (Target::Edges, "$path") => format!("{d}.path"),
269 (Target::Edges, "$dst_path") => {
270 format!("(SELECT dd.path FROM docs dd WHERE dd.doc_id = {s}.dst_node)")
271 }
272 (Target::Edges, "$dst_uri") => {
273 format!("(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = {s}.dst_node)")
274 }
275 _ => return None,
276 };
277 Some((sql, Ty::Text))
278}
279
280pub(crate) const RESERVED_DOC_BASENAMES: [&str; 5] =
284 ["id", "path", "updated_at", "content_hash", "body"];
285
286fn is_seg(s: &str) -> bool {
288 let mut chars = s.chars();
289 chars
290 .next()
291 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
292 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
293}
294
295fn prop_row(doc_alias: &str, key: &str, select: &str) -> Option<String> {
301 if !is_seg(key) {
302 return None;
303 }
304 Some(format!(
305 "(SELECT {select} FROM properties p \
306 WHERE p.doc_id = {doc_alias}.doc_id AND p.key = '{key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL \
307 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 \
308 LIMIT 1)"
309 ))
310}
311
312fn prop_scalar(doc_alias: &str, key: &str) -> Option<String> {
315 prop_row(
316 doc_alias,
317 key,
318 "COALESCE(p.val_text, p.val_num, p.val_bool)",
319 )
320}
321
322fn json_path(segs: &[&str]) -> Option<String> {
325 if segs.iter().any(|s| !is_seg(s)) {
326 return None;
327 }
328 Some(format!("$.{}", segs.join(".")))
329}
330
331#[derive(Clone, Debug, PartialEq)]
335enum Shape {
336 Plain,
338 Const(Value),
340 Json { col: String, path: String },
342 Prop { doc_alias: String, key: String },
344}
345
346#[derive(Clone, Debug, PartialEq)]
349struct Operand {
350 frag: Frag,
351 ty: Ty,
352 shape: Shape,
353}
354
355impl Operand {
356 fn plain(sql: String, ty: Ty) -> Self {
357 Self {
358 frag: Frag::bare(sql),
359 ty,
360 shape: Shape::Plain,
361 }
362 }
363
364 fn text(sql: String) -> Option<Self> {
365 Some(Self::plain(sql, Ty::Text))
366 }
367
368 fn constant(v: &Value) -> Option<Self> {
371 Some(Self {
372 frag: Frag {
373 sql: "?".to_owned(),
374 params: vec![to_sql(v)],
375 },
376 ty: const_ty(v)?,
377 shape: Shape::Const(v.clone()),
378 })
379 }
380
381 fn json(col: String, segs: &[&str]) -> Option<Self> {
382 let path = json_path(segs)?;
383 Some(Self {
384 frag: Frag::bare(format!("json_extract({col}, '{path}')")),
385 ty: Ty::Json,
386 shape: Shape::Json { col, path },
387 })
388 }
389
390 fn prop(doc_alias: &str, key: &str) -> Option<Self> {
391 Some(Self {
392 frag: Frag::bare(prop_scalar(doc_alias, key)?),
393 ty: Ty::Prop,
394 shape: Shape::Prop {
395 doc_alias: doc_alias.to_owned(),
396 key: key.to_owned(),
397 },
398 })
399 }
400}
401
402fn member_segments(e: &Expr) -> Option<Vec<&str>> {
405 match e {
406 Expr::Ident { name } => Some(vec![name.as_str()]),
407 Expr::Member { recv, name } => {
408 let mut base = member_segments(recv)?;
409 base.push(name.as_str());
410 Some(base)
411 }
412 _ => None,
413 }
414}
415
416pub fn translate_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
422 typed_value(e, ctx).map(|(frag, _)| frag)
423}
424
425pub fn typed_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<(Frag, Ty)> {
427 operand(e, ctx).map(|o| (o.frag, o.ty))
428}
429
430fn operand(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Operand> {
432 let (s, d, target) = (ctx.self_alias, ctx.doc_alias, ctx.target);
433 match e {
434 Expr::Lit(v) => Operand::constant(v),
435 Expr::Binding { index } => {
436 Operand::constant(ctx.params.get(*index).unwrap_or(&Value::Undefined))
437 }
438 Expr::Ident { name } => {
439 if name.starts_with('$') {
440 return intrinsic_sql(name, ctx).map(|(sql, ty)| Operand::plain(sql, ty));
441 }
442 let name = name.as_str();
443 if non_property_handles(target).contains(&name) {
446 return None;
447 }
448 match target {
449 Target::Docs => {
450 if name == "format" {
452 return Operand::text(format!("{s}.format"));
453 }
454 if RESERVED_DOC_BASENAMES.contains(&name) {
456 return None;
457 }
458 Operand::prop(d, name)
459 }
460 Target::Blocks => {
461 if name == "type" || name == "text" {
462 return Operand::text(format!("{s}.{name}"));
463 }
464 Operand::json(format!("{s}.attrs"), &[name])
467 }
468 Target::Nodes => {
469 if matches!(name, "kind" | "name" | "value") {
470 return Operand::text(format!("{s}.{name}"));
471 }
472 Operand::json(format!("{s}.attrs"), &[name])
473 }
474 Target::Edges => {
475 if matches!(
476 name,
477 "predicate" | "provenance" | "dst_kind" | "anchor" | "src_field"
478 ) {
479 return Operand::text(format!("{s}.{name}"));
480 }
481 None
482 }
483 }
484 }
485 Expr::Member { .. } => {
486 let segs = member_segments(e)?;
487 let (head, rest) = segs.split_first()?;
488 if rest.is_empty() {
489 return None;
490 }
491 if *head == "attrs" && matches!(target, Target::Blocks | Target::Nodes) {
493 return Operand::json(format!("{s}.attrs"), rest);
494 }
495 if *head == "doc" {
498 if rest.len() != 1 {
499 return None;
500 }
501 let k = rest[0];
502 if k == "$path" {
503 return Operand::text(format!("{d}.path"));
504 }
505 if k == "format" {
506 return Operand::text(format!("{d}.format"));
507 }
508 if k.starts_with('$')
511 || RESERVED_DOC_BASENAMES.contains(&k)
512 || non_property_handles(Target::Docs).contains(&k)
513 {
514 return None;
515 }
516 return Operand::prop(d, k);
517 }
518 if *head == "block"
520 && target == Target::Nodes
521 && rest.len() == 1
522 && matches!(rest[0], "type" | "text")
523 {
524 return Operand::text(format!(
525 "(SELECT bb.{} FROM blocks bb WHERE bb.block_id = {s}.block_id)",
526 rest[0]
527 ));
528 }
529 None
530 }
531 Expr::Call {
533 recv: Some(recv),
534 name,
535 args,
536 } if args.is_empty() && (name == "lower" || name == "upper") => {
537 let recv = translate_value(recv, ctx)?;
538 Some(Operand {
539 frag: Frag {
540 sql: format!("{name}({})", recv.sql),
541 params: recv.params,
542 },
543 ty: Ty::Text,
544 shape: Shape::Plain,
545 })
546 }
547 _ => None,
548 }
549}
550
551fn is_op(op: BinaryOp) -> Option<&'static str> {
555 Some(match op {
556 BinaryOp::Eq => "IS",
557 BinaryOp::Ne => "IS NOT",
558 BinaryOp::Lt => "<",
559 BinaryOp::Le => "<=",
560 BinaryOp::Gt => ">",
561 BinaryOp::Ge => ">=",
562 BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => {
563 return None;
564 }
565 })
566}
567
568fn typed_compare(op: BinaryOp, sql_op: &str, l: &Operand, r: &Operand) -> Option<Frag> {
591 let (read, konst, read_left) = match (&l.shape, &r.shape) {
592 (Shape::Json { .. } | Shape::Prop { .. }, Shape::Const(v)) => (&l.shape, v, true),
593 (Shape::Const(v), Shape::Json { .. } | Shape::Prop { .. }) => (&r.shape, v, false),
594 _ => return None,
595 };
596 let equality = matches!(op, BinaryOp::Eq | BinaryOp::Ne);
597 let wrap = if op == BinaryOp::Ne {
598 "IS NOT 1"
599 } else {
600 "IS 1"
601 };
602 let inner_op = match (equality, read_left, sql_op) {
605 (true, _, _) => "=",
606 (false, true, _) => sql_op,
607 (false, false, "<") => ">",
608 (false, false, "<=") => ">=",
609 (false, false, ">") => "<",
610 (false, false, ">=") => "<=",
611 (false, false, _) => return None,
612 };
613 let sides = |read_sql: &str| format!("{read_sql} {inner_op} ?");
614 let (sql, params) = match (read, konst) {
615 (_, Value::Bool(_)) if !equality => return None,
617 (Shape::Json { col, path }, Value::Bool(b)) => (
618 format!("(json_type({col}, '{path}') = '{b}') {wrap}"),
619 Vec::new(),
620 ),
621 (Shape::Json { col, path }, Value::Number(_)) => (
622 format!(
623 "(json_type({col}, '{path}') IN ('integer', 'real') AND {}) {wrap}",
624 sides(&format!("json_extract({col}, '{path}')"))
625 ),
626 vec![to_sql(konst)],
627 ),
628 (Shape::Prop { doc_alias, key }, Value::Bool(_)) => (
629 format!(
630 "{} {wrap}",
631 prop_row(
632 doc_alias,
633 key,
634 &format!("p.type = 'bool' AND {}", sides("p.val_bool"))
635 )?
636 ),
637 vec![to_sql(konst)],
638 ),
639 (Shape::Prop { doc_alias, key }, Value::Number(_)) => (
640 format!(
641 "{} {wrap}",
642 prop_row(
643 doc_alias,
644 key,
645 &format!("p.type = 'number' AND {}", sides("p.val_num"))
646 )?
647 ),
648 vec![to_sql(konst)],
649 ),
650 _ => return None,
651 };
652 Some(Frag {
653 sql: format!("({sql})"),
654 params,
655 })
656}
657
658pub fn translate_predicate(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
663 match e {
664 Expr::Logical {
667 op: LogicalOp::And,
668 left,
669 right,
670 } => join2(
671 translate_predicate(left, ctx),
672 translate_predicate(right, ctx),
673 "AND",
674 ),
675 Expr::Binary { op, left, right } => {
676 let sql_op = is_op(*op)?;
678 let l = operand(left, ctx)?;
679 let r = operand(right, ctx)?;
680 if !comparable(*op, l.ty, r.ty) {
683 return None;
684 }
685 if let Some(typed) = typed_compare(*op, sql_op, &l, &r) {
688 return Some(typed);
689 }
690 let op = sql_op;
691 let mut params = l.frag.params;
696 params.extend(r.frag.params);
697 Some(Frag {
698 sql: format!("({} {op} {})", l.frag.sql, r.frag.sql),
699 params,
700 })
701 }
702 Expr::Call {
703 recv: Some(recv),
704 name,
705 args,
706 } if args.len() == 1 => {
707 let recv = translate_value(recv, ctx)?;
710 let arg = translate_value(&args[0], ctx)?;
711 let mut params = recv.params;
712 let sql = match name.as_str() {
713 "startsWith" => {
714 params.extend(arg.params.iter().cloned());
716 params.extend(arg.params);
717 format!("(substr({}, 1, length({a})) = {a})", recv.sql, a = arg.sql)
718 }
719 "endsWith" => {
720 params.extend(arg.params.iter().cloned());
724 params.extend(arg.params);
725 format!("(substr({}, -length({a})) = {a})", recv.sql, a = arg.sql)
726 }
727 "contains" => {
728 params.extend(arg.params);
729 format!("(instr({}, {}) > 0)", recv.sql, arg.sql)
730 }
731 _ => return None,
732 };
733 Some(Frag { sql, params })
734 }
735 _ => None,
736 }
737}
738
739fn join2(a: Option<Frag>, b: Option<Frag>, connective: &str) -> Option<Frag> {
742 let (a, b) = (a?, b?);
743 let mut params = a.params;
744 params.extend(b.params);
745 Some(Frag {
746 sql: format!("({} {connective} {})", a.sql, b.sql),
747 params,
748 })
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754 use oqx::ast::Where;
755
756 const DOCS: TranslateCtx<'static> = TranslateCtx {
757 target: Target::Docs,
758 self_alias: "d",
759 doc_alias: "d",
760 params: &[],
761 };
762
763 fn text(s: &str) -> SqlValue {
764 SqlValue::Text(s.to_owned())
765 }
766
767 fn frag(sql: &str, params: &[SqlValue]) -> Option<Frag> {
768 Some(Frag {
769 sql: sql.to_owned(),
770 params: params.to_vec(),
771 })
772 }
773
774 fn pred(src: &str) -> Expr {
776 let q = oqx::parse_string(&format!("from docs where {src}")).expect("parses");
777 match q.r#where {
778 Some(Where::Scalar { expr }) => expr,
779 other => panic!("expected a single scalar predicate, got {other:?}"),
780 }
781 }
782
783 fn ident(name: &str) -> Box<Expr> {
784 Box::new(Expr::Ident {
785 name: name.to_owned(),
786 })
787 }
788
789 fn lit(s: &str) -> Box<Expr> {
790 Box::new(Expr::Lit(Value::from(s)))
791 }
792
793 fn eq(l: Box<Expr>, r: Box<Expr>) -> Box<Expr> {
794 Box::new(Expr::Binary {
795 op: BinaryOp::Eq,
796 left: l,
797 right: r,
798 })
799 }
800
801 #[test]
804 fn equality_is_null_safe_is() {
805 assert_eq!(
806 translate_predicate(&pred("$path == \"index.md\""), &DOCS),
807 frag("(d.path IS ?)", &[text("index.md")])
808 );
809 }
810
811 #[test]
812 fn inequality_is_null_safe_is_not() {
813 assert_eq!(
814 translate_predicate(&pred("$path != \"x\""), &DOCS),
815 frag("(d.path IS NOT ?)", &[text("x")])
816 );
817 }
818
819 #[test]
820 fn intrinsic_column_mapping() {
821 assert_eq!(
822 translate_predicate(&pred("$id == \"d_1\""), &DOCS),
823 frag("(d.doc_id IS ?)", &[text("d_1")])
824 );
825 }
826
827 #[test]
830 fn relational_ops_are_plain_comparisons() {
831 assert_eq!(
832 translate_predicate(&pred("$path < \"m\""), &DOCS),
833 frag("(d.path < ?)", &[text("m")])
834 );
835 assert_eq!(
836 translate_predicate(&pred("$path >= \"m\""), &DOCS),
837 frag("(d.path >= ?)", &[text("m")])
838 );
839 assert_eq!(translate_predicate(&pred("$path + 1"), &DOCS), None);
841 }
842
843 #[test]
846 fn starts_with_is_substr_equality() {
847 assert_eq!(
848 translate_predicate(&pred("$path.startsWith(\"lab/\")"), &DOCS),
849 frag(
850 "(substr(d.path, 1, length(?)) = ?)",
851 &[text("lab/"), text("lab/")]
852 )
853 );
854 }
855
856 #[test]
857 fn lower_then_starts_with_pushes_with_explicit_lower() {
858 assert_eq!(
859 translate_predicate(&pred("$path.lower().startsWith(\"lab/\")"), &DOCS),
860 frag(
861 "(substr(lower(d.path), 1, length(?)) = ?)",
862 &[text("lab/"), text("lab/")]
863 )
864 );
865 }
866
867 #[test]
868 fn contains_is_instr() {
869 assert_eq!(
870 translate_predicate(&pred("$path.contains(\"notes\")"), &DOCS),
871 frag("(instr(d.path, ?) > 0)", &[text("notes")])
872 );
873 }
874
875 #[test]
876 fn ends_with_is_negative_substr_equality() {
877 assert_eq!(
878 translate_predicate(&pred("$path.endsWith(\".md\")"), &DOCS),
879 frag(
880 "(substr(d.path, -length(?)) = ?)",
881 &[text(".md"), text(".md")]
882 )
883 );
884 }
885
886 #[test]
887 fn upper_wraps_the_receiver_in_value_position() {
888 assert_eq!(
889 translate_value(&pred("$path.upper()"), &DOCS),
890 frag("upper(d.path)", &[])
891 );
892 }
893
894 #[test]
897 fn bare_doc_property_is_the_scalar_in_scope_subquery() {
898 let f = translate_predicate(&pred("layer == \"canon\""), &DOCS).expect("pushable");
899 assert!(f.sql.contains("FROM properties p"), "{}", f.sql);
900 assert!(f.sql.contains("p.key = 'layer'"), "{}", f.sql);
901 assert!(f.sql.contains("p.card = 'scalar'"), "{}", f.sql);
902 assert!(
903 f.sql.starts_with('(') && f.sql.contains(" IS ?)"),
904 "{}",
905 f.sql
906 );
907 assert_eq!(f.params, vec![text("canon")]);
908 }
909
910 #[test]
911 fn updated_at_pushes_as_its_revisions_subquery() {
912 let f =
913 translate_predicate(&pred("$updated_at >= \"2026-01-01\""), &DOCS).expect("pushable");
914 assert!(
915 f.sql.contains("FROM revisions r JOIN commits c"),
916 "{}",
917 f.sql
918 );
919 }
920
921 #[test]
922 fn format_is_a_column_not_a_property() {
923 assert_eq!(
924 translate_predicate(&pred("format == \"markdown\""), &DOCS),
925 frag("(d.format IS ?)", &[text("markdown")])
926 );
927 }
928
929 #[test]
930 fn booleans_bind_as_one_and_zero() {
931 let blocks = TranslateCtx {
932 target: Target::Blocks,
933 self_alias: "b",
934 ..DOCS
935 };
936 assert_eq!(
939 translate_predicate(&pred("type == true"), &blocks).map(|f| f.params),
940 Some(vec![SqlValue::Integer(1)])
941 );
942 assert_eq!(
943 translate_predicate(&pred("type == false"), &blocks).map(|f| f.params),
944 Some(vec![SqlValue::Integer(0)])
945 );
946 assert_eq!(
947 translate_predicate(&pred("$ordinal < 1000"), &blocks).map(|f| f.params),
948 Some(vec![SqlValue::Real(1000.0)])
949 );
950 assert_eq!(
951 translate_predicate(&pred("$path == null"), &DOCS).map(|f| f.params),
952 Some(vec![SqlValue::Null])
953 );
954 }
955
956 const REPRESENTATIVES: [(Ty, &str); 7] = [
961 (Ty::Text, "$path"),
962 (Ty::Int, "$ordinal"),
963 (Ty::Num, "1"),
964 (Ty::Bool, "true"),
965 (Ty::Null, "null"),
966 (Ty::Json, "checked"),
967 (Ty::Prop, "doc.layer"),
968 ];
969
970 #[test]
971 fn representatives_carry_their_type() {
972 let blocks = TranslateCtx {
973 target: Target::Blocks,
974 self_alias: "b",
975 ..DOCS
976 };
977 for (ty, src) in REPRESENTATIVES {
978 let (_, got) = typed_value(&pred(src), &blocks).expect(src);
979 assert_eq!(got, ty, "{src}");
980 }
981 assert_eq!(
982 typed_value(&pred("attrs.a.b"), &blocks).map(|(_, t)| t),
983 Some(Ty::Json)
984 );
985 assert_eq!(
986 typed_value(&pred("$depth"), &blocks).map(|(_, t)| t),
987 Some(Ty::Int)
988 );
989 assert_eq!(
990 typed_value(&pred("type.lower()"), &blocks).map(|(_, t)| t),
991 Some(Ty::Text)
992 );
993 assert_eq!(
994 typed_value(&pred("checked.upper()"), &blocks).map(|(_, t)| t),
995 Some(Ty::Text)
996 );
997 assert_eq!(
998 typed_value(&pred("layer"), &DOCS).map(|(_, t)| t),
999 Some(Ty::Prop)
1000 );
1001 assert_eq!(
1002 typed_value(&pred("format"), &DOCS).map(|(_, t)| t),
1003 Some(Ty::Text)
1004 );
1005 }
1006
1007 #[test]
1008 fn the_comparison_matrix_decides_every_cell() {
1009 const P: bool = true;
1011 const D: bool = false;
1012 const T: bool = true;
1014 #[rustfmt::skip]
1017 const EQUALITY: [[bool; 7]; 7] = [
1018 [P, P, P, P, P, P, P],
1019 [P, P, P, D, P, D, D],
1020 [P, P, P, D, P, T, T],
1021 [P, D, D, D, P, T, T],
1022 [P, P, P, P, P, P, D],
1023 [P, D, T, T, P, D, D],
1024 [P, D, T, T, D, D, D],
1025 ];
1026 #[rustfmt::skip]
1029 const RELATIONAL: [[bool; 7]; 7] = [
1030 [P, D, D, D, D, D, D],
1031 [D, P, P, D, D, D, D],
1032 [D, P, P, D, D, T, T],
1033 [D, D, D, D, D, D, D],
1034 [D, D, D, D, D, D, D],
1035 [D, D, T, D, D, D, D],
1036 [D, D, T, D, D, D, D],
1037 ];
1038 let blocks = TranslateCtx {
1039 target: Target::Blocks,
1040 self_alias: "b",
1041 ..DOCS
1042 };
1043 let ops = [
1044 (BinaryOp::Eq, "==", &EQUALITY),
1045 (BinaryOp::Ne, "!=", &EQUALITY),
1046 (BinaryOp::Lt, "<", &RELATIONAL),
1047 (BinaryOp::Le, "<=", &RELATIONAL),
1048 (BinaryOp::Gt, ">", &RELATIONAL),
1049 (BinaryOp::Ge, ">=", &RELATIONAL),
1050 ];
1051 for (i, (a, l)) in REPRESENTATIVES.iter().enumerate() {
1052 for (j, (b, r)) in REPRESENTATIVES.iter().enumerate() {
1053 for (op, spelled, matrix) in ops {
1054 let want = matrix[i][j];
1055 assert_eq!(matrix[j][i], want, "the matrix is symmetric ({a:?}, {b:?})");
1056 assert_eq!(
1057 comparable(op, *a, *b),
1058 want,
1059 "comparable({spelled}, {a:?}, {b:?})"
1060 );
1061 let src = format!("{l} {spelled} {r}");
1062 assert_eq!(
1063 translate_predicate(&pred(&src), &blocks).is_some(),
1064 want,
1065 "{src}"
1066 );
1067 }
1068 }
1069 }
1070 }
1071
1072 #[test]
1073 fn the_spec_shapes_of_decline_a() {
1074 let blocks = TranslateCtx {
1075 target: Target::Blocks,
1076 self_alias: "b",
1077 ..DOCS
1078 };
1079 let typed = |src: &str, ctx: &TranslateCtx<'_>| {
1082 let f = translate_predicate(&pred(src), ctx).expect(src);
1083 assert!(
1084 f.sql.contains("json_type(") || f.sql.contains("p.type = "),
1085 "{src}: {}",
1086 f.sql
1087 );
1088 assert!(
1089 f.sql.ends_with(" IS 1)") || f.sql.ends_with(" IS NOT 1)"),
1090 "{src}: {}",
1091 f.sql
1092 );
1093 };
1094 typed("checked == 1", &blocks);
1095 typed("checked == true", &blocks);
1096 typed("attrs.checked == true", &blocks);
1097 typed("verified == 1", &DOCS);
1099 typed("verified == true", &DOCS);
1100 typed("era < 1000", &DOCS);
1101 typed("doc.era < 1000", &blocks);
1102 assert_eq!(
1104 translate_predicate(&pred("$ordinal == true"), &blocks),
1105 None
1106 );
1107 assert_eq!(
1108 translate_predicate(&pred("$ordinal == 1"), &blocks),
1109 frag("(b.ordinal IS ?)", &[SqlValue::Real(1.0)])
1110 );
1111 assert_eq!(translate_predicate(&pred("tags != null"), &DOCS), None);
1113 assert_eq!(translate_predicate(&pred("tags == null"), &DOCS), None);
1114 assert_eq!(
1115 translate_predicate(&pred("doc.tags == null"), &blocks),
1116 None
1117 );
1118 assert_eq!(
1119 translate_predicate(&pred("checked == null"), &blocks),
1120 frag(
1121 "(json_extract(b.attrs, '$.checked') IS ?)",
1122 &[SqlValue::Null]
1123 )
1124 );
1125 assert_eq!(
1126 translate_predicate(&pred("$ordinal != null"), &blocks),
1127 frag("(b.ordinal IS NOT ?)", &[SqlValue::Null])
1128 );
1129 assert_eq!(
1131 translate_predicate(&pred("checked == \"x\""), &blocks),
1132 frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1133 );
1134 assert!(translate_predicate(&pred("layer == \"canon\""), &DOCS).is_some());
1135 assert!(translate_predicate(&pred("$ordinal == \"1\""), &blocks).is_some());
1136 assert!(translate_predicate(&pred("$ordinal != \"1\""), &blocks).is_some());
1137 assert_eq!(
1140 translate_predicate(&pred("$ordinal < \"3\""), &blocks),
1141 None
1142 );
1143 assert_eq!(
1144 translate_predicate(&pred("\"3\" >= $ordinal"), &blocks),
1145 None
1146 );
1147 assert_eq!(translate_predicate(&pred("$path > 5"), &DOCS), None);
1148 assert_eq!(translate_predicate(&pred("type <= 1"), &blocks), None);
1149 assert_eq!(
1150 translate_predicate(&pred("$ordinal < 3"), &blocks),
1151 frag("(b.ordinal < ?)", &[SqlValue::Real(3.0)])
1152 );
1153 assert_eq!(
1154 translate_predicate(&pred("$path > \"m\""), &DOCS),
1155 frag("(d.path > ?)", &[text("m")])
1156 );
1157 assert_eq!(
1159 translate_predicate(&pred("$ordinal == checked"), &blocks),
1160 None
1161 );
1162 assert_eq!(
1163 translate_predicate(&pred("checked == level"), &blocks),
1164 None
1165 );
1166 assert_eq!(
1167 translate_predicate(&pred("doc.era == doc.year"), &blocks),
1168 None
1169 );
1170 assert_eq!(translate_predicate(&pred("level < \"x\""), &blocks), None);
1171 typed("level < 3", &blocks);
1172 assert_eq!(translate_predicate(&pred("true == false"), &blocks), None);
1173 assert_eq!(translate_predicate(&pred("checked < true"), &blocks), None);
1174 assert_eq!(
1175 translate_predicate(&pred("doc.verified >= false"), &blocks),
1176 None
1177 );
1178 assert_eq!(translate_predicate(&pred("$ordinal > null"), &blocks), None);
1179 assert!(translate_predicate(&pred("$ordinal == $depth"), &blocks).is_some());
1180 assert!(translate_predicate(&pred("$ordinal <= $depth"), &blocks).is_some());
1181 assert!(translate_predicate(&pred("type == null"), &blocks).is_some());
1182 }
1183
1184 const PROP_SCOPE: &str = "FROM properties p WHERE p.doc_id = d.doc_id AND p.key = 'K' AND p.card = 'scalar' AND p.deleted_commit IS NULL AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = d.doc_id AND p2.key = 'K' AND p2.deleted_commit IS NULL) = 1 LIMIT 1";
1189
1190 fn prop_sql(key: &str, select: &str, wrap: &str) -> String {
1191 format!(
1192 "((SELECT {select} {}) {wrap})",
1193 PROP_SCOPE.replace('K', key)
1194 )
1195 }
1196
1197 #[test]
1198 fn json_against_a_boolean_tests_json_type_for_the_literal() {
1199 let blocks = TranslateCtx {
1200 target: Target::Blocks,
1201 self_alias: "b",
1202 ..DOCS
1203 };
1204 assert_eq!(
1206 translate_predicate(&pred("checked == true"), &blocks),
1207 frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1208 );
1209 assert_eq!(
1210 translate_predicate(&pred("checked == false"), &blocks),
1211 frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1212 );
1213 assert_eq!(
1214 translate_predicate(&pred("checked != true"), &blocks),
1215 frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1216 );
1217 assert_eq!(
1218 translate_predicate(&pred("false == attrs.checked"), &blocks),
1219 frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1220 );
1221 let params = [Value::Bool(true)];
1223 let e = Expr::Binary {
1224 op: BinaryOp::Ne,
1225 left: ident("checked"),
1226 right: Box::new(Expr::Binding { index: 0 }),
1227 };
1228 assert_eq!(
1229 translate_predicate(
1230 &e,
1231 &TranslateCtx {
1232 params: ¶ms,
1233 ..blocks
1234 }
1235 ),
1236 frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1237 );
1238 }
1239
1240 #[test]
1241 fn json_against_a_number_tests_the_numeric_types_then_compares() {
1242 let nodes = TranslateCtx {
1243 target: Target::Nodes,
1244 self_alias: "n",
1245 ..DOCS
1246 };
1247 let shape = |op: &str, wrap: &str| {
1248 format!(
1249 "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') {op} ?) {wrap})"
1250 )
1251 };
1252 let two = [SqlValue::Real(2.0)];
1253 for (src, op, wrap) in [
1254 ("level == 2", "=", "IS 1"),
1255 ("level != 2", "=", "IS NOT 1"),
1256 ("level < 2", "<", "IS 1"),
1257 ("level <= 2", "<=", "IS 1"),
1258 ("level > 2", ">", "IS 1"),
1259 ("level >= 2", ">=", "IS 1"),
1260 ] {
1261 assert_eq!(
1262 translate_predicate(&pred(src), &nodes),
1263 frag(&shape(op, wrap), &two),
1264 "{src}"
1265 );
1266 }
1267 assert_eq!(
1269 translate_predicate(&pred("2 <= attrs.level"), &nodes),
1270 frag(&shape(">=", "IS 1"), &two)
1271 );
1272 assert_eq!(
1273 translate_predicate(&pred("2 > level"), &nodes),
1274 frag(&shape("<", "IS 1"), &two)
1275 );
1276 assert_eq!(
1277 translate_predicate(&pred("2 != level"), &nodes),
1278 frag(&shape("=", "IS NOT 1"), &two)
1279 );
1280 }
1281
1282 #[test]
1283 fn property_against_a_boolean_tests_p_type_bool_in_the_scalar_row_subquery() {
1284 let blocks = TranslateCtx {
1285 target: Target::Blocks,
1286 self_alias: "b",
1287 ..DOCS
1288 };
1289 assert_eq!(
1290 translate_predicate(&pred("verified == true"), &DOCS),
1291 frag(
1292 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1293 &[SqlValue::Integer(1)]
1294 )
1295 );
1296 assert_eq!(
1297 translate_predicate(&pred("verified != false"), &DOCS),
1298 frag(
1299 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS NOT 1"),
1300 &[SqlValue::Integer(0)]
1301 )
1302 );
1303 assert_eq!(
1305 translate_predicate(&pred("doc.verified == false"), &blocks),
1306 frag(
1307 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1308 &[SqlValue::Integer(0)]
1309 )
1310 );
1311 assert_eq!(
1312 translate_predicate(&pred("true == verified"), &DOCS),
1313 frag(
1314 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1315 &[SqlValue::Integer(1)]
1316 )
1317 );
1318 }
1319
1320 #[test]
1321 fn property_against_a_number_tests_p_type_number_in_the_scalar_row_subquery() {
1322 let blocks = TranslateCtx {
1323 target: Target::Blocks,
1324 self_alias: "b",
1325 ..DOCS
1326 };
1327 let thousand = [SqlValue::Real(1000.0)];
1328 for (src, op, wrap) in [
1329 ("era == 1000", "=", "IS 1"),
1330 ("era != 1000", "=", "IS NOT 1"),
1331 ("era < 1000", "<", "IS 1"),
1332 ("era <= 1000", "<=", "IS 1"),
1333 ("era > 1000", ">", "IS 1"),
1334 ("era >= 1000", ">=", "IS 1"),
1335 ] {
1336 assert_eq!(
1337 translate_predicate(&pred(src), &DOCS),
1338 frag(
1339 &prop_sql(
1340 "era",
1341 &format!("p.type = 'number' AND p.val_num {op} ?"),
1342 wrap
1343 ),
1344 &thousand
1345 ),
1346 "{src}"
1347 );
1348 }
1349 assert_eq!(
1350 translate_predicate(&pred("doc.era >= 1000"), &blocks),
1351 frag(
1352 &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1353 &thousand
1354 )
1355 );
1356 assert_eq!(
1358 translate_predicate(&pred("1000 > era"), &DOCS),
1359 frag(
1360 &prop_sql("era", "p.type = 'number' AND p.val_num < ?", "IS 1"),
1361 &thousand
1362 )
1363 );
1364 assert_eq!(
1365 translate_predicate(&pred("1000 <= era"), &DOCS),
1366 frag(
1367 &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1368 &thousand
1369 )
1370 );
1371 }
1372
1373 #[test]
1374 fn typed_pushes_compose_under_and_and_keep_the_untyped_forms() {
1375 let blocks = TranslateCtx {
1376 target: Target::Blocks,
1377 self_alias: "b",
1378 ..DOCS
1379 };
1380 let e = Expr::Logical {
1381 op: LogicalOp::And,
1382 left: eq(ident("type"), lit("task")),
1383 right: Box::new(Expr::Binary {
1384 op: BinaryOp::Eq,
1385 left: ident("checked"),
1386 right: Box::new(Expr::Lit(Value::Bool(false))),
1387 }),
1388 };
1389 assert_eq!(
1390 translate_predicate(&e, &blocks),
1391 frag(
1392 "((b.type IS ?) AND ((json_type(b.attrs, '$.checked') = 'false') IS 1))",
1393 &[text("task")]
1394 )
1395 );
1396 assert_eq!(
1398 translate_predicate(&pred("checked == \"x\""), &blocks),
1399 frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1400 );
1401 assert_eq!(
1402 translate_predicate(&pred("checked != null"), &blocks),
1403 frag(
1404 "(json_extract(b.attrs, '$.checked') IS NOT ?)",
1405 &[SqlValue::Null]
1406 )
1407 );
1408 assert_eq!(
1410 prop_row("d", "x'y", "p.type = 'number' AND p.val_num = ?"),
1411 None
1412 );
1413 }
1414
1415 #[test]
1416 fn bindings_are_typed_by_their_value() {
1417 let blocks = TranslateCtx {
1418 target: Target::Blocks,
1419 self_alias: "b",
1420 ..DOCS
1421 };
1422 let params = [
1423 Value::from("s"),
1424 Value::from(1.0),
1425 Value::Bool(true),
1426 Value::Null,
1427 Value::Array(vec![]),
1428 ];
1429 let ctx = TranslateCtx {
1430 params: ¶ms,
1431 ..blocks
1432 };
1433 let against = |i: usize, rhs: &str| {
1434 let e = Expr::Binary {
1435 op: BinaryOp::Eq,
1436 left: Box::new(Expr::Binding { index: i }),
1437 right: Box::new(pred(rhs)),
1438 };
1439 translate_predicate(&e, &ctx).is_some()
1440 };
1441 assert!(against(0, "checked"));
1444 assert!(against(1, "checked"));
1445 assert!(against(2, "checked"));
1446 assert!(!against(2, "$ordinal"));
1447 assert!(against(1, "$ordinal"));
1448 assert!(against(3, "checked"));
1450 assert!(!against(3, "doc.tags"));
1451 assert!(against(9, "checked"));
1453 assert!(!against(9, "doc.tags"));
1454 assert!(!against(4, "type"));
1456 }
1457
1458 #[test]
1461 fn relation_and_handle_names_are_not_property_reads() {
1462 let blocks = TranslateCtx {
1463 target: Target::Blocks,
1464 self_alias: "b",
1465 ..DOCS
1466 };
1467 let nodes = TranslateCtx {
1468 target: Target::Nodes,
1469 self_alias: "n",
1470 ..DOCS
1471 };
1472 let edges = TranslateCtx {
1473 target: Target::Edges,
1474 self_alias: "e",
1475 ..DOCS
1476 };
1477 for (ctx, target) in [
1478 (&DOCS, Target::Docs),
1479 (&blocks, Target::Blocks),
1480 (&nodes, Target::Nodes),
1481 (&edges, Target::Edges),
1482 ] {
1483 for name in non_property_handles(target) {
1484 let src = format!("{name} == null");
1485 assert_eq!(
1486 translate_predicate(&pred(&src), ctx),
1487 None,
1488 "{target:?}: {src}"
1489 );
1490 let src = format!("{name} == \"x\"");
1491 assert_eq!(
1492 translate_predicate(&pred(&src), ctx),
1493 None,
1494 "{target:?}: {src}"
1495 );
1496 }
1497 }
1498 assert_eq!(translate_predicate(&pred("nodes == null"), &DOCS), None);
1500 assert_eq!(
1501 translate_predicate(&pred("frontmatter == null"), &DOCS),
1502 None
1503 );
1504 assert_eq!(translate_predicate(&pred("nodes == null"), &blocks), None);
1505 assert_eq!(translate_predicate(&pred("attrs == null"), &blocks), None);
1506 assert_eq!(
1508 translate_predicate(&pred("doc.nodes == null"), &blocks),
1509 None
1510 );
1511 assert_eq!(
1512 translate_predicate(&pred("doc.frontmatter == null"), &blocks),
1513 None
1514 );
1515 assert_eq!(translate_predicate(&pred("doc.doc == null"), &DOCS), None);
1516 assert!(translate_predicate(&pred("doc.layer == \"canon\""), &blocks).is_some());
1517 assert!(translate_predicate(&pred("section == \"x\""), &DOCS).is_some());
1519 assert!(translate_predicate(&pred("frontmatter == \"x\""), &blocks).is_some());
1520 }
1521
1522 #[test]
1528 fn handle_sets_match_the_store_context() {
1529 use std::collections::HashMap;
1530
1531 use omgbase_reconcile::Config;
1532 use omgbase_store::{BatchItem, Store};
1533 use oqx::DataContext;
1534
1535 use crate::context::StoreContext;
1536
1537 let mut store = Store::open_in_memory().expect("store");
1538 let repo = store.create_repo("handles").expect("repo");
1539 let items = [
1540 BatchItem::observed(
1541 "a.md",
1542 "---\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",
1543 ),
1544 BatchItem::observed("b.md", "# B\n\nBack to [a](a.md).\n"),
1545 ];
1546 store
1547 .observe_batch(
1548 &repo,
1549 &items,
1550 "2026-09-26T00:00:00.000Z",
1551 &Config::default(),
1552 )
1553 .expect("observe");
1554 let ctx = StoreContext::new(store.conn(), &repo, HashMap::new());
1555
1556 let mut universe: Vec<&str> = [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges]
1557 .into_iter()
1558 .flat_map(|t| non_property_handles(t).iter().copied())
1559 .collect();
1560 universe.extend([
1561 "layer",
1562 "title",
1563 "verified",
1564 "checked",
1565 "level",
1566 "format",
1567 "type",
1568 "text",
1569 "kind",
1570 "name",
1571 "value",
1572 "predicate",
1573 "key",
1574 "nope",
1575 ]);
1576 universe.sort_unstable();
1577 universe.dedup();
1578
1579 let is_shape = |v: &Value| matches!(v, Value::Array(_) | Value::Object(_));
1580 for target in [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges] {
1581 let Value::Array(rows) = ctx.root(target.as_str()) else {
1582 panic!("{target:?} root is not an array");
1583 };
1584 assert!(!rows.is_empty(), "{target:?} has rows");
1585 let set = non_property_handles(target);
1586 for name in &universe {
1587 let mut shaped = 0;
1588 for row in &rows {
1589 let v = ctx.get(row, name).expect("get");
1590 if set.contains(name) {
1591 assert!(
1592 v.is_absent() || is_shape(&v),
1593 "{target:?}.{name} read as a scalar: {v:?}"
1594 );
1595 } else {
1596 assert!(!is_shape(&v), "{target:?}.{name} is a handle: {v:?}");
1597 }
1598 shaped += usize::from(is_shape(&v));
1599 }
1600 if set.contains(name) {
1601 assert!(
1602 shaped > 0,
1603 "{target:?}.{name} never resolved to rows or a bag"
1604 );
1605 }
1606 }
1607 }
1608 }
1609
1610 #[test]
1613 fn reserved_bare_basename_is_not_pushed() {
1614 assert_eq!(translate_predicate(&pred("path == \"x\""), &DOCS), None);
1615 assert_eq!(translate_predicate(&pred("body == \"x\""), &DOCS), None);
1616 assert_eq!(translate_predicate(&pred("doc.path == \"x\""), &DOCS), None);
1617 }
1618
1619 #[test]
1620 fn docs_body_and_computed_intrinsics_are_not_columns() {
1621 assert_eq!(translate_predicate(&pred("$body == \"x\""), &DOCS), None);
1622 assert_eq!(translate_predicate(&pred("$title == \"x\""), &DOCS), None);
1623 assert_eq!(translate_predicate(&pred("$tags == \"x\""), &DOCS), None);
1624 }
1625
1626 #[test]
1627 fn matches_needs_a_regexp_udf() {
1628 assert_eq!(
1629 translate_predicate(&pred("$path.matches(\"^lab/\")"), &DOCS),
1630 None
1631 );
1632 }
1633
1634 #[test]
1635 fn negation_as_a_nested_expr_is_not_and_safe() {
1636 let e = Expr::Unary {
1637 op: oqx::ast::UnaryOp::Not,
1638 expr: ident("$path"),
1639 };
1640 assert_eq!(translate_predicate(&e, &DOCS), None);
1641 }
1642
1643 #[test]
1644 fn disjunction_as_a_nested_expr_is_declined() {
1645 let e = Expr::Logical {
1646 op: LogicalOp::Or,
1647 left: eq(ident("$path"), lit("a")),
1648 right: eq(ident("$path"), lit("b")),
1649 };
1650 assert_eq!(translate_predicate(&e, &DOCS), None);
1651 }
1652
1653 #[test]
1654 fn unmapped_node_intrinsic_is_not_pushed() {
1655 let nodes = TranslateCtx {
1656 target: Target::Nodes,
1657 self_alias: "n",
1658 ..DOCS
1659 };
1660 assert_eq!(
1661 translate_predicate(&pred("$locator == \"x\""), &nodes),
1662 None
1663 );
1664 let blocks = TranslateCtx {
1666 target: Target::Blocks,
1667 self_alias: "b",
1668 ..DOCS
1669 };
1670 assert_eq!(
1671 translate_predicate(&pred("$updated_at == \"x\""), &blocks),
1672 None
1673 );
1674 }
1675
1676 #[test]
1677 fn range_membership_in_and_bare_idents_are_declined() {
1678 assert_eq!(translate_predicate(&pred("era in 800..1680"), &DOCS), None);
1679 assert_eq!(
1680 translate_predicate(&pred("\"a\" in list(tags)"), &DOCS),
1681 None
1682 );
1683 assert_eq!(translate_predicate(&pred("verified"), &DOCS), None);
1684 assert_eq!(translate_predicate(&pred("doc.verified"), &DOCS), None);
1685 assert_eq!(translate_predicate(&pred("size(tags) > 1"), &DOCS), None);
1686 assert_eq!(translate_predicate(&pred("$self.text(\"x\")"), &DOCS), None);
1687 assert_eq!(translate_predicate(&pred("$value == \"x\""), &DOCS), None);
1688 assert_eq!(translate_predicate(&pred("^slug == \"x\""), &DOCS), None);
1689 assert_eq!(
1690 translate_predicate(&pred("frontmatter.era == 1"), &DOCS),
1691 None
1692 );
1693 }
1694
1695 #[test]
1698 fn and_composes_two_pushable_comparisons() {
1699 let e = Expr::Logical {
1700 op: LogicalOp::And,
1701 left: eq(ident("$path"), lit("a")),
1702 right: Box::new(Expr::Binary {
1703 op: BinaryOp::Ne,
1704 left: ident("$id"),
1705 right: lit("d_2"),
1706 }),
1707 };
1708 assert_eq!(
1709 translate_predicate(&e, &DOCS),
1710 frag(
1711 "((d.path IS ?) AND (d.doc_id IS NOT ?))",
1712 &[text("a"), text("d_2")]
1713 )
1714 );
1715 }
1716
1717 #[test]
1718 fn and_declines_wholesale_if_either_side_is_not_pushable() {
1719 let e = Expr::Logical {
1720 op: LogicalOp::And,
1721 left: eq(ident("$path"), lit("a")),
1722 right: eq(ident("$body"), lit("x")),
1724 };
1725 assert_eq!(translate_predicate(&e, &DOCS), None);
1726 }
1727
1728 #[test]
1729 fn resolves_a_binding_to_its_param_value() {
1730 let e = eq(ident("$path"), Box::new(Expr::Binding { index: 0 }));
1731 let params = [Value::from("from-binding.md")];
1732 let ctx = TranslateCtx {
1733 params: ¶ms,
1734 ..DOCS
1735 };
1736 assert_eq!(
1737 translate_predicate(&e, &ctx),
1738 frag("(d.path IS ?)", &[text("from-binding.md")])
1739 );
1740 assert_eq!(
1742 translate_predicate(&e, &DOCS),
1743 frag("(d.path IS ?)", &[SqlValue::Null])
1744 );
1745 }
1746
1747 #[test]
1750 fn blocks_and_nodes_flatten_bare_identifiers_into_attrs() {
1751 let blocks = TranslateCtx {
1752 target: Target::Blocks,
1753 self_alias: "b",
1754 ..DOCS
1755 };
1756 let both = Expr::Logical {
1759 op: LogicalOp::And,
1760 left: eq(ident("type"), lit("task")),
1761 right: eq(ident("marker"), lit("x")),
1762 };
1763 assert_eq!(
1764 translate_predicate(&both, &blocks),
1765 frag(
1766 "((b.type IS ?) AND (json_extract(b.attrs, '$.marker') IS ?))",
1767 &[text("task"), text("x")]
1768 )
1769 );
1770 assert_eq!(
1771 translate_predicate(&pred("attrs.marker == \"x\""), &blocks),
1772 frag("(json_extract(b.attrs, '$.marker') IS ?)", &[text("x")])
1773 );
1774 assert_eq!(
1777 translate_predicate(&pred("attrs.checked == true"), &blocks),
1778 frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1779 );
1780 let nodes = TranslateCtx {
1781 target: Target::Nodes,
1782 self_alias: "n",
1783 ..DOCS
1784 };
1785 assert_eq!(
1786 translate_predicate(&pred("kind == \"md:section\""), &nodes),
1787 frag("(n.kind IS ?)", &[text("md:section")])
1788 );
1789 assert_eq!(
1790 translate_predicate(&pred("level == \"1\""), &nodes),
1791 frag("(json_extract(n.attrs, '$.level') IS ?)", &[text("1")])
1792 );
1793 assert_eq!(
1794 translate_predicate(&pred("level == 1"), &nodes),
1795 frag(
1796 "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') = ?) IS 1)",
1797 &[SqlValue::Real(1.0)]
1798 )
1799 );
1800 assert_eq!(
1801 translate_predicate(&pred("attrs.a.b == \"c\""), &nodes),
1802 frag("(json_extract(n.attrs, '$.a.b') IS ?)", &[text("c")])
1803 );
1804 assert_eq!(
1806 translate_predicate(&pred("attrs.marker == \"x\""), &DOCS),
1807 None
1808 );
1809 }
1810
1811 #[test]
1812 fn doc_and_block_reach_through() {
1813 let blocks = TranslateCtx {
1814 target: Target::Blocks,
1815 self_alias: "b",
1816 ..DOCS
1817 };
1818 let f = translate_predicate(&pred("doc.type == \"lab-note\""), &blocks).expect("pushable");
1819 assert!(
1820 f.sql.contains("p.doc_id = d.doc_id AND p.key = 'type'"),
1821 "{}",
1822 f.sql
1823 );
1824 assert_eq!(
1825 translate_predicate(&pred("doc.$path == \"a.md\""), &blocks),
1826 frag("(d.path IS ?)", &[text("a.md")])
1827 );
1828 assert_eq!(
1829 translate_predicate(&pred("doc.format == \"markdown\""), &blocks),
1830 frag("(d.format IS ?)", &[text("markdown")])
1831 );
1832 assert_eq!(
1833 translate_predicate(&pred("doc.$id == \"d_1\""), &blocks),
1834 None
1835 );
1836 assert_eq!(translate_predicate(&pred("doc.a.b == 1"), &blocks), None);
1837 let nodes = TranslateCtx {
1838 target: Target::Nodes,
1839 self_alias: "n",
1840 ..DOCS
1841 };
1842 assert_eq!(
1843 translate_predicate(&pred("block.type == \"task\""), &nodes),
1844 frag(
1845 "((SELECT bb.type FROM blocks bb WHERE bb.block_id = n.block_id) IS ?)",
1846 &[text("task")]
1847 )
1848 );
1849 assert_eq!(
1850 translate_predicate(&pred("block.type == \"task\""), &blocks),
1851 None
1852 );
1853 assert_eq!(
1854 translate_predicate(&pred("section.level == 1"), &nodes),
1855 None
1856 );
1857 }
1858
1859 #[test]
1860 fn edges_push_their_five_fields_and_intrinsics() {
1861 let edges = TranslateCtx {
1862 target: Target::Edges,
1863 self_alias: "e",
1864 ..DOCS
1865 };
1866 assert_eq!(
1867 translate_predicate(&pred("predicate == \"references\""), &edges),
1868 frag("(e.predicate IS ?)", &[text("references")])
1869 );
1870 assert_eq!(
1871 translate_predicate(&pred("$dst_path == \"index.md\""), &edges),
1872 frag(
1873 "((SELECT dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
1874 &[text("index.md")]
1875 )
1876 );
1877 assert_eq!(translate_predicate(&pred("weight == 1"), &edges), None);
1878 }
1879
1880 #[test]
1881 fn unsafe_identifier_segments_are_never_inlined() {
1882 assert!(is_seg("layer") && is_seg("_x9"));
1883 assert!(!is_seg("") && !is_seg("9a") && !is_seg("a-b") && !is_seg("a'b"));
1884 assert_eq!(json_path(&["ok", "no-pe"]), None);
1885 assert_eq!(json_path(&["ok", "a_1"]).as_deref(), Some("$.ok.a_1"));
1886 assert_eq!(prop_scalar("d", "x'y"), None);
1887 }
1888}