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//!
30//! The surface 1.1 patch (`spec/surface` §1, §9) added two more declines
31//! here, both about SQLite seeing less type than the in-memory engine does:
32//!
33//! * **Operand typing** ([`Ty`], [`comparable`]): JSON `true` and `1` are
34//!   both `1` after `json_extract`, and a property's `val_bool` / `val_num`
35//!   coalesce into one column, so a boolean or number literal against a JSON
36//!   or property read cannot be compared faithfully; `null` against a
37//!   property read cannot either (a list-valued or nested key has no scalar
38//!   row, so SQL reads `NULL` where the in-memory value is an array or an
39//!   object). See the matrix at [`comparable`].
40//! * **Handles are not properties** ([`non_property_handles`]): a bare
41//!   identifier or `doc.<k>` head that names a relation, reach-through
42//!   handle, source handle or bag (`nodes`, `doc`, `frontmatter`, `attrs`, …)
43//!   resolves to rows or an object in memory, never to a property row or a
44//!   JSON attribute, so it is declined rather than read as a key.
45
46use oqx::Value;
47use oqx::ast::{BinaryOp, Expr, LogicalOp};
48use rusqlite::types::Value as SqlValue;
49
50use crate::context::{Target, to_sql};
51
52/// The SQL aliases the planner assigned to the current scope's row (`self`)
53/// and its owning document (`doc`); on the `docs` target both are the same
54/// alias. `params` are the query bindings, for `${…}` interpolations.
55#[derive(Clone, Copy, Debug)]
56pub struct TranslateCtx<'a> {
57    pub target: Target,
58    pub self_alias: &'a str,
59    pub doc_alias: &'a str,
60    pub params: &'a [Value],
61}
62
63/// A SQL fragment plus its positional bind params, in statement order.
64#[derive(Clone, Debug, PartialEq)]
65pub struct Frag {
66    pub sql: String,
67    pub params: Vec<SqlValue>,
68}
69
70impl Frag {
71    fn bare(sql: impl Into<String>) -> Self {
72        Self {
73            sql: sql.into(),
74            params: Vec::new(),
75        }
76    }
77}
78
79// ---- operand typing ----------------------------------------------------------------
80
81/// What a translated operand carries in SQL, as far as the translator can
82/// tell statically. The comparison gate ([`comparable`]) declines the pairs
83/// SQLite would compare with less type than the in-memory engine has.
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum Ty {
86    /// A string literal or binding, a text column, a text intrinsic,
87    /// `lower()` / `upper()` output.
88    Text,
89    /// An integer intrinsic (`$ordinal`, `$depth`).
90    Int,
91    /// A number literal or binding.
92    Num,
93    /// A boolean literal or binding.
94    Bool,
95    /// A `json_extract` read (an `attrs` path or a bare attribute name):
96    /// JSON `true` and `1` both surface as `1`.
97    Json,
98    /// A document property scalar (bare key on docs, `doc.<k>`): `val_bool`,
99    /// `val_num` and `val_text` coalesce into one column, and a list-valued or
100    /// nested key has no scalar row (`NULL`).
101    Prop,
102    /// `null` (or an absent binding).
103    Null,
104}
105
106/// The comparison gate (`spec/surface` §1), stated positively: a pair pushes
107/// only when it is provably compared the same way in SQLite and in memory.
108///
109/// **Equality** (`==`, `!=`) pushes iff one operand is `Text` (SQLite's typed
110/// comparison and strict equality agree that a string equals nothing but an
111/// equal string), or one is `Null` and the other is not `Prop` (a list-valued
112/// or nested key has no scalar row, so SQL reads `NULL` where memory has an
113/// array or an object), or both are numeric (`Int` / `Num`):
114///
115/// ```text
116///          Text   Int    Num    Bool   Null   Json   Prop
117///   Text   push   push   push   push   push   push   push
118///   Int    push   push   push   decl   push   decl   decl
119///   Num    push   push   push   decl   push   decl   decl
120///   Bool   push   decl   decl   decl   push   decl   decl
121///   Null   push   push   push   push   push   push   decl
122///   Json   push   decl   decl   decl   push   decl   decl
123///   Prop   push   decl   decl   decl   decl   decl   decl
124/// ```
125///
126/// **Relational** (`<`, `<=`, `>`, `>=`) pushes iff both operands are `Text`
127/// or both are numeric; every other cell declines (SQLite orders every
128/// integer before every text, `NULL` compares to nothing):
129///
130/// ```text
131///          Text   Int    Num    Bool   Null   Json   Prop
132///   Text   push   decl   decl   decl   decl   decl   decl
133///   Int    decl   push   push   decl   decl   decl   decl
134///   Num    decl   push   push   decl   decl   decl   decl
135///   Bool   decl   decl   decl   decl   decl   decl   decl
136///   Null   decl   decl   decl   decl   decl   decl   decl
137///   Json   decl   decl   decl   decl   decl   decl   decl
138///   Prop   decl   decl   decl   decl   decl   decl   decl
139/// ```
140///
141/// Why the declines: SQLite sees JSON `true` and `1`, `val_bool` and
142/// `val_num` alike (`checked == 1`, `$ordinal == checked`, `true == 1`
143/// binds as `1 IS 1`), orders every integer before every text
144/// (`$ordinal < "3"`, `level < "x"`), and two JSON or property reads carry
145/// no type at plan time.
146#[must_use]
147pub fn comparable(op: BinaryOp, a: Ty, b: Ty) -> bool {
148    use Ty::{Int, Null, Num, Prop, Text};
149    let numeric = |t: Ty| matches!(t, Int | Num);
150    let both_numeric = numeric(a) && numeric(b);
151    if matches!(op, BinaryOp::Eq | BinaryOp::Ne) {
152        a == Text
153            || b == Text
154            || (a == Null && b != Prop)
155            || (b == Null && a != Prop)
156            || both_numeric
157    } else {
158        (a == Text && b == Text) || both_numeric
159    }
160}
161
162/// The [`Ty`] of a literal or bound value; `None` for a non-scalar (an array,
163/// an object, a range), which has no faithful SQL binding.
164fn const_ty(v: &Value) -> Option<Ty> {
165    Some(match v {
166        Value::Str(_) => Ty::Text,
167        Value::Number(_) => Ty::Num,
168        Value::Bool(_) => Ty::Bool,
169        Value::Null | Value::Undefined => Ty::Null,
170        Value::Array(_) | Value::Object(_) | Value::Range(_) => return None,
171    })
172}
173
174// ---- handles -----------------------------------------------------------------------
175
176/// The bare names that resolve to something other than a property or
177/// attribute on each target — the self alias, the reach-through handles, the
178/// relations and the bags (`spec/surface` §1.2) — exactly the keys
179/// [`crate::StoreContext`]'s `get` answers before its property fallback. A
180/// comparison against one of these is declined rather than read as a key
181/// (`nodes == null` is not `NULL IS NULL`). A unit test proves the sets
182/// match the context.
183#[must_use]
184pub fn non_property_handles(t: Target) -> &'static [&'static str] {
185    match t {
186        Target::Docs => &[
187            "doc",
188            "blocks",
189            "nodes",
190            "out",
191            "in",
192            "out_edges",
193            "in_edges",
194            "frontmatter",
195            "inline",
196        ],
197        Target::Blocks => &[
198            "block",
199            "doc",
200            "children",
201            "nodes",
202            "out_edges",
203            "section",
204            "attrs",
205        ],
206        Target::Nodes => &[
207            "section",
208            "doc",
209            "block",
210            "blocks",
211            "subsections",
212            "children",
213            "attrs",
214        ],
215        Target::Edges => &["doc"],
216    }
217}
218
219// ---- intrinsics ------------------------------------------------------------------
220
221/// A `$`-namespaced intrinsic → a param-free SQL scalar and its type, per
222/// target. Anything not mapped (docs `$body`, reconstructed; `$title` /
223/// `$tags`, computed; blocks `$updated_at`; nodes `$locator`) returns `None`
224/// → residual. `$updated_at` / `$dst_path` / `$dst_uri` are correlated
225/// subqueries. Only `$ordinal` / `$depth` are integers; every other mapped
226/// intrinsic is text.
227fn intrinsic_sql(name: &str, ctx: &TranslateCtx<'_>) -> Option<(String, Ty)> {
228    let (s, d) = (ctx.self_alias, ctx.doc_alias);
229    let sql = match (ctx.target, name) {
230        (Target::Docs, "$id") => format!("{s}.doc_id"),
231        (Target::Docs, "$path") => format!("{d}.path"),
232        (Target::Docs, "$content_hash") => format!("lower(hex({s}.file_hash))"),
233        (Target::Docs, "$updated_at") => format!(
234            "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = {s}.current_rev)"
235        ),
236        (Target::Blocks, "$id") => format!("{s}.block_id"),
237        (Target::Blocks, "$doc") => format!("{s}.doc_id"),
238        (Target::Blocks, "$path") => format!("{d}.path"),
239        (Target::Blocks, "$ordinal") => return Some((format!("{s}.ordinal"), Ty::Int)),
240        (Target::Blocks, "$depth") => return Some((format!("{s}.depth"), Ty::Int)),
241        (Target::Blocks, "$body") => format!("{s}.text"),
242        (Target::Blocks, "$content_hash") => format!("lower(hex({s}.raw_hash))"),
243        (Target::Nodes, "$id" | "$node_id") => format!("{s}.node_id"),
244        (Target::Nodes, "$doc_id") => format!("{s}.doc_id"),
245        (Target::Nodes, "$block_id") => format!("{s}.block_id"),
246        (Target::Nodes, "$path") => format!("{d}.path"),
247        (Target::Edges, "$id") => format!("{s}.edge_id"),
248        (Target::Edges, "$src") => format!("{s}.src_doc"),
249        (Target::Edges, "$dst") => format!("{s}.dst_node"),
250        (Target::Edges, "$src_block") => format!("{s}.src_block"),
251        (Target::Edges, "$via") => format!("{s}.via_node"),
252        (Target::Edges, "$from_commit") => format!("{s}.from_commit"),
253        (Target::Edges, "$path") => format!("{d}.path"),
254        (Target::Edges, "$dst_path") => {
255            format!("(SELECT dd.path FROM docs dd WHERE dd.doc_id = {s}.dst_node)")
256        }
257        (Target::Edges, "$dst_uri") => {
258            format!("(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = {s}.dst_node)")
259        }
260        _ => return None,
261    };
262    Some((sql, Ty::Text))
263}
264
265/// docs intrinsics whose BARE (non-`$`) form is a loud error in-memory — not
266/// pushable, so the residual raises the guard (and the planner declines the
267/// whole query when such a read is left residual, see [`crate::planner`]).
268pub(crate) const RESERVED_DOC_BASENAMES: [&str; 5] =
269    ["id", "path", "updated_at", "content_hash", "body"];
270
271/// An injection-safe inlined identifier: `^[A-Za-z_][A-Za-z0-9_]*$`.
272fn is_seg(s: &str) -> bool {
273    let mut chars = s.chars();
274    chars
275        .next()
276        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
277        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
278}
279
280/// A single-valued document property (the scalar-in-scope rule): the scalar
281/// value only when the key has exactly one row in scope and it is
282/// `card = 'scalar'`, else NULL — matching the context's `doc_prop` for a
283/// scalar read.
284fn prop_scalar(doc_alias: &str, key: &str) -> Option<String> {
285    if !is_seg(key) {
286        return None;
287    }
288    Some(format!(
289        "(SELECT COALESCE(p.val_text, p.val_num, p.val_bool) FROM properties p \
290         WHERE p.doc_id = {doc_alias}.doc_id AND p.key = '{key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL \
291         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 \
292         LIMIT 1)"
293    ))
294}
295
296/// `json_extract(col, '$.a.b')` from validated segments; `None` if any
297/// segment is unsafe.
298fn json_extract(col: &str, segs: &[&str]) -> Option<String> {
299    if segs.iter().any(|s| !is_seg(s)) {
300        return None;
301    }
302    Some(format!("json_extract({col}, '$.{}')", segs.join(".")))
303}
304
305/// The dotted `attrs.a.b` / `doc.x` receiver chain as segments, or `None` if
306/// it is not a plain identifier navigation.
307fn member_segments(e: &Expr) -> Option<Vec<&str>> {
308    match e {
309        Expr::Ident { name } => Some(vec![name.as_str()]),
310        Expr::Member { recv, name } => {
311            let mut base = member_segments(recv)?;
312            base.push(name.as_str());
313            Some(base)
314        }
315        _ => None,
316    }
317}
318
319// ---- value position ----------------------------------------------------------------
320
321/// Translate an expression used as a VALUE (comparison operand, method
322/// receiver, function argument) to a SQL scalar. `None` if not faithfully
323/// translatable.
324pub fn translate_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
325    typed_value(e, ctx).map(|(frag, _)| frag)
326}
327
328/// [`translate_value`] plus the operand's [`Ty`], for the comparison gate.
329pub fn typed_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<(Frag, Ty)> {
330    let (s, d, target) = (ctx.self_alias, ctx.doc_alias, ctx.target);
331    let text = |sql: String| Some((Frag::bare(sql), Ty::Text));
332    let constant = |v: &Value| {
333        Some((
334            Frag {
335                sql: "?".to_owned(),
336                params: vec![to_sql(v)],
337            },
338            const_ty(v)?,
339        ))
340    };
341    match e {
342        Expr::Lit(v) => constant(v),
343        Expr::Binding { index } => constant(ctx.params.get(*index).unwrap_or(&Value::Undefined)),
344        Expr::Ident { name } => {
345            if name.starts_with('$') {
346                return intrinsic_sql(name, ctx).map(|(sql, ty)| (Frag::bare(sql), ty));
347            }
348            let name = name.as_str();
349            // A relation, reach-through handle, source handle or bag is not a
350            // property read (§1): rows or an object in memory, never a key.
351            if non_property_handles(target).contains(&name) {
352                return None;
353            }
354            match target {
355                Target::Docs => {
356                    // `format` is a column, not a property.
357                    if name == "format" {
358                        return text(format!("{s}.format"));
359                    }
360                    // A reserved basename stays residual so the guard fires.
361                    if RESERVED_DOC_BASENAMES.contains(&name) {
362                        return None;
363                    }
364                    prop_scalar(d, name).map(|sql| (Frag::bare(sql), Ty::Prop))
365                }
366                Target::Blocks => {
367                    if name == "type" || name == "text" {
368                        return text(format!("{s}.{name}"));
369                    }
370                    // A bare non-structural identifier flattens into attrs —
371                    // the same pushdown as the `attrs.<k>` member form.
372                    json_extract(&format!("{s}.attrs"), &[name])
373                        .map(|sql| (Frag::bare(sql), Ty::Json))
374                }
375                Target::Nodes => {
376                    if matches!(name, "kind" | "name" | "value") {
377                        return text(format!("{s}.{name}"));
378                    }
379                    json_extract(&format!("{s}.attrs"), &[name])
380                        .map(|sql| (Frag::bare(sql), Ty::Json))
381                }
382                Target::Edges => {
383                    if matches!(
384                        name,
385                        "predicate" | "provenance" | "dst_kind" | "anchor" | "src_field"
386                    ) {
387                        return text(format!("{s}.{name}"));
388                    }
389                    None
390                }
391            }
392        }
393        Expr::Member { .. } => {
394            let segs = member_segments(e)?;
395            let (head, rest) = segs.split_first()?;
396            if rest.is_empty() {
397                return None;
398            }
399            // attrs.<path> → json_extract on the row's attrs (blocks/nodes).
400            if *head == "attrs" && matches!(target, Target::Blocks | Target::Nodes) {
401                return json_extract(&format!("{s}.attrs"), rest)
402                    .map(|sql| (Frag::bare(sql), Ty::Json));
403            }
404            // doc.<x> reach-through — the owning doc (alias `doc`). On the docs
405            // target `doc` is the row itself; either way it resolves against `d`.
406            if *head == "doc" {
407                if rest.len() != 1 {
408                    return None;
409                }
410                let k = rest[0];
411                if k == "$path" {
412                    return text(format!("{d}.path"));
413                }
414                if k == "format" {
415                    return text(format!("{d}.format"));
416                }
417                // `doc.nodes`, `doc.frontmatter`, `doc.doc`… are the doc's
418                // handles, not its properties.
419                if k.starts_with('$')
420                    || RESERVED_DOC_BASENAMES.contains(&k)
421                    || non_property_handles(Target::Docs).contains(&k)
422                {
423                    return None;
424                }
425                return prop_scalar(d, k).map(|sql| (Frag::bare(sql), Ty::Prop));
426            }
427            // block.type / block.text reach-through from a node.
428            if *head == "block"
429                && target == Target::Nodes
430                && rest.len() == 1
431                && matches!(rest[0], "type" | "text")
432            {
433                return text(format!(
434                    "(SELECT bb.{} FROM blocks bb WHERE bb.block_id = {s}.block_id)",
435                    rest[0]
436                ));
437            }
438            None
439        }
440        // `.lower()` / `.upper()` are the value-position string methods.
441        Expr::Call {
442            recv: Some(recv),
443            name,
444            args,
445        } if args.is_empty() && (name == "lower" || name == "upper") => {
446            let recv = translate_value(recv, ctx)?;
447            Some((
448                Frag {
449                    sql: format!("{name}({})", recv.sql),
450                    params: recv.params,
451                },
452                Ty::Text,
453            ))
454        }
455        _ => None,
456    }
457}
458
459// ---- predicate position --------------------------------------------------------------
460
461/// `==` / `!=` → null-safe `IS` / `IS NOT`; the relational ops as plain SQL.
462fn is_op(op: BinaryOp) -> Option<&'static str> {
463    Some(match op {
464        BinaryOp::Eq => "IS",
465        BinaryOp::Ne => "IS NOT",
466        BinaryOp::Lt => "<",
467        BinaryOp::Le => "<=",
468        BinaryOp::Gt => ">",
469        BinaryOp::Ge => ">=",
470        BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => {
471            return None;
472        }
473    })
474}
475
476/// Translate an expression used as a boolean PREDICATE to a SQL boolean, or
477/// `None` if it cannot be pushed faithfully. Only positive, AND-safe forms
478/// are handled: `unary` (`!`), `in`, bare truthy idents and member
479/// reach-through in predicate position stay residual.
480pub fn translate_predicate(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
481    match e {
482        // Only `&&` composes faithfully in a positive context; `||` is
483        // declined (its NULL / short-circuit interaction stays residual).
484        Expr::Logical {
485            op: LogicalOp::And,
486            left,
487            right,
488        } => join2(
489            translate_predicate(left, ctx),
490            translate_predicate(right, ctx),
491            "AND",
492        ),
493        Expr::Binary { op, left, right } => {
494            // An arithmetic operator in predicate position → residual.
495            let sql_op = is_op(*op)?;
496            let (l, lt) = typed_value(left, ctx)?;
497            let (r, rt) = typed_value(right, ctx)?;
498            // The operand-typing gate (§1): the pairs SQLite would compare
499            // with less type than the engine has stay residual.
500            if !comparable(*op, lt, rt) {
501                return None;
502            }
503            let op = sql_op;
504            // `==`/`!=` → IS / IS NOT (absence-normalized equality, faithful in
505            // any context). Relational ops → plain SQL: a NULL operand yields
506            // NULL, which is excluded in the positive AND context these
507            // fragments are pushed into, matching the absent-operand ⇒ false rule.
508            let mut params = l.params;
509            params.extend(r.params);
510            Some(Frag {
511                sql: format!("({} {op} {})", l.sql, r.sql),
512                params,
513            })
514        }
515        Expr::Call {
516            recv: Some(recv),
517            name,
518            args,
519        } if args.len() == 1 => {
520            // startsWith / contains / endsWith — CASE-SENSITIVE, via
521            // substr/instr (never LIKE). `matches` (regex) is declined.
522            let recv = translate_value(recv, ctx)?;
523            let arg = translate_value(&args[0], ctx)?;
524            let mut params = recv.params;
525            let sql = match name.as_str() {
526                "startsWith" => {
527                    // recv begins with arg ⇔ its first length(arg) chars equal arg.
528                    params.extend(arg.params.iter().cloned());
529                    params.extend(arg.params);
530                    format!("(substr({}, 1, length({a})) = {a})", recv.sql, a = arg.sql)
531                }
532                "endsWith" => {
533                    // recv ends with arg ⇔ its last length(arg) chars equal arg.
534                    // When arg is longer than recv, substr clamps to the whole
535                    // (shorter) string, so the equality is false.
536                    params.extend(arg.params.iter().cloned());
537                    params.extend(arg.params);
538                    format!("(substr({}, -length({a})) = {a})", recv.sql, a = arg.sql)
539                }
540                "contains" => {
541                    params.extend(arg.params);
542                    format!("(instr({}, {}) > 0)", recv.sql, arg.sql)
543                }
544                _ => return None,
545            };
546            Some(Frag { sql, params })
547        }
548        _ => None,
549    }
550}
551
552/// Combine two optional fragments with a boolean connective; `None` if either
553/// is untranslatable (the whole conjunct then stays residual).
554fn join2(a: Option<Frag>, b: Option<Frag>, connective: &str) -> Option<Frag> {
555    let (a, b) = (a?, b?);
556    let mut params = a.params;
557    params.extend(b.params);
558    Some(Frag {
559        sql: format!("({} {connective} {})", a.sql, b.sql),
560        params,
561    })
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use oqx::ast::Where;
568
569    const DOCS: TranslateCtx<'static> = TranslateCtx {
570        target: Target::Docs,
571        self_alias: "d",
572        doc_alias: "d",
573        params: &[],
574    };
575
576    fn text(s: &str) -> SqlValue {
577        SqlValue::Text(s.to_owned())
578    }
579
580    fn frag(sql: &str, params: &[SqlValue]) -> Option<Frag> {
581        Some(Frag {
582            sql: sql.to_owned(),
583            params: params.to_vec(),
584        })
585    }
586
587    /// Parse `from docs where <src>` and return the single scalar predicate.
588    fn pred(src: &str) -> Expr {
589        let q = oqx::parse_string(&format!("from docs where {src}")).expect("parses");
590        match q.r#where {
591            Some(Where::Scalar { expr }) => expr,
592            other => panic!("expected a single scalar predicate, got {other:?}"),
593        }
594    }
595
596    fn ident(name: &str) -> Box<Expr> {
597        Box::new(Expr::Ident {
598            name: name.to_owned(),
599        })
600    }
601
602    fn lit(s: &str) -> Box<Expr> {
603        Box::new(Expr::Lit(Value::from(s)))
604    }
605
606    fn eq(l: Box<Expr>, r: Box<Expr>) -> Box<Expr> {
607        Box::new(Expr::Binary {
608            op: BinaryOp::Eq,
609            left: l,
610            right: r,
611        })
612    }
613
614    // -- equality is absence-normalized (IS / IS NOT) --
615
616    #[test]
617    fn equality_is_null_safe_is() {
618        assert_eq!(
619            translate_predicate(&pred("$path == \"index.md\""), &DOCS),
620            frag("(d.path IS ?)", &[text("index.md")])
621        );
622    }
623
624    #[test]
625    fn inequality_is_null_safe_is_not() {
626        assert_eq!(
627            translate_predicate(&pred("$path != \"x\""), &DOCS),
628            frag("(d.path IS NOT ?)", &[text("x")])
629        );
630    }
631
632    #[test]
633    fn intrinsic_column_mapping() {
634        assert_eq!(
635            translate_predicate(&pred("$id == \"d_1\""), &DOCS),
636            frag("(d.doc_id IS ?)", &[text("d_1")])
637        );
638    }
639
640    // -- relational ops (plain SQL) --
641
642    #[test]
643    fn relational_ops_are_plain_comparisons() {
644        assert_eq!(
645            translate_predicate(&pred("$path < \"m\""), &DOCS),
646            frag("(d.path < ?)", &[text("m")])
647        );
648        assert_eq!(
649            translate_predicate(&pred("$path >= \"m\""), &DOCS),
650            frag("(d.path >= ?)", &[text("m")])
651        );
652        // arithmetic in predicate position → residual
653        assert_eq!(translate_predicate(&pred("$path + 1"), &DOCS), None);
654    }
655
656    // -- string ops are case-sensitive (substr/instr, never LIKE) --
657
658    #[test]
659    fn starts_with_is_substr_equality() {
660        assert_eq!(
661            translate_predicate(&pred("$path.startsWith(\"lab/\")"), &DOCS),
662            frag(
663                "(substr(d.path, 1, length(?)) = ?)",
664                &[text("lab/"), text("lab/")]
665            )
666        );
667    }
668
669    #[test]
670    fn lower_then_starts_with_pushes_with_explicit_lower() {
671        assert_eq!(
672            translate_predicate(&pred("$path.lower().startsWith(\"lab/\")"), &DOCS),
673            frag(
674                "(substr(lower(d.path), 1, length(?)) = ?)",
675                &[text("lab/"), text("lab/")]
676            )
677        );
678    }
679
680    #[test]
681    fn contains_is_instr() {
682        assert_eq!(
683            translate_predicate(&pred("$path.contains(\"notes\")"), &DOCS),
684            frag("(instr(d.path, ?) > 0)", &[text("notes")])
685        );
686    }
687
688    #[test]
689    fn ends_with_is_negative_substr_equality() {
690        assert_eq!(
691            translate_predicate(&pred("$path.endsWith(\".md\")"), &DOCS),
692            frag(
693                "(substr(d.path, -length(?)) = ?)",
694                &[text(".md"), text(".md")]
695            )
696        );
697    }
698
699    #[test]
700    fn upper_wraps_the_receiver_in_value_position() {
701        assert_eq!(
702            translate_value(&pred("$path.upper()"), &DOCS),
703            frag("upper(d.path)", &[])
704        );
705    }
706
707    // -- bare document properties push via the properties table --
708
709    #[test]
710    fn bare_doc_property_is_the_scalar_in_scope_subquery() {
711        let f = translate_predicate(&pred("layer == \"canon\""), &DOCS).expect("pushable");
712        assert!(f.sql.contains("FROM properties p"), "{}", f.sql);
713        assert!(f.sql.contains("p.key = 'layer'"), "{}", f.sql);
714        assert!(f.sql.contains("p.card = 'scalar'"), "{}", f.sql);
715        assert!(
716            f.sql.starts_with('(') && f.sql.contains(" IS ?)"),
717            "{}",
718            f.sql
719        );
720        assert_eq!(f.params, vec![text("canon")]);
721    }
722
723    #[test]
724    fn updated_at_pushes_as_its_revisions_subquery() {
725        let f =
726            translate_predicate(&pred("$updated_at >= \"2026-01-01\""), &DOCS).expect("pushable");
727        assert!(
728            f.sql.contains("FROM revisions r JOIN commits c"),
729            "{}",
730            f.sql
731        );
732    }
733
734    #[test]
735    fn format_is_a_column_not_a_property() {
736        assert_eq!(
737            translate_predicate(&pred("format == \"markdown\""), &DOCS),
738            frag("(d.format IS ?)", &[text("markdown")])
739        );
740    }
741
742    #[test]
743    fn booleans_bind_as_one_and_zero() {
744        let blocks = TranslateCtx {
745            target: Target::Blocks,
746            self_alias: "b",
747            ..DOCS
748        };
749        // (against a text column — a boolean against a JSON or property read
750        // is declined, see the matrix tests)
751        assert_eq!(
752            translate_predicate(&pred("type == true"), &blocks).map(|f| f.params),
753            Some(vec![SqlValue::Integer(1)])
754        );
755        assert_eq!(
756            translate_predicate(&pred("type == false"), &blocks).map(|f| f.params),
757            Some(vec![SqlValue::Integer(0)])
758        );
759        assert_eq!(
760            translate_predicate(&pred("$ordinal < 1000"), &blocks).map(|f| f.params),
761            Some(vec![SqlValue::Real(1000.0)])
762        );
763        assert_eq!(
764            translate_predicate(&pred("$path == null"), &DOCS).map(|f| f.params),
765            Some(vec![SqlValue::Null])
766        );
767    }
768
769    // -- decline (a): operand typing (spec/surface §1) --
770
771    /// One representative expression per [`Ty`] on the blocks target (the only
772    /// target with an integer intrinsic; `doc.<k>` is its property read).
773    const REPRESENTATIVES: [(Ty, &str); 7] = [
774        (Ty::Text, "$path"),
775        (Ty::Int, "$ordinal"),
776        (Ty::Num, "1"),
777        (Ty::Bool, "true"),
778        (Ty::Null, "null"),
779        (Ty::Json, "checked"),
780        (Ty::Prop, "doc.layer"),
781    ];
782
783    #[test]
784    fn representatives_carry_their_type() {
785        let blocks = TranslateCtx {
786            target: Target::Blocks,
787            self_alias: "b",
788            ..DOCS
789        };
790        for (ty, src) in REPRESENTATIVES {
791            let (_, got) = typed_value(&pred(src), &blocks).expect(src);
792            assert_eq!(got, ty, "{src}");
793        }
794        assert_eq!(
795            typed_value(&pred("attrs.a.b"), &blocks).map(|(_, t)| t),
796            Some(Ty::Json)
797        );
798        assert_eq!(
799            typed_value(&pred("$depth"), &blocks).map(|(_, t)| t),
800            Some(Ty::Int)
801        );
802        assert_eq!(
803            typed_value(&pred("type.lower()"), &blocks).map(|(_, t)| t),
804            Some(Ty::Text)
805        );
806        assert_eq!(
807            typed_value(&pred("checked.upper()"), &blocks).map(|(_, t)| t),
808            Some(Ty::Text)
809        );
810        assert_eq!(
811            typed_value(&pred("layer"), &DOCS).map(|(_, t)| t),
812            Some(Ty::Prop)
813        );
814        assert_eq!(
815            typed_value(&pred("format"), &DOCS).map(|(_, t)| t),
816            Some(Ty::Text)
817        );
818    }
819
820    #[test]
821    fn the_comparison_matrix_decides_every_cell() {
822        // Row/column order: Text Int Num Bool Null Json Prop.
823        const P: bool = true;
824        const D: bool = false;
825        // Equality: one side text, or null against a non-property, or both numeric.
826        #[rustfmt::skip]
827        const EQUALITY: [[bool; 7]; 7] = [
828            /* Text */ [P, P, P, P, P, P, P],
829            /* Int  */ [P, P, P, D, P, D, D],
830            /* Num  */ [P, P, P, D, P, D, D],
831            /* Bool */ [P, D, D, D, P, D, D],
832            /* Null */ [P, P, P, P, P, P, D],
833            /* Json */ [P, D, D, D, P, D, D],
834            /* Prop */ [P, D, D, D, D, D, D],
835        ];
836        // Relational: both text or both numeric, nothing else.
837        #[rustfmt::skip]
838        const RELATIONAL: [[bool; 7]; 7] = [
839            /* Text */ [P, D, D, D, D, D, D],
840            /* Int  */ [D, P, P, D, D, D, D],
841            /* Num  */ [D, P, P, D, D, D, D],
842            /* Bool */ [D, D, D, D, D, D, D],
843            /* Null */ [D, D, D, D, D, D, D],
844            /* Json */ [D, D, D, D, D, D, D],
845            /* Prop */ [D, D, D, D, D, D, D],
846        ];
847        let blocks = TranslateCtx {
848            target: Target::Blocks,
849            self_alias: "b",
850            ..DOCS
851        };
852        let ops = [
853            (BinaryOp::Eq, "==", &EQUALITY),
854            (BinaryOp::Ne, "!=", &EQUALITY),
855            (BinaryOp::Lt, "<", &RELATIONAL),
856            (BinaryOp::Le, "<=", &RELATIONAL),
857            (BinaryOp::Gt, ">", &RELATIONAL),
858            (BinaryOp::Ge, ">=", &RELATIONAL),
859        ];
860        for (i, (a, l)) in REPRESENTATIVES.iter().enumerate() {
861            for (j, (b, r)) in REPRESENTATIVES.iter().enumerate() {
862                for (op, spelled, matrix) in ops {
863                    let want = matrix[i][j];
864                    assert_eq!(matrix[j][i], want, "the matrix is symmetric ({a:?}, {b:?})");
865                    assert_eq!(
866                        comparable(op, *a, *b),
867                        want,
868                        "comparable({spelled}, {a:?}, {b:?})"
869                    );
870                    let src = format!("{l} {spelled} {r}");
871                    assert_eq!(
872                        translate_predicate(&pred(&src), &blocks).is_some(),
873                        want,
874                        "{src}"
875                    );
876                }
877            }
878        }
879    }
880
881    #[test]
882    fn the_spec_shapes_of_decline_a() {
883        let blocks = TranslateCtx {
884            target: Target::Blocks,
885            self_alias: "b",
886            ..DOCS
887        };
888        // a boolean or number against a JSON read
889        assert_eq!(translate_predicate(&pred("checked == 1"), &blocks), None);
890        assert_eq!(translate_predicate(&pred("checked == true"), &blocks), None);
891        assert_eq!(
892            translate_predicate(&pred("attrs.checked == true"), &blocks),
893            None
894        );
895        // … or a property read (bare on docs, `doc.<k>` elsewhere)
896        assert_eq!(translate_predicate(&pred("verified == 1"), &DOCS), None);
897        assert_eq!(translate_predicate(&pred("verified == true"), &DOCS), None);
898        assert_eq!(translate_predicate(&pred("era < 1000"), &DOCS), None);
899        assert_eq!(translate_predicate(&pred("doc.era < 1000"), &blocks), None);
900        // a boolean against an integer intrinsic; a number stays pushable
901        assert_eq!(
902            translate_predicate(&pred("$ordinal == true"), &blocks),
903            None
904        );
905        assert_eq!(
906            translate_predicate(&pred("$ordinal == 1"), &blocks),
907            frag("(b.ordinal IS ?)", &[SqlValue::Real(1.0)])
908        );
909        // null against a property read; against a JSON read or a column it pushes
910        assert_eq!(translate_predicate(&pred("tags != null"), &DOCS), None);
911        assert_eq!(translate_predicate(&pred("tags == null"), &DOCS), None);
912        assert_eq!(
913            translate_predicate(&pred("doc.tags == null"), &blocks),
914            None
915        );
916        assert_eq!(
917            translate_predicate(&pred("checked == null"), &blocks),
918            frag(
919                "(json_extract(b.attrs, '$.checked') IS ?)",
920                &[SqlValue::Null]
921            )
922        );
923        assert_eq!(
924            translate_predicate(&pred("$ordinal != null"), &blocks),
925            frag("(b.ordinal IS NOT ?)", &[SqlValue::Null])
926        );
927        // a string literal against anything pushes under equality
928        assert_eq!(
929            translate_predicate(&pred("checked == \"x\""), &blocks),
930            frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
931        );
932        assert!(translate_predicate(&pred("layer == \"canon\""), &DOCS).is_some());
933        assert!(translate_predicate(&pred("$ordinal == \"1\""), &blocks).is_some());
934        assert!(translate_predicate(&pred("$ordinal != \"1\""), &blocks).is_some());
935        // … but a relational comparison across text and a number / integer
936        // declines (the fifth shape): SQLite orders integers before text
937        assert_eq!(
938            translate_predicate(&pred("$ordinal < \"3\""), &blocks),
939            None
940        );
941        assert_eq!(
942            translate_predicate(&pred("\"3\" >= $ordinal"), &blocks),
943            None
944        );
945        assert_eq!(translate_predicate(&pred("$path > 5"), &DOCS), None);
946        assert_eq!(translate_predicate(&pred("type <= 1"), &blocks), None);
947        assert_eq!(
948            translate_predicate(&pred("$ordinal < 3"), &blocks),
949            frag("(b.ordinal < ?)", &[SqlValue::Real(3.0)])
950        );
951        assert_eq!(
952            translate_predicate(&pred("$path > \"m\""), &DOCS),
953            frag("(d.path > ?)", &[text("m")])
954        );
955        // two reads carry no type at plan time; a boolean equals only text/null
956        assert_eq!(
957            translate_predicate(&pred("$ordinal == checked"), &blocks),
958            None
959        );
960        assert_eq!(
961            translate_predicate(&pred("checked == level"), &blocks),
962            None
963        );
964        assert_eq!(
965            translate_predicate(&pred("doc.era == doc.year"), &blocks),
966            None
967        );
968        assert_eq!(translate_predicate(&pred("level < \"x\""), &blocks), None);
969        assert_eq!(translate_predicate(&pred("level < 3"), &blocks), None);
970        assert_eq!(translate_predicate(&pred("true == false"), &blocks), None);
971        assert_eq!(translate_predicate(&pred("$ordinal > null"), &blocks), None);
972        assert!(translate_predicate(&pred("$ordinal == $depth"), &blocks).is_some());
973        assert!(translate_predicate(&pred("$ordinal <= $depth"), &blocks).is_some());
974        assert!(translate_predicate(&pred("type == null"), &blocks).is_some());
975    }
976
977    #[test]
978    fn bindings_are_typed_by_their_value() {
979        let blocks = TranslateCtx {
980            target: Target::Blocks,
981            self_alias: "b",
982            ..DOCS
983        };
984        let params = [
985            Value::from("s"),
986            Value::from(1.0),
987            Value::Bool(true),
988            Value::Null,
989            Value::Array(vec![]),
990        ];
991        let ctx = TranslateCtx {
992            params: &params,
993            ..blocks
994        };
995        let against = |i: usize, rhs: &str| {
996            let e = Expr::Binary {
997                op: BinaryOp::Eq,
998                left: Box::new(Expr::Binding { index: i }),
999                right: Box::new(pred(rhs)),
1000            };
1001            translate_predicate(&e, &ctx).is_some()
1002        };
1003        // text binding pushes against anything; number/boolean not against JSON
1004        assert!(against(0, "checked"));
1005        assert!(!against(1, "checked"));
1006        assert!(!against(2, "checked"));
1007        assert!(!against(2, "$ordinal"));
1008        assert!(against(1, "$ordinal"));
1009        // a null binding pushes against JSON, not against a property
1010        assert!(against(3, "checked"));
1011        assert!(!against(3, "doc.tags"));
1012        // an absent binding (past the end) is null
1013        assert!(against(9, "checked"));
1014        assert!(!against(9, "doc.tags"));
1015        // a non-scalar binding has no faithful SQL value
1016        assert!(!against(4, "type"));
1017    }
1018
1019    // -- decline (b): handles are not property reads --
1020
1021    #[test]
1022    fn relation_and_handle_names_are_not_property_reads() {
1023        let blocks = TranslateCtx {
1024            target: Target::Blocks,
1025            self_alias: "b",
1026            ..DOCS
1027        };
1028        let nodes = TranslateCtx {
1029            target: Target::Nodes,
1030            self_alias: "n",
1031            ..DOCS
1032        };
1033        let edges = TranslateCtx {
1034            target: Target::Edges,
1035            self_alias: "e",
1036            ..DOCS
1037        };
1038        for (ctx, target) in [
1039            (&DOCS, Target::Docs),
1040            (&blocks, Target::Blocks),
1041            (&nodes, Target::Nodes),
1042            (&edges, Target::Edges),
1043        ] {
1044            for name in non_property_handles(target) {
1045                let src = format!("{name} == null");
1046                assert_eq!(
1047                    translate_predicate(&pred(&src), ctx),
1048                    None,
1049                    "{target:?}: {src}"
1050                );
1051                let src = format!("{name} == \"x\"");
1052                assert_eq!(
1053                    translate_predicate(&pred(&src), ctx),
1054                    None,
1055                    "{target:?}: {src}"
1056                );
1057            }
1058        }
1059        // the fixtures' shapes
1060        assert_eq!(translate_predicate(&pred("nodes == null"), &DOCS), None);
1061        assert_eq!(
1062            translate_predicate(&pred("frontmatter == null"), &DOCS),
1063            None
1064        );
1065        assert_eq!(translate_predicate(&pred("nodes == null"), &blocks), None);
1066        assert_eq!(translate_predicate(&pred("attrs == null"), &blocks), None);
1067        // `doc.<handle>` is the doc's handle, not its property; `doc.<k>` still pushes
1068        assert_eq!(
1069            translate_predicate(&pred("doc.nodes == null"), &blocks),
1070            None
1071        );
1072        assert_eq!(
1073            translate_predicate(&pred("doc.frontmatter == null"), &blocks),
1074            None
1075        );
1076        assert_eq!(translate_predicate(&pred("doc.doc == null"), &DOCS), None);
1077        assert!(translate_predicate(&pred("doc.layer == \"canon\""), &blocks).is_some());
1078        // a plain attribute or property of the same spelling elsewhere still pushes
1079        assert!(translate_predicate(&pred("section == \"x\""), &DOCS).is_some());
1080        assert!(translate_predicate(&pred("frontmatter == \"x\""), &blocks).is_some());
1081    }
1082
1083    /// The handle sets are exactly the keys the store context resolves to
1084    /// rows, a row or a bag before its property fallback: over a small
1085    /// observed corpus, on every row of a target, a name in the set never
1086    /// reads as a scalar, and at least one row resolves it to rows / a row /
1087    /// an object; a name outside the set never does.
1088    #[test]
1089    fn handle_sets_match_the_store_context() {
1090        use std::collections::HashMap;
1091
1092        use omgbase_reconcile::Config;
1093        use omgbase_store::{BatchItem, Store};
1094        use oqx::DataContext;
1095
1096        use crate::context::StoreContext;
1097
1098        let mut store = Store::open_in_memory().expect("store");
1099        let repo = store.create_repo("handles").expect("repo");
1100        let items = [
1101            BatchItem::observed(
1102                "a.md",
1103                "---\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",
1104            ),
1105            BatchItem::observed("b.md", "# B\n\nBack to [a](a.md).\n"),
1106        ];
1107        store
1108            .observe_batch(
1109                &repo,
1110                &items,
1111                "2026-09-26T00:00:00.000Z",
1112                &Config::default(),
1113            )
1114            .expect("observe");
1115        let ctx = StoreContext::new(store.conn(), &repo, HashMap::new());
1116
1117        let mut universe: Vec<&str> = [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges]
1118            .into_iter()
1119            .flat_map(|t| non_property_handles(t).iter().copied())
1120            .collect();
1121        universe.extend([
1122            "layer",
1123            "title",
1124            "verified",
1125            "checked",
1126            "level",
1127            "format",
1128            "type",
1129            "text",
1130            "kind",
1131            "name",
1132            "value",
1133            "predicate",
1134            "key",
1135            "nope",
1136        ]);
1137        universe.sort_unstable();
1138        universe.dedup();
1139
1140        let is_shape = |v: &Value| matches!(v, Value::Array(_) | Value::Object(_));
1141        for target in [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges] {
1142            let Value::Array(rows) = ctx.root(target.as_str()) else {
1143                panic!("{target:?} root is not an array");
1144            };
1145            assert!(!rows.is_empty(), "{target:?} has rows");
1146            let set = non_property_handles(target);
1147            for name in &universe {
1148                let mut shaped = 0;
1149                for row in &rows {
1150                    let v = ctx.get(row, name).expect("get");
1151                    if set.contains(name) {
1152                        assert!(
1153                            v.is_absent() || is_shape(&v),
1154                            "{target:?}.{name} read as a scalar: {v:?}"
1155                        );
1156                    } else {
1157                        assert!(!is_shape(&v), "{target:?}.{name} is a handle: {v:?}");
1158                    }
1159                    shaped += usize::from(is_shape(&v));
1160                }
1161                if set.contains(name) {
1162                    assert!(
1163                        shaped > 0,
1164                        "{target:?}.{name} never resolved to rows or a bag"
1165                    );
1166                }
1167            }
1168        }
1169    }
1170
1171    // -- declines (left residual) return None --
1172
1173    #[test]
1174    fn reserved_bare_basename_is_not_pushed() {
1175        assert_eq!(translate_predicate(&pred("path == \"x\""), &DOCS), None);
1176        assert_eq!(translate_predicate(&pred("body == \"x\""), &DOCS), None);
1177        assert_eq!(translate_predicate(&pred("doc.path == \"x\""), &DOCS), None);
1178    }
1179
1180    #[test]
1181    fn docs_body_and_computed_intrinsics_are_not_columns() {
1182        assert_eq!(translate_predicate(&pred("$body == \"x\""), &DOCS), None);
1183        assert_eq!(translate_predicate(&pred("$title == \"x\""), &DOCS), None);
1184        assert_eq!(translate_predicate(&pred("$tags == \"x\""), &DOCS), None);
1185    }
1186
1187    #[test]
1188    fn matches_needs_a_regexp_udf() {
1189        assert_eq!(
1190            translate_predicate(&pred("$path.matches(\"^lab/\")"), &DOCS),
1191            None
1192        );
1193    }
1194
1195    #[test]
1196    fn negation_as_a_nested_expr_is_not_and_safe() {
1197        let e = Expr::Unary {
1198            op: oqx::ast::UnaryOp::Not,
1199            expr: ident("$path"),
1200        };
1201        assert_eq!(translate_predicate(&e, &DOCS), None);
1202    }
1203
1204    #[test]
1205    fn disjunction_as_a_nested_expr_is_declined() {
1206        let e = Expr::Logical {
1207            op: LogicalOp::Or,
1208            left: eq(ident("$path"), lit("a")),
1209            right: eq(ident("$path"), lit("b")),
1210        };
1211        assert_eq!(translate_predicate(&e, &DOCS), None);
1212    }
1213
1214    #[test]
1215    fn unmapped_node_intrinsic_is_not_pushed() {
1216        let nodes = TranslateCtx {
1217            target: Target::Nodes,
1218            self_alias: "n",
1219            ..DOCS
1220        };
1221        assert_eq!(
1222            translate_predicate(&pred("$locator == \"x\""), &nodes),
1223            None
1224        );
1225        // `$updated_at` is mapped on docs only.
1226        let blocks = TranslateCtx {
1227            target: Target::Blocks,
1228            self_alias: "b",
1229            ..DOCS
1230        };
1231        assert_eq!(
1232            translate_predicate(&pred("$updated_at == \"x\""), &blocks),
1233            None
1234        );
1235    }
1236
1237    #[test]
1238    fn range_membership_in_and_bare_idents_are_declined() {
1239        assert_eq!(translate_predicate(&pred("era in 800..1680"), &DOCS), None);
1240        assert_eq!(
1241            translate_predicate(&pred("\"a\" in list(tags)"), &DOCS),
1242            None
1243        );
1244        assert_eq!(translate_predicate(&pred("verified"), &DOCS), None);
1245        assert_eq!(translate_predicate(&pred("doc.verified"), &DOCS), None);
1246        assert_eq!(translate_predicate(&pred("size(tags) > 1"), &DOCS), None);
1247        assert_eq!(translate_predicate(&pred("$self.text(\"x\")"), &DOCS), None);
1248        assert_eq!(translate_predicate(&pred("$value == \"x\""), &DOCS), None);
1249        assert_eq!(translate_predicate(&pred("^slug == \"x\""), &DOCS), None);
1250        assert_eq!(
1251            translate_predicate(&pred("frontmatter.era == 1"), &DOCS),
1252            None
1253        );
1254    }
1255
1256    // -- conjunction and bindings via constructed AST --
1257
1258    #[test]
1259    fn and_composes_two_pushable_comparisons() {
1260        let e = Expr::Logical {
1261            op: LogicalOp::And,
1262            left: eq(ident("$path"), lit("a")),
1263            right: Box::new(Expr::Binary {
1264                op: BinaryOp::Ne,
1265                left: ident("$id"),
1266                right: lit("d_2"),
1267            }),
1268        };
1269        assert_eq!(
1270            translate_predicate(&e, &DOCS),
1271            frag(
1272                "((d.path IS ?) AND (d.doc_id IS NOT ?))",
1273                &[text("a"), text("d_2")]
1274            )
1275        );
1276    }
1277
1278    #[test]
1279    fn and_declines_wholesale_if_either_side_is_not_pushable() {
1280        let e = Expr::Logical {
1281            op: LogicalOp::And,
1282            left: eq(ident("$path"), lit("a")),
1283            // $body is reconstructed, not a column → the whole && declines.
1284            right: eq(ident("$body"), lit("x")),
1285        };
1286        assert_eq!(translate_predicate(&e, &DOCS), None);
1287    }
1288
1289    #[test]
1290    fn resolves_a_binding_to_its_param_value() {
1291        let e = eq(ident("$path"), Box::new(Expr::Binding { index: 0 }));
1292        let params = [Value::from("from-binding.md")];
1293        let ctx = TranslateCtx {
1294            params: &params,
1295            ..DOCS
1296        };
1297        assert_eq!(
1298            translate_predicate(&e, &ctx),
1299            frag("(d.path IS ?)", &[text("from-binding.md")])
1300        );
1301        // A binding past the end reads as absent → NULL.
1302        assert_eq!(
1303            translate_predicate(&e, &DOCS),
1304            frag("(d.path IS ?)", &[SqlValue::Null])
1305        );
1306    }
1307
1308    // -- per-target fields --
1309
1310    #[test]
1311    fn blocks_and_nodes_flatten_bare_identifiers_into_attrs() {
1312        let blocks = TranslateCtx {
1313            target: Target::Blocks,
1314            self_alias: "b",
1315            ..DOCS
1316        };
1317        // (a top-level `&&` is a `Where::And` of scalars; the nested form is
1318        // reached through constructed AST, as in the reference's tests)
1319        let both = Expr::Logical {
1320            op: LogicalOp::And,
1321            left: eq(ident("type"), lit("task")),
1322            right: eq(ident("marker"), lit("x")),
1323        };
1324        assert_eq!(
1325            translate_predicate(&both, &blocks),
1326            frag(
1327                "((b.type IS ?) AND (json_extract(b.attrs, '$.marker') IS ?))",
1328                &[text("task"), text("x")]
1329            )
1330        );
1331        assert_eq!(
1332            translate_predicate(&pred("attrs.marker == \"x\""), &blocks),
1333            frag("(json_extract(b.attrs, '$.marker') IS ?)", &[text("x")])
1334        );
1335        // (a boolean or number against a JSON read is declined — the matrix)
1336        assert_eq!(
1337            translate_predicate(&pred("attrs.checked == true"), &blocks),
1338            None
1339        );
1340        let nodes = TranslateCtx {
1341            target: Target::Nodes,
1342            self_alias: "n",
1343            ..DOCS
1344        };
1345        assert_eq!(
1346            translate_predicate(&pred("kind == \"md:section\""), &nodes),
1347            frag("(n.kind IS ?)", &[text("md:section")])
1348        );
1349        assert_eq!(
1350            translate_predicate(&pred("level == \"1\""), &nodes),
1351            frag("(json_extract(n.attrs, '$.level') IS ?)", &[text("1")])
1352        );
1353        assert_eq!(translate_predicate(&pred("level == 1"), &nodes), None);
1354        assert_eq!(
1355            translate_predicate(&pred("attrs.a.b == \"c\""), &nodes),
1356            frag("(json_extract(n.attrs, '$.a.b') IS ?)", &[text("c")])
1357        );
1358        // `attrs.<k>` is a blocks/nodes form; on docs it is not a column.
1359        assert_eq!(
1360            translate_predicate(&pred("attrs.marker == \"x\""), &DOCS),
1361            None
1362        );
1363    }
1364
1365    #[test]
1366    fn doc_and_block_reach_through() {
1367        let blocks = TranslateCtx {
1368            target: Target::Blocks,
1369            self_alias: "b",
1370            ..DOCS
1371        };
1372        let f = translate_predicate(&pred("doc.type == \"lab-note\""), &blocks).expect("pushable");
1373        assert!(
1374            f.sql.contains("p.doc_id = d.doc_id AND p.key = 'type'"),
1375            "{}",
1376            f.sql
1377        );
1378        assert_eq!(
1379            translate_predicate(&pred("doc.$path == \"a.md\""), &blocks),
1380            frag("(d.path IS ?)", &[text("a.md")])
1381        );
1382        assert_eq!(
1383            translate_predicate(&pred("doc.format == \"markdown\""), &blocks),
1384            frag("(d.format IS ?)", &[text("markdown")])
1385        );
1386        assert_eq!(
1387            translate_predicate(&pred("doc.$id == \"d_1\""), &blocks),
1388            None
1389        );
1390        assert_eq!(translate_predicate(&pred("doc.a.b == 1"), &blocks), None);
1391        let nodes = TranslateCtx {
1392            target: Target::Nodes,
1393            self_alias: "n",
1394            ..DOCS
1395        };
1396        assert_eq!(
1397            translate_predicate(&pred("block.type == \"task\""), &nodes),
1398            frag(
1399                "((SELECT bb.type FROM blocks bb WHERE bb.block_id = n.block_id) IS ?)",
1400                &[text("task")]
1401            )
1402        );
1403        assert_eq!(
1404            translate_predicate(&pred("block.type == \"task\""), &blocks),
1405            None
1406        );
1407        assert_eq!(
1408            translate_predicate(&pred("section.level == 1"), &nodes),
1409            None
1410        );
1411    }
1412
1413    #[test]
1414    fn edges_push_their_five_fields_and_intrinsics() {
1415        let edges = TranslateCtx {
1416            target: Target::Edges,
1417            self_alias: "e",
1418            ..DOCS
1419        };
1420        assert_eq!(
1421            translate_predicate(&pred("predicate == \"references\""), &edges),
1422            frag("(e.predicate IS ?)", &[text("references")])
1423        );
1424        assert_eq!(
1425            translate_predicate(&pred("$dst_path == \"index.md\""), &edges),
1426            frag(
1427                "((SELECT dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
1428                &[text("index.md")]
1429            )
1430        );
1431        assert_eq!(translate_predicate(&pred("weight == 1"), &edges), None);
1432    }
1433
1434    #[test]
1435    fn unsafe_identifier_segments_are_never_inlined() {
1436        assert!(is_seg("layer") && is_seg("_x9"));
1437        assert!(!is_seg("") && !is_seg("9a") && !is_seg("a-b") && !is_seg("a'b"));
1438        assert_eq!(json_extract("n.attrs", &["ok", "no-pe"]), None);
1439        assert_eq!(prop_scalar("d", "x'y"), None);
1440    }
1441}