1use oqx::Value;
31use oqx::ast::{BinaryOp, Expr, LogicalOp};
32use rusqlite::types::Value as SqlValue;
33
34use crate::context::{Target, to_sql};
35
36#[derive(Clone, Copy, Debug)]
40pub struct TranslateCtx<'a> {
41 pub target: Target,
42 pub self_alias: &'a str,
43 pub doc_alias: &'a str,
44 pub params: &'a [Value],
45}
46
47#[derive(Clone, Debug, PartialEq)]
49pub struct Frag {
50 pub sql: String,
51 pub params: Vec<SqlValue>,
52}
53
54impl Frag {
55 fn bare(sql: impl Into<String>) -> Self {
56 Self {
57 sql: sql.into(),
58 params: Vec::new(),
59 }
60 }
61}
62
63fn intrinsic_sql(name: &str, ctx: &TranslateCtx<'_>) -> Option<String> {
70 let (s, d) = (ctx.self_alias, ctx.doc_alias);
71 Some(match (ctx.target, name) {
72 (Target::Docs, "$id") => format!("{s}.doc_id"),
73 (Target::Docs, "$path") => format!("{d}.path"),
74 (Target::Docs, "$content_hash") => format!("lower(hex({s}.file_hash))"),
75 (Target::Docs, "$updated_at") => format!(
76 "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = {s}.current_rev)"
77 ),
78 (Target::Blocks, "$id") => format!("{s}.block_id"),
79 (Target::Blocks, "$doc") => format!("{s}.doc_id"),
80 (Target::Blocks, "$path") => format!("{d}.path"),
81 (Target::Blocks, "$ordinal") => format!("{s}.ordinal"),
82 (Target::Blocks, "$depth") => format!("{s}.depth"),
83 (Target::Blocks, "$body") => format!("{s}.text"),
84 (Target::Blocks, "$content_hash") => format!("lower(hex({s}.raw_hash))"),
85 (Target::Nodes, "$id" | "$node_id") => format!("{s}.node_id"),
86 (Target::Nodes, "$doc_id") => format!("{s}.doc_id"),
87 (Target::Nodes, "$block_id") => format!("{s}.block_id"),
88 (Target::Nodes, "$path") => format!("{d}.path"),
89 (Target::Edges, "$id") => format!("{s}.edge_id"),
90 (Target::Edges, "$src") => format!("{s}.src_doc"),
91 (Target::Edges, "$dst") => format!("{s}.dst_node"),
92 (Target::Edges, "$src_block") => format!("{s}.src_block"),
93 (Target::Edges, "$via") => format!("{s}.via_node"),
94 (Target::Edges, "$from_commit") => format!("{s}.from_commit"),
95 (Target::Edges, "$path") => format!("{d}.path"),
96 (Target::Edges, "$dst_path") => {
97 format!("(SELECT dd.path FROM docs dd WHERE dd.doc_id = {s}.dst_node)")
98 }
99 (Target::Edges, "$dst_uri") => {
100 format!("(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = {s}.dst_node)")
101 }
102 _ => return None,
103 })
104}
105
106const RESERVED_DOC_BASENAMES: [&str; 5] = ["id", "path", "updated_at", "content_hash", "body"];
109
110fn is_seg(s: &str) -> bool {
112 let mut chars = s.chars();
113 chars
114 .next()
115 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
116 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
117}
118
119fn prop_scalar(doc_alias: &str, key: &str) -> Option<String> {
124 if !is_seg(key) {
125 return None;
126 }
127 Some(format!(
128 "(SELECT COALESCE(p.val_text, p.val_num, p.val_bool) FROM properties p \
129 WHERE p.doc_id = {doc_alias}.doc_id AND p.key = '{key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL \
130 AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = {doc_alias}.doc_id AND p2.key = '{key}' AND p2.deleted_commit IS NULL) = 1 \
131 LIMIT 1)"
132 ))
133}
134
135fn json_extract(col: &str, segs: &[&str]) -> Option<String> {
138 if segs.iter().any(|s| !is_seg(s)) {
139 return None;
140 }
141 Some(format!("json_extract({col}, '$.{}')", segs.join(".")))
142}
143
144fn member_segments(e: &Expr) -> Option<Vec<&str>> {
147 match e {
148 Expr::Ident { name } => Some(vec![name.as_str()]),
149 Expr::Member { recv, name } => {
150 let mut base = member_segments(recv)?;
151 base.push(name.as_str());
152 Some(base)
153 }
154 _ => None,
155 }
156}
157
158pub fn translate_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
164 let (s, d, target) = (ctx.self_alias, ctx.doc_alias, ctx.target);
165 match e {
166 Expr::Lit(v) => Some(Frag {
167 sql: "?".to_owned(),
168 params: vec![to_sql(v)],
169 }),
170 Expr::Binding { index } => Some(Frag {
171 sql: "?".to_owned(),
172 params: vec![to_sql(ctx.params.get(*index).unwrap_or(&Value::Undefined))],
173 }),
174 Expr::Ident { name } => {
175 if name.starts_with('$') {
176 return intrinsic_sql(name, ctx).map(Frag::bare);
177 }
178 let name = name.as_str();
179 match target {
180 Target::Docs => {
181 if name == "format" {
183 return Some(Frag::bare(format!("{s}.format")));
184 }
185 if RESERVED_DOC_BASENAMES.contains(&name) {
187 return None;
188 }
189 prop_scalar(d, name).map(Frag::bare)
190 }
191 Target::Blocks => {
192 if name == "type" || name == "text" {
193 return Some(Frag::bare(format!("{s}.{name}")));
194 }
195 json_extract(&format!("{s}.attrs"), &[name]).map(Frag::bare)
198 }
199 Target::Nodes => {
200 if matches!(name, "kind" | "name" | "value") {
201 return Some(Frag::bare(format!("{s}.{name}")));
202 }
203 json_extract(&format!("{s}.attrs"), &[name]).map(Frag::bare)
204 }
205 Target::Edges => {
206 if matches!(
207 name,
208 "predicate" | "provenance" | "dst_kind" | "anchor" | "src_field"
209 ) {
210 return Some(Frag::bare(format!("{s}.{name}")));
211 }
212 None
213 }
214 }
215 }
216 Expr::Member { .. } => {
217 let segs = member_segments(e)?;
218 let (head, rest) = segs.split_first()?;
219 if rest.is_empty() {
220 return None;
221 }
222 if *head == "attrs" && matches!(target, Target::Blocks | Target::Nodes) {
224 return json_extract(&format!("{s}.attrs"), rest).map(Frag::bare);
225 }
226 if *head == "doc" {
229 if rest.len() != 1 {
230 return None;
231 }
232 let k = rest[0];
233 if k == "$path" {
234 return Some(Frag::bare(format!("{d}.path")));
235 }
236 if k == "format" {
237 return Some(Frag::bare(format!("{d}.format")));
238 }
239 if k.starts_with('$') || RESERVED_DOC_BASENAMES.contains(&k) {
240 return None;
241 }
242 return prop_scalar(d, k).map(Frag::bare);
243 }
244 if *head == "block"
246 && target == Target::Nodes
247 && rest.len() == 1
248 && matches!(rest[0], "type" | "text")
249 {
250 return Some(Frag::bare(format!(
251 "(SELECT bb.{} FROM blocks bb WHERE bb.block_id = {s}.block_id)",
252 rest[0]
253 )));
254 }
255 None
256 }
257 Expr::Call {
259 recv: Some(recv),
260 name,
261 args,
262 } if args.is_empty() && (name == "lower" || name == "upper") => {
263 let recv = translate_value(recv, ctx)?;
264 Some(Frag {
265 sql: format!("{name}({})", recv.sql),
266 params: recv.params,
267 })
268 }
269 _ => None,
270 }
271}
272
273fn is_op(op: BinaryOp) -> Option<&'static str> {
277 Some(match op {
278 BinaryOp::Eq => "IS",
279 BinaryOp::Ne => "IS NOT",
280 BinaryOp::Lt => "<",
281 BinaryOp::Le => "<=",
282 BinaryOp::Gt => ">",
283 BinaryOp::Ge => ">=",
284 BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => {
285 return None;
286 }
287 })
288}
289
290pub fn translate_predicate(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
295 match e {
296 Expr::Logical {
299 op: LogicalOp::And,
300 left,
301 right,
302 } => join2(
303 translate_predicate(left, ctx),
304 translate_predicate(right, ctx),
305 "AND",
306 ),
307 Expr::Binary { op, left, right } => {
308 let op = is_op(*op)?;
310 let l = translate_value(left, ctx)?;
311 let r = translate_value(right, ctx)?;
312 let mut params = l.params;
317 params.extend(r.params);
318 Some(Frag {
319 sql: format!("({} {op} {})", l.sql, r.sql),
320 params,
321 })
322 }
323 Expr::Call {
324 recv: Some(recv),
325 name,
326 args,
327 } if args.len() == 1 => {
328 let recv = translate_value(recv, ctx)?;
331 let arg = translate_value(&args[0], ctx)?;
332 let mut params = recv.params;
333 let sql = match name.as_str() {
334 "startsWith" => {
335 params.extend(arg.params.iter().cloned());
337 params.extend(arg.params);
338 format!("(substr({}, 1, length({a})) = {a})", recv.sql, a = arg.sql)
339 }
340 "endsWith" => {
341 params.extend(arg.params.iter().cloned());
345 params.extend(arg.params);
346 format!("(substr({}, -length({a})) = {a})", recv.sql, a = arg.sql)
347 }
348 "contains" => {
349 params.extend(arg.params);
350 format!("(instr({}, {}) > 0)", recv.sql, arg.sql)
351 }
352 _ => return None,
353 };
354 Some(Frag { sql, params })
355 }
356 _ => None,
357 }
358}
359
360fn join2(a: Option<Frag>, b: Option<Frag>, connective: &str) -> Option<Frag> {
363 let (a, b) = (a?, b?);
364 let mut params = a.params;
365 params.extend(b.params);
366 Some(Frag {
367 sql: format!("({} {connective} {})", a.sql, b.sql),
368 params,
369 })
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use oqx::ast::Where;
376
377 const DOCS: TranslateCtx<'static> = TranslateCtx {
378 target: Target::Docs,
379 self_alias: "d",
380 doc_alias: "d",
381 params: &[],
382 };
383
384 fn text(s: &str) -> SqlValue {
385 SqlValue::Text(s.to_owned())
386 }
387
388 fn frag(sql: &str, params: &[SqlValue]) -> Option<Frag> {
389 Some(Frag {
390 sql: sql.to_owned(),
391 params: params.to_vec(),
392 })
393 }
394
395 fn pred(src: &str) -> Expr {
397 let q = oqx::parse_string(&format!("from docs where {src}")).expect("parses");
398 match q.r#where {
399 Some(Where::Scalar { expr }) => expr,
400 other => panic!("expected a single scalar predicate, got {other:?}"),
401 }
402 }
403
404 fn ident(name: &str) -> Box<Expr> {
405 Box::new(Expr::Ident {
406 name: name.to_owned(),
407 })
408 }
409
410 fn lit(s: &str) -> Box<Expr> {
411 Box::new(Expr::Lit(Value::from(s)))
412 }
413
414 fn eq(l: Box<Expr>, r: Box<Expr>) -> Box<Expr> {
415 Box::new(Expr::Binary {
416 op: BinaryOp::Eq,
417 left: l,
418 right: r,
419 })
420 }
421
422 #[test]
425 fn equality_is_null_safe_is() {
426 assert_eq!(
427 translate_predicate(&pred("$path == \"index.md\""), &DOCS),
428 frag("(d.path IS ?)", &[text("index.md")])
429 );
430 }
431
432 #[test]
433 fn inequality_is_null_safe_is_not() {
434 assert_eq!(
435 translate_predicate(&pred("$path != \"x\""), &DOCS),
436 frag("(d.path IS NOT ?)", &[text("x")])
437 );
438 }
439
440 #[test]
441 fn intrinsic_column_mapping() {
442 assert_eq!(
443 translate_predicate(&pred("$id == \"d_1\""), &DOCS),
444 frag("(d.doc_id IS ?)", &[text("d_1")])
445 );
446 }
447
448 #[test]
451 fn relational_ops_are_plain_comparisons() {
452 assert_eq!(
453 translate_predicate(&pred("$path < \"m\""), &DOCS),
454 frag("(d.path < ?)", &[text("m")])
455 );
456 assert_eq!(
457 translate_predicate(&pred("$path >= \"m\""), &DOCS),
458 frag("(d.path >= ?)", &[text("m")])
459 );
460 assert_eq!(translate_predicate(&pred("$path + 1"), &DOCS), None);
462 }
463
464 #[test]
467 fn starts_with_is_substr_equality() {
468 assert_eq!(
469 translate_predicate(&pred("$path.startsWith(\"lab/\")"), &DOCS),
470 frag(
471 "(substr(d.path, 1, length(?)) = ?)",
472 &[text("lab/"), text("lab/")]
473 )
474 );
475 }
476
477 #[test]
478 fn lower_then_starts_with_pushes_with_explicit_lower() {
479 assert_eq!(
480 translate_predicate(&pred("$path.lower().startsWith(\"lab/\")"), &DOCS),
481 frag(
482 "(substr(lower(d.path), 1, length(?)) = ?)",
483 &[text("lab/"), text("lab/")]
484 )
485 );
486 }
487
488 #[test]
489 fn contains_is_instr() {
490 assert_eq!(
491 translate_predicate(&pred("$path.contains(\"notes\")"), &DOCS),
492 frag("(instr(d.path, ?) > 0)", &[text("notes")])
493 );
494 }
495
496 #[test]
497 fn ends_with_is_negative_substr_equality() {
498 assert_eq!(
499 translate_predicate(&pred("$path.endsWith(\".md\")"), &DOCS),
500 frag(
501 "(substr(d.path, -length(?)) = ?)",
502 &[text(".md"), text(".md")]
503 )
504 );
505 }
506
507 #[test]
508 fn upper_wraps_the_receiver_in_value_position() {
509 assert_eq!(
510 translate_value(&pred("$path.upper()"), &DOCS),
511 frag("upper(d.path)", &[])
512 );
513 }
514
515 #[test]
518 fn bare_doc_property_is_the_scalar_in_scope_subquery() {
519 let f = translate_predicate(&pred("layer == \"canon\""), &DOCS).expect("pushable");
520 assert!(f.sql.contains("FROM properties p"), "{}", f.sql);
521 assert!(f.sql.contains("p.key = 'layer'"), "{}", f.sql);
522 assert!(f.sql.contains("p.card = 'scalar'"), "{}", f.sql);
523 assert!(
524 f.sql.starts_with('(') && f.sql.contains(" IS ?)"),
525 "{}",
526 f.sql
527 );
528 assert_eq!(f.params, vec![text("canon")]);
529 }
530
531 #[test]
532 fn updated_at_pushes_as_its_revisions_subquery() {
533 let f =
534 translate_predicate(&pred("$updated_at >= \"2026-01-01\""), &DOCS).expect("pushable");
535 assert!(
536 f.sql.contains("FROM revisions r JOIN commits c"),
537 "{}",
538 f.sql
539 );
540 }
541
542 #[test]
543 fn format_is_a_column_not_a_property() {
544 assert_eq!(
545 translate_predicate(&pred("format == \"markdown\""), &DOCS),
546 frag("(d.format IS ?)", &[text("markdown")])
547 );
548 }
549
550 #[test]
551 fn booleans_bind_as_one_and_zero() {
552 assert_eq!(
553 translate_predicate(&pred("verified == true"), &DOCS).map(|f| f.params),
554 Some(vec![SqlValue::Integer(1)])
555 );
556 assert_eq!(
557 translate_predicate(&pred("verified == false"), &DOCS).map(|f| f.params),
558 Some(vec![SqlValue::Integer(0)])
559 );
560 assert_eq!(
561 translate_predicate(&pred("era < 1000"), &DOCS).map(|f| f.params),
562 Some(vec![SqlValue::Real(1000.0)])
563 );
564 assert_eq!(
565 translate_predicate(&pred("layer == null"), &DOCS).map(|f| f.params),
566 Some(vec![SqlValue::Null])
567 );
568 }
569
570 #[test]
573 fn reserved_bare_basename_is_not_pushed() {
574 assert_eq!(translate_predicate(&pred("path == \"x\""), &DOCS), None);
575 assert_eq!(translate_predicate(&pred("body == \"x\""), &DOCS), None);
576 assert_eq!(translate_predicate(&pred("doc.path == \"x\""), &DOCS), None);
577 }
578
579 #[test]
580 fn docs_body_and_computed_intrinsics_are_not_columns() {
581 assert_eq!(translate_predicate(&pred("$body == \"x\""), &DOCS), None);
582 assert_eq!(translate_predicate(&pred("$title == \"x\""), &DOCS), None);
583 assert_eq!(translate_predicate(&pred("$tags == \"x\""), &DOCS), None);
584 }
585
586 #[test]
587 fn matches_needs_a_regexp_udf() {
588 assert_eq!(
589 translate_predicate(&pred("$path.matches(\"^lab/\")"), &DOCS),
590 None
591 );
592 }
593
594 #[test]
595 fn negation_as_a_nested_expr_is_not_and_safe() {
596 let e = Expr::Unary {
597 op: oqx::ast::UnaryOp::Not,
598 expr: ident("$path"),
599 };
600 assert_eq!(translate_predicate(&e, &DOCS), None);
601 }
602
603 #[test]
604 fn disjunction_as_a_nested_expr_is_declined() {
605 let e = Expr::Logical {
606 op: LogicalOp::Or,
607 left: eq(ident("$path"), lit("a")),
608 right: eq(ident("$path"), lit("b")),
609 };
610 assert_eq!(translate_predicate(&e, &DOCS), None);
611 }
612
613 #[test]
614 fn unmapped_node_intrinsic_is_not_pushed() {
615 let nodes = TranslateCtx {
616 target: Target::Nodes,
617 self_alias: "n",
618 ..DOCS
619 };
620 assert_eq!(
621 translate_predicate(&pred("$locator == \"x\""), &nodes),
622 None
623 );
624 let blocks = TranslateCtx {
626 target: Target::Blocks,
627 self_alias: "b",
628 ..DOCS
629 };
630 assert_eq!(
631 translate_predicate(&pred("$updated_at == \"x\""), &blocks),
632 None
633 );
634 }
635
636 #[test]
637 fn range_membership_in_and_bare_idents_are_declined() {
638 assert_eq!(translate_predicate(&pred("era in 800..1680"), &DOCS), None);
639 assert_eq!(
640 translate_predicate(&pred("\"a\" in list(tags)"), &DOCS),
641 None
642 );
643 assert_eq!(translate_predicate(&pred("verified"), &DOCS), None);
644 assert_eq!(translate_predicate(&pred("doc.verified"), &DOCS), None);
645 assert_eq!(translate_predicate(&pred("size(tags) > 1"), &DOCS), None);
646 assert_eq!(translate_predicate(&pred("$self.text(\"x\")"), &DOCS), None);
647 assert_eq!(translate_predicate(&pred("$value == \"x\""), &DOCS), None);
648 assert_eq!(translate_predicate(&pred("^slug == \"x\""), &DOCS), None);
649 assert_eq!(
650 translate_predicate(&pred("frontmatter.era == 1"), &DOCS),
651 None
652 );
653 }
654
655 #[test]
658 fn and_composes_two_pushable_comparisons() {
659 let e = Expr::Logical {
660 op: LogicalOp::And,
661 left: eq(ident("$path"), lit("a")),
662 right: Box::new(Expr::Binary {
663 op: BinaryOp::Ne,
664 left: ident("$id"),
665 right: lit("d_2"),
666 }),
667 };
668 assert_eq!(
669 translate_predicate(&e, &DOCS),
670 frag(
671 "((d.path IS ?) AND (d.doc_id IS NOT ?))",
672 &[text("a"), text("d_2")]
673 )
674 );
675 }
676
677 #[test]
678 fn and_declines_wholesale_if_either_side_is_not_pushable() {
679 let e = Expr::Logical {
680 op: LogicalOp::And,
681 left: eq(ident("$path"), lit("a")),
682 right: eq(ident("$body"), lit("x")),
684 };
685 assert_eq!(translate_predicate(&e, &DOCS), None);
686 }
687
688 #[test]
689 fn resolves_a_binding_to_its_param_value() {
690 let e = eq(ident("$path"), Box::new(Expr::Binding { index: 0 }));
691 let params = [Value::from("from-binding.md")];
692 let ctx = TranslateCtx {
693 params: ¶ms,
694 ..DOCS
695 };
696 assert_eq!(
697 translate_predicate(&e, &ctx),
698 frag("(d.path IS ?)", &[text("from-binding.md")])
699 );
700 assert_eq!(
702 translate_predicate(&e, &DOCS),
703 frag("(d.path IS ?)", &[SqlValue::Null])
704 );
705 }
706
707 #[test]
710 fn blocks_and_nodes_flatten_bare_identifiers_into_attrs() {
711 let blocks = TranslateCtx {
712 target: Target::Blocks,
713 self_alias: "b",
714 ..DOCS
715 };
716 let both = Expr::Logical {
719 op: LogicalOp::And,
720 left: eq(ident("type"), lit("task")),
721 right: eq(ident("checked"), Box::new(Expr::Lit(Value::Bool(false)))),
722 };
723 assert_eq!(
724 translate_predicate(&both, &blocks),
725 frag(
726 "((b.type IS ?) AND (json_extract(b.attrs, '$.checked') IS ?))",
727 &[text("task"), SqlValue::Integer(0)]
728 )
729 );
730 assert_eq!(
731 translate_predicate(&pred("attrs.checked == true"), &blocks),
732 frag(
733 "(json_extract(b.attrs, '$.checked') IS ?)",
734 &[SqlValue::Integer(1)]
735 )
736 );
737 let nodes = TranslateCtx {
738 target: Target::Nodes,
739 self_alias: "n",
740 ..DOCS
741 };
742 assert_eq!(
743 translate_predicate(&pred("kind == \"md:section\""), &nodes),
744 frag("(n.kind IS ?)", &[text("md:section")])
745 );
746 assert_eq!(
747 translate_predicate(&pred("level == 1"), &nodes),
748 frag(
749 "(json_extract(n.attrs, '$.level') IS ?)",
750 &[SqlValue::Real(1.0)]
751 )
752 );
753 assert_eq!(
754 translate_predicate(&pred("attrs.a.b == 1"), &nodes),
755 frag(
756 "(json_extract(n.attrs, '$.a.b') IS ?)",
757 &[SqlValue::Real(1.0)]
758 )
759 );
760 assert_eq!(
762 translate_predicate(&pred("attrs.checked == true"), &DOCS),
763 None
764 );
765 }
766
767 #[test]
768 fn doc_and_block_reach_through() {
769 let blocks = TranslateCtx {
770 target: Target::Blocks,
771 self_alias: "b",
772 ..DOCS
773 };
774 let f = translate_predicate(&pred("doc.type == \"lab-note\""), &blocks).expect("pushable");
775 assert!(
776 f.sql.contains("p.doc_id = d.doc_id AND p.key = 'type'"),
777 "{}",
778 f.sql
779 );
780 assert_eq!(
781 translate_predicate(&pred("doc.$path == \"a.md\""), &blocks),
782 frag("(d.path IS ?)", &[text("a.md")])
783 );
784 assert_eq!(
785 translate_predicate(&pred("doc.format == \"markdown\""), &blocks),
786 frag("(d.format IS ?)", &[text("markdown")])
787 );
788 assert_eq!(
789 translate_predicate(&pred("doc.$id == \"d_1\""), &blocks),
790 None
791 );
792 assert_eq!(translate_predicate(&pred("doc.a.b == 1"), &blocks), None);
793 let nodes = TranslateCtx {
794 target: Target::Nodes,
795 self_alias: "n",
796 ..DOCS
797 };
798 assert_eq!(
799 translate_predicate(&pred("block.type == \"task\""), &nodes),
800 frag(
801 "((SELECT bb.type FROM blocks bb WHERE bb.block_id = n.block_id) IS ?)",
802 &[text("task")]
803 )
804 );
805 assert_eq!(
806 translate_predicate(&pred("block.type == \"task\""), &blocks),
807 None
808 );
809 assert_eq!(
810 translate_predicate(&pred("section.level == 1"), &nodes),
811 None
812 );
813 }
814
815 #[test]
816 fn edges_push_their_five_fields_and_intrinsics() {
817 let edges = TranslateCtx {
818 target: Target::Edges,
819 self_alias: "e",
820 ..DOCS
821 };
822 assert_eq!(
823 translate_predicate(&pred("predicate == \"references\""), &edges),
824 frag("(e.predicate IS ?)", &[text("references")])
825 );
826 assert_eq!(
827 translate_predicate(&pred("$dst_path == \"index.md\""), &edges),
828 frag(
829 "((SELECT dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
830 &[text("index.md")]
831 )
832 );
833 assert_eq!(translate_predicate(&pred("weight == 1"), &edges), None);
834 }
835
836 #[test]
837 fn unsafe_identifier_segments_are_never_inlined() {
838 assert!(is_seg("layer") && is_seg("_x9"));
839 assert!(!is_seg("") && !is_seg("9a") && !is_seg("a-b") && !is_seg("a'b"));
840 assert_eq!(json_extract("n.attrs", &["ok", "no-pe"]), None);
841 assert_eq!(prop_scalar("d", "x'y"), None);
842 }
843}