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