1use oqx::Value;
57use oqx::ast::{BinaryOp, Expr, LogicalOp};
58use rusqlite::types::Value as SqlValue;
59
60use crate::context::{Target, to_sql};
61use crate::paths::storage_path;
62
63#[derive(Clone, Copy, Debug)]
67pub struct TranslateCtx<'a> {
68 pub target: Target,
69 pub self_alias: &'a str,
70 pub doc_alias: &'a str,
71 pub params: &'a [Value],
72}
73
74#[derive(Clone, Debug, PartialEq)]
76pub struct Frag {
77 pub sql: String,
78 pub params: Vec<SqlValue>,
79}
80
81impl Frag {
82 fn bare(sql: impl Into<String>) -> Self {
83 Self {
84 sql: sql.into(),
85 params: Vec::new(),
86 }
87 }
88}
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub enum Ty {
97 Text,
100 Int,
102 Num,
104 Bool,
106 Json,
110 Prop,
115 Null,
117}
118
119#[must_use]
162pub fn comparable(op: BinaryOp, a: Ty, b: Ty) -> bool {
163 use Ty::{Bool, Int, Json, Null, Num, Prop, Text};
164 let numeric = |t: Ty| matches!(t, Int | Num);
165 let read = |t: Ty| matches!(t, Json | Prop);
166 let both_numeric = numeric(a) && numeric(b);
167 let typed = |konst: fn(Ty) -> bool| (read(a) && konst(b)) || (read(b) && konst(a));
169 if matches!(op, BinaryOp::Eq | BinaryOp::Ne) {
170 a == Text
171 || b == Text
172 || (a == Null && b != Prop)
173 || (b == Null && a != Prop)
174 || both_numeric
175 || typed(|t| matches!(t, Bool | Num))
176 } else {
177 (a == Text && b == Text) || both_numeric || typed(|t| t == Num)
178 }
179}
180
181fn const_ty(v: &Value) -> Option<Ty> {
184 Some(match v {
185 Value::Str(_) => Ty::Text,
186 Value::Number(_) => Ty::Num,
187 Value::Bool(_) => Ty::Bool,
188 Value::Null | Value::Undefined => Ty::Null,
189 Value::Array(_) | Value::Object(_) | Value::Range(_) => return None,
190 })
191}
192
193#[must_use]
203pub fn non_property_handles(t: Target) -> &'static [&'static str] {
204 match t {
205 Target::Docs => &[
206 "_blocks",
207 "_nodes",
208 "_out_links",
209 "_in_links",
210 "_revisions",
211 "_data",
212 "_frontmatter",
213 "_inline",
214 ],
215 Target::Blocks => &[
216 "_doc",
217 "_children",
218 "_nodes",
219 "_out_links",
220 "_section",
221 "_attrs",
222 ],
223 Target::Nodes => &[
224 "_doc",
225 "_block",
226 "_blocks",
227 "_subsections",
228 "_children",
229 "_attrs",
230 ],
231 Target::Links => &["_doc", "_dst", "_block"],
232 Target::Revisions => &["_doc"],
234 }
235}
236
237pub(crate) fn row_attributes(t: Target) -> &'static [&'static str] {
245 match t {
246 Target::Docs => &[
247 "_id",
248 "_path",
249 "_raw",
250 "_hash",
251 "_updated_at",
252 "_created_at",
253 "_title",
254 "_tags",
255 ],
256 Target::Blocks => &[
257 "_id",
258 "_path",
259 "_raw",
260 "_hash",
261 "_updated_at",
262 "_created_at",
263 ],
264 Target::Nodes | Target::Links | Target::Revisions => &["_id", "_path"],
265 }
266}
267
268fn attribute_sql(name: &str, ctx: &TranslateCtx<'_>) -> Option<(String, Ty)> {
278 let (s, d) = (ctx.self_alias, ctx.doc_alias);
279 let sql = match (ctx.target, name) {
280 (Target::Docs, "_id") => format!("{s}.doc_id"),
281 (Target::Docs, "_path") => format!("('/' || {d}.path)"),
282 (Target::Docs, "_hash") => format!("lower(hex({s}.file_hash))"),
283 (Target::Docs, "_updated_at") => format!(
284 "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = {s}.current_rev)"
285 ),
286 (Target::Docs, "_created_at") => format!(
287 "(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)"
288 ),
289 (Target::Blocks, "_id") => format!("{s}.block_id"),
290 (Target::Blocks, "_path") => format!("('/' || {d}.path)"),
291 (Target::Blocks, "_hash") => format!("lower(hex({s}.raw_hash))"),
292 (Target::Nodes, "_id") => format!("{s}.node_id"),
293 (Target::Nodes, "_path") => format!("('/' || {d}.path)"),
294 (Target::Links, "_id") => format!("{s}.edge_id"),
295 (Target::Links, "_path") => format!("('/' || {d}.path)"),
296 _ => return None,
297 };
298 Some((sql, Ty::Text))
299}
300
301fn edge_field(name: &str, s: &str) -> Option<Operand> {
306 let sql = match name {
307 "_doc_id" => format!("{s}.src_doc"),
308 "_block_id" => format!("{s}.src_block"),
309 "_via_id" => format!("{s}.via_node"),
310 "_dst_id" => format!("{s}.dst_node"),
311 "_dst_path" => {
312 format!("(SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = {s}.dst_node)")
313 }
314 "_uri" => format!("(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = {s}.dst_node)"),
315 "_field" => format!("{s}.src_field"),
316 "_predicate" | "_provenance" | "_dst_kind" | "_anchor" => format!("{s}.{}", &name[1..]),
317 _ => return None,
318 };
319 Operand::text(sql)
320}
321
322fn is_seg(s: &str) -> bool {
324 let mut chars = s.chars();
325 chars
326 .next()
327 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
328 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
329}
330
331fn prop_row(doc_id: &str, key: &str, select: &str, source: Option<&str>) -> Option<String> {
347 if !is_seg(key) {
348 return None;
349 }
350 let src = source.map_or(String::new(), |s| format!(" AND p.source = '{s}'"));
351 let src2 = source.map_or(String::new(), |s| format!(" AND p2.source = '{s}'"));
352 Some(format!(
353 "(SELECT {select} FROM properties p \
354 WHERE p.doc_id = {doc_id} AND p.key = '{key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL{src} \
355 AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = {doc_id} AND p2.key = '{key}' AND p2.deleted_commit IS NULL{src2}) = 1 \
356 LIMIT 1)"
357 ))
358}
359
360fn prop_scalar(doc_id: &str, key: &str, source: Option<&str>) -> Option<String> {
363 prop_row(
364 doc_id,
365 key,
366 "COALESCE(p.val_text, p.val_num, p.val_bool)",
367 source,
368 )
369}
370
371fn handle_source(handle: &str) -> Option<Option<&'static str>> {
376 match handle {
377 "_data" => Some(None),
378 "_frontmatter" => Some(Some("frontmatter")),
379 "_inline" => Some(Some("inline")),
380 _ => None,
381 }
382}
383
384fn json_path(segs: &[&str]) -> Option<String> {
387 if segs.iter().any(|s| !is_seg(s)) {
388 return None;
389 }
390 Some(format!("$.{}", segs.join(".")))
391}
392
393#[derive(Clone, Debug, PartialEq)]
397enum Shape {
398 Plain,
400 PathCol(String),
404 Const(Value),
406 Json { col: String, path: String },
408 Prop {
413 doc_id: String,
414 key: String,
415 source: Option<&'static str>,
416 },
417}
418
419#[derive(Clone, Debug, PartialEq)]
422struct Operand {
423 frag: Frag,
424 ty: Ty,
425 shape: Shape,
426}
427
428impl Operand {
429 fn plain(sql: String, ty: Ty) -> Self {
430 Self {
431 frag: Frag::bare(sql),
432 ty,
433 shape: Shape::Plain,
434 }
435 }
436
437 fn text(sql: String) -> Option<Self> {
438 Some(Self::plain(sql, Ty::Text))
439 }
440
441 fn path_col(col: String) -> Option<Self> {
445 Some(Self {
446 frag: Frag::bare(format!("('/' || {col})")),
447 ty: Ty::Text,
448 shape: Shape::PathCol(col),
449 })
450 }
451
452 fn constant(v: &Value) -> Option<Self> {
455 Some(Self {
456 frag: Frag {
457 sql: "?".to_owned(),
458 params: vec![to_sql(v)],
459 },
460 ty: const_ty(v)?,
461 shape: Shape::Const(v.clone()),
462 })
463 }
464
465 fn json(col: String, segs: &[&str]) -> Option<Self> {
466 let path = json_path(segs)?;
467 Some(Self {
468 frag: Frag::bare(format!("json_extract({col}, '{path}')")),
469 ty: Ty::Json,
470 shape: Shape::Json { col, path },
471 })
472 }
473
474 fn prop(doc_alias: &str, key: &str, source: Option<&'static str>) -> Option<Self> {
476 Self::prop_of(&format!("{doc_alias}.doc_id"), key, source)
477 }
478
479 fn prop_of(doc_id: &str, key: &str, source: Option<&'static str>) -> Option<Self> {
481 Some(Self {
482 frag: Frag::bare(prop_scalar(doc_id, key, source)?),
483 ty: Ty::Prop,
484 shape: Shape::Prop {
485 doc_id: doc_id.to_owned(),
486 key: key.to_owned(),
487 source,
488 },
489 })
490 }
491}
492
493fn member_segments(e: &Expr) -> Option<Vec<&str>> {
496 match e {
497 Expr::Ident { name, .. } => Some(vec![name.as_str()]),
498 Expr::Member { recv, name, .. } => {
499 let mut base = member_segments(recv)?;
500 base.push(name.as_str());
501 Some(base)
502 }
503 _ => None,
504 }
505}
506
507pub fn translate_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
513 typed_value(e, ctx).map(|(frag, _)| frag)
514}
515
516pub fn typed_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<(Frag, Ty)> {
518 operand(e, ctx).map(|o| (o.frag, o.ty))
519}
520
521fn operand(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Operand> {
523 let (s, d, target) = (ctx.self_alias, ctx.doc_alias, ctx.target);
524 match e {
525 Expr::Lit { value: v, .. } => Operand::constant(v),
526 Expr::Binding { index, .. } => {
527 Operand::constant(ctx.params.get(*index).unwrap_or(&Value::Undefined))
528 }
529 Expr::Ident { name, .. } => {
530 if name.starts_with('$') {
533 return None;
534 }
535 let name = name.as_str();
536 if let Some(column) = name.strip_prefix('_') {
543 if row_attributes(target).contains(&name) {
544 if name == "_path" {
545 return Operand::path_col(format!("{d}.path"));
546 }
547 return attribute_sql(name, ctx).map(|(sql, ty)| Operand::plain(sql, ty));
548 }
549 if non_property_handles(target).contains(&name) {
550 return None;
551 }
552 return match target {
553 Target::Docs if name == "_format" => Operand::text(format!("{s}.format")),
555 Target::Blocks => match name {
556 "_doc_id" | "_type" | "_text" => Operand::text(format!("{s}.{column}")),
557 "_ordinal" | "_depth" => {
558 Some(Operand::plain(format!("{s}.{column}"), Ty::Int))
559 }
560 _ => None,
561 },
562 Target::Nodes => match name {
563 "_doc_id" | "_block_id" | "_kind" | "_name" | "_value" => {
564 Operand::text(format!("{s}.{column}"))
565 }
566 _ => None,
567 },
568 Target::Links => edge_field(name, s),
569 Target::Docs | Target::Revisions => None,
571 };
572 }
573 if Target::parse(name).is_some() {
576 return None;
577 }
578 match target {
584 Target::Docs => Operand::prop(d, name, None),
585 Target::Blocks | Target::Nodes => Operand::json(format!("{s}.attrs"), &[name]),
586 Target::Links | Target::Revisions => None,
587 }
588 }
589 Expr::Member { .. } => {
590 let segs = member_segments(e)?;
591 let (head, rest) = segs.split_first()?;
592 if rest.is_empty() {
593 return None;
594 }
595 if *head == "_attrs" && matches!(target, Target::Blocks | Target::Nodes) {
597 return Operand::json(format!("{s}.attrs"), rest);
598 }
599 if target == Target::Docs
606 && rest.len() == 1
607 && let Some(source) = handle_source(head)
608 {
609 return Operand::prop(d, rest[0], source);
610 }
611 if *head == "_doc"
612 && target != Target::Docs
613 && rest.len() == 2
614 && let Some(source) = handle_source(rest[0])
615 {
616 return Operand::prop(d, rest[1], source);
617 }
618 if *head == "_doc" && target != Target::Docs {
626 if rest.len() != 1 {
627 return None;
628 }
629 let k = rest[0];
630 return match k {
631 "_id" => Operand::text(if target == Target::Links {
632 format!("{s}.src_doc")
633 } else {
634 format!("{s}.doc_id")
635 }),
636 "_path" => Operand::path_col(format!("{d}.path")),
637 "_hash" => Operand::text(format!("lower(hex({d}.file_hash))")),
638 "_updated_at" => Operand::text(format!(
639 "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = {d}.current_rev)"
640 )),
641 "_created_at" => Operand::text(format!(
642 "(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)"
643 )),
644 "_format" => Operand::text(format!("{d}.format")),
645 _ if k.starts_with('$') || k.starts_with('_') || Target::parse(k).is_some() => {
649 None
650 }
651 _ => Operand::prop(d, k, None),
652 };
653 }
654 if *head == "_dst" && target == Target::Links {
664 let dst_id = format!("{s}.dst_node");
665 if rest.len() == 2
666 && let Some(source) = handle_source(rest[0])
667 {
668 return Operand::prop_of(&dst_id, rest[1], source);
669 }
670 if rest.len() != 1 {
671 return None;
672 }
673 let k = rest[0];
674 let probe = |col: &str| {
675 Operand::text(format!(
676 "(SELECT {col} FROM docs dd WHERE dd.doc_id = {dst_id})"
677 ))
678 };
679 return match k {
680 "_id" => probe("dd.doc_id"),
681 "_path" => probe("'/' || dd.path"),
682 "_hash" => probe("lower(hex(dd.file_hash))"),
683 "_updated_at" => probe(
684 "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = dd.current_rev)",
685 ),
686 "_created_at" => probe(
687 "(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)",
688 ),
689 "_format" => probe("dd.format"),
690 _ if k.starts_with('$') || k.starts_with('_') || Target::parse(k).is_some() => {
691 None
692 }
693 _ => Operand::prop_of(&dst_id, k, None),
694 };
695 }
696 if *head == "_block" && target == Target::Nodes && rest.len() == 1 {
701 return match rest[0] {
702 "_id" => Operand::text(format!("{s}.block_id")),
703 "_path" => Operand::path_col(format!("{d}.path")),
704 "_doc_id" => Operand::text(format!("{s}.doc_id")),
705 k @ ("_type" | "_text") => Operand::text(format!(
706 "(SELECT bb.{} FROM blocks bb WHERE bb.block_id = {s}.block_id)",
707 &k[1..]
708 )),
709 _ => None,
710 };
711 }
712 if *head == "_block" && target == Target::Links && rest.len() == 1 {
721 let probe = |col: &str| {
722 Operand::text(format!(
723 "(SELECT {col} FROM blocks bb WHERE bb.block_id = {s}.src_block)"
724 ))
725 };
726 return match rest[0] {
727 "_id" => Operand::text(format!("{s}.src_block")),
728 "_path" => probe(&format!("'/' || {d}.path")),
729 "_doc_id" => probe("bb.doc_id"),
730 k @ ("_type" | "_text") => probe(&format!("bb.{}", &k[1..])),
731 _ => None,
732 };
733 }
734 None
735 }
736 Expr::Call {
738 recv: Some(recv),
739 name,
740 args,
741 ..
742 } if args.is_empty() && (name == "lower" || name == "upper") => {
743 let recv = translate_value(recv, ctx)?;
744 Some(Operand {
745 frag: Frag {
746 sql: format!("{name}({})", recv.sql),
747 params: recv.params,
748 },
749 ty: Ty::Text,
750 shape: Shape::Plain,
751 })
752 }
753 _ => None,
754 }
755}
756
757fn is_op(op: BinaryOp) -> Option<&'static str> {
761 Some(match op {
762 BinaryOp::Eq => "IS",
763 BinaryOp::Ne => "IS NOT",
764 BinaryOp::Lt => "<",
765 BinaryOp::Le => "<=",
766 BinaryOp::Gt => ">",
767 BinaryOp::Ge => ">=",
768 BinaryOp::Is
775 | BinaryOp::IsNot
776 | BinaryOp::Match
777 | BinaryOp::MatchI
778 | BinaryOp::NotMatch
779 | BinaryOp::NotMatchI
780 | BinaryOp::Add
781 | BinaryOp::Sub
782 | BinaryOp::Mul
783 | BinaryOp::Div
784 | BinaryOp::Mod => {
785 return None;
786 }
787 })
788}
789
790fn typed_compare(op: BinaryOp, sql_op: &str, l: &Operand, r: &Operand) -> Option<Frag> {
813 let (read, konst, read_left) = match (&l.shape, &r.shape) {
814 (Shape::Json { .. } | Shape::Prop { .. }, Shape::Const(v)) => (&l.shape, v, true),
815 (Shape::Const(v), Shape::Json { .. } | Shape::Prop { .. }) => (&r.shape, v, false),
816 _ => return None,
817 };
818 let equality = matches!(op, BinaryOp::Eq | BinaryOp::Ne);
819 let wrap = if op == BinaryOp::Ne {
820 "IS NOT 1"
821 } else {
822 "IS 1"
823 };
824 let inner_op = match (equality, read_left, sql_op) {
827 (true, _, _) => "=",
828 (false, true, _) => sql_op,
829 (false, false, "<") => ">",
830 (false, false, "<=") => ">=",
831 (false, false, ">") => "<",
832 (false, false, ">=") => "<=",
833 (false, false, _) => return None,
834 };
835 let sides = |read_sql: &str| format!("{read_sql} {inner_op} ?");
836 let (sql, params) = match (read, konst) {
837 (_, Value::Bool(_)) if !equality => return None,
839 (Shape::Json { col, path }, Value::Bool(b)) => (
840 format!("(json_type({col}, '{path}') = '{b}') {wrap}"),
841 Vec::new(),
842 ),
843 (Shape::Json { col, path }, Value::Number(_)) => (
844 format!(
845 "(json_type({col}, '{path}') IN ('integer', 'real') AND {}) {wrap}",
846 sides(&format!("json_extract({col}, '{path}')"))
847 ),
848 vec![to_sql(konst)],
849 ),
850 (
851 Shape::Prop {
852 doc_id,
853 key,
854 source,
855 },
856 Value::Bool(_),
857 ) => (
858 format!(
859 "{} {wrap}",
860 prop_row(
861 doc_id,
862 key,
863 &format!("p.type = 'bool' AND {}", sides("p.val_bool")),
864 *source,
865 )?
866 ),
867 vec![to_sql(konst)],
868 ),
869 (
870 Shape::Prop {
871 doc_id,
872 key,
873 source,
874 },
875 Value::Number(_),
876 ) => (
877 format!(
878 "{} {wrap}",
879 prop_row(
880 doc_id,
881 key,
882 &format!("p.type = 'number' AND {}", sides("p.val_num")),
883 *source,
884 )?
885 ),
886 vec![to_sql(konst)],
887 ),
888 _ => return None,
889 };
890 Some(Frag {
891 sql: format!("({sql})"),
892 params,
893 })
894}
895
896fn path_equality(op: BinaryOp, l: &Operand, r: &Operand) -> Option<Frag> {
905 if !matches!(op, BinaryOp::Eq | BinaryOp::Ne) {
906 return None;
907 }
908 let (col, konst) = match (&l.shape, &r.shape) {
909 (Shape::PathCol(c), Shape::Const(Value::Str(s))) if s.starts_with('/') => (c, s),
910 (Shape::Const(Value::Str(s)), Shape::PathCol(c)) if s.starts_with('/') => (c, s),
911 _ => return None,
912 };
913 Some(Frag {
914 sql: format!("({col} {} ?)", is_op(op)?),
915 params: vec![SqlValue::Text(storage_path(konst).to_owned())],
916 })
917}
918
919pub fn translate_predicate(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
924 match e {
925 Expr::Logical {
928 op: LogicalOp::And,
929 left,
930 right,
931 ..
932 } => join2(
933 translate_predicate(left, ctx),
934 translate_predicate(right, ctx),
935 "AND",
936 ),
937 Expr::Binary {
938 op, left, right, ..
939 } => {
940 let sql_op = is_op(*op)?;
942 let l = operand(left, ctx)?;
943 let r = operand(right, ctx)?;
944 if let Some(fast) = path_equality(*op, &l, &r) {
945 return Some(fast);
946 }
947 if !comparable(*op, l.ty, r.ty) {
950 return None;
951 }
952 if let Some(typed) = typed_compare(*op, sql_op, &l, &r) {
955 return Some(typed);
956 }
957 let op = sql_op;
958 let mut params = l.frag.params;
963 params.extend(r.frag.params);
964 Some(Frag {
965 sql: format!("({} {op} {})", l.frag.sql, r.frag.sql),
966 params,
967 })
968 }
969 Expr::Call {
970 recv: Some(recv),
971 name,
972 args,
973 ..
974 } if args.len() == 1 => {
975 let recv = translate_value(recv, ctx)?;
978 let arg = translate_value(&args[0], ctx)?;
979 let mut params = recv.params;
980 let sql = match name.as_str() {
981 "startsWith" => {
982 params.extend(arg.params.iter().cloned());
984 params.extend(arg.params);
985 format!("(substr({}, 1, length({a})) = {a})", recv.sql, a = arg.sql)
986 }
987 "endsWith" => {
988 params.extend(arg.params.iter().cloned());
992 params.extend(arg.params);
993 format!("(substr({}, -length({a})) = {a})", recv.sql, a = arg.sql)
994 }
995 "contains" => {
996 params.extend(arg.params);
997 format!("(instr({}, {}) > 0)", recv.sql, arg.sql)
998 }
999 _ => return None,
1000 };
1001 Some(Frag { sql, params })
1002 }
1003 _ => None,
1004 }
1005}
1006
1007fn join2(a: Option<Frag>, b: Option<Frag>, connective: &str) -> Option<Frag> {
1010 let (a, b) = (a?, b?);
1011 let mut params = a.params;
1012 params.extend(b.params);
1013 Some(Frag {
1014 sql: format!("({} {connective} {})", a.sql, b.sql),
1015 params,
1016 })
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022 use oqx::ast::Where;
1023
1024 const DOCS: TranslateCtx<'static> = TranslateCtx {
1025 target: Target::Docs,
1026 self_alias: "d",
1027 doc_alias: "d",
1028 params: &[],
1029 };
1030 const BLOCKS: TranslateCtx<'static> = TranslateCtx {
1031 target: Target::Blocks,
1032 self_alias: "b",
1033 doc_alias: "d",
1034 params: &[],
1035 };
1036
1037 fn text(s: &str) -> SqlValue {
1038 SqlValue::Text(s.to_owned())
1039 }
1040
1041 fn frag(sql: &str, params: &[SqlValue]) -> Option<Frag> {
1042 Some(Frag {
1043 sql: sql.to_owned(),
1044 params: params.to_vec(),
1045 })
1046 }
1047
1048 fn pred(src: &str) -> Expr {
1050 let q = oqx::parse_string(&format!("from docs where {src}")).expect("parses");
1051 match q.r#where {
1052 Some(Where::Scalar { expr, .. }) => expr,
1053 other => panic!("expected a single scalar predicate, got {other:?}"),
1054 }
1055 }
1056
1057 fn ident(name: &str) -> Box<Expr> {
1058 Box::new(Expr::Ident {
1059 name: name.to_owned(),
1060 span: oqx::Span::EMPTY,
1061 })
1062 }
1063
1064 fn lit(s: &str) -> Box<Expr> {
1065 Box::new(Expr::Lit {
1066 value: Value::from(s),
1067 span: oqx::Span::EMPTY,
1068 })
1069 }
1070
1071 fn eq(l: Box<Expr>, r: Box<Expr>) -> Box<Expr> {
1072 Box::new(Expr::Binary {
1073 op: BinaryOp::Eq,
1074 left: l,
1075 right: r,
1076 span: oqx::Span::EMPTY,
1077 })
1078 }
1079
1080 #[test]
1083 fn equality_is_null_safe_is() {
1084 assert_eq!(
1085 translate_predicate(&pred("_path == \"index.md\""), &DOCS),
1086 frag("(('/' || d.path) IS ?)", &[text("index.md")])
1087 );
1088 }
1089
1090 #[test]
1095 fn a_rooted_path_literal_is_the_indexed_fast_path() {
1096 assert_eq!(
1097 translate_predicate(&pred("_path == \"/index.md\""), &DOCS),
1098 frag("(d.path IS ?)", &[text("index.md")])
1099 );
1100 assert_eq!(
1101 translate_predicate(&pred("\"/x.md\" != _path"), &DOCS),
1102 frag("(d.path IS NOT ?)", &[text("x.md")])
1103 );
1104 assert_eq!(
1105 translate_predicate(&pred("_path < \"/m\""), &DOCS),
1106 frag("(('/' || d.path) < ?)", &[text("/m")])
1107 );
1108 assert_eq!(
1109 translate_predicate(&pred("_path == \"index.md\""), &DOCS),
1110 frag("(('/' || d.path) IS ?)", &[text("index.md")])
1111 );
1112 }
1113
1114 #[test]
1115 fn inequality_is_null_safe_is_not() {
1116 assert_eq!(
1117 translate_predicate(&pred("_path != \"x\""), &DOCS),
1118 frag("(('/' || d.path) IS NOT ?)", &[text("x")])
1119 );
1120 }
1121
1122 #[test]
1123 fn intrinsic_column_mapping() {
1124 assert_eq!(
1125 translate_predicate(&pred("_id == \"d_1\""), &DOCS),
1126 frag("(d.doc_id IS ?)", &[text("d_1")])
1127 );
1128 }
1129
1130 #[test]
1133 fn relational_ops_are_plain_comparisons() {
1134 assert_eq!(
1135 translate_predicate(&pred("_path < \"m\""), &DOCS),
1136 frag("(('/' || d.path) < ?)", &[text("m")])
1137 );
1138 assert_eq!(
1139 translate_predicate(&pred("_path >= \"m\""), &DOCS),
1140 frag("(('/' || d.path) >= ?)", &[text("m")])
1141 );
1142 assert_eq!(translate_predicate(&pred("_path + 1"), &DOCS), None);
1144 }
1145
1146 #[test]
1149 fn starts_with_is_substr_equality() {
1150 assert_eq!(
1151 translate_predicate(&pred("_path.startsWith(\"lab/\")"), &DOCS),
1152 frag(
1153 "(substr(('/' || d.path), 1, length(?)) = ?)",
1154 &[text("lab/"), text("lab/")]
1155 )
1156 );
1157 }
1158
1159 #[test]
1160 fn lower_then_starts_with_pushes_with_explicit_lower() {
1161 assert_eq!(
1162 translate_predicate(&pred("_path.lower().startsWith(\"lab/\")"), &DOCS),
1163 frag(
1164 "(substr(lower(('/' || d.path)), 1, length(?)) = ?)",
1165 &[text("lab/"), text("lab/")]
1166 )
1167 );
1168 }
1169
1170 #[test]
1171 fn contains_is_instr() {
1172 assert_eq!(
1173 translate_predicate(&pred("_path.contains(\"notes\")"), &DOCS),
1174 frag("(instr(('/' || d.path), ?) > 0)", &[text("notes")])
1175 );
1176 }
1177
1178 #[test]
1179 fn ends_with_is_negative_substr_equality() {
1180 assert_eq!(
1181 translate_predicate(&pred("_path.endsWith(\".md\")"), &DOCS),
1182 frag(
1183 "(substr(('/' || d.path), -length(?)) = ?)",
1184 &[text(".md"), text(".md")]
1185 )
1186 );
1187 }
1188
1189 #[test]
1190 fn upper_wraps_the_receiver_in_value_position() {
1191 assert_eq!(
1192 translate_value(&pred("_path.upper()"), &DOCS),
1193 frag("upper(('/' || d.path))", &[])
1194 );
1195 }
1196
1197 #[test]
1200 fn bare_doc_property_is_the_scalar_in_scope_subquery() {
1201 let f = translate_predicate(&pred("layer == \"canon\""), &DOCS).expect("pushable");
1202 assert!(f.sql.contains("FROM properties p"), "{}", f.sql);
1203 assert!(f.sql.contains("p.key = 'layer'"), "{}", f.sql);
1204 assert!(f.sql.contains("p.card = 'scalar'"), "{}", f.sql);
1205 assert!(
1206 f.sql.starts_with('(') && f.sql.contains(" IS ?)"),
1207 "{}",
1208 f.sql
1209 );
1210 assert_eq!(f.params, vec![text("canon")]);
1211 }
1212
1213 #[test]
1214 fn updated_at_pushes_as_its_revisions_subquery() {
1215 let f =
1216 translate_predicate(&pred("_updated_at >= \"2026-01-01\""), &DOCS).expect("pushable");
1217 assert!(
1218 f.sql.contains("FROM revisions r JOIN commits c"),
1219 "{}",
1220 f.sql
1221 );
1222 }
1223
1224 #[test]
1226 fn created_at_pushes_as_its_first_revision_subquery() {
1227 let f =
1228 translate_predicate(&pred("_created_at < \"2026-01-01\""), &DOCS).expect("pushable");
1229 assert_eq!(
1230 f.sql,
1231 "((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) < ?)"
1232 );
1233 assert_eq!(f.params, vec![text("2026-01-01")]);
1234 assert_eq!(
1236 translate_predicate(&pred("_created_at == \"x\""), &BLOCKS),
1237 None
1238 );
1239 }
1240
1241 #[test]
1242 fn format_is_a_column_not_a_property() {
1243 assert_eq!(
1244 translate_predicate(&pred("_format == \"markdown\""), &DOCS),
1245 frag("(d.format IS ?)", &[text("markdown")])
1246 );
1247 }
1248
1249 #[test]
1250 fn booleans_bind_as_one_and_zero() {
1251 let blocks = TranslateCtx {
1252 target: Target::Blocks,
1253 self_alias: "b",
1254 ..DOCS
1255 };
1256 assert_eq!(
1259 translate_predicate(&pred("_type == true"), &blocks).map(|f| f.params),
1260 Some(vec![SqlValue::Integer(1)])
1261 );
1262 assert_eq!(
1263 translate_predicate(&pred("_type == false"), &blocks).map(|f| f.params),
1264 Some(vec![SqlValue::Integer(0)])
1265 );
1266 assert_eq!(
1267 translate_predicate(&pred("_ordinal < 1000"), &blocks).map(|f| f.params),
1268 Some(vec![SqlValue::Real(1000.0)])
1269 );
1270 assert_eq!(
1271 translate_predicate(&pred("_path == null"), &DOCS).map(|f| f.params),
1272 Some(vec![SqlValue::Null])
1273 );
1274 }
1275
1276 const REPRESENTATIVES: [(Ty, &str); 7] = [
1281 (Ty::Text, "_path"),
1282 (Ty::Int, "_ordinal"),
1283 (Ty::Num, "1"),
1284 (Ty::Bool, "true"),
1285 (Ty::Null, "null"),
1286 (Ty::Json, "checked"),
1287 (Ty::Prop, "_doc.layer"),
1288 ];
1289
1290 #[test]
1291 fn representatives_carry_their_type() {
1292 let blocks = TranslateCtx {
1293 target: Target::Blocks,
1294 self_alias: "b",
1295 ..DOCS
1296 };
1297 for (ty, src) in REPRESENTATIVES {
1298 let (_, got) = typed_value(&pred(src), &blocks).expect(src);
1299 assert_eq!(got, ty, "{src}");
1300 }
1301 assert_eq!(
1302 typed_value(&pred("_attrs.a.b"), &blocks).map(|(_, t)| t),
1303 Some(Ty::Json)
1304 );
1305 assert_eq!(
1306 typed_value(&pred("_depth"), &blocks).map(|(_, t)| t),
1307 Some(Ty::Int)
1308 );
1309 assert_eq!(
1310 typed_value(&pred("_doc_id"), &blocks).map(|(_, t)| t),
1311 Some(Ty::Text)
1312 );
1313 assert_eq!(
1314 typed_value(&pred("_type.lower()"), &blocks).map(|(_, t)| t),
1315 Some(Ty::Text)
1316 );
1317 assert_eq!(
1318 typed_value(&pred("checked.upper()"), &blocks).map(|(_, t)| t),
1319 Some(Ty::Text)
1320 );
1321 assert_eq!(
1322 typed_value(&pred("layer"), &DOCS).map(|(_, t)| t),
1323 Some(Ty::Prop)
1324 );
1325 assert_eq!(
1326 typed_value(&pred("_format"), &DOCS).map(|(_, t)| t),
1327 Some(Ty::Text)
1328 );
1329 }
1330
1331 #[test]
1332 fn the_comparison_matrix_decides_every_cell() {
1333 const P: bool = true;
1335 const D: bool = false;
1336 const T: bool = true;
1338 #[rustfmt::skip]
1341 const EQUALITY: [[bool; 7]; 7] = [
1342 [P, P, P, P, P, P, P],
1343 [P, P, P, D, P, D, D],
1344 [P, P, P, D, P, T, T],
1345 [P, D, D, D, P, T, T],
1346 [P, P, P, P, P, P, D],
1347 [P, D, T, T, P, D, D],
1348 [P, D, T, T, D, D, D],
1349 ];
1350 #[rustfmt::skip]
1353 const RELATIONAL: [[bool; 7]; 7] = [
1354 [P, D, D, D, D, D, D],
1355 [D, P, P, D, D, D, D],
1356 [D, P, P, D, D, T, T],
1357 [D, D, D, D, D, D, D],
1358 [D, D, D, D, D, D, D],
1359 [D, D, T, D, D, D, D],
1360 [D, D, T, D, D, D, D],
1361 ];
1362 let blocks = TranslateCtx {
1363 target: Target::Blocks,
1364 self_alias: "b",
1365 ..DOCS
1366 };
1367 let ops = [
1368 (BinaryOp::Eq, "==", &EQUALITY),
1369 (BinaryOp::Ne, "!=", &EQUALITY),
1370 (BinaryOp::Lt, "<", &RELATIONAL),
1371 (BinaryOp::Le, "<=", &RELATIONAL),
1372 (BinaryOp::Gt, ">", &RELATIONAL),
1373 (BinaryOp::Ge, ">=", &RELATIONAL),
1374 ];
1375 for (i, (a, l)) in REPRESENTATIVES.iter().enumerate() {
1376 for (j, (b, r)) in REPRESENTATIVES.iter().enumerate() {
1377 for (op, spelled, matrix) in ops {
1378 let want = matrix[i][j];
1379 assert_eq!(matrix[j][i], want, "the matrix is symmetric ({a:?}, {b:?})");
1380 assert_eq!(
1381 comparable(op, *a, *b),
1382 want,
1383 "comparable({spelled}, {a:?}, {b:?})"
1384 );
1385 let src = format!("{l} {spelled} {r}");
1386 assert_eq!(
1387 translate_predicate(&pred(&src), &blocks).is_some(),
1388 want,
1389 "{src}"
1390 );
1391 }
1392 }
1393 }
1394 }
1395
1396 #[test]
1397 fn the_spec_shapes_of_decline_a() {
1398 let blocks = TranslateCtx {
1399 target: Target::Blocks,
1400 self_alias: "b",
1401 ..DOCS
1402 };
1403 let typed = |src: &str, ctx: &TranslateCtx<'_>| {
1406 let f = translate_predicate(&pred(src), ctx).expect(src);
1407 assert!(
1408 f.sql.contains("json_type(") || f.sql.contains("p.type = "),
1409 "{src}: {}",
1410 f.sql
1411 );
1412 assert!(
1413 f.sql.ends_with(" IS 1)") || f.sql.ends_with(" IS NOT 1)"),
1414 "{src}: {}",
1415 f.sql
1416 );
1417 };
1418 typed("checked == 1", &blocks);
1419 typed("checked == true", &blocks);
1420 typed("_attrs.checked == true", &blocks);
1421 typed("verified == 1", &DOCS);
1423 typed("verified == true", &DOCS);
1424 typed("era < 1000", &DOCS);
1425 typed("_doc.era < 1000", &blocks);
1426 assert_eq!(
1428 translate_predicate(&pred("_ordinal == true"), &blocks),
1429 None
1430 );
1431 assert_eq!(
1432 translate_predicate(&pred("_ordinal == 1"), &blocks),
1433 frag("(b.ordinal IS ?)", &[SqlValue::Real(1.0)])
1434 );
1435 assert_eq!(translate_predicate(&pred("_tags != null"), &DOCS), None);
1437 assert_eq!(translate_predicate(&pred("_tags == null"), &DOCS), None);
1438 assert_eq!(
1439 translate_predicate(&pred("_doc._tags == null"), &blocks),
1440 None
1441 );
1442 assert_eq!(
1443 translate_predicate(&pred("checked == null"), &blocks),
1444 frag(
1445 "(json_extract(b.attrs, '$.checked') IS ?)",
1446 &[SqlValue::Null]
1447 )
1448 );
1449 assert_eq!(
1450 translate_predicate(&pred("_ordinal != null"), &blocks),
1451 frag("(b.ordinal IS NOT ?)", &[SqlValue::Null])
1452 );
1453 assert_eq!(
1455 translate_predicate(&pred("checked == \"x\""), &blocks),
1456 frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1457 );
1458 assert!(translate_predicate(&pred("layer == \"canon\""), &DOCS).is_some());
1459 assert!(translate_predicate(&pred("_ordinal == \"1\""), &blocks).is_some());
1460 assert!(translate_predicate(&pred("_ordinal != \"1\""), &blocks).is_some());
1461 assert_eq!(
1464 translate_predicate(&pred("_ordinal < \"3\""), &blocks),
1465 None
1466 );
1467 assert_eq!(
1468 translate_predicate(&pred("\"3\" >= _ordinal"), &blocks),
1469 None
1470 );
1471 assert_eq!(translate_predicate(&pred("_path > 5"), &DOCS), None);
1472 assert_eq!(translate_predicate(&pred("_type <= 1"), &blocks), None);
1473 assert_eq!(
1474 translate_predicate(&pred("_ordinal < 3"), &blocks),
1475 frag("(b.ordinal < ?)", &[SqlValue::Real(3.0)])
1476 );
1477 assert_eq!(
1478 translate_predicate(&pred("_path > \"m\""), &DOCS),
1479 frag("(('/' || d.path) > ?)", &[text("m")])
1480 );
1481 assert_eq!(
1483 translate_predicate(&pred("_ordinal == checked"), &blocks),
1484 None
1485 );
1486 assert_eq!(
1487 translate_predicate(&pred("checked == level"), &blocks),
1488 None
1489 );
1490 assert_eq!(
1491 translate_predicate(&pred("_doc.era == _doc.year"), &blocks),
1492 None
1493 );
1494 assert_eq!(translate_predicate(&pred("level < \"x\""), &blocks), None);
1495 typed("level < 3", &blocks);
1496 assert_eq!(translate_predicate(&pred("true == false"), &blocks), None);
1497 assert_eq!(translate_predicate(&pred("checked < true"), &blocks), None);
1498 assert_eq!(
1499 translate_predicate(&pred("_doc.verified >= false"), &blocks),
1500 None
1501 );
1502 assert_eq!(translate_predicate(&pred("_ordinal > null"), &blocks), None);
1503 assert!(translate_predicate(&pred("_ordinal == _depth"), &blocks).is_some());
1504 assert!(translate_predicate(&pred("_ordinal <= _depth"), &blocks).is_some());
1505 assert!(translate_predicate(&pred("_type == null"), &blocks).is_some());
1506 }
1507
1508 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";
1513
1514 fn prop_sql(key: &str, select: &str, wrap: &str) -> String {
1515 format!(
1516 "((SELECT {select} {}) {wrap})",
1517 PROP_SCOPE.replace('K', key)
1518 )
1519 }
1520
1521 #[test]
1522 fn json_against_a_boolean_tests_json_type_for_the_literal() {
1523 let blocks = TranslateCtx {
1524 target: Target::Blocks,
1525 self_alias: "b",
1526 ..DOCS
1527 };
1528 assert_eq!(
1530 translate_predicate(&pred("checked == true"), &blocks),
1531 frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1532 );
1533 assert_eq!(
1534 translate_predicate(&pred("checked == false"), &blocks),
1535 frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1536 );
1537 assert_eq!(
1538 translate_predicate(&pred("checked != true"), &blocks),
1539 frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1540 );
1541 assert_eq!(
1542 translate_predicate(&pred("false == _attrs.checked"), &blocks),
1543 frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1544 );
1545 let params = [Value::Bool(true)];
1547 let e = Expr::Binary {
1548 op: BinaryOp::Ne,
1549 left: ident("checked"),
1550 right: Box::new(Expr::Binding {
1551 index: 0,
1552 span: oqx::Span::EMPTY,
1553 }),
1554 span: oqx::Span::EMPTY,
1555 };
1556 assert_eq!(
1557 translate_predicate(
1558 &e,
1559 &TranslateCtx {
1560 params: ¶ms,
1561 ..blocks
1562 }
1563 ),
1564 frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1565 );
1566 }
1567
1568 #[test]
1569 fn json_against_a_number_tests_the_numeric_types_then_compares() {
1570 let nodes = TranslateCtx {
1571 target: Target::Nodes,
1572 self_alias: "n",
1573 ..DOCS
1574 };
1575 let shape = |op: &str, wrap: &str| {
1576 format!(
1577 "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') {op} ?) {wrap})"
1578 )
1579 };
1580 let two = [SqlValue::Real(2.0)];
1581 for (src, op, wrap) in [
1582 ("level == 2", "=", "IS 1"),
1583 ("level != 2", "=", "IS NOT 1"),
1584 ("level < 2", "<", "IS 1"),
1585 ("level <= 2", "<=", "IS 1"),
1586 ("level > 2", ">", "IS 1"),
1587 ("level >= 2", ">=", "IS 1"),
1588 ] {
1589 assert_eq!(
1590 translate_predicate(&pred(src), &nodes),
1591 frag(&shape(op, wrap), &two),
1592 "{src}"
1593 );
1594 }
1595 assert_eq!(
1597 translate_predicate(&pred("2 <= _attrs.level"), &nodes),
1598 frag(&shape(">=", "IS 1"), &two)
1599 );
1600 assert_eq!(
1601 translate_predicate(&pred("2 > level"), &nodes),
1602 frag(&shape("<", "IS 1"), &two)
1603 );
1604 assert_eq!(
1605 translate_predicate(&pred("2 != level"), &nodes),
1606 frag(&shape("=", "IS NOT 1"), &two)
1607 );
1608 }
1609
1610 #[test]
1611 fn property_against_a_boolean_tests_p_type_bool_in_the_scalar_row_subquery() {
1612 let blocks = TranslateCtx {
1613 target: Target::Blocks,
1614 self_alias: "b",
1615 ..DOCS
1616 };
1617 assert_eq!(
1618 translate_predicate(&pred("verified == true"), &DOCS),
1619 frag(
1620 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1621 &[SqlValue::Integer(1)]
1622 )
1623 );
1624 assert_eq!(
1625 translate_predicate(&pred("verified != false"), &DOCS),
1626 frag(
1627 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS NOT 1"),
1628 &[SqlValue::Integer(0)]
1629 )
1630 );
1631 assert_eq!(
1633 translate_predicate(&pred("_doc.verified == false"), &blocks),
1634 frag(
1635 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1636 &[SqlValue::Integer(0)]
1637 )
1638 );
1639 assert_eq!(
1640 translate_predicate(&pred("true == verified"), &DOCS),
1641 frag(
1642 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1643 &[SqlValue::Integer(1)]
1644 )
1645 );
1646 }
1647
1648 #[test]
1649 fn property_against_a_number_tests_p_type_number_in_the_scalar_row_subquery() {
1650 let blocks = TranslateCtx {
1651 target: Target::Blocks,
1652 self_alias: "b",
1653 ..DOCS
1654 };
1655 let thousand = [SqlValue::Real(1000.0)];
1656 for (src, op, wrap) in [
1657 ("era == 1000", "=", "IS 1"),
1658 ("era != 1000", "=", "IS NOT 1"),
1659 ("era < 1000", "<", "IS 1"),
1660 ("era <= 1000", "<=", "IS 1"),
1661 ("era > 1000", ">", "IS 1"),
1662 ("era >= 1000", ">=", "IS 1"),
1663 ] {
1664 assert_eq!(
1665 translate_predicate(&pred(src), &DOCS),
1666 frag(
1667 &prop_sql(
1668 "era",
1669 &format!("p.type = 'number' AND p.val_num {op} ?"),
1670 wrap
1671 ),
1672 &thousand
1673 ),
1674 "{src}"
1675 );
1676 }
1677 assert_eq!(
1678 translate_predicate(&pred("_doc.era >= 1000"), &blocks),
1679 frag(
1680 &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1681 &thousand
1682 )
1683 );
1684 assert_eq!(
1686 translate_predicate(&pred("1000 > era"), &DOCS),
1687 frag(
1688 &prop_sql("era", "p.type = 'number' AND p.val_num < ?", "IS 1"),
1689 &thousand
1690 )
1691 );
1692 assert_eq!(
1693 translate_predicate(&pred("1000 <= era"), &DOCS),
1694 frag(
1695 &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1696 &thousand
1697 )
1698 );
1699 }
1700
1701 #[test]
1702 fn typed_pushes_compose_under_and_and_keep_the_untyped_forms() {
1703 let blocks = TranslateCtx {
1704 target: Target::Blocks,
1705 self_alias: "b",
1706 ..DOCS
1707 };
1708 let e = Expr::Logical {
1709 op: LogicalOp::And,
1710 left: eq(ident("_type"), lit("task")),
1711 right: Box::new(Expr::Binary {
1712 op: BinaryOp::Eq,
1713 left: ident("checked"),
1714 right: Box::new(Expr::Lit {
1715 value: Value::Bool(false),
1716 span: oqx::Span::EMPTY,
1717 }),
1718 span: oqx::Span::EMPTY,
1719 }),
1720 span: oqx::Span::EMPTY,
1721 };
1722 assert_eq!(
1723 translate_predicate(&e, &blocks),
1724 frag(
1725 "((b.type IS ?) AND ((json_type(b.attrs, '$.checked') = 'false') IS 1))",
1726 &[text("task")]
1727 )
1728 );
1729 assert_eq!(
1731 translate_predicate(&pred("checked == \"x\""), &blocks),
1732 frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1733 );
1734 assert_eq!(
1735 translate_predicate(&pred("checked != null"), &blocks),
1736 frag(
1737 "(json_extract(b.attrs, '$.checked') IS NOT ?)",
1738 &[SqlValue::Null]
1739 )
1740 );
1741 assert_eq!(
1743 prop_row("d", "x'y", "p.type = 'number' AND p.val_num = ?", None),
1744 None
1745 );
1746 }
1747
1748 #[test]
1749 fn bindings_are_typed_by_their_value() {
1750 let blocks = TranslateCtx {
1751 target: Target::Blocks,
1752 self_alias: "b",
1753 ..DOCS
1754 };
1755 let params = [
1756 Value::from("s"),
1757 Value::from(1.0),
1758 Value::Bool(true),
1759 Value::Null,
1760 Value::Array(vec![]),
1761 ];
1762 let ctx = TranslateCtx {
1763 params: ¶ms,
1764 ..blocks
1765 };
1766 let against = |i: usize, rhs: &str| {
1767 let e = Expr::Binary {
1768 op: BinaryOp::Eq,
1769 left: Box::new(Expr::Binding {
1770 index: i,
1771 span: oqx::Span::EMPTY,
1772 }),
1773 right: Box::new(pred(rhs)),
1774 span: oqx::Span::EMPTY,
1775 };
1776 translate_predicate(&e, &ctx).is_some()
1777 };
1778 assert!(against(0, "checked"));
1781 assert!(against(1, "checked"));
1782 assert!(against(2, "checked"));
1783 assert!(!against(2, "_ordinal"));
1784 assert!(against(1, "_ordinal"));
1785 assert!(against(3, "checked"));
1787 assert!(!against(3, "_doc.tags"));
1788 assert!(against(9, "checked"));
1790 assert!(!against(9, "_doc.tags"));
1791 assert!(!against(4, "_type"));
1793 }
1794
1795 #[test]
1798 fn relation_and_handle_names_are_not_property_reads() {
1799 let blocks = TranslateCtx {
1800 target: Target::Blocks,
1801 self_alias: "b",
1802 ..DOCS
1803 };
1804 let nodes = TranslateCtx {
1805 target: Target::Nodes,
1806 self_alias: "n",
1807 ..DOCS
1808 };
1809 let edges = TranslateCtx {
1810 target: Target::Links,
1811 self_alias: "e",
1812 ..DOCS
1813 };
1814 for (ctx, target) in [
1815 (&DOCS, Target::Docs),
1816 (&blocks, Target::Blocks),
1817 (&nodes, Target::Nodes),
1818 (&edges, Target::Links),
1819 ] {
1820 for name in non_property_handles(target) {
1821 let src = format!("{name} == null");
1822 assert_eq!(
1823 translate_predicate(&pred(&src), ctx),
1824 None,
1825 "{target:?}: {src}"
1826 );
1827 let src = format!("{name} == \"x\"");
1828 assert_eq!(
1829 translate_predicate(&pred(&src), ctx),
1830 None,
1831 "{target:?}: {src}"
1832 );
1833 }
1834 }
1835 assert_eq!(translate_predicate(&pred("_nodes == null"), &DOCS), None);
1837 assert_eq!(
1838 translate_predicate(&pred("_frontmatter == null"), &DOCS),
1839 None
1840 );
1841 assert_eq!(translate_predicate(&pred("_nodes == null"), &blocks), None);
1842 assert_eq!(translate_predicate(&pred("_attrs == null"), &blocks), None);
1843 assert_eq!(
1845 translate_predicate(&pred("_doc._nodes == null"), &blocks),
1846 None
1847 );
1848 assert_eq!(
1849 translate_predicate(&pred("_doc._frontmatter == null"), &blocks),
1850 None
1851 );
1852 assert_eq!(translate_predicate(&pred("_doc._doc == null"), &DOCS), None);
1853 assert!(translate_predicate(&pred("_doc.layer == \"canon\""), &blocks).is_some());
1854 assert!(translate_predicate(&pred("section == \"x\""), &DOCS).is_some());
1857 assert!(translate_predicate(&pred("frontmatter == \"x\""), &blocks).is_some());
1858 assert!(translate_predicate(&pred("doc == \"x\""), &DOCS).is_some());
1859 }
1860
1861 #[test]
1867 fn handle_sets_match_the_store_context() {
1868 use std::collections::HashMap;
1869
1870 use omgbase_reconcile::Config;
1871 use omgbase_store::{BatchItem, Store};
1872 use oqx::DataContext;
1873
1874 use crate::context::StoreContext;
1875
1876 let mut store = Store::open_in_memory().expect("store");
1877 let repo = store.create_repo("handles").expect("repo");
1878 let items = [
1879 BatchItem::observed(
1880 "a.md",
1881 "---\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",
1882 ),
1883 BatchItem::observed("b.md", "# B\n\nBack to [a](a.md).\n"),
1884 ];
1885 store
1886 .observe_batch(
1887 &repo,
1888 &items,
1889 "2026-09-26T00:00:00.000Z",
1890 &Config::default(),
1891 )
1892 .expect("observe");
1893 let ctx = StoreContext::new(store.conn(), &repo, HashMap::new());
1894
1895 let mut universe: Vec<&str> = crate::context::ALL_TARGETS
1896 .into_iter()
1897 .flat_map(|t| non_property_handles(t).iter().copied())
1898 .collect();
1899 universe.extend([
1900 "layer",
1901 "title",
1902 "verified",
1903 "checked",
1904 "level",
1905 "format",
1906 "type",
1907 "text",
1908 "kind",
1909 "name",
1910 "value",
1911 "predicate",
1912 "key",
1913 "nope",
1914 ]);
1915 universe.sort_unstable();
1916 universe.dedup();
1917
1918 let is_shape = |v: &Value| matches!(v, Value::Array(_) | Value::Object(_));
1919 for target in crate::context::ALL_TARGETS {
1920 let rows = ctx.to_rows(&ctx.root(target.as_str()));
1922 assert!(!rows.is_empty(), "{target:?} has rows");
1923 let set = non_property_handles(target);
1924 for name in &universe {
1925 let mut shaped = 0;
1926 for row in &rows {
1927 let v = match ctx.get(row, name) {
1928 Ok(v) => v,
1929 Err(_) if Target::parse(name).is_some() && !set.contains(name) => continue,
1932 Err(e) => panic!("{target:?}.{name}: {e}"),
1933 };
1934 if set.contains(name) {
1935 assert!(
1936 v.is_absent() || is_shape(&v),
1937 "{target:?}.{name} read as a scalar: {v:?}"
1938 );
1939 } else {
1940 assert!(!is_shape(&v), "{target:?}.{name} is a handle: {v:?}");
1941 }
1942 shaped += usize::from(is_shape(&v));
1943 }
1944 if set.contains(name) {
1945 assert!(
1946 shaped > 0,
1947 "{target:?}.{name} never resolved to rows or a bag"
1948 );
1949 }
1950 }
1951 }
1952 }
1953
1954 #[test]
1957 fn attributes_that_are_not_columns_decline_and_are_never_properties() {
1958 assert_eq!(translate_predicate(&pred("_title == \"x\""), &DOCS), None);
1961 assert_eq!(translate_predicate(&pred("_tags == \"x\""), &DOCS), None);
1962 assert_eq!(translate_predicate(&pred("_raw == \"x\""), &DOCS), None);
1963 assert!(
1964 translate_predicate(&pred("title == \"x\""), &DOCS)
1965 .is_some_and(|f| f.sql.contains("p.key = 'title'"))
1966 );
1967 assert!(
1968 translate_predicate(&pred("path == \"x\""), &DOCS)
1969 .is_some_and(|f| f.sql.contains("p.key = 'path'"))
1970 );
1971 assert!(
1972 translate_predicate(&pred("_doc.title == \"x\""), &BLOCKS)
1973 .is_some_and(|f| f.sql.contains("p.key = 'title'"))
1974 );
1975 assert_eq!(
1976 translate_predicate(&pred("_doc._title == \"x\""), &BLOCKS),
1977 None
1978 );
1979 assert_eq!(
1980 translate_predicate(&pred("_doc._raw == \"x\""), &BLOCKS),
1981 None
1982 );
1983 assert_eq!(translate_predicate(&pred("_raw == \"x\""), &BLOCKS), None);
1984 assert_eq!(translate_predicate(&pred("_data == null"), &DOCS), None);
1986 assert!(translate_predicate(&pred("_path == \"x\""), &DOCS).is_some());
1988 assert!(translate_predicate(&pred("_hash == \"x\""), &DOCS).is_some());
1989 assert!(translate_predicate(&pred("_doc._path == \"x\""), &BLOCKS).is_some());
1990 }
1991
1992 #[test]
1993 fn a_key_read_through_a_source_handle_is_pushed_scoped_to_its_source() {
1994 let bare = translate_predicate(&pred("layer == \"canon\""), &DOCS).unwrap();
1996 let data = translate_predicate(&pred("_data.layer == \"canon\""), &DOCS).unwrap();
1997 assert_eq!(bare, data);
1998 assert!(!bare.sql.contains("p.source"));
1999 let fm = translate_predicate(&pred("_frontmatter.layer == \"canon\""), &DOCS).unwrap();
2001 assert_eq!(
2002 fm.sql,
2003 "((SELECT COALESCE(p.val_text, p.val_num, p.val_bool) FROM properties p \
2004 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' \
2005 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 \
2006 LIMIT 1) IS ?)"
2007 );
2008 let inl = translate_predicate(&pred("_doc._inline.element == \"fire\""), &BLOCKS).unwrap();
2009 assert!(
2010 inl.sql.contains("p.source = 'inline'") && inl.sql.contains("p2.source = 'inline'")
2011 );
2012 assert!(translate_predicate(&pred("_doc._data.layer == \"canon\""), &BLOCKS).is_some());
2013 let typed = translate_predicate(&pred("_frontmatter.verified == true"), &DOCS).unwrap();
2015 assert!(
2016 typed.sql.contains("p.type = 'bool'") && typed.sql.contains("p.source = 'frontmatter'")
2017 );
2018 assert_eq!(
2020 translate_predicate(&pred("_frontmatter == null"), &DOCS),
2021 None
2022 );
2023 assert_eq!(translate_predicate(&pred("_data == null"), &DOCS), None);
2024 assert_eq!(
2025 translate_predicate(&pred("_frontmatter.meta.owner == \"x\""), &DOCS),
2026 None
2027 );
2028 assert_eq!(
2029 translate_predicate(&pred("_doc._frontmatter == null"), &BLOCKS),
2030 None
2031 );
2032 assert_eq!(
2033 translate_predicate(&pred("size(entries(_frontmatter)) > 0"), &DOCS),
2034 None
2035 );
2036 }
2037
2038 #[test]
2039 fn docs_raw_and_computed_intrinsics_are_not_columns() {
2040 assert_eq!(translate_predicate(&pred("_raw == \"x\""), &DOCS), None);
2041 assert_eq!(translate_predicate(&pred("_title == \"x\""), &DOCS), None);
2042 assert_eq!(translate_predicate(&pred("_tags == \"x\""), &DOCS), None);
2043 }
2044
2045 #[test]
2046 fn matches_needs_a_regexp_udf() {
2047 assert_eq!(
2048 translate_predicate(&pred("_path.matches(\"^lab/\")"), &DOCS),
2049 None
2050 );
2051 }
2052
2053 #[test]
2054 fn negation_as_a_nested_expr_is_not_and_safe() {
2055 let e = Expr::Unary {
2056 op: oqx::ast::UnaryOp::Not,
2057 expr: ident("_path"),
2058 span: oqx::Span::EMPTY,
2059 };
2060 assert_eq!(translate_predicate(&e, &DOCS), None);
2061 }
2062
2063 #[test]
2064 fn disjunction_as_a_nested_expr_is_declined() {
2065 let e = Expr::Logical {
2066 op: LogicalOp::Or,
2067 left: eq(ident("_path"), lit("a")),
2068 right: eq(ident("_path"), lit("b")),
2069 span: oqx::Span::EMPTY,
2070 };
2071 assert_eq!(translate_predicate(&e, &DOCS), None);
2072 }
2073
2074 #[test]
2075 fn unmapped_node_intrinsic_is_not_pushed() {
2076 let nodes = TranslateCtx {
2077 target: Target::Nodes,
2078 self_alias: "n",
2079 ..DOCS
2080 };
2081 assert_eq!(
2082 translate_predicate(&pred("$locator == \"x\""), &nodes),
2083 None
2084 );
2085 let blocks = TranslateCtx {
2087 target: Target::Blocks,
2088 self_alias: "b",
2089 ..DOCS
2090 };
2091 assert_eq!(
2092 translate_predicate(&pred("_updated_at == \"x\""), &blocks),
2093 None
2094 );
2095 }
2096
2097 #[test]
2098 fn range_membership_in_and_bare_idents_are_declined() {
2099 assert_eq!(translate_predicate(&pred("era in 800..1680"), &DOCS), None);
2100 assert_eq!(
2101 translate_predicate(&pred("\"a\" in list(_tags)"), &DOCS),
2102 None
2103 );
2104 assert_eq!(translate_predicate(&pred("verified"), &DOCS), None);
2105 assert_eq!(translate_predicate(&pred("_doc.verified"), &DOCS), None);
2106 assert_eq!(translate_predicate(&pred("size(_tags) > 1"), &DOCS), None);
2107 assert_eq!(translate_predicate(&pred("$it.text(\"x\")"), &DOCS), None);
2108 assert_eq!(translate_predicate(&pred("$it == \"x\""), &DOCS), None);
2109 assert_eq!(translate_predicate(&pred("^slug == \"x\""), &DOCS), None);
2110 assert_eq!(
2112 translate_predicate(&pred("_frontmatter == null"), &DOCS),
2113 None
2114 );
2115 }
2116
2117 #[test]
2120 fn and_composes_two_pushable_comparisons() {
2121 let e = Expr::Logical {
2122 op: LogicalOp::And,
2123 left: eq(ident("_path"), lit("a")),
2124 right: Box::new(Expr::Binary {
2125 op: BinaryOp::Ne,
2126 left: ident("_id"),
2127 right: lit("d_2"),
2128 span: oqx::Span::EMPTY,
2129 }),
2130 span: oqx::Span::EMPTY,
2131 };
2132 assert_eq!(
2133 translate_predicate(&e, &DOCS),
2134 frag(
2135 "((('/' || d.path) IS ?) AND (d.doc_id IS NOT ?))",
2136 &[text("a"), text("d_2")]
2137 )
2138 );
2139 }
2140
2141 #[test]
2142 fn and_declines_wholesale_if_either_side_is_not_pushable() {
2143 let e = Expr::Logical {
2144 op: LogicalOp::And,
2145 left: eq(ident("_path"), lit("a")),
2146 right: eq(ident("_raw"), lit("x")),
2148 span: oqx::Span::EMPTY,
2149 };
2150 assert_eq!(translate_predicate(&e, &DOCS), None);
2151 }
2152
2153 #[test]
2154 fn resolves_a_binding_to_its_param_value() {
2155 let e = eq(
2156 ident("_path"),
2157 Box::new(Expr::Binding {
2158 index: 0,
2159 span: oqx::Span::EMPTY,
2160 }),
2161 );
2162 let params = [Value::from("from-binding.md")];
2163 let ctx = TranslateCtx {
2164 params: ¶ms,
2165 ..DOCS
2166 };
2167 assert_eq!(
2168 translate_predicate(&e, &ctx),
2169 frag("(('/' || d.path) IS ?)", &[text("from-binding.md")])
2170 );
2171 assert_eq!(
2173 translate_predicate(&e, &DOCS),
2174 frag("(('/' || d.path) IS ?)", &[SqlValue::Null])
2175 );
2176 }
2177
2178 #[test]
2181 fn blocks_and_nodes_flatten_bare_identifiers_into_attrs() {
2182 let blocks = TranslateCtx {
2183 target: Target::Blocks,
2184 self_alias: "b",
2185 ..DOCS
2186 };
2187 let both = Expr::Logical {
2190 op: LogicalOp::And,
2191 left: eq(ident("_type"), lit("task")),
2192 right: eq(ident("marker"), lit("x")),
2193 span: oqx::Span::EMPTY,
2194 };
2195 assert_eq!(
2196 translate_predicate(&both, &blocks),
2197 frag(
2198 "((b.type IS ?) AND (json_extract(b.attrs, '$.marker') IS ?))",
2199 &[text("task"), text("x")]
2200 )
2201 );
2202 assert_eq!(
2203 translate_predicate(&pred("_attrs.marker == \"x\""), &blocks),
2204 frag("(json_extract(b.attrs, '$.marker') IS ?)", &[text("x")])
2205 );
2206 assert_eq!(
2209 translate_predicate(&pred("_attrs.checked == true"), &blocks),
2210 frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
2211 );
2212 let nodes = TranslateCtx {
2213 target: Target::Nodes,
2214 self_alias: "n",
2215 ..DOCS
2216 };
2217 assert_eq!(
2218 translate_predicate(&pred("_kind == \"md:section\""), &nodes),
2219 frag("(n.kind IS ?)", &[text("md:section")])
2220 );
2221 assert_eq!(
2222 translate_predicate(&pred("level == \"1\""), &nodes),
2223 frag("(json_extract(n.attrs, '$.level') IS ?)", &[text("1")])
2224 );
2225 assert_eq!(
2226 translate_predicate(&pred("level == 1"), &nodes),
2227 frag(
2228 "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') = ?) IS 1)",
2229 &[SqlValue::Real(1.0)]
2230 )
2231 );
2232 assert_eq!(
2233 translate_predicate(&pred("_attrs.a.b == \"c\""), &nodes),
2234 frag("(json_extract(n.attrs, '$.a.b') IS ?)", &[text("c")])
2235 );
2236 assert_eq!(
2238 translate_predicate(&pred("_attrs.marker == \"x\""), &DOCS),
2239 None
2240 );
2241 }
2242
2243 #[test]
2244 fn doc_and_block_reach_through() {
2245 let blocks = TranslateCtx {
2246 target: Target::Blocks,
2247 self_alias: "b",
2248 ..DOCS
2249 };
2250 let f = translate_predicate(&pred("_doc.type == \"lab-note\""), &blocks).expect("pushable");
2251 assert!(
2252 f.sql.contains("p.doc_id = d.doc_id AND p.key = 'type'"),
2253 "{}",
2254 f.sql
2255 );
2256 assert_eq!(
2257 translate_predicate(&pred("_doc._path == \"a.md\""), &blocks),
2258 frag("(('/' || d.path) IS ?)", &[text("a.md")])
2259 );
2260 assert_eq!(
2263 translate_predicate(&pred("_doc._path == \"/a.md\""), &blocks),
2264 frag("(d.path IS ?)", &[text("a.md")])
2265 );
2266 assert_eq!(
2267 translate_predicate(&pred("_doc._format == \"markdown\""), &blocks),
2268 frag("(d.format IS ?)", &[text("markdown")])
2269 );
2270 assert_eq!(
2272 translate_predicate(&pred("_doc._id == \"d_1\""), &blocks),
2273 frag("(b.doc_id IS ?)", &[text("d_1")])
2274 );
2275 assert_eq!(translate_predicate(&pred("_doc.a.b == 1"), &blocks), None);
2276 let nodes = TranslateCtx {
2277 target: Target::Nodes,
2278 self_alias: "n",
2279 ..DOCS
2280 };
2281 assert_eq!(
2282 translate_predicate(&pred("_block._type == \"task\""), &nodes),
2283 frag(
2284 "((SELECT bb.type FROM blocks bb WHERE bb.block_id = n.block_id) IS ?)",
2285 &[text("task")]
2286 )
2287 );
2288 assert_eq!(
2289 translate_predicate(&pred("_block._type == \"task\""), &blocks),
2290 None
2291 );
2292 assert_eq!(
2293 translate_predicate(&pred("_section.level == 1"), &nodes),
2294 None
2295 );
2296 }
2297
2298 #[test]
2299 fn edges_push_their_plain_fields_under_the_surface_names() {
2300 let edges = TranslateCtx {
2301 target: Target::Links,
2302 self_alias: "e",
2303 ..DOCS
2304 };
2305 assert_eq!(
2306 translate_predicate(&pred("_predicate == \"references\""), &edges),
2307 frag("(e.predicate IS ?)", &[text("references")])
2308 );
2309 assert_eq!(
2310 translate_predicate(&pred("_dst_path == \"index.md\""), &edges),
2311 frag(
2312 "((SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2313 &[text("index.md")]
2314 )
2315 );
2316 assert_eq!(
2317 translate_predicate(&pred("_block_id == \"b_1\""), &edges),
2318 frag("(e.src_block IS ?)", &[text("b_1")])
2319 );
2320 assert_eq!(
2321 translate_predicate(&pred("_via_id == null"), &edges),
2322 frag("(e.via_node IS ?)", &[SqlValue::Null])
2323 );
2324 assert_eq!(
2325 translate_predicate(&pred("_dst_id == \"d_2\""), &edges),
2326 frag("(e.dst_node IS ?)", &[text("d_2")])
2327 );
2328 assert_eq!(
2329 translate_predicate(&pred("_field == \"before\""), &edges),
2330 frag("(e.src_field IS ?)", &[text("before")])
2331 );
2332 assert!(
2333 translate_predicate(&pred("_uri == \"https://x\""), &edges)
2334 .is_some_and(|f| f.sql.contains("FROM external_nodes xn"))
2335 );
2336 assert_eq!(translate_predicate(&pred("weight == 1"), &edges), None);
2337 assert_eq!(translate_predicate(&pred("$src == \"d_1\""), &edges), None);
2340 assert_eq!(
2341 translate_predicate(&pred("$from_commit == \"c_1\""), &edges),
2342 None
2343 );
2344 assert_eq!(
2345 translate_predicate(&pred("src_field == \"x\""), &edges),
2346 None
2347 );
2348 let blocks = TranslateCtx {
2349 target: Target::Blocks,
2350 self_alias: "b",
2351 ..DOCS
2352 };
2353 assert_eq!(translate_predicate(&pred("$doc == \"d_1\""), &blocks), None);
2354 assert_eq!(translate_predicate(&pred("$ordinal == 1"), &blocks), None);
2355 }
2356
2357 #[test]
2361 fn reach_through_identity_is_the_foreign_key() {
2362 let blocks = TranslateCtx {
2363 target: Target::Blocks,
2364 self_alias: "b",
2365 ..DOCS
2366 };
2367 let nodes = TranslateCtx {
2368 target: Target::Nodes,
2369 self_alias: "n",
2370 ..DOCS
2371 };
2372 let edges = TranslateCtx {
2373 target: Target::Links,
2374 self_alias: "e",
2375 ..DOCS
2376 };
2377 assert_eq!(
2379 translate_predicate(&pred("_doc._id == \"d_1\""), &blocks),
2380 frag("(b.doc_id IS ?)", &[text("d_1")])
2381 );
2382 assert_eq!(
2383 translate_predicate(&pred("_doc_id == \"d_1\""), &blocks),
2384 frag("(b.doc_id IS ?)", &[text("d_1")])
2385 );
2386 assert_eq!(
2387 translate_predicate(&pred("_doc._id == \"d_1\""), &nodes),
2388 frag("(n.doc_id IS ?)", &[text("d_1")])
2389 );
2390 assert_eq!(
2391 translate_predicate(&pred("_doc._id == \"d_1\""), &edges),
2392 frag("(e.src_doc IS ?)", &[text("d_1")])
2393 );
2394 assert_eq!(
2395 translate_predicate(&pred("_doc_id == \"d_1\""), &edges),
2396 frag("(e.src_doc IS ?)", &[text("d_1")])
2397 );
2398 assert_eq!(
2400 translate_predicate(&pred("_doc._id == \"d_1\""), &DOCS),
2401 None
2402 );
2403 assert_eq!(
2405 translate_predicate(&pred("_doc._path == \"/a.md\""), &blocks),
2406 frag("(d.path IS ?)", &[text("a.md")])
2407 );
2408 assert_eq!(
2409 translate_predicate(&pred("_doc._path == \"/a.md\""), &edges),
2410 frag("(d.path IS ?)", &[text("a.md")])
2411 );
2412 assert_eq!(
2413 translate_predicate(&pred("_doc._hash == \"ab\""), &blocks),
2414 frag("(lower(hex(d.file_hash)) IS ?)", &[text("ab")])
2415 );
2416 assert!(
2417 translate_predicate(&pred("_doc._updated_at >= \"2026\""), &nodes)
2418 .is_some_and(|f| f.sql.contains("r.rev_id = d.current_rev"))
2419 );
2420 assert!(
2421 translate_predicate(&pred("_doc._created_at >= \"2026\""), &nodes)
2422 .is_some_and(|f| f.sql.contains("r.doc_id = d.doc_id AND r.seq = 1"))
2423 );
2424 assert_eq!(
2425 translate_predicate(&pred("_doc._raw == \"x\""), &blocks),
2426 None
2427 );
2428 assert_eq!(
2430 translate_predicate(&pred("_block._id == \"b_1\""), &nodes),
2431 frag("(n.block_id IS ?)", &[text("b_1")])
2432 );
2433 assert_eq!(
2434 translate_predicate(&pred("_block_id == \"b_1\""), &nodes),
2435 frag("(n.block_id IS ?)", &[text("b_1")])
2436 );
2437 assert_eq!(
2438 translate_predicate(&pred("_block._path == \"/a.md\""), &nodes),
2439 frag("(d.path IS ?)", &[text("a.md")])
2440 );
2441 assert_eq!(
2442 translate_predicate(&pred("_block._doc_id == \"d_1\""), &nodes),
2443 frag("(n.doc_id IS ?)", &[text("d_1")])
2444 );
2445 assert!(
2446 translate_predicate(&pred("_block._text == \"x\""), &nodes)
2447 .is_some_and(|f| f.sql.contains("SELECT bb.text FROM blocks bb"))
2448 );
2449 assert_eq!(
2450 translate_predicate(&pred("_block._hash == \"x\""), &nodes),
2451 None
2452 );
2453 }
2454
2455 #[test]
2458 fn dst_reach_through_reads_the_destination_by_its_foreign_key() {
2459 let edges = TranslateCtx {
2460 target: Target::Links,
2461 self_alias: "e",
2462 ..DOCS
2463 };
2464 assert_eq!(
2467 translate_predicate(&pred("_dst._id == \"d_2\""), &edges),
2468 frag(
2469 "((SELECT dd.doc_id FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2470 &[text("d_2")]
2471 )
2472 );
2473 assert_eq!(
2474 translate_predicate(&pred("_dst._path == \"/a.md\""), &edges),
2475 frag(
2476 "((SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2477 &[text("/a.md")]
2478 )
2479 );
2480 assert_eq!(
2481 translate_predicate(&pred("_dst._format == \"yaml\""), &edges),
2482 frag(
2483 "((SELECT dd.format FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2484 &[text("yaml")]
2485 )
2486 );
2487 assert!(
2488 translate_predicate(&pred("_dst._hash == \"ab\""), &edges)
2489 .is_some_and(|f| f.sql.contains("lower(hex(dd.file_hash))"))
2490 );
2491 assert!(
2492 translate_predicate(&pred("_dst._updated_at == \"t\""), &edges)
2493 .is_some_and(|f| f.sql.contains("WHERE r.rev_id = dd.current_rev"))
2494 );
2495 assert!(
2496 translate_predicate(&pred("_dst._created_at == \"t\""), &edges)
2497 .is_some_and(|f| f.sql.contains("WHERE r.doc_id = dd.doc_id AND r.seq = 1"))
2498 );
2499 let prop = translate_predicate(&pred("_dst.layer == \"canon\""), &edges).expect("pushed");
2501 assert!(
2502 prop.sql
2503 .contains("p.doc_id = e.dst_node AND p.key = 'layer'"),
2504 "{}",
2505 prop.sql
2506 );
2507 assert_eq!(prop.params, vec![text("canon")]);
2508 assert!(
2510 translate_predicate(&pred("_dst.era > 1600"), &edges)
2511 .is_some_and(|f| f.sql.contains("p.doc_id = e.dst_node AND p.key = 'era'"))
2512 );
2513 assert!(
2515 translate_predicate(&pred("_dst._frontmatter.layer == \"canon\""), &edges)
2516 .is_some_and(|f| f.sql.contains("p.source = 'frontmatter'"))
2517 );
2518 for k in [
2521 "_raw",
2522 "_title",
2523 "_tags",
2524 "_nodes",
2525 "_out_links",
2526 "_data",
2527 "docs",
2528 "$x",
2529 ] {
2530 assert_eq!(
2531 translate_predicate(&pred(&format!("_dst.{k} == \"x\"")), &edges),
2532 None,
2533 "dst.{k}"
2534 );
2535 }
2536 assert_eq!(translate_predicate(&pred("_dst == null"), &edges), None);
2537 assert_eq!(
2539 translate_predicate(&pred("_dst.layer == \"canon\""), &DOCS),
2540 None
2541 );
2542 }
2543
2544 #[test]
2548 fn block_reach_through_on_a_link_reads_the_source_block_by_its_foreign_key() {
2549 let edges = TranslateCtx {
2550 target: Target::Links,
2551 self_alias: "e",
2552 ..DOCS
2553 };
2554 assert_eq!(
2555 translate_predicate(&pred("_block._id == \"b_1\""), &edges),
2556 frag("(e.src_block IS ?)", &[text("b_1")])
2557 );
2558 assert_eq!(
2559 translate_predicate(&pred("_block._type == \"paragraph\""), &edges),
2560 frag(
2561 "((SELECT bb.type FROM blocks bb WHERE bb.block_id = e.src_block) IS ?)",
2562 &[text("paragraph")]
2563 )
2564 );
2565 assert!(
2566 translate_predicate(&pred("_block._text == \"x\""), &edges).is_some_and(|f| f
2567 .sql
2568 .contains("SELECT bb.text FROM blocks bb WHERE bb.block_id = e.src_block"))
2569 );
2570 assert_eq!(
2571 translate_predicate(&pred("_block._path == \"/a.md\""), &edges),
2572 frag(
2573 "((SELECT '/' || d.path FROM blocks bb WHERE bb.block_id = e.src_block) IS ?)",
2574 &[text("/a.md")]
2575 )
2576 );
2577 assert_eq!(
2578 translate_predicate(&pred("_block._doc_id == \"d_1\""), &edges),
2579 frag(
2580 "((SELECT bb.doc_id FROM blocks bb WHERE bb.block_id = e.src_block) IS ?)",
2581 &[text("d_1")]
2582 )
2583 );
2584 for k in ["_hash", "_raw", "_updated_at", "_attrs", "_children"] {
2586 assert_eq!(
2587 translate_predicate(&pred(&format!("_block.{k} == \"x\"")), &edges),
2588 None,
2589 "block.{k}"
2590 );
2591 }
2592 assert_eq!(translate_predicate(&pred("_block == null"), &edges), None);
2593 }
2594
2595 #[test]
2596 fn unsafe_identifier_segments_are_never_inlined() {
2597 assert!(is_seg("layer") && is_seg("_x9"));
2598 assert!(!is_seg("") && !is_seg("9a") && !is_seg("a-b") && !is_seg("a'b"));
2599 assert_eq!(json_path(&["ok", "no-pe"]), None);
2600 assert_eq!(json_path(&["ok", "a_1"]).as_deref(), Some("$.ok.a_1"));
2601 assert_eq!(prop_scalar("d", "x'y", None), None);
2602 }
2603}