Skip to main content

omgbase_surface/
translate.rs

1//! Semantics-faithful OQX expression → SQLite translator: the pushdown seam
2//! of the tier-3 planner ([`crate::planner`]). Port of
3//! `packages/core/src/oqx-js/sql/translate.ts`.
4//!
5//! Walks a scalar [`Expr`] and returns a SQL fragment plus its bound
6//! parameters, or `None` when the construction cannot be translated
7//! faithfully — in which case the planner leaves that conjunct residual
8//! (correct, just slower). The cardinal rule is FIDELITY, not cleverness: the
9//! SQL a fragment emits must evaluate to the same result as the `oqx`
10//! in-memory semantics for every input, because the differential gate runs
11//! each query both ways and asserts equality. Two consequences drive the
12//! design:
13//!
14//! * OQX string ops are CASE-SENSITIVE, but SQLite `LIKE` is
15//!   case-insensitive for ASCII, so `startsWith` / `contains` / `endsWith`
16//!   translate to `substr` / `instr`, never `LIKE`;
17//!   `$path.lower().startsWith("lab/")` becomes
18//!   `substr(lower(d.path), 1, length(?)) = ?`.
19//! * OQX `==` / `!=` are absence-normalized (two absent values are equal,
20//!   `absent != v` is true). SQLite `=` / `<>` are not null-safe, so `==` →
21//!   `IS` and `!=` → `IS NOT`, which reproduce `equals(a, b)` including the
22//!   both-absent and negation cases while honoring SQLite's typed comparison
23//!   (`5 IS '5'` is false, matching strict equality).
24//!
25//! Only forms that are faithful in a POSITIVE, AND-composed context are
26//! translated (the only context [`oqx::partition_pushable`] pushes into):
27//! `||`, `!`, `in`, `matches` (no regexp UDF) and bare content-property
28//! routing are declined and stay residual.
29
30use oqx::Value;
31use oqx::ast::{BinaryOp, Expr, LogicalOp};
32use rusqlite::types::Value as SqlValue;
33
34use crate::context::{Target, to_sql};
35
36/// The SQL aliases the planner assigned to the current scope's row (`self`)
37/// and its owning document (`doc`); on the `docs` target both are the same
38/// alias. `params` are the query bindings, for `${…}` interpolations.
39#[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/// A SQL fragment plus its positional bind params, in statement order.
48#[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
63// ---- intrinsics ------------------------------------------------------------------
64
65/// A `$`-namespaced intrinsic → a param-free SQL scalar, per target. Anything
66/// not mapped (docs `$body`, reconstructed; `$title` / `$tags`, computed;
67/// blocks `$updated_at`; nodes `$locator`) returns `None` → residual.
68/// `$updated_at` / `$dst_path` / `$dst_uri` are correlated subqueries.
69fn 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
106/// docs intrinsics whose BARE (non-`$`) form is a loud error in-memory — not
107/// pushable, so the residual raises the guard.
108const RESERVED_DOC_BASENAMES: [&str; 5] = ["id", "path", "updated_at", "content_hash", "body"];
109
110/// An injection-safe inlined identifier: `^[A-Za-z_][A-Za-z0-9_]*$`.
111fn 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
119/// A single-valued document property (the scalar-in-scope rule): the scalar
120/// value only when the key has exactly one row in scope and it is
121/// `card = 'scalar'`, else NULL — matching the context's `doc_prop` for a
122/// scalar read.
123fn 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
135/// `json_extract(col, '$.a.b')` from validated segments; `None` if any
136/// segment is unsafe.
137fn 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
144/// The dotted `attrs.a.b` / `doc.x` receiver chain as segments, or `None` if
145/// it is not a plain identifier navigation.
146fn 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
158// ---- value position ----------------------------------------------------------------
159
160/// Translate an expression used as a VALUE (comparison operand, method
161/// receiver, function argument) to a SQL scalar. `None` if not faithfully
162/// translatable.
163pub 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                    // `format` is a column, not a property.
182                    if name == "format" {
183                        return Some(Frag::bare(format!("{s}.format")));
184                    }
185                    // A reserved basename stays residual so the guard fires.
186                    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                    // A bare non-structural identifier flattens into attrs —
196                    // the same pushdown as the `attrs.<k>` member form.
197                    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            // attrs.<path> → json_extract on the row's attrs (blocks/nodes).
223            if *head == "attrs" && matches!(target, Target::Blocks | Target::Nodes) {
224                return json_extract(&format!("{s}.attrs"), rest).map(Frag::bare);
225            }
226            // doc.<x> reach-through — the owning doc (alias `doc`). On the docs
227            // target `doc` is the row itself; either way it resolves against `d`.
228            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            // block.type / block.text reach-through from a node.
245            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        // `.lower()` / `.upper()` are the value-position string methods.
258        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
273// ---- predicate position --------------------------------------------------------------
274
275/// `==` / `!=` → null-safe `IS` / `IS NOT`; the relational ops as plain SQL.
276fn 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
290/// Translate an expression used as a boolean PREDICATE to a SQL boolean, or
291/// `None` if it cannot be pushed faithfully. Only positive, AND-safe forms
292/// are handled: `unary` (`!`), `in`, bare truthy idents and member
293/// reach-through in predicate position stay residual.
294pub fn translate_predicate(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
295    match e {
296        // Only `&&` composes faithfully in a positive context; `||` is
297        // declined (its NULL / short-circuit interaction stays residual).
298        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            // An arithmetic operator in predicate position → residual.
309            let op = is_op(*op)?;
310            let l = translate_value(left, ctx)?;
311            let r = translate_value(right, ctx)?;
312            // `==`/`!=` → IS / IS NOT (absence-normalized equality, faithful in
313            // any context). Relational ops → plain SQL: a NULL operand yields
314            // NULL, which is excluded in the positive AND context these
315            // fragments are pushed into, matching the absent-operand ⇒ false rule.
316            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            // startsWith / contains / endsWith — CASE-SENSITIVE, via
329            // substr/instr (never LIKE). `matches` (regex) is declined.
330            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                    // recv begins with arg ⇔ its first length(arg) chars equal arg.
336                    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                    // recv ends with arg ⇔ its last length(arg) chars equal arg.
342                    // When arg is longer than recv, substr clamps to the whole
343                    // (shorter) string, so the equality is false.
344                    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
360/// Combine two optional fragments with a boolean connective; `None` if either
361/// is untranslatable (the whole conjunct then stays residual).
362fn 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    /// Parse `from docs where <src>` and return the single scalar predicate.
396    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    // -- equality is absence-normalized (IS / IS NOT) --
423
424    #[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    // -- relational ops (plain SQL) --
449
450    #[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        // arithmetic in predicate position → residual
461        assert_eq!(translate_predicate(&pred("$path + 1"), &DOCS), None);
462    }
463
464    // -- string ops are case-sensitive (substr/instr, never LIKE) --
465
466    #[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    // -- bare document properties push via the properties table --
516
517    #[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    // -- declines (left residual) return None --
571
572    #[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        // `$updated_at` is mapped on docs only.
625        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    // -- conjunction and bindings via constructed AST --
656
657    #[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            // $body is reconstructed, not a column → the whole && declines.
683            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: &params,
694            ..DOCS
695        };
696        assert_eq!(
697            translate_predicate(&e, &ctx),
698            frag("(d.path IS ?)", &[text("from-binding.md")])
699        );
700        // A binding past the end reads as absent → NULL.
701        assert_eq!(
702            translate_predicate(&e, &DOCS),
703            frag("(d.path IS ?)", &[SqlValue::Null])
704        );
705    }
706
707    // -- per-target fields --
708
709    #[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        // (a top-level `&&` is a `Where::And` of scalars; the nested form is
717        // reached through constructed AST, as in the reference's tests)
718        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        // `attrs.<k>` is a blocks/nodes form; on docs it is not a column.
761        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}