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 { value: 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 ..
537 } if args.is_empty() && (name == "lower" || name == "upper") => {
538 let recv = translate_value(recv, ctx)?;
539 Some(Operand {
540 frag: Frag {
541 sql: format!("{name}({})", recv.sql),
542 params: recv.params,
543 },
544 ty: Ty::Text,
545 shape: Shape::Plain,
546 })
547 }
548 _ => None,
549 }
550}
551
552fn is_op(op: BinaryOp) -> Option<&'static str> {
556 Some(match op {
557 BinaryOp::Eq => "IS",
558 BinaryOp::Ne => "IS NOT",
559 BinaryOp::Lt => "<",
560 BinaryOp::Le => "<=",
561 BinaryOp::Gt => ">",
562 BinaryOp::Ge => ">=",
563 BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => {
564 return None;
565 }
566 })
567}
568
569fn typed_compare(op: BinaryOp, sql_op: &str, l: &Operand, r: &Operand) -> Option<Frag> {
592 let (read, konst, read_left) = match (&l.shape, &r.shape) {
593 (Shape::Json { .. } | Shape::Prop { .. }, Shape::Const(v)) => (&l.shape, v, true),
594 (Shape::Const(v), Shape::Json { .. } | Shape::Prop { .. }) => (&r.shape, v, false),
595 _ => return None,
596 };
597 let equality = matches!(op, BinaryOp::Eq | BinaryOp::Ne);
598 let wrap = if op == BinaryOp::Ne {
599 "IS NOT 1"
600 } else {
601 "IS 1"
602 };
603 let inner_op = match (equality, read_left, sql_op) {
606 (true, _, _) => "=",
607 (false, true, _) => sql_op,
608 (false, false, "<") => ">",
609 (false, false, "<=") => ">=",
610 (false, false, ">") => "<",
611 (false, false, ">=") => "<=",
612 (false, false, _) => return None,
613 };
614 let sides = |read_sql: &str| format!("{read_sql} {inner_op} ?");
615 let (sql, params) = match (read, konst) {
616 (_, Value::Bool(_)) if !equality => return None,
618 (Shape::Json { col, path }, Value::Bool(b)) => (
619 format!("(json_type({col}, '{path}') = '{b}') {wrap}"),
620 Vec::new(),
621 ),
622 (Shape::Json { col, path }, Value::Number(_)) => (
623 format!(
624 "(json_type({col}, '{path}') IN ('integer', 'real') AND {}) {wrap}",
625 sides(&format!("json_extract({col}, '{path}')"))
626 ),
627 vec![to_sql(konst)],
628 ),
629 (Shape::Prop { doc_alias, key }, Value::Bool(_)) => (
630 format!(
631 "{} {wrap}",
632 prop_row(
633 doc_alias,
634 key,
635 &format!("p.type = 'bool' AND {}", sides("p.val_bool"))
636 )?
637 ),
638 vec![to_sql(konst)],
639 ),
640 (Shape::Prop { doc_alias, key }, Value::Number(_)) => (
641 format!(
642 "{} {wrap}",
643 prop_row(
644 doc_alias,
645 key,
646 &format!("p.type = 'number' AND {}", sides("p.val_num"))
647 )?
648 ),
649 vec![to_sql(konst)],
650 ),
651 _ => return None,
652 };
653 Some(Frag {
654 sql: format!("({sql})"),
655 params,
656 })
657}
658
659pub fn translate_predicate(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
664 match e {
665 Expr::Logical {
668 op: LogicalOp::And,
669 left,
670 right,
671 ..
672 } => join2(
673 translate_predicate(left, ctx),
674 translate_predicate(right, ctx),
675 "AND",
676 ),
677 Expr::Binary {
678 op, left, right, ..
679 } => {
680 let sql_op = is_op(*op)?;
682 let l = operand(left, ctx)?;
683 let r = operand(right, ctx)?;
684 if !comparable(*op, l.ty, r.ty) {
687 return None;
688 }
689 if let Some(typed) = typed_compare(*op, sql_op, &l, &r) {
692 return Some(typed);
693 }
694 let op = sql_op;
695 let mut params = l.frag.params;
700 params.extend(r.frag.params);
701 Some(Frag {
702 sql: format!("({} {op} {})", l.frag.sql, r.frag.sql),
703 params,
704 })
705 }
706 Expr::Call {
707 recv: Some(recv),
708 name,
709 args,
710 ..
711 } if args.len() == 1 => {
712 let recv = translate_value(recv, ctx)?;
715 let arg = translate_value(&args[0], ctx)?;
716 let mut params = recv.params;
717 let sql = match name.as_str() {
718 "startsWith" => {
719 params.extend(arg.params.iter().cloned());
721 params.extend(arg.params);
722 format!("(substr({}, 1, length({a})) = {a})", recv.sql, a = arg.sql)
723 }
724 "endsWith" => {
725 params.extend(arg.params.iter().cloned());
729 params.extend(arg.params);
730 format!("(substr({}, -length({a})) = {a})", recv.sql, a = arg.sql)
731 }
732 "contains" => {
733 params.extend(arg.params);
734 format!("(instr({}, {}) > 0)", recv.sql, arg.sql)
735 }
736 _ => return None,
737 };
738 Some(Frag { sql, params })
739 }
740 _ => None,
741 }
742}
743
744fn join2(a: Option<Frag>, b: Option<Frag>, connective: &str) -> Option<Frag> {
747 let (a, b) = (a?, b?);
748 let mut params = a.params;
749 params.extend(b.params);
750 Some(Frag {
751 sql: format!("({} {connective} {})", a.sql, b.sql),
752 params,
753 })
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759 use oqx::ast::Where;
760
761 const DOCS: TranslateCtx<'static> = TranslateCtx {
762 target: Target::Docs,
763 self_alias: "d",
764 doc_alias: "d",
765 params: &[],
766 };
767
768 fn text(s: &str) -> SqlValue {
769 SqlValue::Text(s.to_owned())
770 }
771
772 fn frag(sql: &str, params: &[SqlValue]) -> Option<Frag> {
773 Some(Frag {
774 sql: sql.to_owned(),
775 params: params.to_vec(),
776 })
777 }
778
779 fn pred(src: &str) -> Expr {
781 let q = oqx::parse_string(&format!("from docs where {src}")).expect("parses");
782 match q.r#where {
783 Some(Where::Scalar { expr, .. }) => expr,
784 other => panic!("expected a single scalar predicate, got {other:?}"),
785 }
786 }
787
788 fn ident(name: &str) -> Box<Expr> {
789 Box::new(Expr::Ident {
790 name: name.to_owned(),
791 span: oqx::Span::EMPTY,
792 })
793 }
794
795 fn lit(s: &str) -> Box<Expr> {
796 Box::new(Expr::Lit {
797 value: Value::from(s),
798 span: oqx::Span::EMPTY,
799 })
800 }
801
802 fn eq(l: Box<Expr>, r: Box<Expr>) -> Box<Expr> {
803 Box::new(Expr::Binary {
804 op: BinaryOp::Eq,
805 left: l,
806 right: r,
807 span: oqx::Span::EMPTY,
808 })
809 }
810
811 #[test]
814 fn equality_is_null_safe_is() {
815 assert_eq!(
816 translate_predicate(&pred("$path == \"index.md\""), &DOCS),
817 frag("(d.path IS ?)", &[text("index.md")])
818 );
819 }
820
821 #[test]
822 fn inequality_is_null_safe_is_not() {
823 assert_eq!(
824 translate_predicate(&pred("$path != \"x\""), &DOCS),
825 frag("(d.path IS NOT ?)", &[text("x")])
826 );
827 }
828
829 #[test]
830 fn intrinsic_column_mapping() {
831 assert_eq!(
832 translate_predicate(&pred("$id == \"d_1\""), &DOCS),
833 frag("(d.doc_id IS ?)", &[text("d_1")])
834 );
835 }
836
837 #[test]
840 fn relational_ops_are_plain_comparisons() {
841 assert_eq!(
842 translate_predicate(&pred("$path < \"m\""), &DOCS),
843 frag("(d.path < ?)", &[text("m")])
844 );
845 assert_eq!(
846 translate_predicate(&pred("$path >= \"m\""), &DOCS),
847 frag("(d.path >= ?)", &[text("m")])
848 );
849 assert_eq!(translate_predicate(&pred("$path + 1"), &DOCS), None);
851 }
852
853 #[test]
856 fn starts_with_is_substr_equality() {
857 assert_eq!(
858 translate_predicate(&pred("$path.startsWith(\"lab/\")"), &DOCS),
859 frag(
860 "(substr(d.path, 1, length(?)) = ?)",
861 &[text("lab/"), text("lab/")]
862 )
863 );
864 }
865
866 #[test]
867 fn lower_then_starts_with_pushes_with_explicit_lower() {
868 assert_eq!(
869 translate_predicate(&pred("$path.lower().startsWith(\"lab/\")"), &DOCS),
870 frag(
871 "(substr(lower(d.path), 1, length(?)) = ?)",
872 &[text("lab/"), text("lab/")]
873 )
874 );
875 }
876
877 #[test]
878 fn contains_is_instr() {
879 assert_eq!(
880 translate_predicate(&pred("$path.contains(\"notes\")"), &DOCS),
881 frag("(instr(d.path, ?) > 0)", &[text("notes")])
882 );
883 }
884
885 #[test]
886 fn ends_with_is_negative_substr_equality() {
887 assert_eq!(
888 translate_predicate(&pred("$path.endsWith(\".md\")"), &DOCS),
889 frag(
890 "(substr(d.path, -length(?)) = ?)",
891 &[text(".md"), text(".md")]
892 )
893 );
894 }
895
896 #[test]
897 fn upper_wraps_the_receiver_in_value_position() {
898 assert_eq!(
899 translate_value(&pred("$path.upper()"), &DOCS),
900 frag("upper(d.path)", &[])
901 );
902 }
903
904 #[test]
907 fn bare_doc_property_is_the_scalar_in_scope_subquery() {
908 let f = translate_predicate(&pred("layer == \"canon\""), &DOCS).expect("pushable");
909 assert!(f.sql.contains("FROM properties p"), "{}", f.sql);
910 assert!(f.sql.contains("p.key = 'layer'"), "{}", f.sql);
911 assert!(f.sql.contains("p.card = 'scalar'"), "{}", f.sql);
912 assert!(
913 f.sql.starts_with('(') && f.sql.contains(" IS ?)"),
914 "{}",
915 f.sql
916 );
917 assert_eq!(f.params, vec![text("canon")]);
918 }
919
920 #[test]
921 fn updated_at_pushes_as_its_revisions_subquery() {
922 let f =
923 translate_predicate(&pred("$updated_at >= \"2026-01-01\""), &DOCS).expect("pushable");
924 assert!(
925 f.sql.contains("FROM revisions r JOIN commits c"),
926 "{}",
927 f.sql
928 );
929 }
930
931 #[test]
932 fn format_is_a_column_not_a_property() {
933 assert_eq!(
934 translate_predicate(&pred("format == \"markdown\""), &DOCS),
935 frag("(d.format IS ?)", &[text("markdown")])
936 );
937 }
938
939 #[test]
940 fn booleans_bind_as_one_and_zero() {
941 let blocks = TranslateCtx {
942 target: Target::Blocks,
943 self_alias: "b",
944 ..DOCS
945 };
946 assert_eq!(
949 translate_predicate(&pred("type == true"), &blocks).map(|f| f.params),
950 Some(vec![SqlValue::Integer(1)])
951 );
952 assert_eq!(
953 translate_predicate(&pred("type == false"), &blocks).map(|f| f.params),
954 Some(vec![SqlValue::Integer(0)])
955 );
956 assert_eq!(
957 translate_predicate(&pred("$ordinal < 1000"), &blocks).map(|f| f.params),
958 Some(vec![SqlValue::Real(1000.0)])
959 );
960 assert_eq!(
961 translate_predicate(&pred("$path == null"), &DOCS).map(|f| f.params),
962 Some(vec![SqlValue::Null])
963 );
964 }
965
966 const REPRESENTATIVES: [(Ty, &str); 7] = [
971 (Ty::Text, "$path"),
972 (Ty::Int, "$ordinal"),
973 (Ty::Num, "1"),
974 (Ty::Bool, "true"),
975 (Ty::Null, "null"),
976 (Ty::Json, "checked"),
977 (Ty::Prop, "doc.layer"),
978 ];
979
980 #[test]
981 fn representatives_carry_their_type() {
982 let blocks = TranslateCtx {
983 target: Target::Blocks,
984 self_alias: "b",
985 ..DOCS
986 };
987 for (ty, src) in REPRESENTATIVES {
988 let (_, got) = typed_value(&pred(src), &blocks).expect(src);
989 assert_eq!(got, ty, "{src}");
990 }
991 assert_eq!(
992 typed_value(&pred("attrs.a.b"), &blocks).map(|(_, t)| t),
993 Some(Ty::Json)
994 );
995 assert_eq!(
996 typed_value(&pred("$depth"), &blocks).map(|(_, t)| t),
997 Some(Ty::Int)
998 );
999 assert_eq!(
1000 typed_value(&pred("type.lower()"), &blocks).map(|(_, t)| t),
1001 Some(Ty::Text)
1002 );
1003 assert_eq!(
1004 typed_value(&pred("checked.upper()"), &blocks).map(|(_, t)| t),
1005 Some(Ty::Text)
1006 );
1007 assert_eq!(
1008 typed_value(&pred("layer"), &DOCS).map(|(_, t)| t),
1009 Some(Ty::Prop)
1010 );
1011 assert_eq!(
1012 typed_value(&pred("format"), &DOCS).map(|(_, t)| t),
1013 Some(Ty::Text)
1014 );
1015 }
1016
1017 #[test]
1018 fn the_comparison_matrix_decides_every_cell() {
1019 const P: bool = true;
1021 const D: bool = false;
1022 const T: bool = true;
1024 #[rustfmt::skip]
1027 const EQUALITY: [[bool; 7]; 7] = [
1028 [P, P, P, P, P, P, P],
1029 [P, P, P, D, P, D, D],
1030 [P, P, P, D, P, T, T],
1031 [P, D, D, D, P, T, T],
1032 [P, P, P, P, P, P, D],
1033 [P, D, T, T, P, D, D],
1034 [P, D, T, T, D, D, D],
1035 ];
1036 #[rustfmt::skip]
1039 const RELATIONAL: [[bool; 7]; 7] = [
1040 [P, D, D, D, D, D, D],
1041 [D, P, P, D, D, D, D],
1042 [D, P, P, D, D, T, T],
1043 [D, D, D, D, D, D, D],
1044 [D, D, D, D, D, D, D],
1045 [D, D, T, D, D, D, D],
1046 [D, D, T, D, D, D, D],
1047 ];
1048 let blocks = TranslateCtx {
1049 target: Target::Blocks,
1050 self_alias: "b",
1051 ..DOCS
1052 };
1053 let ops = [
1054 (BinaryOp::Eq, "==", &EQUALITY),
1055 (BinaryOp::Ne, "!=", &EQUALITY),
1056 (BinaryOp::Lt, "<", &RELATIONAL),
1057 (BinaryOp::Le, "<=", &RELATIONAL),
1058 (BinaryOp::Gt, ">", &RELATIONAL),
1059 (BinaryOp::Ge, ">=", &RELATIONAL),
1060 ];
1061 for (i, (a, l)) in REPRESENTATIVES.iter().enumerate() {
1062 for (j, (b, r)) in REPRESENTATIVES.iter().enumerate() {
1063 for (op, spelled, matrix) in ops {
1064 let want = matrix[i][j];
1065 assert_eq!(matrix[j][i], want, "the matrix is symmetric ({a:?}, {b:?})");
1066 assert_eq!(
1067 comparable(op, *a, *b),
1068 want,
1069 "comparable({spelled}, {a:?}, {b:?})"
1070 );
1071 let src = format!("{l} {spelled} {r}");
1072 assert_eq!(
1073 translate_predicate(&pred(&src), &blocks).is_some(),
1074 want,
1075 "{src}"
1076 );
1077 }
1078 }
1079 }
1080 }
1081
1082 #[test]
1083 fn the_spec_shapes_of_decline_a() {
1084 let blocks = TranslateCtx {
1085 target: Target::Blocks,
1086 self_alias: "b",
1087 ..DOCS
1088 };
1089 let typed = |src: &str, ctx: &TranslateCtx<'_>| {
1092 let f = translate_predicate(&pred(src), ctx).expect(src);
1093 assert!(
1094 f.sql.contains("json_type(") || f.sql.contains("p.type = "),
1095 "{src}: {}",
1096 f.sql
1097 );
1098 assert!(
1099 f.sql.ends_with(" IS 1)") || f.sql.ends_with(" IS NOT 1)"),
1100 "{src}: {}",
1101 f.sql
1102 );
1103 };
1104 typed("checked == 1", &blocks);
1105 typed("checked == true", &blocks);
1106 typed("attrs.checked == true", &blocks);
1107 typed("verified == 1", &DOCS);
1109 typed("verified == true", &DOCS);
1110 typed("era < 1000", &DOCS);
1111 typed("doc.era < 1000", &blocks);
1112 assert_eq!(
1114 translate_predicate(&pred("$ordinal == true"), &blocks),
1115 None
1116 );
1117 assert_eq!(
1118 translate_predicate(&pred("$ordinal == 1"), &blocks),
1119 frag("(b.ordinal IS ?)", &[SqlValue::Real(1.0)])
1120 );
1121 assert_eq!(translate_predicate(&pred("tags != null"), &DOCS), None);
1123 assert_eq!(translate_predicate(&pred("tags == null"), &DOCS), None);
1124 assert_eq!(
1125 translate_predicate(&pred("doc.tags == null"), &blocks),
1126 None
1127 );
1128 assert_eq!(
1129 translate_predicate(&pred("checked == null"), &blocks),
1130 frag(
1131 "(json_extract(b.attrs, '$.checked') IS ?)",
1132 &[SqlValue::Null]
1133 )
1134 );
1135 assert_eq!(
1136 translate_predicate(&pred("$ordinal != null"), &blocks),
1137 frag("(b.ordinal IS NOT ?)", &[SqlValue::Null])
1138 );
1139 assert_eq!(
1141 translate_predicate(&pred("checked == \"x\""), &blocks),
1142 frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1143 );
1144 assert!(translate_predicate(&pred("layer == \"canon\""), &DOCS).is_some());
1145 assert!(translate_predicate(&pred("$ordinal == \"1\""), &blocks).is_some());
1146 assert!(translate_predicate(&pred("$ordinal != \"1\""), &blocks).is_some());
1147 assert_eq!(
1150 translate_predicate(&pred("$ordinal < \"3\""), &blocks),
1151 None
1152 );
1153 assert_eq!(
1154 translate_predicate(&pred("\"3\" >= $ordinal"), &blocks),
1155 None
1156 );
1157 assert_eq!(translate_predicate(&pred("$path > 5"), &DOCS), None);
1158 assert_eq!(translate_predicate(&pred("type <= 1"), &blocks), None);
1159 assert_eq!(
1160 translate_predicate(&pred("$ordinal < 3"), &blocks),
1161 frag("(b.ordinal < ?)", &[SqlValue::Real(3.0)])
1162 );
1163 assert_eq!(
1164 translate_predicate(&pred("$path > \"m\""), &DOCS),
1165 frag("(d.path > ?)", &[text("m")])
1166 );
1167 assert_eq!(
1169 translate_predicate(&pred("$ordinal == checked"), &blocks),
1170 None
1171 );
1172 assert_eq!(
1173 translate_predicate(&pred("checked == level"), &blocks),
1174 None
1175 );
1176 assert_eq!(
1177 translate_predicate(&pred("doc.era == doc.year"), &blocks),
1178 None
1179 );
1180 assert_eq!(translate_predicate(&pred("level < \"x\""), &blocks), None);
1181 typed("level < 3", &blocks);
1182 assert_eq!(translate_predicate(&pred("true == false"), &blocks), None);
1183 assert_eq!(translate_predicate(&pred("checked < true"), &blocks), None);
1184 assert_eq!(
1185 translate_predicate(&pred("doc.verified >= false"), &blocks),
1186 None
1187 );
1188 assert_eq!(translate_predicate(&pred("$ordinal > null"), &blocks), None);
1189 assert!(translate_predicate(&pred("$ordinal == $depth"), &blocks).is_some());
1190 assert!(translate_predicate(&pred("$ordinal <= $depth"), &blocks).is_some());
1191 assert!(translate_predicate(&pred("type == null"), &blocks).is_some());
1192 }
1193
1194 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";
1199
1200 fn prop_sql(key: &str, select: &str, wrap: &str) -> String {
1201 format!(
1202 "((SELECT {select} {}) {wrap})",
1203 PROP_SCOPE.replace('K', key)
1204 )
1205 }
1206
1207 #[test]
1208 fn json_against_a_boolean_tests_json_type_for_the_literal() {
1209 let blocks = TranslateCtx {
1210 target: Target::Blocks,
1211 self_alias: "b",
1212 ..DOCS
1213 };
1214 assert_eq!(
1216 translate_predicate(&pred("checked == true"), &blocks),
1217 frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1218 );
1219 assert_eq!(
1220 translate_predicate(&pred("checked == false"), &blocks),
1221 frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1222 );
1223 assert_eq!(
1224 translate_predicate(&pred("checked != true"), &blocks),
1225 frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1226 );
1227 assert_eq!(
1228 translate_predicate(&pred("false == attrs.checked"), &blocks),
1229 frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1230 );
1231 let params = [Value::Bool(true)];
1233 let e = Expr::Binary {
1234 op: BinaryOp::Ne,
1235 left: ident("checked"),
1236 right: Box::new(Expr::Binding {
1237 index: 0,
1238 span: oqx::Span::EMPTY,
1239 }),
1240 span: oqx::Span::EMPTY,
1241 };
1242 assert_eq!(
1243 translate_predicate(
1244 &e,
1245 &TranslateCtx {
1246 params: ¶ms,
1247 ..blocks
1248 }
1249 ),
1250 frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1251 );
1252 }
1253
1254 #[test]
1255 fn json_against_a_number_tests_the_numeric_types_then_compares() {
1256 let nodes = TranslateCtx {
1257 target: Target::Nodes,
1258 self_alias: "n",
1259 ..DOCS
1260 };
1261 let shape = |op: &str, wrap: &str| {
1262 format!(
1263 "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') {op} ?) {wrap})"
1264 )
1265 };
1266 let two = [SqlValue::Real(2.0)];
1267 for (src, op, wrap) in [
1268 ("level == 2", "=", "IS 1"),
1269 ("level != 2", "=", "IS NOT 1"),
1270 ("level < 2", "<", "IS 1"),
1271 ("level <= 2", "<=", "IS 1"),
1272 ("level > 2", ">", "IS 1"),
1273 ("level >= 2", ">=", "IS 1"),
1274 ] {
1275 assert_eq!(
1276 translate_predicate(&pred(src), &nodes),
1277 frag(&shape(op, wrap), &two),
1278 "{src}"
1279 );
1280 }
1281 assert_eq!(
1283 translate_predicate(&pred("2 <= attrs.level"), &nodes),
1284 frag(&shape(">=", "IS 1"), &two)
1285 );
1286 assert_eq!(
1287 translate_predicate(&pred("2 > level"), &nodes),
1288 frag(&shape("<", "IS 1"), &two)
1289 );
1290 assert_eq!(
1291 translate_predicate(&pred("2 != level"), &nodes),
1292 frag(&shape("=", "IS NOT 1"), &two)
1293 );
1294 }
1295
1296 #[test]
1297 fn property_against_a_boolean_tests_p_type_bool_in_the_scalar_row_subquery() {
1298 let blocks = TranslateCtx {
1299 target: Target::Blocks,
1300 self_alias: "b",
1301 ..DOCS
1302 };
1303 assert_eq!(
1304 translate_predicate(&pred("verified == true"), &DOCS),
1305 frag(
1306 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1307 &[SqlValue::Integer(1)]
1308 )
1309 );
1310 assert_eq!(
1311 translate_predicate(&pred("verified != false"), &DOCS),
1312 frag(
1313 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS NOT 1"),
1314 &[SqlValue::Integer(0)]
1315 )
1316 );
1317 assert_eq!(
1319 translate_predicate(&pred("doc.verified == false"), &blocks),
1320 frag(
1321 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1322 &[SqlValue::Integer(0)]
1323 )
1324 );
1325 assert_eq!(
1326 translate_predicate(&pred("true == verified"), &DOCS),
1327 frag(
1328 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1329 &[SqlValue::Integer(1)]
1330 )
1331 );
1332 }
1333
1334 #[test]
1335 fn property_against_a_number_tests_p_type_number_in_the_scalar_row_subquery() {
1336 let blocks = TranslateCtx {
1337 target: Target::Blocks,
1338 self_alias: "b",
1339 ..DOCS
1340 };
1341 let thousand = [SqlValue::Real(1000.0)];
1342 for (src, op, wrap) in [
1343 ("era == 1000", "=", "IS 1"),
1344 ("era != 1000", "=", "IS NOT 1"),
1345 ("era < 1000", "<", "IS 1"),
1346 ("era <= 1000", "<=", "IS 1"),
1347 ("era > 1000", ">", "IS 1"),
1348 ("era >= 1000", ">=", "IS 1"),
1349 ] {
1350 assert_eq!(
1351 translate_predicate(&pred(src), &DOCS),
1352 frag(
1353 &prop_sql(
1354 "era",
1355 &format!("p.type = 'number' AND p.val_num {op} ?"),
1356 wrap
1357 ),
1358 &thousand
1359 ),
1360 "{src}"
1361 );
1362 }
1363 assert_eq!(
1364 translate_predicate(&pred("doc.era >= 1000"), &blocks),
1365 frag(
1366 &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1367 &thousand
1368 )
1369 );
1370 assert_eq!(
1372 translate_predicate(&pred("1000 > era"), &DOCS),
1373 frag(
1374 &prop_sql("era", "p.type = 'number' AND p.val_num < ?", "IS 1"),
1375 &thousand
1376 )
1377 );
1378 assert_eq!(
1379 translate_predicate(&pred("1000 <= era"), &DOCS),
1380 frag(
1381 &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1382 &thousand
1383 )
1384 );
1385 }
1386
1387 #[test]
1388 fn typed_pushes_compose_under_and_and_keep_the_untyped_forms() {
1389 let blocks = TranslateCtx {
1390 target: Target::Blocks,
1391 self_alias: "b",
1392 ..DOCS
1393 };
1394 let e = Expr::Logical {
1395 op: LogicalOp::And,
1396 left: eq(ident("type"), lit("task")),
1397 right: Box::new(Expr::Binary {
1398 op: BinaryOp::Eq,
1399 left: ident("checked"),
1400 right: Box::new(Expr::Lit {
1401 value: Value::Bool(false),
1402 span: oqx::Span::EMPTY,
1403 }),
1404 span: oqx::Span::EMPTY,
1405 }),
1406 span: oqx::Span::EMPTY,
1407 };
1408 assert_eq!(
1409 translate_predicate(&e, &blocks),
1410 frag(
1411 "((b.type IS ?) AND ((json_type(b.attrs, '$.checked') = 'false') IS 1))",
1412 &[text("task")]
1413 )
1414 );
1415 assert_eq!(
1417 translate_predicate(&pred("checked == \"x\""), &blocks),
1418 frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1419 );
1420 assert_eq!(
1421 translate_predicate(&pred("checked != null"), &blocks),
1422 frag(
1423 "(json_extract(b.attrs, '$.checked') IS NOT ?)",
1424 &[SqlValue::Null]
1425 )
1426 );
1427 assert_eq!(
1429 prop_row("d", "x'y", "p.type = 'number' AND p.val_num = ?"),
1430 None
1431 );
1432 }
1433
1434 #[test]
1435 fn bindings_are_typed_by_their_value() {
1436 let blocks = TranslateCtx {
1437 target: Target::Blocks,
1438 self_alias: "b",
1439 ..DOCS
1440 };
1441 let params = [
1442 Value::from("s"),
1443 Value::from(1.0),
1444 Value::Bool(true),
1445 Value::Null,
1446 Value::Array(vec![]),
1447 ];
1448 let ctx = TranslateCtx {
1449 params: ¶ms,
1450 ..blocks
1451 };
1452 let against = |i: usize, rhs: &str| {
1453 let e = Expr::Binary {
1454 op: BinaryOp::Eq,
1455 left: Box::new(Expr::Binding {
1456 index: i,
1457 span: oqx::Span::EMPTY,
1458 }),
1459 right: Box::new(pred(rhs)),
1460 span: oqx::Span::EMPTY,
1461 };
1462 translate_predicate(&e, &ctx).is_some()
1463 };
1464 assert!(against(0, "checked"));
1467 assert!(against(1, "checked"));
1468 assert!(against(2, "checked"));
1469 assert!(!against(2, "$ordinal"));
1470 assert!(against(1, "$ordinal"));
1471 assert!(against(3, "checked"));
1473 assert!(!against(3, "doc.tags"));
1474 assert!(against(9, "checked"));
1476 assert!(!against(9, "doc.tags"));
1477 assert!(!against(4, "type"));
1479 }
1480
1481 #[test]
1484 fn relation_and_handle_names_are_not_property_reads() {
1485 let blocks = TranslateCtx {
1486 target: Target::Blocks,
1487 self_alias: "b",
1488 ..DOCS
1489 };
1490 let nodes = TranslateCtx {
1491 target: Target::Nodes,
1492 self_alias: "n",
1493 ..DOCS
1494 };
1495 let edges = TranslateCtx {
1496 target: Target::Edges,
1497 self_alias: "e",
1498 ..DOCS
1499 };
1500 for (ctx, target) in [
1501 (&DOCS, Target::Docs),
1502 (&blocks, Target::Blocks),
1503 (&nodes, Target::Nodes),
1504 (&edges, Target::Edges),
1505 ] {
1506 for name in non_property_handles(target) {
1507 let src = format!("{name} == null");
1508 assert_eq!(
1509 translate_predicate(&pred(&src), ctx),
1510 None,
1511 "{target:?}: {src}"
1512 );
1513 let src = format!("{name} == \"x\"");
1514 assert_eq!(
1515 translate_predicate(&pred(&src), ctx),
1516 None,
1517 "{target:?}: {src}"
1518 );
1519 }
1520 }
1521 assert_eq!(translate_predicate(&pred("nodes == null"), &DOCS), None);
1523 assert_eq!(
1524 translate_predicate(&pred("frontmatter == null"), &DOCS),
1525 None
1526 );
1527 assert_eq!(translate_predicate(&pred("nodes == null"), &blocks), None);
1528 assert_eq!(translate_predicate(&pred("attrs == null"), &blocks), None);
1529 assert_eq!(
1531 translate_predicate(&pred("doc.nodes == null"), &blocks),
1532 None
1533 );
1534 assert_eq!(
1535 translate_predicate(&pred("doc.frontmatter == null"), &blocks),
1536 None
1537 );
1538 assert_eq!(translate_predicate(&pred("doc.doc == null"), &DOCS), None);
1539 assert!(translate_predicate(&pred("doc.layer == \"canon\""), &blocks).is_some());
1540 assert!(translate_predicate(&pred("section == \"x\""), &DOCS).is_some());
1542 assert!(translate_predicate(&pred("frontmatter == \"x\""), &blocks).is_some());
1543 }
1544
1545 #[test]
1551 fn handle_sets_match_the_store_context() {
1552 use std::collections::HashMap;
1553
1554 use omgbase_reconcile::Config;
1555 use omgbase_store::{BatchItem, Store};
1556 use oqx::DataContext;
1557
1558 use crate::context::StoreContext;
1559
1560 let mut store = Store::open_in_memory().expect("store");
1561 let repo = store.create_repo("handles").expect("repo");
1562 let items = [
1563 BatchItem::observed(
1564 "a.md",
1565 "---\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",
1566 ),
1567 BatchItem::observed("b.md", "# B\n\nBack to [a](a.md).\n"),
1568 ];
1569 store
1570 .observe_batch(
1571 &repo,
1572 &items,
1573 "2026-09-26T00:00:00.000Z",
1574 &Config::default(),
1575 )
1576 .expect("observe");
1577 let ctx = StoreContext::new(store.conn(), &repo, HashMap::new());
1578
1579 let mut universe: Vec<&str> = [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges]
1580 .into_iter()
1581 .flat_map(|t| non_property_handles(t).iter().copied())
1582 .collect();
1583 universe.extend([
1584 "layer",
1585 "title",
1586 "verified",
1587 "checked",
1588 "level",
1589 "format",
1590 "type",
1591 "text",
1592 "kind",
1593 "name",
1594 "value",
1595 "predicate",
1596 "key",
1597 "nope",
1598 ]);
1599 universe.sort_unstable();
1600 universe.dedup();
1601
1602 let is_shape = |v: &Value| matches!(v, Value::Array(_) | Value::Object(_));
1603 for target in [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges] {
1604 let rows = ctx.to_rows(&ctx.root(target.as_str()));
1606 assert!(!rows.is_empty(), "{target:?} has rows");
1607 let set = non_property_handles(target);
1608 for name in &universe {
1609 let mut shaped = 0;
1610 for row in &rows {
1611 let v = ctx.get(row, name).expect("get");
1612 if set.contains(name) {
1613 assert!(
1614 v.is_absent() || is_shape(&v),
1615 "{target:?}.{name} read as a scalar: {v:?}"
1616 );
1617 } else {
1618 assert!(!is_shape(&v), "{target:?}.{name} is a handle: {v:?}");
1619 }
1620 shaped += usize::from(is_shape(&v));
1621 }
1622 if set.contains(name) {
1623 assert!(
1624 shaped > 0,
1625 "{target:?}.{name} never resolved to rows or a bag"
1626 );
1627 }
1628 }
1629 }
1630 }
1631
1632 #[test]
1635 fn reserved_bare_basename_is_not_pushed() {
1636 assert_eq!(translate_predicate(&pred("path == \"x\""), &DOCS), None);
1637 assert_eq!(translate_predicate(&pred("body == \"x\""), &DOCS), None);
1638 assert_eq!(translate_predicate(&pred("doc.path == \"x\""), &DOCS), None);
1639 }
1640
1641 #[test]
1642 fn docs_body_and_computed_intrinsics_are_not_columns() {
1643 assert_eq!(translate_predicate(&pred("$body == \"x\""), &DOCS), None);
1644 assert_eq!(translate_predicate(&pred("$title == \"x\""), &DOCS), None);
1645 assert_eq!(translate_predicate(&pred("$tags == \"x\""), &DOCS), None);
1646 }
1647
1648 #[test]
1649 fn matches_needs_a_regexp_udf() {
1650 assert_eq!(
1651 translate_predicate(&pred("$path.matches(\"^lab/\")"), &DOCS),
1652 None
1653 );
1654 }
1655
1656 #[test]
1657 fn negation_as_a_nested_expr_is_not_and_safe() {
1658 let e = Expr::Unary {
1659 op: oqx::ast::UnaryOp::Not,
1660 expr: ident("$path"),
1661 span: oqx::Span::EMPTY,
1662 };
1663 assert_eq!(translate_predicate(&e, &DOCS), None);
1664 }
1665
1666 #[test]
1667 fn disjunction_as_a_nested_expr_is_declined() {
1668 let e = Expr::Logical {
1669 op: LogicalOp::Or,
1670 left: eq(ident("$path"), lit("a")),
1671 right: eq(ident("$path"), lit("b")),
1672 span: oqx::Span::EMPTY,
1673 };
1674 assert_eq!(translate_predicate(&e, &DOCS), None);
1675 }
1676
1677 #[test]
1678 fn unmapped_node_intrinsic_is_not_pushed() {
1679 let nodes = TranslateCtx {
1680 target: Target::Nodes,
1681 self_alias: "n",
1682 ..DOCS
1683 };
1684 assert_eq!(
1685 translate_predicate(&pred("$locator == \"x\""), &nodes),
1686 None
1687 );
1688 let blocks = TranslateCtx {
1690 target: Target::Blocks,
1691 self_alias: "b",
1692 ..DOCS
1693 };
1694 assert_eq!(
1695 translate_predicate(&pred("$updated_at == \"x\""), &blocks),
1696 None
1697 );
1698 }
1699
1700 #[test]
1701 fn range_membership_in_and_bare_idents_are_declined() {
1702 assert_eq!(translate_predicate(&pred("era in 800..1680"), &DOCS), None);
1703 assert_eq!(
1704 translate_predicate(&pred("\"a\" in list(tags)"), &DOCS),
1705 None
1706 );
1707 assert_eq!(translate_predicate(&pred("verified"), &DOCS), None);
1708 assert_eq!(translate_predicate(&pred("doc.verified"), &DOCS), None);
1709 assert_eq!(translate_predicate(&pred("size(tags) > 1"), &DOCS), None);
1710 assert_eq!(translate_predicate(&pred("$self.text(\"x\")"), &DOCS), None);
1711 assert_eq!(translate_predicate(&pred("$it == \"x\""), &DOCS), None);
1712 assert_eq!(translate_predicate(&pred("^slug == \"x\""), &DOCS), None);
1713 assert_eq!(
1714 translate_predicate(&pred("frontmatter.era == 1"), &DOCS),
1715 None
1716 );
1717 }
1718
1719 #[test]
1722 fn and_composes_two_pushable_comparisons() {
1723 let e = Expr::Logical {
1724 op: LogicalOp::And,
1725 left: eq(ident("$path"), lit("a")),
1726 right: Box::new(Expr::Binary {
1727 op: BinaryOp::Ne,
1728 left: ident("$id"),
1729 right: lit("d_2"),
1730 span: oqx::Span::EMPTY,
1731 }),
1732 span: oqx::Span::EMPTY,
1733 };
1734 assert_eq!(
1735 translate_predicate(&e, &DOCS),
1736 frag(
1737 "((d.path IS ?) AND (d.doc_id IS NOT ?))",
1738 &[text("a"), text("d_2")]
1739 )
1740 );
1741 }
1742
1743 #[test]
1744 fn and_declines_wholesale_if_either_side_is_not_pushable() {
1745 let e = Expr::Logical {
1746 op: LogicalOp::And,
1747 left: eq(ident("$path"), lit("a")),
1748 right: eq(ident("$body"), lit("x")),
1750 span: oqx::Span::EMPTY,
1751 };
1752 assert_eq!(translate_predicate(&e, &DOCS), None);
1753 }
1754
1755 #[test]
1756 fn resolves_a_binding_to_its_param_value() {
1757 let e = eq(
1758 ident("$path"),
1759 Box::new(Expr::Binding {
1760 index: 0,
1761 span: oqx::Span::EMPTY,
1762 }),
1763 );
1764 let params = [Value::from("from-binding.md")];
1765 let ctx = TranslateCtx {
1766 params: ¶ms,
1767 ..DOCS
1768 };
1769 assert_eq!(
1770 translate_predicate(&e, &ctx),
1771 frag("(d.path IS ?)", &[text("from-binding.md")])
1772 );
1773 assert_eq!(
1775 translate_predicate(&e, &DOCS),
1776 frag("(d.path IS ?)", &[SqlValue::Null])
1777 );
1778 }
1779
1780 #[test]
1783 fn blocks_and_nodes_flatten_bare_identifiers_into_attrs() {
1784 let blocks = TranslateCtx {
1785 target: Target::Blocks,
1786 self_alias: "b",
1787 ..DOCS
1788 };
1789 let both = Expr::Logical {
1792 op: LogicalOp::And,
1793 left: eq(ident("type"), lit("task")),
1794 right: eq(ident("marker"), lit("x")),
1795 span: oqx::Span::EMPTY,
1796 };
1797 assert_eq!(
1798 translate_predicate(&both, &blocks),
1799 frag(
1800 "((b.type IS ?) AND (json_extract(b.attrs, '$.marker') IS ?))",
1801 &[text("task"), text("x")]
1802 )
1803 );
1804 assert_eq!(
1805 translate_predicate(&pred("attrs.marker == \"x\""), &blocks),
1806 frag("(json_extract(b.attrs, '$.marker') IS ?)", &[text("x")])
1807 );
1808 assert_eq!(
1811 translate_predicate(&pred("attrs.checked == true"), &blocks),
1812 frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1813 );
1814 let nodes = TranslateCtx {
1815 target: Target::Nodes,
1816 self_alias: "n",
1817 ..DOCS
1818 };
1819 assert_eq!(
1820 translate_predicate(&pred("kind == \"md:section\""), &nodes),
1821 frag("(n.kind IS ?)", &[text("md:section")])
1822 );
1823 assert_eq!(
1824 translate_predicate(&pred("level == \"1\""), &nodes),
1825 frag("(json_extract(n.attrs, '$.level') IS ?)", &[text("1")])
1826 );
1827 assert_eq!(
1828 translate_predicate(&pred("level == 1"), &nodes),
1829 frag(
1830 "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') = ?) IS 1)",
1831 &[SqlValue::Real(1.0)]
1832 )
1833 );
1834 assert_eq!(
1835 translate_predicate(&pred("attrs.a.b == \"c\""), &nodes),
1836 frag("(json_extract(n.attrs, '$.a.b') IS ?)", &[text("c")])
1837 );
1838 assert_eq!(
1840 translate_predicate(&pred("attrs.marker == \"x\""), &DOCS),
1841 None
1842 );
1843 }
1844
1845 #[test]
1846 fn doc_and_block_reach_through() {
1847 let blocks = TranslateCtx {
1848 target: Target::Blocks,
1849 self_alias: "b",
1850 ..DOCS
1851 };
1852 let f = translate_predicate(&pred("doc.type == \"lab-note\""), &blocks).expect("pushable");
1853 assert!(
1854 f.sql.contains("p.doc_id = d.doc_id AND p.key = 'type'"),
1855 "{}",
1856 f.sql
1857 );
1858 assert_eq!(
1859 translate_predicate(&pred("doc.$path == \"a.md\""), &blocks),
1860 frag("(d.path IS ?)", &[text("a.md")])
1861 );
1862 assert_eq!(
1863 translate_predicate(&pred("doc.format == \"markdown\""), &blocks),
1864 frag("(d.format IS ?)", &[text("markdown")])
1865 );
1866 assert_eq!(
1867 translate_predicate(&pred("doc.$id == \"d_1\""), &blocks),
1868 None
1869 );
1870 assert_eq!(translate_predicate(&pred("doc.a.b == 1"), &blocks), None);
1871 let nodes = TranslateCtx {
1872 target: Target::Nodes,
1873 self_alias: "n",
1874 ..DOCS
1875 };
1876 assert_eq!(
1877 translate_predicate(&pred("block.type == \"task\""), &nodes),
1878 frag(
1879 "((SELECT bb.type FROM blocks bb WHERE bb.block_id = n.block_id) IS ?)",
1880 &[text("task")]
1881 )
1882 );
1883 assert_eq!(
1884 translate_predicate(&pred("block.type == \"task\""), &blocks),
1885 None
1886 );
1887 assert_eq!(
1888 translate_predicate(&pred("section.level == 1"), &nodes),
1889 None
1890 );
1891 }
1892
1893 #[test]
1894 fn edges_push_their_five_fields_and_intrinsics() {
1895 let edges = TranslateCtx {
1896 target: Target::Edges,
1897 self_alias: "e",
1898 ..DOCS
1899 };
1900 assert_eq!(
1901 translate_predicate(&pred("predicate == \"references\""), &edges),
1902 frag("(e.predicate IS ?)", &[text("references")])
1903 );
1904 assert_eq!(
1905 translate_predicate(&pred("$dst_path == \"index.md\""), &edges),
1906 frag(
1907 "((SELECT dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
1908 &[text("index.md")]
1909 )
1910 );
1911 assert_eq!(translate_predicate(&pred("weight == 1"), &edges), None);
1912 }
1913
1914 #[test]
1915 fn unsafe_identifier_segments_are_never_inlined() {
1916 assert!(is_seg("layer") && is_seg("_x9"));
1917 assert!(!is_seg("") && !is_seg("9a") && !is_seg("a-b") && !is_seg("a'b"));
1918 assert_eq!(json_path(&["ok", "no-pe"]), None);
1919 assert_eq!(json_path(&["ok", "a_1"]).as_deref(), Some("$.ok.a_1"));
1920 assert_eq!(prop_scalar("d", "x'y"), None);
1921 }
1922}