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_links",
207 "in_links",
208 "revisions",
209 "data",
210 "frontmatter",
211 "inline",
212 ],
213 Target::Blocks => &[
214 "block",
215 "doc",
216 "children",
217 "nodes",
218 "out_links",
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::Links => &["doc", "dst"],
232 Target::Revisions => &["doc"],
234 }
235}
236
237pub(crate) fn row_attributes(t: Target) -> &'static [&'static str] {
244 match t {
245 Target::Docs => &["id", "path", "raw", "hash", "updated_at", "title", "tags"],
246 Target::Blocks => &["id", "path", "raw", "hash", "updated_at"],
247 Target::Nodes | Target::Links | Target::Revisions => &["id", "path"],
248 }
249}
250
251fn attribute_sql(name: &str, ctx: &TranslateCtx<'_>) -> Option<(String, Ty)> {
260 let (s, d) = (ctx.self_alias, ctx.doc_alias);
261 let sql = match (ctx.target, name) {
262 (Target::Docs, "id") => format!("{s}.doc_id"),
263 (Target::Docs, "path") => format!("('/' || {d}.path)"),
264 (Target::Docs, "hash") => format!("lower(hex({s}.file_hash))"),
265 (Target::Docs, "updated_at") => format!(
266 "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = {s}.current_rev)"
267 ),
268 (Target::Blocks, "id") => format!("{s}.block_id"),
269 (Target::Blocks, "path") => format!("('/' || {d}.path)"),
270 (Target::Blocks, "hash") => format!("lower(hex({s}.raw_hash))"),
271 (Target::Nodes, "id") => format!("{s}.node_id"),
272 (Target::Nodes, "path") => format!("('/' || {d}.path)"),
273 (Target::Links, "id") => format!("{s}.edge_id"),
274 (Target::Links, "path") => format!("('/' || {d}.path)"),
275 _ => return None,
276 };
277 Some((sql, Ty::Text))
278}
279
280fn edge_field(name: &str, s: &str) -> Option<Operand> {
285 let sql = match name {
286 "doc_id" => format!("{s}.src_doc"),
287 "block_id" => format!("{s}.src_block"),
288 "via_id" => format!("{s}.via_node"),
289 "dst_id" => format!("{s}.dst_node"),
290 "dst_path" => {
291 format!("(SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = {s}.dst_node)")
292 }
293 "uri" => format!("(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = {s}.dst_node)"),
294 "field" => format!("{s}.src_field"),
295 "predicate" | "provenance" | "dst_kind" | "anchor" => format!("{s}.{name}"),
296 _ => return None,
297 };
298 Operand::text(sql)
299}
300
301fn is_seg(s: &str) -> bool {
303 let mut chars = s.chars();
304 chars
305 .next()
306 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
307 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
308}
309
310fn prop_row(doc_id: &str, key: &str, select: &str, source: Option<&str>) -> Option<String> {
326 if !is_seg(key) {
327 return None;
328 }
329 let src = source.map_or(String::new(), |s| format!(" AND p.source = '{s}'"));
330 let src2 = source.map_or(String::new(), |s| format!(" AND p2.source = '{s}'"));
331 Some(format!(
332 "(SELECT {select} FROM properties p \
333 WHERE p.doc_id = {doc_id} AND p.key = '{key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL{src} \
334 AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = {doc_id} AND p2.key = '{key}' AND p2.deleted_commit IS NULL{src2}) = 1 \
335 LIMIT 1)"
336 ))
337}
338
339fn prop_scalar(doc_id: &str, key: &str, source: Option<&str>) -> Option<String> {
342 prop_row(
343 doc_id,
344 key,
345 "COALESCE(p.val_text, p.val_num, p.val_bool)",
346 source,
347 )
348}
349
350fn handle_source(handle: &str) -> Option<Option<&'static str>> {
355 match handle {
356 "data" => Some(None),
357 "frontmatter" => Some(Some("frontmatter")),
358 "inline" => Some(Some("inline")),
359 _ => None,
360 }
361}
362
363fn json_path(segs: &[&str]) -> Option<String> {
366 if segs.iter().any(|s| !is_seg(s)) {
367 return None;
368 }
369 Some(format!("$.{}", segs.join(".")))
370}
371
372#[derive(Clone, Debug, PartialEq)]
376enum Shape {
377 Plain,
379 PathCol(String),
383 Const(Value),
385 Json { col: String, path: String },
387 Prop {
392 doc_id: String,
393 key: String,
394 source: Option<&'static str>,
395 },
396}
397
398#[derive(Clone, Debug, PartialEq)]
401struct Operand {
402 frag: Frag,
403 ty: Ty,
404 shape: Shape,
405}
406
407impl Operand {
408 fn plain(sql: String, ty: Ty) -> Self {
409 Self {
410 frag: Frag::bare(sql),
411 ty,
412 shape: Shape::Plain,
413 }
414 }
415
416 fn text(sql: String) -> Option<Self> {
417 Some(Self::plain(sql, Ty::Text))
418 }
419
420 fn path_col(col: String) -> Option<Self> {
424 Some(Self {
425 frag: Frag::bare(format!("('/' || {col})")),
426 ty: Ty::Text,
427 shape: Shape::PathCol(col),
428 })
429 }
430
431 fn constant(v: &Value) -> Option<Self> {
434 Some(Self {
435 frag: Frag {
436 sql: "?".to_owned(),
437 params: vec![to_sql(v)],
438 },
439 ty: const_ty(v)?,
440 shape: Shape::Const(v.clone()),
441 })
442 }
443
444 fn json(col: String, segs: &[&str]) -> Option<Self> {
445 let path = json_path(segs)?;
446 Some(Self {
447 frag: Frag::bare(format!("json_extract({col}, '{path}')")),
448 ty: Ty::Json,
449 shape: Shape::Json { col, path },
450 })
451 }
452
453 fn prop(doc_alias: &str, key: &str, source: Option<&'static str>) -> Option<Self> {
455 Self::prop_of(&format!("{doc_alias}.doc_id"), key, source)
456 }
457
458 fn prop_of(doc_id: &str, key: &str, source: Option<&'static str>) -> Option<Self> {
460 Some(Self {
461 frag: Frag::bare(prop_scalar(doc_id, key, source)?),
462 ty: Ty::Prop,
463 shape: Shape::Prop {
464 doc_id: doc_id.to_owned(),
465 key: key.to_owned(),
466 source,
467 },
468 })
469 }
470}
471
472fn member_segments(e: &Expr) -> Option<Vec<&str>> {
475 match e {
476 Expr::Ident { name, .. } => Some(vec![name.as_str()]),
477 Expr::Member { recv, name, .. } => {
478 let mut base = member_segments(recv)?;
479 base.push(name.as_str());
480 Some(base)
481 }
482 _ => None,
483 }
484}
485
486pub fn translate_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
492 typed_value(e, ctx).map(|(frag, _)| frag)
493}
494
495pub fn typed_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<(Frag, Ty)> {
497 operand(e, ctx).map(|o| (o.frag, o.ty))
498}
499
500fn operand(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Operand> {
502 let (s, d, target) = (ctx.self_alias, ctx.doc_alias, ctx.target);
503 match e {
504 Expr::Lit { value: v, .. } => Operand::constant(v),
505 Expr::Binding { index, .. } => {
506 Operand::constant(ctx.params.get(*index).unwrap_or(&Value::Undefined))
507 }
508 Expr::Ident { name, .. } => {
509 if name.starts_with('$') {
512 return None;
513 }
514 let name = name.as_str();
515 if row_attributes(target).contains(&name) {
518 if name == "path" {
519 return Operand::path_col(format!("{d}.path"));
520 }
521 return attribute_sql(name, ctx).map(|(sql, ty)| Operand::plain(sql, ty));
522 }
523 if non_property_handles(target).contains(&name) || Target::parse(name).is_some() {
528 return None;
529 }
530 match target {
531 Target::Docs => {
532 if name == "format" {
534 return Operand::text(format!("{s}.format"));
535 }
536 Operand::prop(d, name, None)
537 }
538 Target::Blocks => {
539 if matches!(name, "doc_id" | "type" | "text") {
540 return Operand::text(format!("{s}.{name}"));
541 }
542 if matches!(name, "ordinal" | "depth") {
543 return Some(Operand::plain(format!("{s}.{name}"), Ty::Int));
544 }
545 Operand::json(format!("{s}.attrs"), &[name])
548 }
549 Target::Nodes => {
550 if matches!(name, "doc_id" | "block_id" | "kind" | "name" | "value") {
551 return Operand::text(format!("{s}.{name}"));
552 }
553 Operand::json(format!("{s}.attrs"), &[name])
554 }
555 Target::Links => edge_field(name, s),
556 Target::Revisions => None,
558 }
559 }
560 Expr::Member { .. } => {
561 let segs = member_segments(e)?;
562 let (head, rest) = segs.split_first()?;
563 if rest.is_empty() {
564 return None;
565 }
566 if *head == "attrs" && matches!(target, Target::Blocks | Target::Nodes) {
568 return Operand::json(format!("{s}.attrs"), rest);
569 }
570 if target == Target::Docs
576 && rest.len() == 1
577 && let Some(source) = handle_source(head)
578 {
579 return Operand::prop(d, rest[0], source);
580 }
581 if *head == "doc"
582 && rest.len() == 2
583 && let Some(source) = handle_source(rest[0])
584 {
585 return Operand::prop(d, rest[1], source);
586 }
587 if *head == "doc" {
595 if rest.len() != 1 {
596 return None;
597 }
598 let k = rest[0];
599 if k == "id" {
600 return Operand::text(if target == Target::Links {
601 format!("{s}.src_doc")
602 } else {
603 format!("{s}.doc_id")
604 });
605 }
606 if k == "path" {
607 return Operand::path_col(format!("{d}.path"));
608 }
609 if k == "hash" {
610 return Operand::text(format!("lower(hex({d}.file_hash))"));
611 }
612 if k == "updated_at" {
613 return Operand::text(format!(
614 "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = {d}.current_rev)"
615 ));
616 }
617 if k == "format" {
618 return Operand::text(format!("{d}.format"));
619 }
620 if k.starts_with('$')
624 || row_attributes(Target::Docs).contains(&k)
625 || non_property_handles(Target::Docs).contains(&k)
626 || Target::parse(k).is_some()
627 {
628 return None;
629 }
630 return Operand::prop(d, k, None);
631 }
632 if *head == "dst" && target == Target::Links {
642 let dst_id = format!("{s}.dst_node");
643 if rest.len() == 2
644 && let Some(source) = handle_source(rest[0])
645 {
646 return Operand::prop_of(&dst_id, rest[1], source);
647 }
648 if rest.len() != 1 {
649 return None;
650 }
651 let k = rest[0];
652 let probe = |col: &str| {
653 Operand::text(format!(
654 "(SELECT {col} FROM docs dd WHERE dd.doc_id = {dst_id})"
655 ))
656 };
657 return match k {
658 "id" => probe("dd.doc_id"),
659 "path" => probe("'/' || dd.path"),
660 "hash" => probe("lower(hex(dd.file_hash))"),
661 "updated_at" => probe(
662 "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = dd.current_rev)",
663 ),
664 "format" => probe("dd.format"),
665 _ if k.starts_with('$')
666 || row_attributes(Target::Docs).contains(&k)
667 || non_property_handles(Target::Docs).contains(&k)
668 || Target::parse(k).is_some() =>
669 {
670 None
671 }
672 _ => Operand::prop_of(&dst_id, k, None),
673 };
674 }
675 if *head == "block" && target == Target::Nodes && rest.len() == 1 {
680 return match rest[0] {
681 "id" => Operand::text(format!("{s}.block_id")),
682 "path" => Operand::path_col(format!("{d}.path")),
683 "doc_id" => Operand::text(format!("{s}.doc_id")),
684 k @ ("type" | "text") => Operand::text(format!(
685 "(SELECT bb.{k} FROM blocks bb WHERE bb.block_id = {s}.block_id)"
686 )),
687 _ => None,
688 };
689 }
690 None
691 }
692 Expr::Call {
694 recv: Some(recv),
695 name,
696 args,
697 ..
698 } if args.is_empty() && (name == "lower" || name == "upper") => {
699 let recv = translate_value(recv, ctx)?;
700 Some(Operand {
701 frag: Frag {
702 sql: format!("{name}({})", recv.sql),
703 params: recv.params,
704 },
705 ty: Ty::Text,
706 shape: Shape::Plain,
707 })
708 }
709 _ => None,
710 }
711}
712
713fn is_op(op: BinaryOp) -> Option<&'static str> {
717 Some(match op {
718 BinaryOp::Eq => "IS",
719 BinaryOp::Ne => "IS NOT",
720 BinaryOp::Lt => "<",
721 BinaryOp::Le => "<=",
722 BinaryOp::Gt => ">",
723 BinaryOp::Ge => ">=",
724 BinaryOp::Is
731 | BinaryOp::IsNot
732 | BinaryOp::Match
733 | BinaryOp::MatchI
734 | BinaryOp::NotMatch
735 | BinaryOp::NotMatchI
736 | BinaryOp::Add
737 | BinaryOp::Sub
738 | BinaryOp::Mul
739 | BinaryOp::Div
740 | BinaryOp::Mod => {
741 return None;
742 }
743 })
744}
745
746fn typed_compare(op: BinaryOp, sql_op: &str, l: &Operand, r: &Operand) -> Option<Frag> {
769 let (read, konst, read_left) = match (&l.shape, &r.shape) {
770 (Shape::Json { .. } | Shape::Prop { .. }, Shape::Const(v)) => (&l.shape, v, true),
771 (Shape::Const(v), Shape::Json { .. } | Shape::Prop { .. }) => (&r.shape, v, false),
772 _ => return None,
773 };
774 let equality = matches!(op, BinaryOp::Eq | BinaryOp::Ne);
775 let wrap = if op == BinaryOp::Ne {
776 "IS NOT 1"
777 } else {
778 "IS 1"
779 };
780 let inner_op = match (equality, read_left, sql_op) {
783 (true, _, _) => "=",
784 (false, true, _) => sql_op,
785 (false, false, "<") => ">",
786 (false, false, "<=") => ">=",
787 (false, false, ">") => "<",
788 (false, false, ">=") => "<=",
789 (false, false, _) => return None,
790 };
791 let sides = |read_sql: &str| format!("{read_sql} {inner_op} ?");
792 let (sql, params) = match (read, konst) {
793 (_, Value::Bool(_)) if !equality => return None,
795 (Shape::Json { col, path }, Value::Bool(b)) => (
796 format!("(json_type({col}, '{path}') = '{b}') {wrap}"),
797 Vec::new(),
798 ),
799 (Shape::Json { col, path }, Value::Number(_)) => (
800 format!(
801 "(json_type({col}, '{path}') IN ('integer', 'real') AND {}) {wrap}",
802 sides(&format!("json_extract({col}, '{path}')"))
803 ),
804 vec![to_sql(konst)],
805 ),
806 (
807 Shape::Prop {
808 doc_id,
809 key,
810 source,
811 },
812 Value::Bool(_),
813 ) => (
814 format!(
815 "{} {wrap}",
816 prop_row(
817 doc_id,
818 key,
819 &format!("p.type = 'bool' AND {}", sides("p.val_bool")),
820 *source,
821 )?
822 ),
823 vec![to_sql(konst)],
824 ),
825 (
826 Shape::Prop {
827 doc_id,
828 key,
829 source,
830 },
831 Value::Number(_),
832 ) => (
833 format!(
834 "{} {wrap}",
835 prop_row(
836 doc_id,
837 key,
838 &format!("p.type = 'number' AND {}", sides("p.val_num")),
839 *source,
840 )?
841 ),
842 vec![to_sql(konst)],
843 ),
844 _ => return None,
845 };
846 Some(Frag {
847 sql: format!("({sql})"),
848 params,
849 })
850}
851
852fn path_equality(op: BinaryOp, l: &Operand, r: &Operand) -> Option<Frag> {
861 if !matches!(op, BinaryOp::Eq | BinaryOp::Ne) {
862 return None;
863 }
864 let (col, konst) = match (&l.shape, &r.shape) {
865 (Shape::PathCol(c), Shape::Const(Value::Str(s))) if s.starts_with('/') => (c, s),
866 (Shape::Const(Value::Str(s)), Shape::PathCol(c)) if s.starts_with('/') => (c, s),
867 _ => return None,
868 };
869 Some(Frag {
870 sql: format!("({col} {} ?)", is_op(op)?),
871 params: vec![SqlValue::Text(storage_path(konst).to_owned())],
872 })
873}
874
875pub fn translate_predicate(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
880 match e {
881 Expr::Logical {
884 op: LogicalOp::And,
885 left,
886 right,
887 ..
888 } => join2(
889 translate_predicate(left, ctx),
890 translate_predicate(right, ctx),
891 "AND",
892 ),
893 Expr::Binary {
894 op, left, right, ..
895 } => {
896 let sql_op = is_op(*op)?;
898 let l = operand(left, ctx)?;
899 let r = operand(right, ctx)?;
900 if let Some(fast) = path_equality(*op, &l, &r) {
901 return Some(fast);
902 }
903 if !comparable(*op, l.ty, r.ty) {
906 return None;
907 }
908 if let Some(typed) = typed_compare(*op, sql_op, &l, &r) {
911 return Some(typed);
912 }
913 let op = sql_op;
914 let mut params = l.frag.params;
919 params.extend(r.frag.params);
920 Some(Frag {
921 sql: format!("({} {op} {})", l.frag.sql, r.frag.sql),
922 params,
923 })
924 }
925 Expr::Call {
926 recv: Some(recv),
927 name,
928 args,
929 ..
930 } if args.len() == 1 => {
931 let recv = translate_value(recv, ctx)?;
934 let arg = translate_value(&args[0], ctx)?;
935 let mut params = recv.params;
936 let sql = match name.as_str() {
937 "startsWith" => {
938 params.extend(arg.params.iter().cloned());
940 params.extend(arg.params);
941 format!("(substr({}, 1, length({a})) = {a})", recv.sql, a = arg.sql)
942 }
943 "endsWith" => {
944 params.extend(arg.params.iter().cloned());
948 params.extend(arg.params);
949 format!("(substr({}, -length({a})) = {a})", recv.sql, a = arg.sql)
950 }
951 "contains" => {
952 params.extend(arg.params);
953 format!("(instr({}, {}) > 0)", recv.sql, arg.sql)
954 }
955 _ => return None,
956 };
957 Some(Frag { sql, params })
958 }
959 _ => None,
960 }
961}
962
963fn join2(a: Option<Frag>, b: Option<Frag>, connective: &str) -> Option<Frag> {
966 let (a, b) = (a?, b?);
967 let mut params = a.params;
968 params.extend(b.params);
969 Some(Frag {
970 sql: format!("({} {connective} {})", a.sql, b.sql),
971 params,
972 })
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978 use oqx::ast::Where;
979
980 const DOCS: TranslateCtx<'static> = TranslateCtx {
981 target: Target::Docs,
982 self_alias: "d",
983 doc_alias: "d",
984 params: &[],
985 };
986 const BLOCKS: TranslateCtx<'static> = TranslateCtx {
987 target: Target::Blocks,
988 self_alias: "b",
989 doc_alias: "d",
990 params: &[],
991 };
992
993 fn text(s: &str) -> SqlValue {
994 SqlValue::Text(s.to_owned())
995 }
996
997 fn frag(sql: &str, params: &[SqlValue]) -> Option<Frag> {
998 Some(Frag {
999 sql: sql.to_owned(),
1000 params: params.to_vec(),
1001 })
1002 }
1003
1004 fn pred(src: &str) -> Expr {
1006 let q = oqx::parse_string(&format!("from docs where {src}")).expect("parses");
1007 match q.r#where {
1008 Some(Where::Scalar { expr, .. }) => expr,
1009 other => panic!("expected a single scalar predicate, got {other:?}"),
1010 }
1011 }
1012
1013 fn ident(name: &str) -> Box<Expr> {
1014 Box::new(Expr::Ident {
1015 name: name.to_owned(),
1016 span: oqx::Span::EMPTY,
1017 })
1018 }
1019
1020 fn lit(s: &str) -> Box<Expr> {
1021 Box::new(Expr::Lit {
1022 value: Value::from(s),
1023 span: oqx::Span::EMPTY,
1024 })
1025 }
1026
1027 fn eq(l: Box<Expr>, r: Box<Expr>) -> Box<Expr> {
1028 Box::new(Expr::Binary {
1029 op: BinaryOp::Eq,
1030 left: l,
1031 right: r,
1032 span: oqx::Span::EMPTY,
1033 })
1034 }
1035
1036 #[test]
1039 fn equality_is_null_safe_is() {
1040 assert_eq!(
1041 translate_predicate(&pred("path == \"index.md\""), &DOCS),
1042 frag("(('/' || d.path) IS ?)", &[text("index.md")])
1043 );
1044 }
1045
1046 #[test]
1051 fn a_rooted_path_literal_is_the_indexed_fast_path() {
1052 assert_eq!(
1053 translate_predicate(&pred("path == \"/index.md\""), &DOCS),
1054 frag("(d.path IS ?)", &[text("index.md")])
1055 );
1056 assert_eq!(
1057 translate_predicate(&pred("\"/x.md\" != path"), &DOCS),
1058 frag("(d.path IS NOT ?)", &[text("x.md")])
1059 );
1060 assert_eq!(
1061 translate_predicate(&pred("path < \"/m\""), &DOCS),
1062 frag("(('/' || d.path) < ?)", &[text("/m")])
1063 );
1064 assert_eq!(
1065 translate_predicate(&pred("path == \"index.md\""), &DOCS),
1066 frag("(('/' || d.path) IS ?)", &[text("index.md")])
1067 );
1068 }
1069
1070 #[test]
1071 fn inequality_is_null_safe_is_not() {
1072 assert_eq!(
1073 translate_predicate(&pred("path != \"x\""), &DOCS),
1074 frag("(('/' || d.path) IS NOT ?)", &[text("x")])
1075 );
1076 }
1077
1078 #[test]
1079 fn intrinsic_column_mapping() {
1080 assert_eq!(
1081 translate_predicate(&pred("id == \"d_1\""), &DOCS),
1082 frag("(d.doc_id IS ?)", &[text("d_1")])
1083 );
1084 }
1085
1086 #[test]
1089 fn relational_ops_are_plain_comparisons() {
1090 assert_eq!(
1091 translate_predicate(&pred("path < \"m\""), &DOCS),
1092 frag("(('/' || d.path) < ?)", &[text("m")])
1093 );
1094 assert_eq!(
1095 translate_predicate(&pred("path >= \"m\""), &DOCS),
1096 frag("(('/' || d.path) >= ?)", &[text("m")])
1097 );
1098 assert_eq!(translate_predicate(&pred("path + 1"), &DOCS), None);
1100 }
1101
1102 #[test]
1105 fn starts_with_is_substr_equality() {
1106 assert_eq!(
1107 translate_predicate(&pred("path.startsWith(\"lab/\")"), &DOCS),
1108 frag(
1109 "(substr(('/' || d.path), 1, length(?)) = ?)",
1110 &[text("lab/"), text("lab/")]
1111 )
1112 );
1113 }
1114
1115 #[test]
1116 fn lower_then_starts_with_pushes_with_explicit_lower() {
1117 assert_eq!(
1118 translate_predicate(&pred("path.lower().startsWith(\"lab/\")"), &DOCS),
1119 frag(
1120 "(substr(lower(('/' || d.path)), 1, length(?)) = ?)",
1121 &[text("lab/"), text("lab/")]
1122 )
1123 );
1124 }
1125
1126 #[test]
1127 fn contains_is_instr() {
1128 assert_eq!(
1129 translate_predicate(&pred("path.contains(\"notes\")"), &DOCS),
1130 frag("(instr(('/' || d.path), ?) > 0)", &[text("notes")])
1131 );
1132 }
1133
1134 #[test]
1135 fn ends_with_is_negative_substr_equality() {
1136 assert_eq!(
1137 translate_predicate(&pred("path.endsWith(\".md\")"), &DOCS),
1138 frag(
1139 "(substr(('/' || d.path), -length(?)) = ?)",
1140 &[text(".md"), text(".md")]
1141 )
1142 );
1143 }
1144
1145 #[test]
1146 fn upper_wraps_the_receiver_in_value_position() {
1147 assert_eq!(
1148 translate_value(&pred("path.upper()"), &DOCS),
1149 frag("upper(('/' || d.path))", &[])
1150 );
1151 }
1152
1153 #[test]
1156 fn bare_doc_property_is_the_scalar_in_scope_subquery() {
1157 let f = translate_predicate(&pred("layer == \"canon\""), &DOCS).expect("pushable");
1158 assert!(f.sql.contains("FROM properties p"), "{}", f.sql);
1159 assert!(f.sql.contains("p.key = 'layer'"), "{}", f.sql);
1160 assert!(f.sql.contains("p.card = 'scalar'"), "{}", f.sql);
1161 assert!(
1162 f.sql.starts_with('(') && f.sql.contains(" IS ?)"),
1163 "{}",
1164 f.sql
1165 );
1166 assert_eq!(f.params, vec![text("canon")]);
1167 }
1168
1169 #[test]
1170 fn updated_at_pushes_as_its_revisions_subquery() {
1171 let f =
1172 translate_predicate(&pred("updated_at >= \"2026-01-01\""), &DOCS).expect("pushable");
1173 assert!(
1174 f.sql.contains("FROM revisions r JOIN commits c"),
1175 "{}",
1176 f.sql
1177 );
1178 }
1179
1180 #[test]
1181 fn format_is_a_column_not_a_property() {
1182 assert_eq!(
1183 translate_predicate(&pred("format == \"markdown\""), &DOCS),
1184 frag("(d.format IS ?)", &[text("markdown")])
1185 );
1186 }
1187
1188 #[test]
1189 fn booleans_bind_as_one_and_zero() {
1190 let blocks = TranslateCtx {
1191 target: Target::Blocks,
1192 self_alias: "b",
1193 ..DOCS
1194 };
1195 assert_eq!(
1198 translate_predicate(&pred("type == true"), &blocks).map(|f| f.params),
1199 Some(vec![SqlValue::Integer(1)])
1200 );
1201 assert_eq!(
1202 translate_predicate(&pred("type == false"), &blocks).map(|f| f.params),
1203 Some(vec![SqlValue::Integer(0)])
1204 );
1205 assert_eq!(
1206 translate_predicate(&pred("ordinal < 1000"), &blocks).map(|f| f.params),
1207 Some(vec![SqlValue::Real(1000.0)])
1208 );
1209 assert_eq!(
1210 translate_predicate(&pred("path == null"), &DOCS).map(|f| f.params),
1211 Some(vec![SqlValue::Null])
1212 );
1213 }
1214
1215 const REPRESENTATIVES: [(Ty, &str); 7] = [
1220 (Ty::Text, "path"),
1221 (Ty::Int, "ordinal"),
1222 (Ty::Num, "1"),
1223 (Ty::Bool, "true"),
1224 (Ty::Null, "null"),
1225 (Ty::Json, "checked"),
1226 (Ty::Prop, "doc.layer"),
1227 ];
1228
1229 #[test]
1230 fn representatives_carry_their_type() {
1231 let blocks = TranslateCtx {
1232 target: Target::Blocks,
1233 self_alias: "b",
1234 ..DOCS
1235 };
1236 for (ty, src) in REPRESENTATIVES {
1237 let (_, got) = typed_value(&pred(src), &blocks).expect(src);
1238 assert_eq!(got, ty, "{src}");
1239 }
1240 assert_eq!(
1241 typed_value(&pred("attrs.a.b"), &blocks).map(|(_, t)| t),
1242 Some(Ty::Json)
1243 );
1244 assert_eq!(
1245 typed_value(&pred("depth"), &blocks).map(|(_, t)| t),
1246 Some(Ty::Int)
1247 );
1248 assert_eq!(
1249 typed_value(&pred("doc_id"), &blocks).map(|(_, t)| t),
1250 Some(Ty::Text)
1251 );
1252 assert_eq!(
1253 typed_value(&pred("type.lower()"), &blocks).map(|(_, t)| t),
1254 Some(Ty::Text)
1255 );
1256 assert_eq!(
1257 typed_value(&pred("checked.upper()"), &blocks).map(|(_, t)| t),
1258 Some(Ty::Text)
1259 );
1260 assert_eq!(
1261 typed_value(&pred("layer"), &DOCS).map(|(_, t)| t),
1262 Some(Ty::Prop)
1263 );
1264 assert_eq!(
1265 typed_value(&pred("format"), &DOCS).map(|(_, t)| t),
1266 Some(Ty::Text)
1267 );
1268 }
1269
1270 #[test]
1271 fn the_comparison_matrix_decides_every_cell() {
1272 const P: bool = true;
1274 const D: bool = false;
1275 const T: bool = true;
1277 #[rustfmt::skip]
1280 const EQUALITY: [[bool; 7]; 7] = [
1281 [P, P, P, P, P, P, P],
1282 [P, P, P, D, P, D, D],
1283 [P, P, P, D, P, T, T],
1284 [P, D, D, D, P, T, T],
1285 [P, P, P, P, P, P, D],
1286 [P, D, T, T, P, D, D],
1287 [P, D, T, T, D, D, D],
1288 ];
1289 #[rustfmt::skip]
1292 const RELATIONAL: [[bool; 7]; 7] = [
1293 [P, D, D, D, D, D, D],
1294 [D, P, P, D, D, D, D],
1295 [D, P, P, D, D, T, T],
1296 [D, D, D, D, D, D, D],
1297 [D, D, D, D, D, D, D],
1298 [D, D, T, D, D, D, D],
1299 [D, D, T, D, D, D, D],
1300 ];
1301 let blocks = TranslateCtx {
1302 target: Target::Blocks,
1303 self_alias: "b",
1304 ..DOCS
1305 };
1306 let ops = [
1307 (BinaryOp::Eq, "==", &EQUALITY),
1308 (BinaryOp::Ne, "!=", &EQUALITY),
1309 (BinaryOp::Lt, "<", &RELATIONAL),
1310 (BinaryOp::Le, "<=", &RELATIONAL),
1311 (BinaryOp::Gt, ">", &RELATIONAL),
1312 (BinaryOp::Ge, ">=", &RELATIONAL),
1313 ];
1314 for (i, (a, l)) in REPRESENTATIVES.iter().enumerate() {
1315 for (j, (b, r)) in REPRESENTATIVES.iter().enumerate() {
1316 for (op, spelled, matrix) in ops {
1317 let want = matrix[i][j];
1318 assert_eq!(matrix[j][i], want, "the matrix is symmetric ({a:?}, {b:?})");
1319 assert_eq!(
1320 comparable(op, *a, *b),
1321 want,
1322 "comparable({spelled}, {a:?}, {b:?})"
1323 );
1324 let src = format!("{l} {spelled} {r}");
1325 assert_eq!(
1326 translate_predicate(&pred(&src), &blocks).is_some(),
1327 want,
1328 "{src}"
1329 );
1330 }
1331 }
1332 }
1333 }
1334
1335 #[test]
1336 fn the_spec_shapes_of_decline_a() {
1337 let blocks = TranslateCtx {
1338 target: Target::Blocks,
1339 self_alias: "b",
1340 ..DOCS
1341 };
1342 let typed = |src: &str, ctx: &TranslateCtx<'_>| {
1345 let f = translate_predicate(&pred(src), ctx).expect(src);
1346 assert!(
1347 f.sql.contains("json_type(") || f.sql.contains("p.type = "),
1348 "{src}: {}",
1349 f.sql
1350 );
1351 assert!(
1352 f.sql.ends_with(" IS 1)") || f.sql.ends_with(" IS NOT 1)"),
1353 "{src}: {}",
1354 f.sql
1355 );
1356 };
1357 typed("checked == 1", &blocks);
1358 typed("checked == true", &blocks);
1359 typed("attrs.checked == true", &blocks);
1360 typed("verified == 1", &DOCS);
1362 typed("verified == true", &DOCS);
1363 typed("era < 1000", &DOCS);
1364 typed("doc.era < 1000", &blocks);
1365 assert_eq!(translate_predicate(&pred("ordinal == true"), &blocks), None);
1367 assert_eq!(
1368 translate_predicate(&pred("ordinal == 1"), &blocks),
1369 frag("(b.ordinal IS ?)", &[SqlValue::Real(1.0)])
1370 );
1371 assert_eq!(translate_predicate(&pred("tags != null"), &DOCS), None);
1373 assert_eq!(translate_predicate(&pred("tags == null"), &DOCS), None);
1374 assert_eq!(
1375 translate_predicate(&pred("doc.tags == null"), &blocks),
1376 None
1377 );
1378 assert_eq!(
1379 translate_predicate(&pred("checked == null"), &blocks),
1380 frag(
1381 "(json_extract(b.attrs, '$.checked') IS ?)",
1382 &[SqlValue::Null]
1383 )
1384 );
1385 assert_eq!(
1386 translate_predicate(&pred("ordinal != null"), &blocks),
1387 frag("(b.ordinal IS NOT ?)", &[SqlValue::Null])
1388 );
1389 assert_eq!(
1391 translate_predicate(&pred("checked == \"x\""), &blocks),
1392 frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1393 );
1394 assert!(translate_predicate(&pred("layer == \"canon\""), &DOCS).is_some());
1395 assert!(translate_predicate(&pred("ordinal == \"1\""), &blocks).is_some());
1396 assert!(translate_predicate(&pred("ordinal != \"1\""), &blocks).is_some());
1397 assert_eq!(translate_predicate(&pred("ordinal < \"3\""), &blocks), None);
1400 assert_eq!(
1401 translate_predicate(&pred("\"3\" >= ordinal"), &blocks),
1402 None
1403 );
1404 assert_eq!(translate_predicate(&pred("path > 5"), &DOCS), None);
1405 assert_eq!(translate_predicate(&pred("type <= 1"), &blocks), None);
1406 assert_eq!(
1407 translate_predicate(&pred("ordinal < 3"), &blocks),
1408 frag("(b.ordinal < ?)", &[SqlValue::Real(3.0)])
1409 );
1410 assert_eq!(
1411 translate_predicate(&pred("path > \"m\""), &DOCS),
1412 frag("(('/' || d.path) > ?)", &[text("m")])
1413 );
1414 assert_eq!(
1416 translate_predicate(&pred("ordinal == checked"), &blocks),
1417 None
1418 );
1419 assert_eq!(
1420 translate_predicate(&pred("checked == level"), &blocks),
1421 None
1422 );
1423 assert_eq!(
1424 translate_predicate(&pred("doc.era == doc.year"), &blocks),
1425 None
1426 );
1427 assert_eq!(translate_predicate(&pred("level < \"x\""), &blocks), None);
1428 typed("level < 3", &blocks);
1429 assert_eq!(translate_predicate(&pred("true == false"), &blocks), None);
1430 assert_eq!(translate_predicate(&pred("checked < true"), &blocks), None);
1431 assert_eq!(
1432 translate_predicate(&pred("doc.verified >= false"), &blocks),
1433 None
1434 );
1435 assert_eq!(translate_predicate(&pred("ordinal > null"), &blocks), None);
1436 assert!(translate_predicate(&pred("ordinal == depth"), &blocks).is_some());
1437 assert!(translate_predicate(&pred("ordinal <= depth"), &blocks).is_some());
1438 assert!(translate_predicate(&pred("type == null"), &blocks).is_some());
1439 }
1440
1441 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";
1446
1447 fn prop_sql(key: &str, select: &str, wrap: &str) -> String {
1448 format!(
1449 "((SELECT {select} {}) {wrap})",
1450 PROP_SCOPE.replace('K', key)
1451 )
1452 }
1453
1454 #[test]
1455 fn json_against_a_boolean_tests_json_type_for_the_literal() {
1456 let blocks = TranslateCtx {
1457 target: Target::Blocks,
1458 self_alias: "b",
1459 ..DOCS
1460 };
1461 assert_eq!(
1463 translate_predicate(&pred("checked == true"), &blocks),
1464 frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1465 );
1466 assert_eq!(
1467 translate_predicate(&pred("checked == false"), &blocks),
1468 frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1469 );
1470 assert_eq!(
1471 translate_predicate(&pred("checked != true"), &blocks),
1472 frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1473 );
1474 assert_eq!(
1475 translate_predicate(&pred("false == attrs.checked"), &blocks),
1476 frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1477 );
1478 let params = [Value::Bool(true)];
1480 let e = Expr::Binary {
1481 op: BinaryOp::Ne,
1482 left: ident("checked"),
1483 right: Box::new(Expr::Binding {
1484 index: 0,
1485 span: oqx::Span::EMPTY,
1486 }),
1487 span: oqx::Span::EMPTY,
1488 };
1489 assert_eq!(
1490 translate_predicate(
1491 &e,
1492 &TranslateCtx {
1493 params: ¶ms,
1494 ..blocks
1495 }
1496 ),
1497 frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1498 );
1499 }
1500
1501 #[test]
1502 fn json_against_a_number_tests_the_numeric_types_then_compares() {
1503 let nodes = TranslateCtx {
1504 target: Target::Nodes,
1505 self_alias: "n",
1506 ..DOCS
1507 };
1508 let shape = |op: &str, wrap: &str| {
1509 format!(
1510 "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') {op} ?) {wrap})"
1511 )
1512 };
1513 let two = [SqlValue::Real(2.0)];
1514 for (src, op, wrap) in [
1515 ("level == 2", "=", "IS 1"),
1516 ("level != 2", "=", "IS NOT 1"),
1517 ("level < 2", "<", "IS 1"),
1518 ("level <= 2", "<=", "IS 1"),
1519 ("level > 2", ">", "IS 1"),
1520 ("level >= 2", ">=", "IS 1"),
1521 ] {
1522 assert_eq!(
1523 translate_predicate(&pred(src), &nodes),
1524 frag(&shape(op, wrap), &two),
1525 "{src}"
1526 );
1527 }
1528 assert_eq!(
1530 translate_predicate(&pred("2 <= attrs.level"), &nodes),
1531 frag(&shape(">=", "IS 1"), &two)
1532 );
1533 assert_eq!(
1534 translate_predicate(&pred("2 > level"), &nodes),
1535 frag(&shape("<", "IS 1"), &two)
1536 );
1537 assert_eq!(
1538 translate_predicate(&pred("2 != level"), &nodes),
1539 frag(&shape("=", "IS NOT 1"), &two)
1540 );
1541 }
1542
1543 #[test]
1544 fn property_against_a_boolean_tests_p_type_bool_in_the_scalar_row_subquery() {
1545 let blocks = TranslateCtx {
1546 target: Target::Blocks,
1547 self_alias: "b",
1548 ..DOCS
1549 };
1550 assert_eq!(
1551 translate_predicate(&pred("verified == true"), &DOCS),
1552 frag(
1553 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1554 &[SqlValue::Integer(1)]
1555 )
1556 );
1557 assert_eq!(
1558 translate_predicate(&pred("verified != false"), &DOCS),
1559 frag(
1560 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS NOT 1"),
1561 &[SqlValue::Integer(0)]
1562 )
1563 );
1564 assert_eq!(
1566 translate_predicate(&pred("doc.verified == false"), &blocks),
1567 frag(
1568 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1569 &[SqlValue::Integer(0)]
1570 )
1571 );
1572 assert_eq!(
1573 translate_predicate(&pred("true == verified"), &DOCS),
1574 frag(
1575 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1576 &[SqlValue::Integer(1)]
1577 )
1578 );
1579 }
1580
1581 #[test]
1582 fn property_against_a_number_tests_p_type_number_in_the_scalar_row_subquery() {
1583 let blocks = TranslateCtx {
1584 target: Target::Blocks,
1585 self_alias: "b",
1586 ..DOCS
1587 };
1588 let thousand = [SqlValue::Real(1000.0)];
1589 for (src, op, wrap) in [
1590 ("era == 1000", "=", "IS 1"),
1591 ("era != 1000", "=", "IS NOT 1"),
1592 ("era < 1000", "<", "IS 1"),
1593 ("era <= 1000", "<=", "IS 1"),
1594 ("era > 1000", ">", "IS 1"),
1595 ("era >= 1000", ">=", "IS 1"),
1596 ] {
1597 assert_eq!(
1598 translate_predicate(&pred(src), &DOCS),
1599 frag(
1600 &prop_sql(
1601 "era",
1602 &format!("p.type = 'number' AND p.val_num {op} ?"),
1603 wrap
1604 ),
1605 &thousand
1606 ),
1607 "{src}"
1608 );
1609 }
1610 assert_eq!(
1611 translate_predicate(&pred("doc.era >= 1000"), &blocks),
1612 frag(
1613 &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1614 &thousand
1615 )
1616 );
1617 assert_eq!(
1619 translate_predicate(&pred("1000 > era"), &DOCS),
1620 frag(
1621 &prop_sql("era", "p.type = 'number' AND p.val_num < ?", "IS 1"),
1622 &thousand
1623 )
1624 );
1625 assert_eq!(
1626 translate_predicate(&pred("1000 <= era"), &DOCS),
1627 frag(
1628 &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1629 &thousand
1630 )
1631 );
1632 }
1633
1634 #[test]
1635 fn typed_pushes_compose_under_and_and_keep_the_untyped_forms() {
1636 let blocks = TranslateCtx {
1637 target: Target::Blocks,
1638 self_alias: "b",
1639 ..DOCS
1640 };
1641 let e = Expr::Logical {
1642 op: LogicalOp::And,
1643 left: eq(ident("type"), lit("task")),
1644 right: Box::new(Expr::Binary {
1645 op: BinaryOp::Eq,
1646 left: ident("checked"),
1647 right: Box::new(Expr::Lit {
1648 value: Value::Bool(false),
1649 span: oqx::Span::EMPTY,
1650 }),
1651 span: oqx::Span::EMPTY,
1652 }),
1653 span: oqx::Span::EMPTY,
1654 };
1655 assert_eq!(
1656 translate_predicate(&e, &blocks),
1657 frag(
1658 "((b.type IS ?) AND ((json_type(b.attrs, '$.checked') = 'false') IS 1))",
1659 &[text("task")]
1660 )
1661 );
1662 assert_eq!(
1664 translate_predicate(&pred("checked == \"x\""), &blocks),
1665 frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1666 );
1667 assert_eq!(
1668 translate_predicate(&pred("checked != null"), &blocks),
1669 frag(
1670 "(json_extract(b.attrs, '$.checked') IS NOT ?)",
1671 &[SqlValue::Null]
1672 )
1673 );
1674 assert_eq!(
1676 prop_row("d", "x'y", "p.type = 'number' AND p.val_num = ?", None),
1677 None
1678 );
1679 }
1680
1681 #[test]
1682 fn bindings_are_typed_by_their_value() {
1683 let blocks = TranslateCtx {
1684 target: Target::Blocks,
1685 self_alias: "b",
1686 ..DOCS
1687 };
1688 let params = [
1689 Value::from("s"),
1690 Value::from(1.0),
1691 Value::Bool(true),
1692 Value::Null,
1693 Value::Array(vec![]),
1694 ];
1695 let ctx = TranslateCtx {
1696 params: ¶ms,
1697 ..blocks
1698 };
1699 let against = |i: usize, rhs: &str| {
1700 let e = Expr::Binary {
1701 op: BinaryOp::Eq,
1702 left: Box::new(Expr::Binding {
1703 index: i,
1704 span: oqx::Span::EMPTY,
1705 }),
1706 right: Box::new(pred(rhs)),
1707 span: oqx::Span::EMPTY,
1708 };
1709 translate_predicate(&e, &ctx).is_some()
1710 };
1711 assert!(against(0, "checked"));
1714 assert!(against(1, "checked"));
1715 assert!(against(2, "checked"));
1716 assert!(!against(2, "ordinal"));
1717 assert!(against(1, "ordinal"));
1718 assert!(against(3, "checked"));
1720 assert!(!against(3, "doc.tags"));
1721 assert!(against(9, "checked"));
1723 assert!(!against(9, "doc.tags"));
1724 assert!(!against(4, "type"));
1726 }
1727
1728 #[test]
1731 fn relation_and_handle_names_are_not_property_reads() {
1732 let blocks = TranslateCtx {
1733 target: Target::Blocks,
1734 self_alias: "b",
1735 ..DOCS
1736 };
1737 let nodes = TranslateCtx {
1738 target: Target::Nodes,
1739 self_alias: "n",
1740 ..DOCS
1741 };
1742 let edges = TranslateCtx {
1743 target: Target::Links,
1744 self_alias: "e",
1745 ..DOCS
1746 };
1747 for (ctx, target) in [
1748 (&DOCS, Target::Docs),
1749 (&blocks, Target::Blocks),
1750 (&nodes, Target::Nodes),
1751 (&edges, Target::Links),
1752 ] {
1753 for name in non_property_handles(target) {
1754 let src = format!("{name} == null");
1755 assert_eq!(
1756 translate_predicate(&pred(&src), ctx),
1757 None,
1758 "{target:?}: {src}"
1759 );
1760 let src = format!("{name} == \"x\"");
1761 assert_eq!(
1762 translate_predicate(&pred(&src), ctx),
1763 None,
1764 "{target:?}: {src}"
1765 );
1766 }
1767 }
1768 assert_eq!(translate_predicate(&pred("nodes == null"), &DOCS), None);
1770 assert_eq!(
1771 translate_predicate(&pred("frontmatter == null"), &DOCS),
1772 None
1773 );
1774 assert_eq!(translate_predicate(&pred("nodes == null"), &blocks), None);
1775 assert_eq!(translate_predicate(&pred("attrs == null"), &blocks), None);
1776 assert_eq!(
1778 translate_predicate(&pred("doc.nodes == null"), &blocks),
1779 None
1780 );
1781 assert_eq!(
1782 translate_predicate(&pred("doc.frontmatter == null"), &blocks),
1783 None
1784 );
1785 assert_eq!(translate_predicate(&pred("doc.doc == null"), &DOCS), None);
1786 assert!(translate_predicate(&pred("doc.layer == \"canon\""), &blocks).is_some());
1787 assert!(translate_predicate(&pred("section == \"x\""), &DOCS).is_some());
1789 assert!(translate_predicate(&pred("frontmatter == \"x\""), &blocks).is_some());
1790 }
1791
1792 #[test]
1798 fn handle_sets_match_the_store_context() {
1799 use std::collections::HashMap;
1800
1801 use omgbase_reconcile::Config;
1802 use omgbase_store::{BatchItem, Store};
1803 use oqx::DataContext;
1804
1805 use crate::context::StoreContext;
1806
1807 let mut store = Store::open_in_memory().expect("store");
1808 let repo = store.create_repo("handles").expect("repo");
1809 let items = [
1810 BatchItem::observed(
1811 "a.md",
1812 "---\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",
1813 ),
1814 BatchItem::observed("b.md", "# B\n\nBack to [a](a.md).\n"),
1815 ];
1816 store
1817 .observe_batch(
1818 &repo,
1819 &items,
1820 "2026-09-26T00:00:00.000Z",
1821 &Config::default(),
1822 )
1823 .expect("observe");
1824 let ctx = StoreContext::new(store.conn(), &repo, HashMap::new());
1825
1826 let mut universe: Vec<&str> = crate::context::ALL_TARGETS
1827 .into_iter()
1828 .flat_map(|t| non_property_handles(t).iter().copied())
1829 .collect();
1830 universe.extend([
1831 "layer",
1832 "title",
1833 "verified",
1834 "checked",
1835 "level",
1836 "format",
1837 "type",
1838 "text",
1839 "kind",
1840 "name",
1841 "value",
1842 "predicate",
1843 "key",
1844 "nope",
1845 ]);
1846 universe.sort_unstable();
1847 universe.dedup();
1848
1849 let is_shape = |v: &Value| matches!(v, Value::Array(_) | Value::Object(_));
1850 for target in crate::context::ALL_TARGETS {
1851 let rows = ctx.to_rows(&ctx.root(target.as_str()));
1853 assert!(!rows.is_empty(), "{target:?} has rows");
1854 let set = non_property_handles(target);
1855 for name in &universe {
1856 let mut shaped = 0;
1857 for row in &rows {
1858 let v = match ctx.get(row, name) {
1859 Ok(v) => v,
1860 Err(_) if Target::parse(name).is_some() && !set.contains(name) => continue,
1863 Err(e) => panic!("{target:?}.{name}: {e}"),
1864 };
1865 if set.contains(name) {
1866 assert!(
1867 v.is_absent() || is_shape(&v),
1868 "{target:?}.{name} read as a scalar: {v:?}"
1869 );
1870 } else {
1871 assert!(!is_shape(&v), "{target:?}.{name} is a handle: {v:?}");
1872 }
1873 shaped += usize::from(is_shape(&v));
1874 }
1875 if set.contains(name) {
1876 assert!(
1877 shaped > 0,
1878 "{target:?}.{name} never resolved to rows or a bag"
1879 );
1880 }
1881 }
1882 }
1883 }
1884
1885 #[test]
1888 fn attributes_that_are_not_columns_decline_and_are_never_properties() {
1889 assert_eq!(translate_predicate(&pred("title == \"x\""), &DOCS), None);
1892 assert_eq!(translate_predicate(&pred("tags == \"x\""), &DOCS), None);
1893 assert_eq!(translate_predicate(&pred("raw == \"x\""), &DOCS), None);
1894 assert_eq!(
1895 translate_predicate(&pred("doc.title == \"x\""), &BLOCKS),
1896 None
1897 );
1898 assert_eq!(
1899 translate_predicate(&pred("doc.raw == \"x\""), &BLOCKS),
1900 None
1901 );
1902 assert_eq!(translate_predicate(&pred("raw == \"x\""), &BLOCKS), None);
1903 assert_eq!(translate_predicate(&pred("data == null"), &DOCS), None);
1905 assert!(translate_predicate(&pred("path == \"x\""), &DOCS).is_some());
1907 assert!(translate_predicate(&pred("hash == \"x\""), &DOCS).is_some());
1908 assert!(translate_predicate(&pred("doc.path == \"x\""), &BLOCKS).is_some());
1909 }
1910
1911 #[test]
1912 fn a_key_read_through_a_source_handle_is_pushed_scoped_to_its_source() {
1913 let bare = translate_predicate(&pred("layer == \"canon\""), &DOCS).unwrap();
1915 let data = translate_predicate(&pred("data.layer == \"canon\""), &DOCS).unwrap();
1916 assert_eq!(bare, data);
1917 assert!(!bare.sql.contains("p.source"));
1918 let fm = translate_predicate(&pred("frontmatter.layer == \"canon\""), &DOCS).unwrap();
1920 assert_eq!(
1921 fm.sql,
1922 "((SELECT COALESCE(p.val_text, p.val_num, p.val_bool) FROM properties p \
1923 WHERE p.doc_id = d.doc_id AND p.key = 'layer' AND p.card = 'scalar' AND p.deleted_commit IS NULL AND p.source = 'frontmatter' \
1924 AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = d.doc_id AND p2.key = 'layer' AND p2.deleted_commit IS NULL AND p2.source = 'frontmatter') = 1 \
1925 LIMIT 1) IS ?)"
1926 );
1927 let inl = translate_predicate(&pred("doc.inline.element == \"fire\""), &BLOCKS).unwrap();
1928 assert!(
1929 inl.sql.contains("p.source = 'inline'") && inl.sql.contains("p2.source = 'inline'")
1930 );
1931 assert!(translate_predicate(&pred("doc.data.layer == \"canon\""), &BLOCKS).is_some());
1932 let typed = translate_predicate(&pred("frontmatter.verified == true"), &DOCS).unwrap();
1934 assert!(
1935 typed.sql.contains("p.type = 'bool'") && typed.sql.contains("p.source = 'frontmatter'")
1936 );
1937 assert_eq!(
1939 translate_predicate(&pred("frontmatter == null"), &DOCS),
1940 None
1941 );
1942 assert_eq!(translate_predicate(&pred("data == null"), &DOCS), None);
1943 assert_eq!(
1944 translate_predicate(&pred("frontmatter.meta.owner == \"x\""), &DOCS),
1945 None
1946 );
1947 assert_eq!(
1948 translate_predicate(&pred("doc.frontmatter == null"), &BLOCKS),
1949 None
1950 );
1951 assert_eq!(
1952 translate_predicate(&pred("size(entries(frontmatter)) > 0"), &DOCS),
1953 None
1954 );
1955 }
1956
1957 #[test]
1958 fn docs_raw_and_computed_intrinsics_are_not_columns() {
1959 assert_eq!(translate_predicate(&pred("raw == \"x\""), &DOCS), None);
1960 assert_eq!(translate_predicate(&pred("title == \"x\""), &DOCS), None);
1961 assert_eq!(translate_predicate(&pred("tags == \"x\""), &DOCS), None);
1962 }
1963
1964 #[test]
1965 fn matches_needs_a_regexp_udf() {
1966 assert_eq!(
1967 translate_predicate(&pred("path.matches(\"^lab/\")"), &DOCS),
1968 None
1969 );
1970 }
1971
1972 #[test]
1973 fn negation_as_a_nested_expr_is_not_and_safe() {
1974 let e = Expr::Unary {
1975 op: oqx::ast::UnaryOp::Not,
1976 expr: ident("path"),
1977 span: oqx::Span::EMPTY,
1978 };
1979 assert_eq!(translate_predicate(&e, &DOCS), None);
1980 }
1981
1982 #[test]
1983 fn disjunction_as_a_nested_expr_is_declined() {
1984 let e = Expr::Logical {
1985 op: LogicalOp::Or,
1986 left: eq(ident("path"), lit("a")),
1987 right: eq(ident("path"), lit("b")),
1988 span: oqx::Span::EMPTY,
1989 };
1990 assert_eq!(translate_predicate(&e, &DOCS), None);
1991 }
1992
1993 #[test]
1994 fn unmapped_node_intrinsic_is_not_pushed() {
1995 let nodes = TranslateCtx {
1996 target: Target::Nodes,
1997 self_alias: "n",
1998 ..DOCS
1999 };
2000 assert_eq!(
2001 translate_predicate(&pred("$locator == \"x\""), &nodes),
2002 None
2003 );
2004 let blocks = TranslateCtx {
2006 target: Target::Blocks,
2007 self_alias: "b",
2008 ..DOCS
2009 };
2010 assert_eq!(
2011 translate_predicate(&pred("updated_at == \"x\""), &blocks),
2012 None
2013 );
2014 }
2015
2016 #[test]
2017 fn range_membership_in_and_bare_idents_are_declined() {
2018 assert_eq!(translate_predicate(&pred("era in 800..1680"), &DOCS), None);
2019 assert_eq!(
2020 translate_predicate(&pred("\"a\" in list(tags)"), &DOCS),
2021 None
2022 );
2023 assert_eq!(translate_predicate(&pred("verified"), &DOCS), None);
2024 assert_eq!(translate_predicate(&pred("doc.verified"), &DOCS), None);
2025 assert_eq!(translate_predicate(&pred("size(tags) > 1"), &DOCS), None);
2026 assert_eq!(translate_predicate(&pred("$self.text(\"x\")"), &DOCS), None);
2027 assert_eq!(translate_predicate(&pred("$it == \"x\""), &DOCS), None);
2028 assert_eq!(translate_predicate(&pred("^slug == \"x\""), &DOCS), None);
2029 assert_eq!(
2031 translate_predicate(&pred("frontmatter == null"), &DOCS),
2032 None
2033 );
2034 }
2035
2036 #[test]
2039 fn and_composes_two_pushable_comparisons() {
2040 let e = Expr::Logical {
2041 op: LogicalOp::And,
2042 left: eq(ident("path"), lit("a")),
2043 right: Box::new(Expr::Binary {
2044 op: BinaryOp::Ne,
2045 left: ident("id"),
2046 right: lit("d_2"),
2047 span: oqx::Span::EMPTY,
2048 }),
2049 span: oqx::Span::EMPTY,
2050 };
2051 assert_eq!(
2052 translate_predicate(&e, &DOCS),
2053 frag(
2054 "((('/' || d.path) IS ?) AND (d.doc_id IS NOT ?))",
2055 &[text("a"), text("d_2")]
2056 )
2057 );
2058 }
2059
2060 #[test]
2061 fn and_declines_wholesale_if_either_side_is_not_pushable() {
2062 let e = Expr::Logical {
2063 op: LogicalOp::And,
2064 left: eq(ident("path"), lit("a")),
2065 right: eq(ident("raw"), lit("x")),
2067 span: oqx::Span::EMPTY,
2068 };
2069 assert_eq!(translate_predicate(&e, &DOCS), None);
2070 }
2071
2072 #[test]
2073 fn resolves_a_binding_to_its_param_value() {
2074 let e = eq(
2075 ident("path"),
2076 Box::new(Expr::Binding {
2077 index: 0,
2078 span: oqx::Span::EMPTY,
2079 }),
2080 );
2081 let params = [Value::from("from-binding.md")];
2082 let ctx = TranslateCtx {
2083 params: ¶ms,
2084 ..DOCS
2085 };
2086 assert_eq!(
2087 translate_predicate(&e, &ctx),
2088 frag("(('/' || d.path) IS ?)", &[text("from-binding.md")])
2089 );
2090 assert_eq!(
2092 translate_predicate(&e, &DOCS),
2093 frag("(('/' || d.path) IS ?)", &[SqlValue::Null])
2094 );
2095 }
2096
2097 #[test]
2100 fn blocks_and_nodes_flatten_bare_identifiers_into_attrs() {
2101 let blocks = TranslateCtx {
2102 target: Target::Blocks,
2103 self_alias: "b",
2104 ..DOCS
2105 };
2106 let both = Expr::Logical {
2109 op: LogicalOp::And,
2110 left: eq(ident("type"), lit("task")),
2111 right: eq(ident("marker"), lit("x")),
2112 span: oqx::Span::EMPTY,
2113 };
2114 assert_eq!(
2115 translate_predicate(&both, &blocks),
2116 frag(
2117 "((b.type IS ?) AND (json_extract(b.attrs, '$.marker') IS ?))",
2118 &[text("task"), text("x")]
2119 )
2120 );
2121 assert_eq!(
2122 translate_predicate(&pred("attrs.marker == \"x\""), &blocks),
2123 frag("(json_extract(b.attrs, '$.marker') IS ?)", &[text("x")])
2124 );
2125 assert_eq!(
2128 translate_predicate(&pred("attrs.checked == true"), &blocks),
2129 frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
2130 );
2131 let nodes = TranslateCtx {
2132 target: Target::Nodes,
2133 self_alias: "n",
2134 ..DOCS
2135 };
2136 assert_eq!(
2137 translate_predicate(&pred("kind == \"md:section\""), &nodes),
2138 frag("(n.kind IS ?)", &[text("md:section")])
2139 );
2140 assert_eq!(
2141 translate_predicate(&pred("level == \"1\""), &nodes),
2142 frag("(json_extract(n.attrs, '$.level') IS ?)", &[text("1")])
2143 );
2144 assert_eq!(
2145 translate_predicate(&pred("level == 1"), &nodes),
2146 frag(
2147 "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') = ?) IS 1)",
2148 &[SqlValue::Real(1.0)]
2149 )
2150 );
2151 assert_eq!(
2152 translate_predicate(&pred("attrs.a.b == \"c\""), &nodes),
2153 frag("(json_extract(n.attrs, '$.a.b') IS ?)", &[text("c")])
2154 );
2155 assert_eq!(
2157 translate_predicate(&pred("attrs.marker == \"x\""), &DOCS),
2158 None
2159 );
2160 }
2161
2162 #[test]
2163 fn doc_and_block_reach_through() {
2164 let blocks = TranslateCtx {
2165 target: Target::Blocks,
2166 self_alias: "b",
2167 ..DOCS
2168 };
2169 let f = translate_predicate(&pred("doc.type == \"lab-note\""), &blocks).expect("pushable");
2170 assert!(
2171 f.sql.contains("p.doc_id = d.doc_id AND p.key = 'type'"),
2172 "{}",
2173 f.sql
2174 );
2175 assert_eq!(
2176 translate_predicate(&pred("doc.path == \"a.md\""), &blocks),
2177 frag("(('/' || d.path) IS ?)", &[text("a.md")])
2178 );
2179 assert_eq!(
2182 translate_predicate(&pred("doc.path == \"/a.md\""), &blocks),
2183 frag("(d.path IS ?)", &[text("a.md")])
2184 );
2185 assert_eq!(
2186 translate_predicate(&pred("doc.format == \"markdown\""), &blocks),
2187 frag("(d.format IS ?)", &[text("markdown")])
2188 );
2189 assert_eq!(
2191 translate_predicate(&pred("doc.id == \"d_1\""), &blocks),
2192 frag("(b.doc_id IS ?)", &[text("d_1")])
2193 );
2194 assert_eq!(translate_predicate(&pred("doc.a.b == 1"), &blocks), None);
2195 let nodes = TranslateCtx {
2196 target: Target::Nodes,
2197 self_alias: "n",
2198 ..DOCS
2199 };
2200 assert_eq!(
2201 translate_predicate(&pred("block.type == \"task\""), &nodes),
2202 frag(
2203 "((SELECT bb.type FROM blocks bb WHERE bb.block_id = n.block_id) IS ?)",
2204 &[text("task")]
2205 )
2206 );
2207 assert_eq!(
2208 translate_predicate(&pred("block.type == \"task\""), &blocks),
2209 None
2210 );
2211 assert_eq!(
2212 translate_predicate(&pred("section.level == 1"), &nodes),
2213 None
2214 );
2215 }
2216
2217 #[test]
2218 fn edges_push_their_plain_fields_under_the_surface_names() {
2219 let edges = TranslateCtx {
2220 target: Target::Links,
2221 self_alias: "e",
2222 ..DOCS
2223 };
2224 assert_eq!(
2225 translate_predicate(&pred("predicate == \"references\""), &edges),
2226 frag("(e.predicate IS ?)", &[text("references")])
2227 );
2228 assert_eq!(
2229 translate_predicate(&pred("dst_path == \"index.md\""), &edges),
2230 frag(
2231 "((SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2232 &[text("index.md")]
2233 )
2234 );
2235 assert_eq!(
2236 translate_predicate(&pred("block_id == \"b_1\""), &edges),
2237 frag("(e.src_block IS ?)", &[text("b_1")])
2238 );
2239 assert_eq!(
2240 translate_predicate(&pred("via_id == null"), &edges),
2241 frag("(e.via_node IS ?)", &[SqlValue::Null])
2242 );
2243 assert_eq!(
2244 translate_predicate(&pred("dst_id == \"d_2\""), &edges),
2245 frag("(e.dst_node IS ?)", &[text("d_2")])
2246 );
2247 assert_eq!(
2248 translate_predicate(&pred("field == \"before\""), &edges),
2249 frag("(e.src_field IS ?)", &[text("before")])
2250 );
2251 assert!(
2252 translate_predicate(&pred("uri == \"https://x\""), &edges)
2253 .is_some_and(|f| f.sql.contains("FROM external_nodes xn"))
2254 );
2255 assert_eq!(translate_predicate(&pred("weight == 1"), &edges), None);
2256 assert_eq!(translate_predicate(&pred("$src == \"d_1\""), &edges), None);
2259 assert_eq!(
2260 translate_predicate(&pred("$from_commit == \"c_1\""), &edges),
2261 None
2262 );
2263 assert_eq!(
2264 translate_predicate(&pred("src_field == \"x\""), &edges),
2265 None
2266 );
2267 let blocks = TranslateCtx {
2268 target: Target::Blocks,
2269 self_alias: "b",
2270 ..DOCS
2271 };
2272 assert_eq!(translate_predicate(&pred("$doc == \"d_1\""), &blocks), None);
2273 assert_eq!(translate_predicate(&pred("$ordinal == 1"), &blocks), None);
2274 }
2275
2276 #[test]
2280 fn reach_through_identity_is_the_foreign_key() {
2281 let blocks = TranslateCtx {
2282 target: Target::Blocks,
2283 self_alias: "b",
2284 ..DOCS
2285 };
2286 let nodes = TranslateCtx {
2287 target: Target::Nodes,
2288 self_alias: "n",
2289 ..DOCS
2290 };
2291 let edges = TranslateCtx {
2292 target: Target::Links,
2293 self_alias: "e",
2294 ..DOCS
2295 };
2296 assert_eq!(
2298 translate_predicate(&pred("doc.id == \"d_1\""), &blocks),
2299 frag("(b.doc_id IS ?)", &[text("d_1")])
2300 );
2301 assert_eq!(
2302 translate_predicate(&pred("doc_id == \"d_1\""), &blocks),
2303 frag("(b.doc_id IS ?)", &[text("d_1")])
2304 );
2305 assert_eq!(
2306 translate_predicate(&pred("doc.id == \"d_1\""), &nodes),
2307 frag("(n.doc_id IS ?)", &[text("d_1")])
2308 );
2309 assert_eq!(
2310 translate_predicate(&pred("doc.id == \"d_1\""), &edges),
2311 frag("(e.src_doc IS ?)", &[text("d_1")])
2312 );
2313 assert_eq!(
2314 translate_predicate(&pred("doc_id == \"d_1\""), &edges),
2315 frag("(e.src_doc IS ?)", &[text("d_1")])
2316 );
2317 assert_eq!(
2318 translate_predicate(&pred("doc.id == \"d_1\""), &DOCS),
2319 frag("(d.doc_id IS ?)", &[text("d_1")])
2320 );
2321 assert_eq!(
2323 translate_predicate(&pred("doc.path == \"/a.md\""), &blocks),
2324 frag("(d.path IS ?)", &[text("a.md")])
2325 );
2326 assert_eq!(
2327 translate_predicate(&pred("doc.path == \"/a.md\""), &edges),
2328 frag("(d.path IS ?)", &[text("a.md")])
2329 );
2330 assert_eq!(
2331 translate_predicate(&pred("doc.hash == \"ab\""), &blocks),
2332 frag("(lower(hex(d.file_hash)) IS ?)", &[text("ab")])
2333 );
2334 assert!(
2335 translate_predicate(&pred("doc.updated_at >= \"2026\""), &nodes)
2336 .is_some_and(|f| f.sql.contains("r.rev_id = d.current_rev"))
2337 );
2338 assert_eq!(
2339 translate_predicate(&pred("doc.raw == \"x\""), &blocks),
2340 None
2341 );
2342 assert_eq!(
2344 translate_predicate(&pred("block.id == \"b_1\""), &nodes),
2345 frag("(n.block_id IS ?)", &[text("b_1")])
2346 );
2347 assert_eq!(
2348 translate_predicate(&pred("block_id == \"b_1\""), &nodes),
2349 frag("(n.block_id IS ?)", &[text("b_1")])
2350 );
2351 assert_eq!(
2352 translate_predicate(&pred("block.path == \"/a.md\""), &nodes),
2353 frag("(d.path IS ?)", &[text("a.md")])
2354 );
2355 assert_eq!(
2356 translate_predicate(&pred("block.doc_id == \"d_1\""), &nodes),
2357 frag("(n.doc_id IS ?)", &[text("d_1")])
2358 );
2359 assert!(
2360 translate_predicate(&pred("block.text == \"x\""), &nodes)
2361 .is_some_and(|f| f.sql.contains("SELECT bb.text FROM blocks bb"))
2362 );
2363 assert_eq!(
2364 translate_predicate(&pred("block.hash == \"x\""), &nodes),
2365 None
2366 );
2367 }
2368
2369 #[test]
2372 fn dst_reach_through_reads_the_destination_by_its_foreign_key() {
2373 let edges = TranslateCtx {
2374 target: Target::Links,
2375 self_alias: "e",
2376 ..DOCS
2377 };
2378 assert_eq!(
2381 translate_predicate(&pred("dst.id == \"d_2\""), &edges),
2382 frag(
2383 "((SELECT dd.doc_id FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2384 &[text("d_2")]
2385 )
2386 );
2387 assert_eq!(
2388 translate_predicate(&pred("dst.path == \"/a.md\""), &edges),
2389 frag(
2390 "((SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2391 &[text("/a.md")]
2392 )
2393 );
2394 assert_eq!(
2395 translate_predicate(&pred("dst.format == \"yaml\""), &edges),
2396 frag(
2397 "((SELECT dd.format FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2398 &[text("yaml")]
2399 )
2400 );
2401 assert!(
2402 translate_predicate(&pred("dst.hash == \"ab\""), &edges)
2403 .is_some_and(|f| f.sql.contains("lower(hex(dd.file_hash))"))
2404 );
2405 assert!(
2406 translate_predicate(&pred("dst.updated_at == \"t\""), &edges)
2407 .is_some_and(|f| f.sql.contains("WHERE r.rev_id = dd.current_rev"))
2408 );
2409 let prop = translate_predicate(&pred("dst.layer == \"canon\""), &edges).expect("pushed");
2411 assert!(
2412 prop.sql
2413 .contains("p.doc_id = e.dst_node AND p.key = 'layer'"),
2414 "{}",
2415 prop.sql
2416 );
2417 assert_eq!(prop.params, vec![text("canon")]);
2418 assert!(
2420 translate_predicate(&pred("dst.era > 1600"), &edges)
2421 .is_some_and(|f| f.sql.contains("p.doc_id = e.dst_node AND p.key = 'era'"))
2422 );
2423 assert!(
2425 translate_predicate(&pred("dst.frontmatter.layer == \"canon\""), &edges)
2426 .is_some_and(|f| f.sql.contains("p.source = 'frontmatter'"))
2427 );
2428 for k in [
2431 "raw",
2432 "title",
2433 "tags",
2434 "nodes",
2435 "out_links",
2436 "data",
2437 "docs",
2438 "$x",
2439 ] {
2440 assert_eq!(
2441 translate_predicate(&pred(&format!("dst.{k} == \"x\"")), &edges),
2442 None,
2443 "dst.{k}"
2444 );
2445 }
2446 assert_eq!(translate_predicate(&pred("dst == null"), &edges), None);
2447 assert_eq!(
2449 translate_predicate(&pred("dst.layer == \"canon\""), &DOCS),
2450 None
2451 );
2452 }
2453
2454 #[test]
2455 fn unsafe_identifier_segments_are_never_inlined() {
2456 assert!(is_seg("layer") && is_seg("_x9"));
2457 assert!(!is_seg("") && !is_seg("9a") && !is_seg("a-b") && !is_seg("a'b"));
2458 assert_eq!(json_path(&["ok", "no-pe"]), None);
2459 assert_eq!(json_path(&["ok", "a_1"]).as_deref(), Some("$.ok.a_1"));
2460 assert_eq!(prop_scalar("d", "x'y", None), None);
2461 }
2462}