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