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