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", "block"],
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 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}")),
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 = translate_predicate(&pred("created_at < \"2026-01-01\""), &DOCS).expect("pushable");
1228 assert_eq!(
1229 f.sql,
1230 "((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) < ?)"
1231 );
1232 assert_eq!(f.params, vec![text("2026-01-01")]);
1233 assert_eq!(
1235 translate_predicate(&pred("created_at == \"x\""), &BLOCKS),
1236 None
1237 );
1238 }
1239
1240 #[test]
1241 fn format_is_a_column_not_a_property() {
1242 assert_eq!(
1243 translate_predicate(&pred("format == \"markdown\""), &DOCS),
1244 frag("(d.format IS ?)", &[text("markdown")])
1245 );
1246 }
1247
1248 #[test]
1249 fn booleans_bind_as_one_and_zero() {
1250 let blocks = TranslateCtx {
1251 target: Target::Blocks,
1252 self_alias: "b",
1253 ..DOCS
1254 };
1255 assert_eq!(
1258 translate_predicate(&pred("type == true"), &blocks).map(|f| f.params),
1259 Some(vec![SqlValue::Integer(1)])
1260 );
1261 assert_eq!(
1262 translate_predicate(&pred("type == false"), &blocks).map(|f| f.params),
1263 Some(vec![SqlValue::Integer(0)])
1264 );
1265 assert_eq!(
1266 translate_predicate(&pred("ordinal < 1000"), &blocks).map(|f| f.params),
1267 Some(vec![SqlValue::Real(1000.0)])
1268 );
1269 assert_eq!(
1270 translate_predicate(&pred("path == null"), &DOCS).map(|f| f.params),
1271 Some(vec![SqlValue::Null])
1272 );
1273 }
1274
1275 const REPRESENTATIVES: [(Ty, &str); 7] = [
1280 (Ty::Text, "path"),
1281 (Ty::Int, "ordinal"),
1282 (Ty::Num, "1"),
1283 (Ty::Bool, "true"),
1284 (Ty::Null, "null"),
1285 (Ty::Json, "checked"),
1286 (Ty::Prop, "doc.layer"),
1287 ];
1288
1289 #[test]
1290 fn representatives_carry_their_type() {
1291 let blocks = TranslateCtx {
1292 target: Target::Blocks,
1293 self_alias: "b",
1294 ..DOCS
1295 };
1296 for (ty, src) in REPRESENTATIVES {
1297 let (_, got) = typed_value(&pred(src), &blocks).expect(src);
1298 assert_eq!(got, ty, "{src}");
1299 }
1300 assert_eq!(
1301 typed_value(&pred("attrs.a.b"), &blocks).map(|(_, t)| t),
1302 Some(Ty::Json)
1303 );
1304 assert_eq!(
1305 typed_value(&pred("depth"), &blocks).map(|(_, t)| t),
1306 Some(Ty::Int)
1307 );
1308 assert_eq!(
1309 typed_value(&pred("doc_id"), &blocks).map(|(_, t)| t),
1310 Some(Ty::Text)
1311 );
1312 assert_eq!(
1313 typed_value(&pred("type.lower()"), &blocks).map(|(_, t)| t),
1314 Some(Ty::Text)
1315 );
1316 assert_eq!(
1317 typed_value(&pred("checked.upper()"), &blocks).map(|(_, t)| t),
1318 Some(Ty::Text)
1319 );
1320 assert_eq!(
1321 typed_value(&pred("layer"), &DOCS).map(|(_, t)| t),
1322 Some(Ty::Prop)
1323 );
1324 assert_eq!(
1325 typed_value(&pred("format"), &DOCS).map(|(_, t)| t),
1326 Some(Ty::Text)
1327 );
1328 }
1329
1330 #[test]
1331 fn the_comparison_matrix_decides_every_cell() {
1332 const P: bool = true;
1334 const D: bool = false;
1335 const T: bool = true;
1337 #[rustfmt::skip]
1340 const EQUALITY: [[bool; 7]; 7] = [
1341 [P, P, P, P, P, P, P],
1342 [P, P, P, D, P, D, D],
1343 [P, P, P, D, P, T, T],
1344 [P, D, D, D, P, T, T],
1345 [P, P, P, P, P, P, D],
1346 [P, D, T, T, P, D, D],
1347 [P, D, T, T, D, D, D],
1348 ];
1349 #[rustfmt::skip]
1352 const RELATIONAL: [[bool; 7]; 7] = [
1353 [P, D, D, D, D, D, D],
1354 [D, P, P, D, D, D, D],
1355 [D, P, P, D, D, T, T],
1356 [D, D, D, D, D, D, D],
1357 [D, D, D, D, D, D, D],
1358 [D, D, T, D, D, D, D],
1359 [D, D, T, D, D, D, D],
1360 ];
1361 let blocks = TranslateCtx {
1362 target: Target::Blocks,
1363 self_alias: "b",
1364 ..DOCS
1365 };
1366 let ops = [
1367 (BinaryOp::Eq, "==", &EQUALITY),
1368 (BinaryOp::Ne, "!=", &EQUALITY),
1369 (BinaryOp::Lt, "<", &RELATIONAL),
1370 (BinaryOp::Le, "<=", &RELATIONAL),
1371 (BinaryOp::Gt, ">", &RELATIONAL),
1372 (BinaryOp::Ge, ">=", &RELATIONAL),
1373 ];
1374 for (i, (a, l)) in REPRESENTATIVES.iter().enumerate() {
1375 for (j, (b, r)) in REPRESENTATIVES.iter().enumerate() {
1376 for (op, spelled, matrix) in ops {
1377 let want = matrix[i][j];
1378 assert_eq!(matrix[j][i], want, "the matrix is symmetric ({a:?}, {b:?})");
1379 assert_eq!(
1380 comparable(op, *a, *b),
1381 want,
1382 "comparable({spelled}, {a:?}, {b:?})"
1383 );
1384 let src = format!("{l} {spelled} {r}");
1385 assert_eq!(
1386 translate_predicate(&pred(&src), &blocks).is_some(),
1387 want,
1388 "{src}"
1389 );
1390 }
1391 }
1392 }
1393 }
1394
1395 #[test]
1396 fn the_spec_shapes_of_decline_a() {
1397 let blocks = TranslateCtx {
1398 target: Target::Blocks,
1399 self_alias: "b",
1400 ..DOCS
1401 };
1402 let typed = |src: &str, ctx: &TranslateCtx<'_>| {
1405 let f = translate_predicate(&pred(src), ctx).expect(src);
1406 assert!(
1407 f.sql.contains("json_type(") || f.sql.contains("p.type = "),
1408 "{src}: {}",
1409 f.sql
1410 );
1411 assert!(
1412 f.sql.ends_with(" IS 1)") || f.sql.ends_with(" IS NOT 1)"),
1413 "{src}: {}",
1414 f.sql
1415 );
1416 };
1417 typed("checked == 1", &blocks);
1418 typed("checked == true", &blocks);
1419 typed("attrs.checked == true", &blocks);
1420 typed("verified == 1", &DOCS);
1422 typed("verified == true", &DOCS);
1423 typed("era < 1000", &DOCS);
1424 typed("doc.era < 1000", &blocks);
1425 assert_eq!(translate_predicate(&pred("ordinal == true"), &blocks), None);
1427 assert_eq!(
1428 translate_predicate(&pred("ordinal == 1"), &blocks),
1429 frag("(b.ordinal IS ?)", &[SqlValue::Real(1.0)])
1430 );
1431 assert_eq!(translate_predicate(&pred("tags != null"), &DOCS), None);
1433 assert_eq!(translate_predicate(&pred("tags == null"), &DOCS), None);
1434 assert_eq!(
1435 translate_predicate(&pred("doc.tags == null"), &blocks),
1436 None
1437 );
1438 assert_eq!(
1439 translate_predicate(&pred("checked == null"), &blocks),
1440 frag(
1441 "(json_extract(b.attrs, '$.checked') IS ?)",
1442 &[SqlValue::Null]
1443 )
1444 );
1445 assert_eq!(
1446 translate_predicate(&pred("ordinal != null"), &blocks),
1447 frag("(b.ordinal IS NOT ?)", &[SqlValue::Null])
1448 );
1449 assert_eq!(
1451 translate_predicate(&pred("checked == \"x\""), &blocks),
1452 frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1453 );
1454 assert!(translate_predicate(&pred("layer == \"canon\""), &DOCS).is_some());
1455 assert!(translate_predicate(&pred("ordinal == \"1\""), &blocks).is_some());
1456 assert!(translate_predicate(&pred("ordinal != \"1\""), &blocks).is_some());
1457 assert_eq!(translate_predicate(&pred("ordinal < \"3\""), &blocks), None);
1460 assert_eq!(
1461 translate_predicate(&pred("\"3\" >= ordinal"), &blocks),
1462 None
1463 );
1464 assert_eq!(translate_predicate(&pred("path > 5"), &DOCS), None);
1465 assert_eq!(translate_predicate(&pred("type <= 1"), &blocks), None);
1466 assert_eq!(
1467 translate_predicate(&pred("ordinal < 3"), &blocks),
1468 frag("(b.ordinal < ?)", &[SqlValue::Real(3.0)])
1469 );
1470 assert_eq!(
1471 translate_predicate(&pred("path > \"m\""), &DOCS),
1472 frag("(('/' || d.path) > ?)", &[text("m")])
1473 );
1474 assert_eq!(
1476 translate_predicate(&pred("ordinal == checked"), &blocks),
1477 None
1478 );
1479 assert_eq!(
1480 translate_predicate(&pred("checked == level"), &blocks),
1481 None
1482 );
1483 assert_eq!(
1484 translate_predicate(&pred("doc.era == doc.year"), &blocks),
1485 None
1486 );
1487 assert_eq!(translate_predicate(&pred("level < \"x\""), &blocks), None);
1488 typed("level < 3", &blocks);
1489 assert_eq!(translate_predicate(&pred("true == false"), &blocks), None);
1490 assert_eq!(translate_predicate(&pred("checked < true"), &blocks), None);
1491 assert_eq!(
1492 translate_predicate(&pred("doc.verified >= false"), &blocks),
1493 None
1494 );
1495 assert_eq!(translate_predicate(&pred("ordinal > null"), &blocks), None);
1496 assert!(translate_predicate(&pred("ordinal == depth"), &blocks).is_some());
1497 assert!(translate_predicate(&pred("ordinal <= depth"), &blocks).is_some());
1498 assert!(translate_predicate(&pred("type == null"), &blocks).is_some());
1499 }
1500
1501 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";
1506
1507 fn prop_sql(key: &str, select: &str, wrap: &str) -> String {
1508 format!(
1509 "((SELECT {select} {}) {wrap})",
1510 PROP_SCOPE.replace('K', key)
1511 )
1512 }
1513
1514 #[test]
1515 fn json_against_a_boolean_tests_json_type_for_the_literal() {
1516 let blocks = TranslateCtx {
1517 target: Target::Blocks,
1518 self_alias: "b",
1519 ..DOCS
1520 };
1521 assert_eq!(
1523 translate_predicate(&pred("checked == true"), &blocks),
1524 frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1525 );
1526 assert_eq!(
1527 translate_predicate(&pred("checked == false"), &blocks),
1528 frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1529 );
1530 assert_eq!(
1531 translate_predicate(&pred("checked != true"), &blocks),
1532 frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1533 );
1534 assert_eq!(
1535 translate_predicate(&pred("false == attrs.checked"), &blocks),
1536 frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1537 );
1538 let params = [Value::Bool(true)];
1540 let e = Expr::Binary {
1541 op: BinaryOp::Ne,
1542 left: ident("checked"),
1543 right: Box::new(Expr::Binding {
1544 index: 0,
1545 span: oqx::Span::EMPTY,
1546 }),
1547 span: oqx::Span::EMPTY,
1548 };
1549 assert_eq!(
1550 translate_predicate(
1551 &e,
1552 &TranslateCtx {
1553 params: ¶ms,
1554 ..blocks
1555 }
1556 ),
1557 frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1558 );
1559 }
1560
1561 #[test]
1562 fn json_against_a_number_tests_the_numeric_types_then_compares() {
1563 let nodes = TranslateCtx {
1564 target: Target::Nodes,
1565 self_alias: "n",
1566 ..DOCS
1567 };
1568 let shape = |op: &str, wrap: &str| {
1569 format!(
1570 "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') {op} ?) {wrap})"
1571 )
1572 };
1573 let two = [SqlValue::Real(2.0)];
1574 for (src, op, wrap) in [
1575 ("level == 2", "=", "IS 1"),
1576 ("level != 2", "=", "IS NOT 1"),
1577 ("level < 2", "<", "IS 1"),
1578 ("level <= 2", "<=", "IS 1"),
1579 ("level > 2", ">", "IS 1"),
1580 ("level >= 2", ">=", "IS 1"),
1581 ] {
1582 assert_eq!(
1583 translate_predicate(&pred(src), &nodes),
1584 frag(&shape(op, wrap), &two),
1585 "{src}"
1586 );
1587 }
1588 assert_eq!(
1590 translate_predicate(&pred("2 <= attrs.level"), &nodes),
1591 frag(&shape(">=", "IS 1"), &two)
1592 );
1593 assert_eq!(
1594 translate_predicate(&pred("2 > level"), &nodes),
1595 frag(&shape("<", "IS 1"), &two)
1596 );
1597 assert_eq!(
1598 translate_predicate(&pred("2 != level"), &nodes),
1599 frag(&shape("=", "IS NOT 1"), &two)
1600 );
1601 }
1602
1603 #[test]
1604 fn property_against_a_boolean_tests_p_type_bool_in_the_scalar_row_subquery() {
1605 let blocks = TranslateCtx {
1606 target: Target::Blocks,
1607 self_alias: "b",
1608 ..DOCS
1609 };
1610 assert_eq!(
1611 translate_predicate(&pred("verified == true"), &DOCS),
1612 frag(
1613 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1614 &[SqlValue::Integer(1)]
1615 )
1616 );
1617 assert_eq!(
1618 translate_predicate(&pred("verified != false"), &DOCS),
1619 frag(
1620 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS NOT 1"),
1621 &[SqlValue::Integer(0)]
1622 )
1623 );
1624 assert_eq!(
1626 translate_predicate(&pred("doc.verified == false"), &blocks),
1627 frag(
1628 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1629 &[SqlValue::Integer(0)]
1630 )
1631 );
1632 assert_eq!(
1633 translate_predicate(&pred("true == verified"), &DOCS),
1634 frag(
1635 &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1636 &[SqlValue::Integer(1)]
1637 )
1638 );
1639 }
1640
1641 #[test]
1642 fn property_against_a_number_tests_p_type_number_in_the_scalar_row_subquery() {
1643 let blocks = TranslateCtx {
1644 target: Target::Blocks,
1645 self_alias: "b",
1646 ..DOCS
1647 };
1648 let thousand = [SqlValue::Real(1000.0)];
1649 for (src, op, wrap) in [
1650 ("era == 1000", "=", "IS 1"),
1651 ("era != 1000", "=", "IS NOT 1"),
1652 ("era < 1000", "<", "IS 1"),
1653 ("era <= 1000", "<=", "IS 1"),
1654 ("era > 1000", ">", "IS 1"),
1655 ("era >= 1000", ">=", "IS 1"),
1656 ] {
1657 assert_eq!(
1658 translate_predicate(&pred(src), &DOCS),
1659 frag(
1660 &prop_sql(
1661 "era",
1662 &format!("p.type = 'number' AND p.val_num {op} ?"),
1663 wrap
1664 ),
1665 &thousand
1666 ),
1667 "{src}"
1668 );
1669 }
1670 assert_eq!(
1671 translate_predicate(&pred("doc.era >= 1000"), &blocks),
1672 frag(
1673 &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1674 &thousand
1675 )
1676 );
1677 assert_eq!(
1679 translate_predicate(&pred("1000 > era"), &DOCS),
1680 frag(
1681 &prop_sql("era", "p.type = 'number' AND p.val_num < ?", "IS 1"),
1682 &thousand
1683 )
1684 );
1685 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 }
1693
1694 #[test]
1695 fn typed_pushes_compose_under_and_and_keep_the_untyped_forms() {
1696 let blocks = TranslateCtx {
1697 target: Target::Blocks,
1698 self_alias: "b",
1699 ..DOCS
1700 };
1701 let e = Expr::Logical {
1702 op: LogicalOp::And,
1703 left: eq(ident("type"), lit("task")),
1704 right: Box::new(Expr::Binary {
1705 op: BinaryOp::Eq,
1706 left: ident("checked"),
1707 right: Box::new(Expr::Lit {
1708 value: Value::Bool(false),
1709 span: oqx::Span::EMPTY,
1710 }),
1711 span: oqx::Span::EMPTY,
1712 }),
1713 span: oqx::Span::EMPTY,
1714 };
1715 assert_eq!(
1716 translate_predicate(&e, &blocks),
1717 frag(
1718 "((b.type IS ?) AND ((json_type(b.attrs, '$.checked') = 'false') IS 1))",
1719 &[text("task")]
1720 )
1721 );
1722 assert_eq!(
1724 translate_predicate(&pred("checked == \"x\""), &blocks),
1725 frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1726 );
1727 assert_eq!(
1728 translate_predicate(&pred("checked != null"), &blocks),
1729 frag(
1730 "(json_extract(b.attrs, '$.checked') IS NOT ?)",
1731 &[SqlValue::Null]
1732 )
1733 );
1734 assert_eq!(
1736 prop_row("d", "x'y", "p.type = 'number' AND p.val_num = ?", None),
1737 None
1738 );
1739 }
1740
1741 #[test]
1742 fn bindings_are_typed_by_their_value() {
1743 let blocks = TranslateCtx {
1744 target: Target::Blocks,
1745 self_alias: "b",
1746 ..DOCS
1747 };
1748 let params = [
1749 Value::from("s"),
1750 Value::from(1.0),
1751 Value::Bool(true),
1752 Value::Null,
1753 Value::Array(vec![]),
1754 ];
1755 let ctx = TranslateCtx {
1756 params: ¶ms,
1757 ..blocks
1758 };
1759 let against = |i: usize, rhs: &str| {
1760 let e = Expr::Binary {
1761 op: BinaryOp::Eq,
1762 left: Box::new(Expr::Binding {
1763 index: i,
1764 span: oqx::Span::EMPTY,
1765 }),
1766 right: Box::new(pred(rhs)),
1767 span: oqx::Span::EMPTY,
1768 };
1769 translate_predicate(&e, &ctx).is_some()
1770 };
1771 assert!(against(0, "checked"));
1774 assert!(against(1, "checked"));
1775 assert!(against(2, "checked"));
1776 assert!(!against(2, "ordinal"));
1777 assert!(against(1, "ordinal"));
1778 assert!(against(3, "checked"));
1780 assert!(!against(3, "doc.tags"));
1781 assert!(against(9, "checked"));
1783 assert!(!against(9, "doc.tags"));
1784 assert!(!against(4, "type"));
1786 }
1787
1788 #[test]
1791 fn relation_and_handle_names_are_not_property_reads() {
1792 let blocks = TranslateCtx {
1793 target: Target::Blocks,
1794 self_alias: "b",
1795 ..DOCS
1796 };
1797 let nodes = TranslateCtx {
1798 target: Target::Nodes,
1799 self_alias: "n",
1800 ..DOCS
1801 };
1802 let edges = TranslateCtx {
1803 target: Target::Links,
1804 self_alias: "e",
1805 ..DOCS
1806 };
1807 for (ctx, target) in [
1808 (&DOCS, Target::Docs),
1809 (&blocks, Target::Blocks),
1810 (&nodes, Target::Nodes),
1811 (&edges, Target::Links),
1812 ] {
1813 for name in non_property_handles(target) {
1814 let src = format!("{name} == null");
1815 assert_eq!(
1816 translate_predicate(&pred(&src), ctx),
1817 None,
1818 "{target:?}: {src}"
1819 );
1820 let src = format!("{name} == \"x\"");
1821 assert_eq!(
1822 translate_predicate(&pred(&src), ctx),
1823 None,
1824 "{target:?}: {src}"
1825 );
1826 }
1827 }
1828 assert_eq!(translate_predicate(&pred("nodes == null"), &DOCS), None);
1830 assert_eq!(
1831 translate_predicate(&pred("frontmatter == null"), &DOCS),
1832 None
1833 );
1834 assert_eq!(translate_predicate(&pred("nodes == null"), &blocks), None);
1835 assert_eq!(translate_predicate(&pred("attrs == null"), &blocks), None);
1836 assert_eq!(
1838 translate_predicate(&pred("doc.nodes == null"), &blocks),
1839 None
1840 );
1841 assert_eq!(
1842 translate_predicate(&pred("doc.frontmatter == null"), &blocks),
1843 None
1844 );
1845 assert_eq!(translate_predicate(&pred("doc.doc == null"), &DOCS), None);
1846 assert!(translate_predicate(&pred("doc.layer == \"canon\""), &blocks).is_some());
1847 assert!(translate_predicate(&pred("section == \"x\""), &DOCS).is_some());
1849 assert!(translate_predicate(&pred("frontmatter == \"x\""), &blocks).is_some());
1850 }
1851
1852 #[test]
1858 fn handle_sets_match_the_store_context() {
1859 use std::collections::HashMap;
1860
1861 use omgbase_reconcile::Config;
1862 use omgbase_store::{BatchItem, Store};
1863 use oqx::DataContext;
1864
1865 use crate::context::StoreContext;
1866
1867 let mut store = Store::open_in_memory().expect("store");
1868 let repo = store.create_repo("handles").expect("repo");
1869 let items = [
1870 BatchItem::observed(
1871 "a.md",
1872 "---\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",
1873 ),
1874 BatchItem::observed("b.md", "# B\n\nBack to [a](a.md).\n"),
1875 ];
1876 store
1877 .observe_batch(
1878 &repo,
1879 &items,
1880 "2026-09-26T00:00:00.000Z",
1881 &Config::default(),
1882 )
1883 .expect("observe");
1884 let ctx = StoreContext::new(store.conn(), &repo, HashMap::new());
1885
1886 let mut universe: Vec<&str> = crate::context::ALL_TARGETS
1887 .into_iter()
1888 .flat_map(|t| non_property_handles(t).iter().copied())
1889 .collect();
1890 universe.extend([
1891 "layer",
1892 "title",
1893 "verified",
1894 "checked",
1895 "level",
1896 "format",
1897 "type",
1898 "text",
1899 "kind",
1900 "name",
1901 "value",
1902 "predicate",
1903 "key",
1904 "nope",
1905 ]);
1906 universe.sort_unstable();
1907 universe.dedup();
1908
1909 let is_shape = |v: &Value| matches!(v, Value::Array(_) | Value::Object(_));
1910 for target in crate::context::ALL_TARGETS {
1911 let rows = ctx.to_rows(&ctx.root(target.as_str()));
1913 assert!(!rows.is_empty(), "{target:?} has rows");
1914 let set = non_property_handles(target);
1915 for name in &universe {
1916 let mut shaped = 0;
1917 for row in &rows {
1918 let v = match ctx.get(row, name) {
1919 Ok(v) => v,
1920 Err(_) if Target::parse(name).is_some() && !set.contains(name) => continue,
1923 Err(e) => panic!("{target:?}.{name}: {e}"),
1924 };
1925 if set.contains(name) {
1926 assert!(
1927 v.is_absent() || is_shape(&v),
1928 "{target:?}.{name} read as a scalar: {v:?}"
1929 );
1930 } else {
1931 assert!(!is_shape(&v), "{target:?}.{name} is a handle: {v:?}");
1932 }
1933 shaped += usize::from(is_shape(&v));
1934 }
1935 if set.contains(name) {
1936 assert!(
1937 shaped > 0,
1938 "{target:?}.{name} never resolved to rows or a bag"
1939 );
1940 }
1941 }
1942 }
1943 }
1944
1945 #[test]
1948 fn attributes_that_are_not_columns_decline_and_are_never_properties() {
1949 assert_eq!(translate_predicate(&pred("title == \"x\""), &DOCS), None);
1952 assert_eq!(translate_predicate(&pred("tags == \"x\""), &DOCS), None);
1953 assert_eq!(translate_predicate(&pred("raw == \"x\""), &DOCS), None);
1954 assert_eq!(
1955 translate_predicate(&pred("doc.title == \"x\""), &BLOCKS),
1956 None
1957 );
1958 assert_eq!(
1959 translate_predicate(&pred("doc.raw == \"x\""), &BLOCKS),
1960 None
1961 );
1962 assert_eq!(translate_predicate(&pred("raw == \"x\""), &BLOCKS), None);
1963 assert_eq!(translate_predicate(&pred("data == null"), &DOCS), None);
1965 assert!(translate_predicate(&pred("path == \"x\""), &DOCS).is_some());
1967 assert!(translate_predicate(&pred("hash == \"x\""), &DOCS).is_some());
1968 assert!(translate_predicate(&pred("doc.path == \"x\""), &BLOCKS).is_some());
1969 }
1970
1971 #[test]
1972 fn a_key_read_through_a_source_handle_is_pushed_scoped_to_its_source() {
1973 let bare = translate_predicate(&pred("layer == \"canon\""), &DOCS).unwrap();
1975 let data = translate_predicate(&pred("data.layer == \"canon\""), &DOCS).unwrap();
1976 assert_eq!(bare, data);
1977 assert!(!bare.sql.contains("p.source"));
1978 let fm = translate_predicate(&pred("frontmatter.layer == \"canon\""), &DOCS).unwrap();
1980 assert_eq!(
1981 fm.sql,
1982 "((SELECT COALESCE(p.val_text, p.val_num, p.val_bool) FROM properties p \
1983 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' \
1984 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 \
1985 LIMIT 1) IS ?)"
1986 );
1987 let inl = translate_predicate(&pred("doc.inline.element == \"fire\""), &BLOCKS).unwrap();
1988 assert!(
1989 inl.sql.contains("p.source = 'inline'") && inl.sql.contains("p2.source = 'inline'")
1990 );
1991 assert!(translate_predicate(&pred("doc.data.layer == \"canon\""), &BLOCKS).is_some());
1992 let typed = translate_predicate(&pred("frontmatter.verified == true"), &DOCS).unwrap();
1994 assert!(
1995 typed.sql.contains("p.type = 'bool'") && typed.sql.contains("p.source = 'frontmatter'")
1996 );
1997 assert_eq!(
1999 translate_predicate(&pred("frontmatter == null"), &DOCS),
2000 None
2001 );
2002 assert_eq!(translate_predicate(&pred("data == null"), &DOCS), None);
2003 assert_eq!(
2004 translate_predicate(&pred("frontmatter.meta.owner == \"x\""), &DOCS),
2005 None
2006 );
2007 assert_eq!(
2008 translate_predicate(&pred("doc.frontmatter == null"), &BLOCKS),
2009 None
2010 );
2011 assert_eq!(
2012 translate_predicate(&pred("size(entries(frontmatter)) > 0"), &DOCS),
2013 None
2014 );
2015 }
2016
2017 #[test]
2018 fn docs_raw_and_computed_intrinsics_are_not_columns() {
2019 assert_eq!(translate_predicate(&pred("raw == \"x\""), &DOCS), None);
2020 assert_eq!(translate_predicate(&pred("title == \"x\""), &DOCS), None);
2021 assert_eq!(translate_predicate(&pred("tags == \"x\""), &DOCS), None);
2022 }
2023
2024 #[test]
2025 fn matches_needs_a_regexp_udf() {
2026 assert_eq!(
2027 translate_predicate(&pred("path.matches(\"^lab/\")"), &DOCS),
2028 None
2029 );
2030 }
2031
2032 #[test]
2033 fn negation_as_a_nested_expr_is_not_and_safe() {
2034 let e = Expr::Unary {
2035 op: oqx::ast::UnaryOp::Not,
2036 expr: ident("path"),
2037 span: oqx::Span::EMPTY,
2038 };
2039 assert_eq!(translate_predicate(&e, &DOCS), None);
2040 }
2041
2042 #[test]
2043 fn disjunction_as_a_nested_expr_is_declined() {
2044 let e = Expr::Logical {
2045 op: LogicalOp::Or,
2046 left: eq(ident("path"), lit("a")),
2047 right: eq(ident("path"), lit("b")),
2048 span: oqx::Span::EMPTY,
2049 };
2050 assert_eq!(translate_predicate(&e, &DOCS), None);
2051 }
2052
2053 #[test]
2054 fn unmapped_node_intrinsic_is_not_pushed() {
2055 let nodes = TranslateCtx {
2056 target: Target::Nodes,
2057 self_alias: "n",
2058 ..DOCS
2059 };
2060 assert_eq!(
2061 translate_predicate(&pred("$locator == \"x\""), &nodes),
2062 None
2063 );
2064 let blocks = TranslateCtx {
2066 target: Target::Blocks,
2067 self_alias: "b",
2068 ..DOCS
2069 };
2070 assert_eq!(
2071 translate_predicate(&pred("updated_at == \"x\""), &blocks),
2072 None
2073 );
2074 }
2075
2076 #[test]
2077 fn range_membership_in_and_bare_idents_are_declined() {
2078 assert_eq!(translate_predicate(&pred("era in 800..1680"), &DOCS), None);
2079 assert_eq!(
2080 translate_predicate(&pred("\"a\" in list(tags)"), &DOCS),
2081 None
2082 );
2083 assert_eq!(translate_predicate(&pred("verified"), &DOCS), None);
2084 assert_eq!(translate_predicate(&pred("doc.verified"), &DOCS), None);
2085 assert_eq!(translate_predicate(&pred("size(tags) > 1"), &DOCS), None);
2086 assert_eq!(translate_predicate(&pred("$self.text(\"x\")"), &DOCS), None);
2087 assert_eq!(translate_predicate(&pred("$it == \"x\""), &DOCS), None);
2088 assert_eq!(translate_predicate(&pred("^slug == \"x\""), &DOCS), None);
2089 assert_eq!(
2091 translate_predicate(&pred("frontmatter == null"), &DOCS),
2092 None
2093 );
2094 }
2095
2096 #[test]
2099 fn and_composes_two_pushable_comparisons() {
2100 let e = Expr::Logical {
2101 op: LogicalOp::And,
2102 left: eq(ident("path"), lit("a")),
2103 right: Box::new(Expr::Binary {
2104 op: BinaryOp::Ne,
2105 left: ident("id"),
2106 right: lit("d_2"),
2107 span: oqx::Span::EMPTY,
2108 }),
2109 span: oqx::Span::EMPTY,
2110 };
2111 assert_eq!(
2112 translate_predicate(&e, &DOCS),
2113 frag(
2114 "((('/' || d.path) IS ?) AND (d.doc_id IS NOT ?))",
2115 &[text("a"), text("d_2")]
2116 )
2117 );
2118 }
2119
2120 #[test]
2121 fn and_declines_wholesale_if_either_side_is_not_pushable() {
2122 let e = Expr::Logical {
2123 op: LogicalOp::And,
2124 left: eq(ident("path"), lit("a")),
2125 right: eq(ident("raw"), lit("x")),
2127 span: oqx::Span::EMPTY,
2128 };
2129 assert_eq!(translate_predicate(&e, &DOCS), None);
2130 }
2131
2132 #[test]
2133 fn resolves_a_binding_to_its_param_value() {
2134 let e = eq(
2135 ident("path"),
2136 Box::new(Expr::Binding {
2137 index: 0,
2138 span: oqx::Span::EMPTY,
2139 }),
2140 );
2141 let params = [Value::from("from-binding.md")];
2142 let ctx = TranslateCtx {
2143 params: ¶ms,
2144 ..DOCS
2145 };
2146 assert_eq!(
2147 translate_predicate(&e, &ctx),
2148 frag("(('/' || d.path) IS ?)", &[text("from-binding.md")])
2149 );
2150 assert_eq!(
2152 translate_predicate(&e, &DOCS),
2153 frag("(('/' || d.path) IS ?)", &[SqlValue::Null])
2154 );
2155 }
2156
2157 #[test]
2160 fn blocks_and_nodes_flatten_bare_identifiers_into_attrs() {
2161 let blocks = TranslateCtx {
2162 target: Target::Blocks,
2163 self_alias: "b",
2164 ..DOCS
2165 };
2166 let both = Expr::Logical {
2169 op: LogicalOp::And,
2170 left: eq(ident("type"), lit("task")),
2171 right: eq(ident("marker"), lit("x")),
2172 span: oqx::Span::EMPTY,
2173 };
2174 assert_eq!(
2175 translate_predicate(&both, &blocks),
2176 frag(
2177 "((b.type IS ?) AND (json_extract(b.attrs, '$.marker') IS ?))",
2178 &[text("task"), text("x")]
2179 )
2180 );
2181 assert_eq!(
2182 translate_predicate(&pred("attrs.marker == \"x\""), &blocks),
2183 frag("(json_extract(b.attrs, '$.marker') IS ?)", &[text("x")])
2184 );
2185 assert_eq!(
2188 translate_predicate(&pred("attrs.checked == true"), &blocks),
2189 frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
2190 );
2191 let nodes = TranslateCtx {
2192 target: Target::Nodes,
2193 self_alias: "n",
2194 ..DOCS
2195 };
2196 assert_eq!(
2197 translate_predicate(&pred("kind == \"md:section\""), &nodes),
2198 frag("(n.kind IS ?)", &[text("md:section")])
2199 );
2200 assert_eq!(
2201 translate_predicate(&pred("level == \"1\""), &nodes),
2202 frag("(json_extract(n.attrs, '$.level') IS ?)", &[text("1")])
2203 );
2204 assert_eq!(
2205 translate_predicate(&pred("level == 1"), &nodes),
2206 frag(
2207 "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') = ?) IS 1)",
2208 &[SqlValue::Real(1.0)]
2209 )
2210 );
2211 assert_eq!(
2212 translate_predicate(&pred("attrs.a.b == \"c\""), &nodes),
2213 frag("(json_extract(n.attrs, '$.a.b') IS ?)", &[text("c")])
2214 );
2215 assert_eq!(
2217 translate_predicate(&pred("attrs.marker == \"x\""), &DOCS),
2218 None
2219 );
2220 }
2221
2222 #[test]
2223 fn doc_and_block_reach_through() {
2224 let blocks = TranslateCtx {
2225 target: Target::Blocks,
2226 self_alias: "b",
2227 ..DOCS
2228 };
2229 let f = translate_predicate(&pred("doc.type == \"lab-note\""), &blocks).expect("pushable");
2230 assert!(
2231 f.sql.contains("p.doc_id = d.doc_id AND p.key = 'type'"),
2232 "{}",
2233 f.sql
2234 );
2235 assert_eq!(
2236 translate_predicate(&pred("doc.path == \"a.md\""), &blocks),
2237 frag("(('/' || d.path) IS ?)", &[text("a.md")])
2238 );
2239 assert_eq!(
2242 translate_predicate(&pred("doc.path == \"/a.md\""), &blocks),
2243 frag("(d.path IS ?)", &[text("a.md")])
2244 );
2245 assert_eq!(
2246 translate_predicate(&pred("doc.format == \"markdown\""), &blocks),
2247 frag("(d.format IS ?)", &[text("markdown")])
2248 );
2249 assert_eq!(
2251 translate_predicate(&pred("doc.id == \"d_1\""), &blocks),
2252 frag("(b.doc_id IS ?)", &[text("d_1")])
2253 );
2254 assert_eq!(translate_predicate(&pred("doc.a.b == 1"), &blocks), None);
2255 let nodes = TranslateCtx {
2256 target: Target::Nodes,
2257 self_alias: "n",
2258 ..DOCS
2259 };
2260 assert_eq!(
2261 translate_predicate(&pred("block.type == \"task\""), &nodes),
2262 frag(
2263 "((SELECT bb.type FROM blocks bb WHERE bb.block_id = n.block_id) IS ?)",
2264 &[text("task")]
2265 )
2266 );
2267 assert_eq!(
2268 translate_predicate(&pred("block.type == \"task\""), &blocks),
2269 None
2270 );
2271 assert_eq!(
2272 translate_predicate(&pred("section.level == 1"), &nodes),
2273 None
2274 );
2275 }
2276
2277 #[test]
2278 fn edges_push_their_plain_fields_under_the_surface_names() {
2279 let edges = TranslateCtx {
2280 target: Target::Links,
2281 self_alias: "e",
2282 ..DOCS
2283 };
2284 assert_eq!(
2285 translate_predicate(&pred("predicate == \"references\""), &edges),
2286 frag("(e.predicate IS ?)", &[text("references")])
2287 );
2288 assert_eq!(
2289 translate_predicate(&pred("dst_path == \"index.md\""), &edges),
2290 frag(
2291 "((SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2292 &[text("index.md")]
2293 )
2294 );
2295 assert_eq!(
2296 translate_predicate(&pred("block_id == \"b_1\""), &edges),
2297 frag("(e.src_block IS ?)", &[text("b_1")])
2298 );
2299 assert_eq!(
2300 translate_predicate(&pred("via_id == null"), &edges),
2301 frag("(e.via_node IS ?)", &[SqlValue::Null])
2302 );
2303 assert_eq!(
2304 translate_predicate(&pred("dst_id == \"d_2\""), &edges),
2305 frag("(e.dst_node IS ?)", &[text("d_2")])
2306 );
2307 assert_eq!(
2308 translate_predicate(&pred("field == \"before\""), &edges),
2309 frag("(e.src_field IS ?)", &[text("before")])
2310 );
2311 assert!(
2312 translate_predicate(&pred("uri == \"https://x\""), &edges)
2313 .is_some_and(|f| f.sql.contains("FROM external_nodes xn"))
2314 );
2315 assert_eq!(translate_predicate(&pred("weight == 1"), &edges), None);
2316 assert_eq!(translate_predicate(&pred("$src == \"d_1\""), &edges), None);
2319 assert_eq!(
2320 translate_predicate(&pred("$from_commit == \"c_1\""), &edges),
2321 None
2322 );
2323 assert_eq!(
2324 translate_predicate(&pred("src_field == \"x\""), &edges),
2325 None
2326 );
2327 let blocks = TranslateCtx {
2328 target: Target::Blocks,
2329 self_alias: "b",
2330 ..DOCS
2331 };
2332 assert_eq!(translate_predicate(&pred("$doc == \"d_1\""), &blocks), None);
2333 assert_eq!(translate_predicate(&pred("$ordinal == 1"), &blocks), None);
2334 }
2335
2336 #[test]
2340 fn reach_through_identity_is_the_foreign_key() {
2341 let blocks = TranslateCtx {
2342 target: Target::Blocks,
2343 self_alias: "b",
2344 ..DOCS
2345 };
2346 let nodes = TranslateCtx {
2347 target: Target::Nodes,
2348 self_alias: "n",
2349 ..DOCS
2350 };
2351 let edges = TranslateCtx {
2352 target: Target::Links,
2353 self_alias: "e",
2354 ..DOCS
2355 };
2356 assert_eq!(
2358 translate_predicate(&pred("doc.id == \"d_1\""), &blocks),
2359 frag("(b.doc_id IS ?)", &[text("d_1")])
2360 );
2361 assert_eq!(
2362 translate_predicate(&pred("doc_id == \"d_1\""), &blocks),
2363 frag("(b.doc_id IS ?)", &[text("d_1")])
2364 );
2365 assert_eq!(
2366 translate_predicate(&pred("doc.id == \"d_1\""), &nodes),
2367 frag("(n.doc_id IS ?)", &[text("d_1")])
2368 );
2369 assert_eq!(
2370 translate_predicate(&pred("doc.id == \"d_1\""), &edges),
2371 frag("(e.src_doc IS ?)", &[text("d_1")])
2372 );
2373 assert_eq!(
2374 translate_predicate(&pred("doc_id == \"d_1\""), &edges),
2375 frag("(e.src_doc IS ?)", &[text("d_1")])
2376 );
2377 assert_eq!(
2378 translate_predicate(&pred("doc.id == \"d_1\""), &DOCS),
2379 frag("(d.doc_id IS ?)", &[text("d_1")])
2380 );
2381 assert_eq!(
2383 translate_predicate(&pred("doc.path == \"/a.md\""), &blocks),
2384 frag("(d.path IS ?)", &[text("a.md")])
2385 );
2386 assert_eq!(
2387 translate_predicate(&pred("doc.path == \"/a.md\""), &edges),
2388 frag("(d.path IS ?)", &[text("a.md")])
2389 );
2390 assert_eq!(
2391 translate_predicate(&pred("doc.hash == \"ab\""), &blocks),
2392 frag("(lower(hex(d.file_hash)) IS ?)", &[text("ab")])
2393 );
2394 assert!(
2395 translate_predicate(&pred("doc.updated_at >= \"2026\""), &nodes)
2396 .is_some_and(|f| f.sql.contains("r.rev_id = d.current_rev"))
2397 );
2398 assert!(
2399 translate_predicate(&pred("doc.created_at >= \"2026\""), &nodes)
2400 .is_some_and(|f| f.sql.contains("r.doc_id = d.doc_id AND r.seq = 1"))
2401 );
2402 assert_eq!(
2403 translate_predicate(&pred("doc.raw == \"x\""), &blocks),
2404 None
2405 );
2406 assert_eq!(
2408 translate_predicate(&pred("block.id == \"b_1\""), &nodes),
2409 frag("(n.block_id IS ?)", &[text("b_1")])
2410 );
2411 assert_eq!(
2412 translate_predicate(&pred("block_id == \"b_1\""), &nodes),
2413 frag("(n.block_id IS ?)", &[text("b_1")])
2414 );
2415 assert_eq!(
2416 translate_predicate(&pred("block.path == \"/a.md\""), &nodes),
2417 frag("(d.path IS ?)", &[text("a.md")])
2418 );
2419 assert_eq!(
2420 translate_predicate(&pred("block.doc_id == \"d_1\""), &nodes),
2421 frag("(n.doc_id IS ?)", &[text("d_1")])
2422 );
2423 assert!(
2424 translate_predicate(&pred("block.text == \"x\""), &nodes)
2425 .is_some_and(|f| f.sql.contains("SELECT bb.text FROM blocks bb"))
2426 );
2427 assert_eq!(
2428 translate_predicate(&pred("block.hash == \"x\""), &nodes),
2429 None
2430 );
2431 }
2432
2433 #[test]
2436 fn dst_reach_through_reads_the_destination_by_its_foreign_key() {
2437 let edges = TranslateCtx {
2438 target: Target::Links,
2439 self_alias: "e",
2440 ..DOCS
2441 };
2442 assert_eq!(
2445 translate_predicate(&pred("dst.id == \"d_2\""), &edges),
2446 frag(
2447 "((SELECT dd.doc_id FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2448 &[text("d_2")]
2449 )
2450 );
2451 assert_eq!(
2452 translate_predicate(&pred("dst.path == \"/a.md\""), &edges),
2453 frag(
2454 "((SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2455 &[text("/a.md")]
2456 )
2457 );
2458 assert_eq!(
2459 translate_predicate(&pred("dst.format == \"yaml\""), &edges),
2460 frag(
2461 "((SELECT dd.format FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2462 &[text("yaml")]
2463 )
2464 );
2465 assert!(
2466 translate_predicate(&pred("dst.hash == \"ab\""), &edges)
2467 .is_some_and(|f| f.sql.contains("lower(hex(dd.file_hash))"))
2468 );
2469 assert!(
2470 translate_predicate(&pred("dst.updated_at == \"t\""), &edges)
2471 .is_some_and(|f| f.sql.contains("WHERE r.rev_id = dd.current_rev"))
2472 );
2473 assert!(
2474 translate_predicate(&pred("dst.created_at == \"t\""), &edges)
2475 .is_some_and(|f| f.sql.contains("WHERE r.doc_id = dd.doc_id AND r.seq = 1"))
2476 );
2477 let prop = translate_predicate(&pred("dst.layer == \"canon\""), &edges).expect("pushed");
2479 assert!(
2480 prop.sql
2481 .contains("p.doc_id = e.dst_node AND p.key = 'layer'"),
2482 "{}",
2483 prop.sql
2484 );
2485 assert_eq!(prop.params, vec![text("canon")]);
2486 assert!(
2488 translate_predicate(&pred("dst.era > 1600"), &edges)
2489 .is_some_and(|f| f.sql.contains("p.doc_id = e.dst_node AND p.key = 'era'"))
2490 );
2491 assert!(
2493 translate_predicate(&pred("dst.frontmatter.layer == \"canon\""), &edges)
2494 .is_some_and(|f| f.sql.contains("p.source = 'frontmatter'"))
2495 );
2496 for k in [
2499 "raw",
2500 "title",
2501 "tags",
2502 "nodes",
2503 "out_links",
2504 "data",
2505 "docs",
2506 "$x",
2507 ] {
2508 assert_eq!(
2509 translate_predicate(&pred(&format!("dst.{k} == \"x\"")), &edges),
2510 None,
2511 "dst.{k}"
2512 );
2513 }
2514 assert_eq!(translate_predicate(&pred("dst == null"), &edges), None);
2515 assert_eq!(
2517 translate_predicate(&pred("dst.layer == \"canon\""), &DOCS),
2518 None
2519 );
2520 }
2521
2522 #[test]
2526 fn block_reach_through_on_a_link_reads_the_source_block_by_its_foreign_key() {
2527 let edges = TranslateCtx {
2528 target: Target::Links,
2529 self_alias: "e",
2530 ..DOCS
2531 };
2532 assert_eq!(
2533 translate_predicate(&pred("block.id == \"b_1\""), &edges),
2534 frag("(e.src_block IS ?)", &[text("b_1")])
2535 );
2536 assert_eq!(
2537 translate_predicate(&pred("block.type == \"paragraph\""), &edges),
2538 frag(
2539 "((SELECT bb.type FROM blocks bb WHERE bb.block_id = e.src_block) IS ?)",
2540 &[text("paragraph")]
2541 )
2542 );
2543 assert!(
2544 translate_predicate(&pred("block.text == \"x\""), &edges).is_some_and(|f| f
2545 .sql
2546 .contains("SELECT bb.text FROM blocks bb WHERE bb.block_id = e.src_block"))
2547 );
2548 assert_eq!(
2549 translate_predicate(&pred("block.path == \"/a.md\""), &edges),
2550 frag(
2551 "((SELECT '/' || d.path FROM blocks bb WHERE bb.block_id = e.src_block) IS ?)",
2552 &[text("/a.md")]
2553 )
2554 );
2555 assert_eq!(
2556 translate_predicate(&pred("block.doc_id == \"d_1\""), &edges),
2557 frag(
2558 "((SELECT bb.doc_id FROM blocks bb WHERE bb.block_id = e.src_block) IS ?)",
2559 &[text("d_1")]
2560 )
2561 );
2562 for k in ["hash", "raw", "updated_at", "attrs", "children"] {
2564 assert_eq!(
2565 translate_predicate(&pred(&format!("block.{k} == \"x\"")), &edges),
2566 None,
2567 "block.{k}"
2568 );
2569 }
2570 assert_eq!(translate_predicate(&pred("block == null"), &edges), None);
2571 }
2572
2573 #[test]
2574 fn unsafe_identifier_segments_are_never_inlined() {
2575 assert!(is_seg("layer") && is_seg("_x9"));
2576 assert!(!is_seg("") && !is_seg("9a") && !is_seg("a-b") && !is_seg("a'b"));
2577 assert_eq!(json_path(&["ok", "no-pe"]), None);
2578 assert_eq!(json_path(&["ok", "a_1"]).as_deref(), Some("$.ok.a_1"));
2579 assert_eq!(prop_scalar("d", "x'y", None), None);
2580 }
2581}