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 two reads, or an integer intrinsic against
36//!   a read, cannot be compared faithfully; `null` against a property read
37//!   cannot either (a list-valued or nested key has no scalar row, so SQL
38//!   reads `NULL` where the in-memory value is an array or an object). See
39//!   the matrix at [`comparable`]. The 1.2 patch turned the bool/num literal
40//!   (or binding) against a JSON or property read cells into **typed
41//!   pushes** ([`typed_compare`]): the stored type is tested in SQL before
42//!   the value (`json_type(x) = 'true'`, `json_type(x) IN ('integer',
43//!   'real') AND json_extract(x) <op> ?`, `p.type = 'bool' AND p.val_bool =
44//!   ?`, `p.type = 'number' AND p.val_num <op> ?`), the whole wrapped `(…)
45//!   IS 1` (`IS NOT 1` for `!=`) so an absent or differently typed value
46//!   compares as in memory — unequal, never ordered.
47//! * **Handles are not properties** ([`non_property_handles`]): a bare
48//!   identifier or `doc.<k>` head that names a relation, reach-through
49//!   handle, source handle or bag (`nodes`, `doc`, `frontmatter`, `attrs`, …)
50//!   resolves to rows or an object in memory, never to a property row or a
51//!   JSON attribute, so it is declined rather than read as a key.
52
53use oqx::Value;
54use oqx::ast::{BinaryOp, Expr, LogicalOp};
55use rusqlite::types::Value as SqlValue;
56
57use crate::context::{Target, to_sql};
58use crate::paths::storage_path;
59
60/// The SQL aliases the planner assigned to the current scope's row (`self`)
61/// and its owning document (`doc`); on the `docs` target both are the same
62/// alias. `params` are the query bindings, for `${…}` interpolations.
63#[derive(Clone, Copy, Debug)]
64pub struct TranslateCtx<'a> {
65    pub target: Target,
66    pub self_alias: &'a str,
67    pub doc_alias: &'a str,
68    pub params: &'a [Value],
69}
70
71/// A SQL fragment plus its positional bind params, in statement order.
72#[derive(Clone, Debug, PartialEq)]
73pub struct Frag {
74    pub sql: String,
75    pub params: Vec<SqlValue>,
76}
77
78impl Frag {
79    fn bare(sql: impl Into<String>) -> Self {
80        Self {
81            sql: sql.into(),
82            params: Vec::new(),
83        }
84    }
85}
86
87// ---- operand typing ----------------------------------------------------------------
88
89/// What a translated operand carries in SQL, as far as the translator can
90/// tell statically. The comparison gate ([`comparable`]) declines the pairs
91/// SQLite would compare with less type than the in-memory engine has.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum Ty {
94    /// A string literal or binding, a text column, a text intrinsic,
95    /// `lower()` / `upper()` output.
96    Text,
97    /// An integer intrinsic (`$ordinal`, `$depth`).
98    Int,
99    /// A number literal or binding.
100    Num,
101    /// A boolean literal or binding.
102    Bool,
103    /// A `json_extract` read (an `attrs` path or a bare attribute name):
104    /// JSON `true` and `1` both surface as `1`, so a bool or num against it
105    /// tests `json_type` first ([`typed_compare`]).
106    Json,
107    /// A document property scalar (bare key on docs, `doc.<k>`): `val_bool`,
108    /// `val_num` and `val_text` coalesce into one column, and a list-valued or
109    /// nested key has no scalar row (`NULL`); a bool or num against it tests
110    /// `p.type` first ([`typed_compare`]).
111    Prop,
112    /// `null` (or an absent binding).
113    Null,
114}
115
116/// The comparison gate (`spec/surface` §1), stated positively: a pair pushes
117/// only when it is provably compared the same way in SQLite and in memory.
118///
119/// **Equality** (`==`, `!=`) pushes iff one operand is `Text` (SQLite's typed
120/// comparison and strict equality agree that a string equals nothing but an
121/// equal string), or one is `Null` and the other is not `Prop` (a list-valued
122/// or nested key has no scalar row, so SQL reads `NULL` where memory has an
123/// array or an object), or both are numeric (`Int` / `Num`), or — the 1.2
124/// **typed** cells — one is a `Bool` / `Num` constant and the other a `Json` /
125/// `Prop` read, pushed with the stored type tested first ([`typed_compare`]):
126///
127/// ```text
128///          Text   Int    Num    Bool   Null   Json   Prop
129///   Text   push   push   push   push   push   push   push
130///   Int    push   push   push   decl   push   decl   decl
131///   Num    push   push   push   decl   push   typed  typed
132///   Bool   push   decl   decl   decl   push   typed  typed
133///   Null   push   push   push   push   push   push   decl
134///   Json   push   decl   typed  typed  push   decl   decl
135///   Prop   push   decl   typed  typed  decl   decl   decl
136/// ```
137///
138/// **Relational** (`<`, `<=`, `>`, `>=`) pushes iff both operands are `Text`,
139/// both are numeric, or one is a `Num` constant against a `Json` / `Prop` read
140/// (typed); every other cell declines (SQLite orders every integer before
141/// every text, `NULL` compares to nothing, booleans are not ordered):
142///
143/// ```text
144///          Text   Int    Num    Bool   Null   Json   Prop
145///   Text   push   decl   decl   decl   decl   decl   decl
146///   Int    decl   push   push   decl   decl   decl   decl
147///   Num    decl   push   push   decl   decl   typed  typed
148///   Bool   decl   decl   decl   decl   decl   decl   decl
149///   Null   decl   decl   decl   decl   decl   decl   decl
150///   Json   decl   decl   typed  decl   decl   decl   decl
151///   Prop   decl   decl   typed  decl   decl   decl   decl
152/// ```
153///
154/// Why the declines: SQLite sees JSON `true` and `1`, `val_bool` and
155/// `val_num` alike (`$ordinal == checked`, `true == 1` binds as `1 IS 1`),
156/// orders every integer before every text (`$ordinal < "3"`, `level <
157/// "x"`), and two JSON or property reads carry no type at plan time.
158#[must_use]
159pub fn comparable(op: BinaryOp, a: Ty, b: Ty) -> bool {
160    use Ty::{Bool, Int, Json, Null, Num, Prop, Text};
161    let numeric = |t: Ty| matches!(t, Int | Num);
162    let read = |t: Ty| matches!(t, Json | Prop);
163    let both_numeric = numeric(a) && numeric(b);
164    // The typed cells: a constant of the given kinds against a stored read.
165    let typed = |konst: fn(Ty) -> bool| (read(a) && konst(b)) || (read(b) && konst(a));
166    if matches!(op, BinaryOp::Eq | BinaryOp::Ne) {
167        a == Text
168            || b == Text
169            || (a == Null && b != Prop)
170            || (b == Null && a != Prop)
171            || both_numeric
172            || typed(|t| matches!(t, Bool | Num))
173    } else {
174        (a == Text && b == Text) || both_numeric || typed(|t| t == Num)
175    }
176}
177
178/// The [`Ty`] of a literal or bound value; `None` for a non-scalar (an array,
179/// an object, a range), which has no faithful SQL binding.
180fn const_ty(v: &Value) -> Option<Ty> {
181    Some(match v {
182        Value::Str(_) => Ty::Text,
183        Value::Number(_) => Ty::Num,
184        Value::Bool(_) => Ty::Bool,
185        Value::Null | Value::Undefined => Ty::Null,
186        Value::Array(_) | Value::Object(_) | Value::Range(_) => return None,
187    })
188}
189
190// ---- handles -----------------------------------------------------------------------
191
192/// The bare names that resolve to something other than a property or
193/// attribute on each target — the self alias, the reach-through handles, the
194/// relations and the bags (`spec/surface` §1.2) — exactly the keys
195/// [`crate::StoreContext`]'s `get` answers before its property fallback. A
196/// comparison against one of these is declined rather than read as a key
197/// (`nodes == null` is not `NULL IS NULL`). A unit test proves the sets
198/// match the context.
199#[must_use]
200pub fn non_property_handles(t: Target) -> &'static [&'static str] {
201    match t {
202        Target::Docs => &[
203            "doc",
204            "blocks",
205            "nodes",
206            "out",
207            "in",
208            "out_edges",
209            "in_edges",
210            "frontmatter",
211            "inline",
212        ],
213        Target::Blocks => &[
214            "block",
215            "doc",
216            "children",
217            "nodes",
218            "out_edges",
219            "section",
220            "attrs",
221        ],
222        Target::Nodes => &[
223            "section",
224            "doc",
225            "block",
226            "blocks",
227            "subsections",
228            "children",
229            "attrs",
230        ],
231        Target::Edges => &["doc"],
232    }
233}
234
235// ---- intrinsics ------------------------------------------------------------------
236
237/// A `$`-namespaced intrinsic → a param-free SQL scalar and its type, per
238/// target. Anything not mapped (docs `$body`, reconstructed; `$title` /
239/// `$tags`, computed; blocks `$updated_at`; nodes `$locator`) returns `None`
240/// → residual. `$updated_at` / `$dst_path` / `$dst_uri` are correlated
241/// subqueries. Only `$ordinal` / `$depth` are integers; every other mapped
242/// intrinsic is text. `$path` is rendered by [`Operand::path_col`] (the
243/// rooted read, `spec/surface` §1 "Paths"); `$dst_path` roots its subquery.
244fn intrinsic_sql(name: &str, ctx: &TranslateCtx<'_>) -> Option<(String, Ty)> {
245    let (s, d) = (ctx.self_alias, ctx.doc_alias);
246    let sql = match (ctx.target, name) {
247        (Target::Docs, "$id") => format!("{s}.doc_id"),
248        (Target::Docs, "$path") => format!("('/' || {d}.path)"),
249        (Target::Docs, "$content_hash") => format!("lower(hex({s}.file_hash))"),
250        (Target::Docs, "$updated_at") => format!(
251            "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = {s}.current_rev)"
252        ),
253        (Target::Blocks, "$id") => format!("{s}.block_id"),
254        (Target::Blocks, "$doc") => format!("{s}.doc_id"),
255        (Target::Blocks, "$path") => format!("('/' || {d}.path)"),
256        (Target::Blocks, "$ordinal") => return Some((format!("{s}.ordinal"), Ty::Int)),
257        (Target::Blocks, "$depth") => return Some((format!("{s}.depth"), Ty::Int)),
258        (Target::Blocks, "$body") => format!("{s}.text"),
259        (Target::Blocks, "$content_hash") => format!("lower(hex({s}.raw_hash))"),
260        (Target::Nodes, "$id" | "$node_id") => format!("{s}.node_id"),
261        (Target::Nodes, "$doc_id") => format!("{s}.doc_id"),
262        (Target::Nodes, "$block_id") => format!("{s}.block_id"),
263        (Target::Nodes, "$path") => format!("('/' || {d}.path)"),
264        (Target::Edges, "$id") => format!("{s}.edge_id"),
265        (Target::Edges, "$src") => format!("{s}.src_doc"),
266        (Target::Edges, "$dst") => format!("{s}.dst_node"),
267        (Target::Edges, "$src_block") => format!("{s}.src_block"),
268        (Target::Edges, "$via") => format!("{s}.via_node"),
269        (Target::Edges, "$from_commit") => format!("{s}.from_commit"),
270        (Target::Edges, "$path") => format!("('/' || {d}.path)"),
271        (Target::Edges, "$dst_path") => {
272            format!("(SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = {s}.dst_node)")
273        }
274        (Target::Edges, "$dst_uri") => {
275            format!("(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = {s}.dst_node)")
276        }
277        _ => return None,
278    };
279    Some((sql, Ty::Text))
280}
281
282/// docs intrinsics whose BARE (non-`$`) form is a loud error in-memory — not
283/// pushable, so the residual raises the guard (and the planner declines the
284/// whole query when such a read is left residual, see [`crate::planner`]).
285pub(crate) const RESERVED_DOC_BASENAMES: [&str; 5] =
286    ["id", "path", "updated_at", "content_hash", "body"];
287
288/// An injection-safe inlined identifier: `^[A-Za-z_][A-Za-z0-9_]*$`.
289fn is_seg(s: &str) -> bool {
290    let mut chars = s.chars();
291    chars
292        .next()
293        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
294        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
295}
296
297/// The single-scalar-row property subquery (the scalar-in-scope rule):
298/// `select` evaluated over the property row `p` only when the key has exactly
299/// one row in scope and it is `card = 'scalar'`, else NULL — matching the
300/// context's `doc_prop` for a scalar read. [`prop_scalar`] selects the value;
301/// the typed pushes select a type test ([`typed_compare`]).
302fn prop_row(doc_alias: &str, key: &str, select: &str) -> Option<String> {
303    if !is_seg(key) {
304        return None;
305    }
306    Some(format!(
307        "(SELECT {select} FROM properties p \
308         WHERE p.doc_id = {doc_alias}.doc_id AND p.key = '{key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL \
309         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 \
310         LIMIT 1)"
311    ))
312}
313
314/// A single-valued document property's scalar value (`val_text`, `val_num`
315/// and `val_bool` coalesced), or NULL.
316fn prop_scalar(doc_alias: &str, key: &str) -> Option<String> {
317    prop_row(
318        doc_alias,
319        key,
320        "COALESCE(p.val_text, p.val_num, p.val_bool)",
321    )
322}
323
324/// The JSON path `$.a.b` from validated segments; `None` if any segment is
325/// unsafe.
326fn json_path(segs: &[&str]) -> Option<String> {
327    if segs.iter().any(|s| !is_seg(s)) {
328        return None;
329    }
330    Some(format!("$.{}", segs.join(".")))
331}
332
333/// What an operand IS, beyond the SQL it renders to — the typed pushes
334/// ([`typed_compare`]) rebuild a stored read as a type test and inline a
335/// constant's value, which a finished [`Frag`] no longer exposes.
336#[derive(Clone, Debug, PartialEq)]
337enum Shape {
338    /// A plain SQL scalar: a column, an intrinsic, `lower()` / `upper()`.
339    Plain,
340    /// The rooted path read over a storage path column (`'/' || d.path`,
341    /// `spec/surface` §1 "Paths"): the bare, indexed column is kept for the
342    /// equality fast path ([`path_equality`]).
343    PathCol(String),
344    /// A literal or binding, bound as `?`.
345    Const(Value),
346    /// `json_extract(col, 'path')`.
347    Json { col: String, path: String },
348    /// A document property read: the owning document's alias and the key.
349    Prop { doc_alias: String, key: String },
350}
351
352/// A translated value-position operand: its fragment, its [`Ty`] for the
353/// gate, and its [`Shape`] for the typed pushes.
354#[derive(Clone, Debug, PartialEq)]
355struct Operand {
356    frag: Frag,
357    ty: Ty,
358    shape: Shape,
359}
360
361impl Operand {
362    fn plain(sql: String, ty: Ty) -> Self {
363        Self {
364            frag: Frag::bare(sql),
365            ty,
366            shape: Shape::Plain,
367        }
368    }
369
370    fn text(sql: String) -> Option<Self> {
371        Some(Self::plain(sql, Ty::Text))
372    }
373
374    /// `$path` / `doc.$path`: the reference form over the storage column, so
375    /// every comparison, `startsWith`, `contains`, `lower()`… sees exactly the
376    /// string the in-memory intrinsic yields.
377    fn path_col(col: String) -> Option<Self> {
378        Some(Self {
379            frag: Frag::bare(format!("('/' || {col})")),
380            ty: Ty::Text,
381            shape: Shape::PathCol(col),
382        })
383    }
384
385    /// `None` for a non-scalar (an array, an object, a range), which has no
386    /// faithful SQL binding.
387    fn constant(v: &Value) -> Option<Self> {
388        Some(Self {
389            frag: Frag {
390                sql: "?".to_owned(),
391                params: vec![to_sql(v)],
392            },
393            ty: const_ty(v)?,
394            shape: Shape::Const(v.clone()),
395        })
396    }
397
398    fn json(col: String, segs: &[&str]) -> Option<Self> {
399        let path = json_path(segs)?;
400        Some(Self {
401            frag: Frag::bare(format!("json_extract({col}, '{path}')")),
402            ty: Ty::Json,
403            shape: Shape::Json { col, path },
404        })
405    }
406
407    fn prop(doc_alias: &str, key: &str) -> Option<Self> {
408        Some(Self {
409            frag: Frag::bare(prop_scalar(doc_alias, key)?),
410            ty: Ty::Prop,
411            shape: Shape::Prop {
412                doc_alias: doc_alias.to_owned(),
413                key: key.to_owned(),
414            },
415        })
416    }
417}
418
419/// The dotted `attrs.a.b` / `doc.x` receiver chain as segments, or `None` if
420/// it is not a plain identifier navigation.
421fn member_segments(e: &Expr) -> Option<Vec<&str>> {
422    match e {
423        Expr::Ident { name, .. } => Some(vec![name.as_str()]),
424        Expr::Member { recv, name, .. } => {
425            let mut base = member_segments(recv)?;
426            base.push(name.as_str());
427            Some(base)
428        }
429        _ => None,
430    }
431}
432
433// ---- value position ----------------------------------------------------------------
434
435/// Translate an expression used as a VALUE (comparison operand, method
436/// receiver, function argument) to a SQL scalar. `None` if not faithfully
437/// translatable.
438pub fn translate_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
439    typed_value(e, ctx).map(|(frag, _)| frag)
440}
441
442/// [`translate_value`] plus the operand's [`Ty`], for the comparison gate.
443pub fn typed_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<(Frag, Ty)> {
444    operand(e, ctx).map(|o| (o.frag, o.ty))
445}
446
447/// The full [`Operand`] of a value-position expression.
448fn operand(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Operand> {
449    let (s, d, target) = (ctx.self_alias, ctx.doc_alias, ctx.target);
450    match e {
451        Expr::Lit { value: v, .. } => Operand::constant(v),
452        Expr::Binding { index, .. } => {
453            Operand::constant(ctx.params.get(*index).unwrap_or(&Value::Undefined))
454        }
455        Expr::Ident { name, .. } => {
456            if name == "$path" {
457                return Operand::path_col(format!("{d}.path"));
458            }
459            if name.starts_with('$') {
460                return intrinsic_sql(name, ctx).map(|(sql, ty)| Operand::plain(sql, ty));
461            }
462            let name = name.as_str();
463            // A relation, reach-through handle, source handle or bag is not a
464            // property read (§1): rows or an object in memory, never a key. A
465            // bare target name is a relation or a loud error (the root-row
466            // rule, `crate::query`), never a property.
467            if non_property_handles(target).contains(&name) || Target::parse(name).is_some() {
468                return None;
469            }
470            match target {
471                Target::Docs => {
472                    // `format` is a column, not a property.
473                    if name == "format" {
474                        return Operand::text(format!("{s}.format"));
475                    }
476                    // A reserved basename stays residual so the guard fires.
477                    if RESERVED_DOC_BASENAMES.contains(&name) {
478                        return None;
479                    }
480                    Operand::prop(d, name)
481                }
482                Target::Blocks => {
483                    if name == "type" || name == "text" {
484                        return Operand::text(format!("{s}.{name}"));
485                    }
486                    // A bare non-structural identifier flattens into attrs —
487                    // the same pushdown as the `attrs.<k>` member form.
488                    Operand::json(format!("{s}.attrs"), &[name])
489                }
490                Target::Nodes => {
491                    if matches!(name, "kind" | "name" | "value") {
492                        return Operand::text(format!("{s}.{name}"));
493                    }
494                    Operand::json(format!("{s}.attrs"), &[name])
495                }
496                Target::Edges => {
497                    if matches!(
498                        name,
499                        "predicate" | "provenance" | "dst_kind" | "anchor" | "src_field"
500                    ) {
501                        return Operand::text(format!("{s}.{name}"));
502                    }
503                    None
504                }
505            }
506        }
507        Expr::Member { .. } => {
508            let segs = member_segments(e)?;
509            let (head, rest) = segs.split_first()?;
510            if rest.is_empty() {
511                return None;
512            }
513            // attrs.<path> → json_extract on the row's attrs (blocks/nodes).
514            if *head == "attrs" && matches!(target, Target::Blocks | Target::Nodes) {
515                return Operand::json(format!("{s}.attrs"), rest);
516            }
517            // doc.<x> reach-through — the owning doc (alias `doc`). On the docs
518            // target `doc` is the row itself; either way it resolves against `d`.
519            if *head == "doc" {
520                if rest.len() != 1 {
521                    return None;
522                }
523                let k = rest[0];
524                if k == "$path" {
525                    return Operand::path_col(format!("{d}.path"));
526                }
527                if k == "format" {
528                    return Operand::text(format!("{d}.format"));
529                }
530                // `doc.nodes`, `doc.frontmatter`, `doc.doc`… are the doc's
531                // handles, not its properties; `doc.docs` / `doc.edges` raise.
532                if k.starts_with('$')
533                    || RESERVED_DOC_BASENAMES.contains(&k)
534                    || non_property_handles(Target::Docs).contains(&k)
535                    || Target::parse(k).is_some()
536                {
537                    return None;
538                }
539                return Operand::prop(d, k);
540            }
541            // block.type / block.text reach-through from a node.
542            if *head == "block"
543                && target == Target::Nodes
544                && rest.len() == 1
545                && matches!(rest[0], "type" | "text")
546            {
547                return Operand::text(format!(
548                    "(SELECT bb.{} FROM blocks bb WHERE bb.block_id = {s}.block_id)",
549                    rest[0]
550                ));
551            }
552            None
553        }
554        // `.lower()` / `.upper()` are the value-position string methods.
555        Expr::Call {
556            recv: Some(recv),
557            name,
558            args,
559            ..
560        } if args.is_empty() && (name == "lower" || name == "upper") => {
561            let recv = translate_value(recv, ctx)?;
562            Some(Operand {
563                frag: Frag {
564                    sql: format!("{name}({})", recv.sql),
565                    params: recv.params,
566                },
567                ty: Ty::Text,
568                shape: Shape::Plain,
569            })
570        }
571        _ => None,
572    }
573}
574
575// ---- predicate position --------------------------------------------------------------
576
577/// `==` / `!=` → null-safe `IS` / `IS NOT`; the relational ops as plain SQL.
578fn is_op(op: BinaryOp) -> Option<&'static str> {
579    Some(match op {
580        BinaryOp::Eq => "IS",
581        BinaryOp::Ne => "IS NOT",
582        BinaryOp::Lt => "<",
583        BinaryOp::Le => "<=",
584        BinaryOp::Gt => ">",
585        BinaryOp::Ge => ">=",
586        // Identity (`is` / `is not`, SEMANTICS §5b) compares the engine's
587        // structural identities — not provably SQL's `IS` over typed cells —
588        // so it stays residual, like arithmetic in predicate position.
589        BinaryOp::Is
590        | BinaryOp::IsNot
591        | BinaryOp::Add
592        | BinaryOp::Sub
593        | BinaryOp::Mul
594        | BinaryOp::Div
595        | BinaryOp::Mod => {
596            return None;
597        }
598    })
599}
600
601/// The typed pushes (`spec/surface` §1, 1.2 patch): a bool or num constant
602/// against a JSON or property read, with the stored type tested in SQL
603/// before the value so SQLite cannot conflate JSON `true` with `1` or
604/// `val_bool` with `val_num`. `None` when the pair is not a typed cell (the
605/// plain `IS` / relational form applies) — the gate ([`comparable`]) has
606/// already declined the cells neither form can push.
607///
608/// * json × bool (`==`/`!=`): `(json_type(x) = 'true' | 'false') IS 1`;
609/// * json × num (all six): `(json_type(x) IN ('integer', 'real') AND
610///   json_extract(x) <op> ?) IS 1`;
611/// * prop × bool (`==`/`!=`): the single-scalar-row subquery selecting
612///   `p.type = 'bool' AND p.val_bool = ?` (`spec/properties` §2.1 type
613///   names; booleans bind as 1/0), `(…) IS 1`;
614/// * prop × num (all six): the same subquery selecting `p.type = 'number'
615///   AND p.val_num <op> ?`, `(…) IS 1`.
616///
617/// `!=` wraps `IS NOT 1` around the equality test. `json_type` is NULL for
618/// an absent path and the subquery is NULL for an absent, list-valued or
619/// nested key, so `IS 1` is false and `IS NOT 1` true — the in-memory
620/// absence semantics (unequal, never ordered). The test is normalized to
621/// `read <op> ?`, a relational op flipping when the constant is on the left
622/// (`800 < era` ⇔ `era > 800`), as the reference does.
623fn typed_compare(op: BinaryOp, sql_op: &str, l: &Operand, r: &Operand) -> Option<Frag> {
624    let (read, konst, read_left) = match (&l.shape, &r.shape) {
625        (Shape::Json { .. } | Shape::Prop { .. }, Shape::Const(v)) => (&l.shape, v, true),
626        (Shape::Const(v), Shape::Json { .. } | Shape::Prop { .. }) => (&r.shape, v, false),
627        _ => return None,
628    };
629    let equality = matches!(op, BinaryOp::Eq | BinaryOp::Ne);
630    let wrap = if op == BinaryOp::Ne {
631        "IS NOT 1"
632    } else {
633        "IS 1"
634    };
635    // Normalized to `read <op> ?`: a relational op flips when the constant is
636    // on the left (`800 < era` ⇔ `era > 800`), as in the reference.
637    let inner_op = match (equality, read_left, sql_op) {
638        (true, _, _) => "=",
639        (false, true, _) => sql_op,
640        (false, false, "<") => ">",
641        (false, false, "<=") => ">=",
642        (false, false, ">") => "<",
643        (false, false, ">=") => "<=",
644        (false, false, _) => return None,
645    };
646    let sides = |read_sql: &str| format!("{read_sql} {inner_op} ?");
647    let (sql, params) = match (read, konst) {
648        // Booleans are only ever equal; the gate declines them relational.
649        (_, Value::Bool(_)) if !equality => return None,
650        (Shape::Json { col, path }, Value::Bool(b)) => (
651            format!("(json_type({col}, '{path}') = '{b}') {wrap}"),
652            Vec::new(),
653        ),
654        (Shape::Json { col, path }, Value::Number(_)) => (
655            format!(
656                "(json_type({col}, '{path}') IN ('integer', 'real') AND {}) {wrap}",
657                sides(&format!("json_extract({col}, '{path}')"))
658            ),
659            vec![to_sql(konst)],
660        ),
661        (Shape::Prop { doc_alias, key }, Value::Bool(_)) => (
662            format!(
663                "{} {wrap}",
664                prop_row(
665                    doc_alias,
666                    key,
667                    &format!("p.type = 'bool' AND {}", sides("p.val_bool"))
668                )?
669            ),
670            vec![to_sql(konst)],
671        ),
672        (Shape::Prop { doc_alias, key }, Value::Number(_)) => (
673            format!(
674                "{} {wrap}",
675                prop_row(
676                    doc_alias,
677                    key,
678                    &format!("p.type = 'number' AND {}", sides("p.val_num"))
679                )?
680            ),
681            vec![to_sql(konst)],
682        ),
683        _ => return None,
684    };
685    Some(Frag {
686        sql: format!("({sql})"),
687        params,
688    })
689}
690
691/// The path equality fast path (`spec/surface` §1 "Paths"): `$path == "/a.md"`
692/// (or `!=`, either side) against a ROOTED text constant is `d.path IS ?` with
693/// the constant's storage form, so the `(repo_id, path)` index serves it;
694/// `'/' || d.path IS ?` — what the general form emits — is the same predicate
695/// without the index. Only a rooted constant qualifies (the runner roots every
696/// literal compared with a path read, so that is every literal); a bare
697/// binding stays on the general form, where `'/' || d.path` can never equal
698/// it — exactly the in-memory answer.
699fn path_equality(op: BinaryOp, l: &Operand, r: &Operand) -> Option<Frag> {
700    if !matches!(op, BinaryOp::Eq | BinaryOp::Ne) {
701        return None;
702    }
703    let (col, konst) = match (&l.shape, &r.shape) {
704        (Shape::PathCol(c), Shape::Const(Value::Str(s))) if s.starts_with('/') => (c, s),
705        (Shape::Const(Value::Str(s)), Shape::PathCol(c)) if s.starts_with('/') => (c, s),
706        _ => return None,
707    };
708    Some(Frag {
709        sql: format!("({col} {} ?)", is_op(op)?),
710        params: vec![SqlValue::Text(storage_path(konst).to_owned())],
711    })
712}
713
714/// Translate an expression used as a boolean PREDICATE to a SQL boolean, or
715/// `None` if it cannot be pushed faithfully. Only positive, AND-safe forms
716/// are handled: `unary` (`!`), `in`, bare truthy idents and member
717/// reach-through in predicate position stay residual.
718pub fn translate_predicate(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
719    match e {
720        // Only `&&` composes faithfully in a positive context; `||` is
721        // declined (its NULL / short-circuit interaction stays residual).
722        Expr::Logical {
723            op: LogicalOp::And,
724            left,
725            right,
726            ..
727        } => join2(
728            translate_predicate(left, ctx),
729            translate_predicate(right, ctx),
730            "AND",
731        ),
732        Expr::Binary {
733            op, left, right, ..
734        } => {
735            // An arithmetic operator in predicate position → residual.
736            let sql_op = is_op(*op)?;
737            let l = operand(left, ctx)?;
738            let r = operand(right, ctx)?;
739            if let Some(fast) = path_equality(*op, &l, &r) {
740                return Some(fast);
741            }
742            // The operand-typing gate (§1): the pairs SQLite would compare
743            // with less type than the engine has stay residual.
744            if !comparable(*op, l.ty, r.ty) {
745                return None;
746            }
747            // A bool/num constant against a JSON or property read pushes with
748            // the stored type tested first (the 1.2 typed cells).
749            if let Some(typed) = typed_compare(*op, sql_op, &l, &r) {
750                return Some(typed);
751            }
752            let op = sql_op;
753            // `==`/`!=` → IS / IS NOT (absence-normalized equality, faithful in
754            // any context). Relational ops → plain SQL: a NULL operand yields
755            // NULL, which is excluded in the positive AND context these
756            // fragments are pushed into, matching the absent-operand ⇒ false rule.
757            let mut params = l.frag.params;
758            params.extend(r.frag.params);
759            Some(Frag {
760                sql: format!("({} {op} {})", l.frag.sql, r.frag.sql),
761                params,
762            })
763        }
764        Expr::Call {
765            recv: Some(recv),
766            name,
767            args,
768            ..
769        } if args.len() == 1 => {
770            // startsWith / contains / endsWith — CASE-SENSITIVE, via
771            // substr/instr (never LIKE). `matches` (regex) is declined.
772            let recv = translate_value(recv, ctx)?;
773            let arg = translate_value(&args[0], ctx)?;
774            let mut params = recv.params;
775            let sql = match name.as_str() {
776                "startsWith" => {
777                    // recv begins with arg ⇔ its first length(arg) chars equal arg.
778                    params.extend(arg.params.iter().cloned());
779                    params.extend(arg.params);
780                    format!("(substr({}, 1, length({a})) = {a})", recv.sql, a = arg.sql)
781                }
782                "endsWith" => {
783                    // recv ends with arg ⇔ its last length(arg) chars equal arg.
784                    // When arg is longer than recv, substr clamps to the whole
785                    // (shorter) string, so the equality is false.
786                    params.extend(arg.params.iter().cloned());
787                    params.extend(arg.params);
788                    format!("(substr({}, -length({a})) = {a})", recv.sql, a = arg.sql)
789                }
790                "contains" => {
791                    params.extend(arg.params);
792                    format!("(instr({}, {}) > 0)", recv.sql, arg.sql)
793                }
794                _ => return None,
795            };
796            Some(Frag { sql, params })
797        }
798        _ => None,
799    }
800}
801
802/// Combine two optional fragments with a boolean connective; `None` if either
803/// is untranslatable (the whole conjunct then stays residual).
804fn join2(a: Option<Frag>, b: Option<Frag>, connective: &str) -> Option<Frag> {
805    let (a, b) = (a?, b?);
806    let mut params = a.params;
807    params.extend(b.params);
808    Some(Frag {
809        sql: format!("({} {connective} {})", a.sql, b.sql),
810        params,
811    })
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use oqx::ast::Where;
818
819    const DOCS: TranslateCtx<'static> = TranslateCtx {
820        target: Target::Docs,
821        self_alias: "d",
822        doc_alias: "d",
823        params: &[],
824    };
825
826    fn text(s: &str) -> SqlValue {
827        SqlValue::Text(s.to_owned())
828    }
829
830    fn frag(sql: &str, params: &[SqlValue]) -> Option<Frag> {
831        Some(Frag {
832            sql: sql.to_owned(),
833            params: params.to_vec(),
834        })
835    }
836
837    /// Parse `from docs where <src>` and return the single scalar predicate.
838    fn pred(src: &str) -> Expr {
839        let q = oqx::parse_string(&format!("from docs where {src}")).expect("parses");
840        match q.r#where {
841            Some(Where::Scalar { expr, .. }) => expr,
842            other => panic!("expected a single scalar predicate, got {other:?}"),
843        }
844    }
845
846    fn ident(name: &str) -> Box<Expr> {
847        Box::new(Expr::Ident {
848            name: name.to_owned(),
849            span: oqx::Span::EMPTY,
850        })
851    }
852
853    fn lit(s: &str) -> Box<Expr> {
854        Box::new(Expr::Lit {
855            value: Value::from(s),
856            span: oqx::Span::EMPTY,
857        })
858    }
859
860    fn eq(l: Box<Expr>, r: Box<Expr>) -> Box<Expr> {
861        Box::new(Expr::Binary {
862            op: BinaryOp::Eq,
863            left: l,
864            right: r,
865            span: oqx::Span::EMPTY,
866        })
867    }
868
869    // -- equality is absence-normalized (IS / IS NOT) --
870
871    #[test]
872    fn equality_is_null_safe_is() {
873        assert_eq!(
874            translate_predicate(&pred("$path == \"index.md\""), &DOCS),
875            frag("(('/' || d.path) IS ?)", &[text("index.md")])
876        );
877    }
878
879    // `spec/surface` §1 "Paths" (2.0): `$path` is the reference form,
880    // `'/' || d.path` in SQL; a ROOTED text constant under `==`/`!=` takes the
881    // indexed fast path on the bare column with its storage form bound, a bare
882    // one (never equal to a rooted path) stays on the general form.
883    #[test]
884    fn a_rooted_path_literal_is_the_indexed_fast_path() {
885        assert_eq!(
886            translate_predicate(&pred("$path == \"/index.md\""), &DOCS),
887            frag("(d.path IS ?)", &[text("index.md")])
888        );
889        assert_eq!(
890            translate_predicate(&pred("\"/x.md\" != $path"), &DOCS),
891            frag("(d.path IS NOT ?)", &[text("x.md")])
892        );
893        assert_eq!(
894            translate_predicate(&pred("$path < \"/m\""), &DOCS),
895            frag("(('/' || d.path) < ?)", &[text("/m")])
896        );
897        assert_eq!(
898            translate_predicate(&pred("$path == \"index.md\""), &DOCS),
899            frag("(('/' || d.path) IS ?)", &[text("index.md")])
900        );
901    }
902
903    #[test]
904    fn inequality_is_null_safe_is_not() {
905        assert_eq!(
906            translate_predicate(&pred("$path != \"x\""), &DOCS),
907            frag("(('/' || d.path) IS NOT ?)", &[text("x")])
908        );
909    }
910
911    #[test]
912    fn intrinsic_column_mapping() {
913        assert_eq!(
914            translate_predicate(&pred("$id == \"d_1\""), &DOCS),
915            frag("(d.doc_id IS ?)", &[text("d_1")])
916        );
917    }
918
919    // -- relational ops (plain SQL) --
920
921    #[test]
922    fn relational_ops_are_plain_comparisons() {
923        assert_eq!(
924            translate_predicate(&pred("$path < \"m\""), &DOCS),
925            frag("(('/' || d.path) < ?)", &[text("m")])
926        );
927        assert_eq!(
928            translate_predicate(&pred("$path >= \"m\""), &DOCS),
929            frag("(('/' || d.path) >= ?)", &[text("m")])
930        );
931        // arithmetic in predicate position → residual
932        assert_eq!(translate_predicate(&pred("$path + 1"), &DOCS), None);
933    }
934
935    // -- string ops are case-sensitive (substr/instr, never LIKE) --
936
937    #[test]
938    fn starts_with_is_substr_equality() {
939        assert_eq!(
940            translate_predicate(&pred("$path.startsWith(\"lab/\")"), &DOCS),
941            frag(
942                "(substr(('/' || d.path), 1, length(?)) = ?)",
943                &[text("lab/"), text("lab/")]
944            )
945        );
946    }
947
948    #[test]
949    fn lower_then_starts_with_pushes_with_explicit_lower() {
950        assert_eq!(
951            translate_predicate(&pred("$path.lower().startsWith(\"lab/\")"), &DOCS),
952            frag(
953                "(substr(lower(('/' || d.path)), 1, length(?)) = ?)",
954                &[text("lab/"), text("lab/")]
955            )
956        );
957    }
958
959    #[test]
960    fn contains_is_instr() {
961        assert_eq!(
962            translate_predicate(&pred("$path.contains(\"notes\")"), &DOCS),
963            frag("(instr(('/' || d.path), ?) > 0)", &[text("notes")])
964        );
965    }
966
967    #[test]
968    fn ends_with_is_negative_substr_equality() {
969        assert_eq!(
970            translate_predicate(&pred("$path.endsWith(\".md\")"), &DOCS),
971            frag(
972                "(substr(('/' || d.path), -length(?)) = ?)",
973                &[text(".md"), text(".md")]
974            )
975        );
976    }
977
978    #[test]
979    fn upper_wraps_the_receiver_in_value_position() {
980        assert_eq!(
981            translate_value(&pred("$path.upper()"), &DOCS),
982            frag("upper(('/' || d.path))", &[])
983        );
984    }
985
986    // -- bare document properties push via the properties table --
987
988    #[test]
989    fn bare_doc_property_is_the_scalar_in_scope_subquery() {
990        let f = translate_predicate(&pred("layer == \"canon\""), &DOCS).expect("pushable");
991        assert!(f.sql.contains("FROM properties p"), "{}", f.sql);
992        assert!(f.sql.contains("p.key = 'layer'"), "{}", f.sql);
993        assert!(f.sql.contains("p.card = 'scalar'"), "{}", f.sql);
994        assert!(
995            f.sql.starts_with('(') && f.sql.contains(" IS ?)"),
996            "{}",
997            f.sql
998        );
999        assert_eq!(f.params, vec![text("canon")]);
1000    }
1001
1002    #[test]
1003    fn updated_at_pushes_as_its_revisions_subquery() {
1004        let f =
1005            translate_predicate(&pred("$updated_at >= \"2026-01-01\""), &DOCS).expect("pushable");
1006        assert!(
1007            f.sql.contains("FROM revisions r JOIN commits c"),
1008            "{}",
1009            f.sql
1010        );
1011    }
1012
1013    #[test]
1014    fn format_is_a_column_not_a_property() {
1015        assert_eq!(
1016            translate_predicate(&pred("format == \"markdown\""), &DOCS),
1017            frag("(d.format IS ?)", &[text("markdown")])
1018        );
1019    }
1020
1021    #[test]
1022    fn booleans_bind_as_one_and_zero() {
1023        let blocks = TranslateCtx {
1024            target: Target::Blocks,
1025            self_alias: "b",
1026            ..DOCS
1027        };
1028        // (against a text column — against a JSON or property read the
1029        // boolean pushes typed, see the typed-shape tests)
1030        assert_eq!(
1031            translate_predicate(&pred("type == true"), &blocks).map(|f| f.params),
1032            Some(vec![SqlValue::Integer(1)])
1033        );
1034        assert_eq!(
1035            translate_predicate(&pred("type == false"), &blocks).map(|f| f.params),
1036            Some(vec![SqlValue::Integer(0)])
1037        );
1038        assert_eq!(
1039            translate_predicate(&pred("$ordinal < 1000"), &blocks).map(|f| f.params),
1040            Some(vec![SqlValue::Real(1000.0)])
1041        );
1042        assert_eq!(
1043            translate_predicate(&pred("$path == null"), &DOCS).map(|f| f.params),
1044            Some(vec![SqlValue::Null])
1045        );
1046    }
1047
1048    // -- decline (a): operand typing (spec/surface §1) --
1049
1050    /// One representative expression per [`Ty`] on the blocks target (the only
1051    /// target with an integer intrinsic; `doc.<k>` is its property read).
1052    const REPRESENTATIVES: [(Ty, &str); 7] = [
1053        (Ty::Text, "$path"),
1054        (Ty::Int, "$ordinal"),
1055        (Ty::Num, "1"),
1056        (Ty::Bool, "true"),
1057        (Ty::Null, "null"),
1058        (Ty::Json, "checked"),
1059        (Ty::Prop, "doc.layer"),
1060    ];
1061
1062    #[test]
1063    fn representatives_carry_their_type() {
1064        let blocks = TranslateCtx {
1065            target: Target::Blocks,
1066            self_alias: "b",
1067            ..DOCS
1068        };
1069        for (ty, src) in REPRESENTATIVES {
1070            let (_, got) = typed_value(&pred(src), &blocks).expect(src);
1071            assert_eq!(got, ty, "{src}");
1072        }
1073        assert_eq!(
1074            typed_value(&pred("attrs.a.b"), &blocks).map(|(_, t)| t),
1075            Some(Ty::Json)
1076        );
1077        assert_eq!(
1078            typed_value(&pred("$depth"), &blocks).map(|(_, t)| t),
1079            Some(Ty::Int)
1080        );
1081        assert_eq!(
1082            typed_value(&pred("type.lower()"), &blocks).map(|(_, t)| t),
1083            Some(Ty::Text)
1084        );
1085        assert_eq!(
1086            typed_value(&pred("checked.upper()"), &blocks).map(|(_, t)| t),
1087            Some(Ty::Text)
1088        );
1089        assert_eq!(
1090            typed_value(&pred("layer"), &DOCS).map(|(_, t)| t),
1091            Some(Ty::Prop)
1092        );
1093        assert_eq!(
1094            typed_value(&pred("format"), &DOCS).map(|(_, t)| t),
1095            Some(Ty::Text)
1096        );
1097    }
1098
1099    #[test]
1100    fn the_comparison_matrix_decides_every_cell() {
1101        // Row/column order: Text Int Num Bool Null Json Prop.
1102        const P: bool = true;
1103        const D: bool = false;
1104        // Typed (1.2): a bool/num constant against a JSON or property read.
1105        const T: bool = true;
1106        // Equality: one side text, or null against a non-property, or both
1107        // numeric, or a typed cell.
1108        #[rustfmt::skip]
1109        const EQUALITY: [[bool; 7]; 7] = [
1110            /* Text */ [P, P, P, P, P, P, P],
1111            /* Int  */ [P, P, P, D, P, D, D],
1112            /* Num  */ [P, P, P, D, P, T, T],
1113            /* Bool */ [P, D, D, D, P, T, T],
1114            /* Null */ [P, P, P, P, P, P, D],
1115            /* Json */ [P, D, T, T, P, D, D],
1116            /* Prop */ [P, D, T, T, D, D, D],
1117        ];
1118        // Relational: both text, both numeric, or a num constant against a
1119        // read (typed); nothing else.
1120        #[rustfmt::skip]
1121        const RELATIONAL: [[bool; 7]; 7] = [
1122            /* Text */ [P, D, D, D, D, D, D],
1123            /* Int  */ [D, P, P, D, D, D, D],
1124            /* Num  */ [D, P, P, D, D, T, T],
1125            /* Bool */ [D, D, D, D, D, D, D],
1126            /* Null */ [D, D, D, D, D, D, D],
1127            /* Json */ [D, D, T, D, D, D, D],
1128            /* Prop */ [D, D, T, D, D, D, D],
1129        ];
1130        let blocks = TranslateCtx {
1131            target: Target::Blocks,
1132            self_alias: "b",
1133            ..DOCS
1134        };
1135        let ops = [
1136            (BinaryOp::Eq, "==", &EQUALITY),
1137            (BinaryOp::Ne, "!=", &EQUALITY),
1138            (BinaryOp::Lt, "<", &RELATIONAL),
1139            (BinaryOp::Le, "<=", &RELATIONAL),
1140            (BinaryOp::Gt, ">", &RELATIONAL),
1141            (BinaryOp::Ge, ">=", &RELATIONAL),
1142        ];
1143        for (i, (a, l)) in REPRESENTATIVES.iter().enumerate() {
1144            for (j, (b, r)) in REPRESENTATIVES.iter().enumerate() {
1145                for (op, spelled, matrix) in ops {
1146                    let want = matrix[i][j];
1147                    assert_eq!(matrix[j][i], want, "the matrix is symmetric ({a:?}, {b:?})");
1148                    assert_eq!(
1149                        comparable(op, *a, *b),
1150                        want,
1151                        "comparable({spelled}, {a:?}, {b:?})"
1152                    );
1153                    let src = format!("{l} {spelled} {r}");
1154                    assert_eq!(
1155                        translate_predicate(&pred(&src), &blocks).is_some(),
1156                        want,
1157                        "{src}"
1158                    );
1159                }
1160            }
1161        }
1162    }
1163
1164    #[test]
1165    fn the_spec_shapes_of_decline_a() {
1166        let blocks = TranslateCtx {
1167            target: Target::Blocks,
1168            self_alias: "b",
1169            ..DOCS
1170        };
1171        // a boolean or number against a JSON read pushes TYPED (1.2): the
1172        // json_type is tested first, so the SQL cannot read JSON `true` as 1
1173        let typed = |src: &str, ctx: &TranslateCtx<'_>| {
1174            let f = translate_predicate(&pred(src), ctx).expect(src);
1175            assert!(
1176                f.sql.contains("json_type(") || f.sql.contains("p.type = "),
1177                "{src}: {}",
1178                f.sql
1179            );
1180            assert!(
1181                f.sql.ends_with(" IS 1)") || f.sql.ends_with(" IS NOT 1)"),
1182                "{src}: {}",
1183                f.sql
1184            );
1185        };
1186        typed("checked == 1", &blocks);
1187        typed("checked == true", &blocks);
1188        typed("attrs.checked == true", &blocks);
1189        // … or a property read (bare on docs, `doc.<k>` elsewhere)
1190        typed("verified == 1", &DOCS);
1191        typed("verified == true", &DOCS);
1192        typed("era < 1000", &DOCS);
1193        typed("doc.era < 1000", &blocks);
1194        // a boolean against an integer intrinsic; a number stays pushable
1195        assert_eq!(
1196            translate_predicate(&pred("$ordinal == true"), &blocks),
1197            None
1198        );
1199        assert_eq!(
1200            translate_predicate(&pred("$ordinal == 1"), &blocks),
1201            frag("(b.ordinal IS ?)", &[SqlValue::Real(1.0)])
1202        );
1203        // null against a property read; against a JSON read or a column it pushes
1204        assert_eq!(translate_predicate(&pred("tags != null"), &DOCS), None);
1205        assert_eq!(translate_predicate(&pred("tags == null"), &DOCS), None);
1206        assert_eq!(
1207            translate_predicate(&pred("doc.tags == null"), &blocks),
1208            None
1209        );
1210        assert_eq!(
1211            translate_predicate(&pred("checked == null"), &blocks),
1212            frag(
1213                "(json_extract(b.attrs, '$.checked') IS ?)",
1214                &[SqlValue::Null]
1215            )
1216        );
1217        assert_eq!(
1218            translate_predicate(&pred("$ordinal != null"), &blocks),
1219            frag("(b.ordinal IS NOT ?)", &[SqlValue::Null])
1220        );
1221        // a string literal against anything pushes under equality
1222        assert_eq!(
1223            translate_predicate(&pred("checked == \"x\""), &blocks),
1224            frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1225        );
1226        assert!(translate_predicate(&pred("layer == \"canon\""), &DOCS).is_some());
1227        assert!(translate_predicate(&pred("$ordinal == \"1\""), &blocks).is_some());
1228        assert!(translate_predicate(&pred("$ordinal != \"1\""), &blocks).is_some());
1229        // … but a relational comparison across text and a number / integer
1230        // declines (the fifth shape): SQLite orders integers before text
1231        assert_eq!(
1232            translate_predicate(&pred("$ordinal < \"3\""), &blocks),
1233            None
1234        );
1235        assert_eq!(
1236            translate_predicate(&pred("\"3\" >= $ordinal"), &blocks),
1237            None
1238        );
1239        assert_eq!(translate_predicate(&pred("$path > 5"), &DOCS), None);
1240        assert_eq!(translate_predicate(&pred("type <= 1"), &blocks), None);
1241        assert_eq!(
1242            translate_predicate(&pred("$ordinal < 3"), &blocks),
1243            frag("(b.ordinal < ?)", &[SqlValue::Real(3.0)])
1244        );
1245        assert_eq!(
1246            translate_predicate(&pred("$path > \"m\""), &DOCS),
1247            frag("(('/' || d.path) > ?)", &[text("m")])
1248        );
1249        // two reads carry no type at plan time; a boolean equals only text/null
1250        assert_eq!(
1251            translate_predicate(&pred("$ordinal == checked"), &blocks),
1252            None
1253        );
1254        assert_eq!(
1255            translate_predicate(&pred("checked == level"), &blocks),
1256            None
1257        );
1258        assert_eq!(
1259            translate_predicate(&pred("doc.era == doc.year"), &blocks),
1260            None
1261        );
1262        assert_eq!(translate_predicate(&pred("level < \"x\""), &blocks), None);
1263        typed("level < 3", &blocks);
1264        assert_eq!(translate_predicate(&pred("true == false"), &blocks), None);
1265        assert_eq!(translate_predicate(&pred("checked < true"), &blocks), None);
1266        assert_eq!(
1267            translate_predicate(&pred("doc.verified >= false"), &blocks),
1268            None
1269        );
1270        assert_eq!(translate_predicate(&pred("$ordinal > null"), &blocks), None);
1271        assert!(translate_predicate(&pred("$ordinal == $depth"), &blocks).is_some());
1272        assert!(translate_predicate(&pred("$ordinal <= $depth"), &blocks).is_some());
1273        assert!(translate_predicate(&pred("type == null"), &blocks).is_some());
1274    }
1275
1276    // -- the typed pushes (spec/surface §1, 1.2 patch) --
1277
1278    /// The properties subquery's scope: the same single-scalar-row conditions
1279    /// as the scalar read, so a list-valued or nested key yields NULL.
1280    const PROP_SCOPE: &str = "FROM properties p WHERE p.doc_id = d.doc_id AND p.key = 'K' AND p.card = 'scalar' AND p.deleted_commit IS NULL AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = d.doc_id AND p2.key = 'K' AND p2.deleted_commit IS NULL) = 1 LIMIT 1";
1281
1282    fn prop_sql(key: &str, select: &str, wrap: &str) -> String {
1283        format!(
1284            "((SELECT {select} {}) {wrap})",
1285            PROP_SCOPE.replace('K', key)
1286        )
1287    }
1288
1289    #[test]
1290    fn json_against_a_boolean_tests_json_type_for_the_literal() {
1291        let blocks = TranslateCtx {
1292            target: Target::Blocks,
1293            self_alias: "b",
1294            ..DOCS
1295        };
1296        // The boolean is inlined as the JSON type name; nothing binds.
1297        assert_eq!(
1298            translate_predicate(&pred("checked == true"), &blocks),
1299            frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1300        );
1301        assert_eq!(
1302            translate_predicate(&pred("checked == false"), &blocks),
1303            frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1304        );
1305        assert_eq!(
1306            translate_predicate(&pred("checked != true"), &blocks),
1307            frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1308        );
1309        assert_eq!(
1310            translate_predicate(&pred("false == attrs.checked"), &blocks),
1311            frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1312        );
1313        // a bound boolean is the same shape
1314        let params = [Value::Bool(true)];
1315        let e = Expr::Binary {
1316            op: BinaryOp::Ne,
1317            left: ident("checked"),
1318            right: Box::new(Expr::Binding {
1319                index: 0,
1320                span: oqx::Span::EMPTY,
1321            }),
1322            span: oqx::Span::EMPTY,
1323        };
1324        assert_eq!(
1325            translate_predicate(
1326                &e,
1327                &TranslateCtx {
1328                    params: &params,
1329                    ..blocks
1330                }
1331            ),
1332            frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1333        );
1334    }
1335
1336    #[test]
1337    fn json_against_a_number_tests_the_numeric_types_then_compares() {
1338        let nodes = TranslateCtx {
1339            target: Target::Nodes,
1340            self_alias: "n",
1341            ..DOCS
1342        };
1343        let shape = |op: &str, wrap: &str| {
1344            format!(
1345                "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') {op} ?) {wrap})"
1346            )
1347        };
1348        let two = [SqlValue::Real(2.0)];
1349        for (src, op, wrap) in [
1350            ("level == 2", "=", "IS 1"),
1351            ("level != 2", "=", "IS NOT 1"),
1352            ("level < 2", "<", "IS 1"),
1353            ("level <= 2", "<=", "IS 1"),
1354            ("level > 2", ">", "IS 1"),
1355            ("level >= 2", ">=", "IS 1"),
1356        ] {
1357            assert_eq!(
1358                translate_predicate(&pred(src), &nodes),
1359                frag(&shape(op, wrap), &two),
1360                "{src}"
1361            );
1362        }
1363        // a constant on the left is normalized to the right, the op flipped
1364        assert_eq!(
1365            translate_predicate(&pred("2 <= attrs.level"), &nodes),
1366            frag(&shape(">=", "IS 1"), &two)
1367        );
1368        assert_eq!(
1369            translate_predicate(&pred("2 > level"), &nodes),
1370            frag(&shape("<", "IS 1"), &two)
1371        );
1372        assert_eq!(
1373            translate_predicate(&pred("2 != level"), &nodes),
1374            frag(&shape("=", "IS NOT 1"), &two)
1375        );
1376    }
1377
1378    #[test]
1379    fn property_against_a_boolean_tests_p_type_bool_in_the_scalar_row_subquery() {
1380        let blocks = TranslateCtx {
1381            target: Target::Blocks,
1382            self_alias: "b",
1383            ..DOCS
1384        };
1385        assert_eq!(
1386            translate_predicate(&pred("verified == true"), &DOCS),
1387            frag(
1388                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1389                &[SqlValue::Integer(1)]
1390            )
1391        );
1392        assert_eq!(
1393            translate_predicate(&pred("verified != false"), &DOCS),
1394            frag(
1395                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS NOT 1"),
1396                &[SqlValue::Integer(0)]
1397            )
1398        );
1399        // `doc.<k>` from a block reads the owning document's row
1400        assert_eq!(
1401            translate_predicate(&pred("doc.verified == false"), &blocks),
1402            frag(
1403                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1404                &[SqlValue::Integer(0)]
1405            )
1406        );
1407        assert_eq!(
1408            translate_predicate(&pred("true == verified"), &DOCS),
1409            frag(
1410                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1411                &[SqlValue::Integer(1)]
1412            )
1413        );
1414    }
1415
1416    #[test]
1417    fn property_against_a_number_tests_p_type_number_in_the_scalar_row_subquery() {
1418        let blocks = TranslateCtx {
1419            target: Target::Blocks,
1420            self_alias: "b",
1421            ..DOCS
1422        };
1423        let thousand = [SqlValue::Real(1000.0)];
1424        for (src, op, wrap) in [
1425            ("era == 1000", "=", "IS 1"),
1426            ("era != 1000", "=", "IS NOT 1"),
1427            ("era < 1000", "<", "IS 1"),
1428            ("era <= 1000", "<=", "IS 1"),
1429            ("era > 1000", ">", "IS 1"),
1430            ("era >= 1000", ">=", "IS 1"),
1431        ] {
1432            assert_eq!(
1433                translate_predicate(&pred(src), &DOCS),
1434                frag(
1435                    &prop_sql(
1436                        "era",
1437                        &format!("p.type = 'number' AND p.val_num {op} ?"),
1438                        wrap
1439                    ),
1440                    &thousand
1441                ),
1442                "{src}"
1443            );
1444        }
1445        assert_eq!(
1446            translate_predicate(&pred("doc.era >= 1000"), &blocks),
1447            frag(
1448                &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1449                &thousand
1450            )
1451        );
1452        // a constant on the left is normalized to the right, the op flipped
1453        assert_eq!(
1454            translate_predicate(&pred("1000 > era"), &DOCS),
1455            frag(
1456                &prop_sql("era", "p.type = 'number' AND p.val_num < ?", "IS 1"),
1457                &thousand
1458            )
1459        );
1460        assert_eq!(
1461            translate_predicate(&pred("1000 <= era"), &DOCS),
1462            frag(
1463                &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1464                &thousand
1465            )
1466        );
1467    }
1468
1469    #[test]
1470    fn typed_pushes_compose_under_and_and_keep_the_untyped_forms() {
1471        let blocks = TranslateCtx {
1472            target: Target::Blocks,
1473            self_alias: "b",
1474            ..DOCS
1475        };
1476        let e = Expr::Logical {
1477            op: LogicalOp::And,
1478            left: eq(ident("type"), lit("task")),
1479            right: Box::new(Expr::Binary {
1480                op: BinaryOp::Eq,
1481                left: ident("checked"),
1482                right: Box::new(Expr::Lit {
1483                    value: Value::Bool(false),
1484                    span: oqx::Span::EMPTY,
1485                }),
1486                span: oqx::Span::EMPTY,
1487            }),
1488            span: oqx::Span::EMPTY,
1489        };
1490        assert_eq!(
1491            translate_predicate(&e, &blocks),
1492            frag(
1493                "((b.type IS ?) AND ((json_type(b.attrs, '$.checked') = 'false') IS 1))",
1494                &[text("task")]
1495            )
1496        );
1497        // text and null against a read stay the plain IS form
1498        assert_eq!(
1499            translate_predicate(&pred("checked == \"x\""), &blocks),
1500            frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1501        );
1502        assert_eq!(
1503            translate_predicate(&pred("checked != null"), &blocks),
1504            frag(
1505                "(json_extract(b.attrs, '$.checked') IS NOT ?)",
1506                &[SqlValue::Null]
1507            )
1508        );
1509        // an unsafe key never inlines, typed or not
1510        assert_eq!(
1511            prop_row("d", "x'y", "p.type = 'number' AND p.val_num = ?"),
1512            None
1513        );
1514    }
1515
1516    #[test]
1517    fn bindings_are_typed_by_their_value() {
1518        let blocks = TranslateCtx {
1519            target: Target::Blocks,
1520            self_alias: "b",
1521            ..DOCS
1522        };
1523        let params = [
1524            Value::from("s"),
1525            Value::from(1.0),
1526            Value::Bool(true),
1527            Value::Null,
1528            Value::Array(vec![]),
1529        ];
1530        let ctx = TranslateCtx {
1531            params: &params,
1532            ..blocks
1533        };
1534        let against = |i: usize, rhs: &str| {
1535            let e = Expr::Binary {
1536                op: BinaryOp::Eq,
1537                left: Box::new(Expr::Binding {
1538                    index: i,
1539                    span: oqx::Span::EMPTY,
1540                }),
1541                right: Box::new(pred(rhs)),
1542                span: oqx::Span::EMPTY,
1543            };
1544            translate_predicate(&e, &ctx).is_some()
1545        };
1546        // text binding pushes against anything; number/boolean push typed
1547        // against JSON (1.2), a boolean not against an integer intrinsic
1548        assert!(against(0, "checked"));
1549        assert!(against(1, "checked"));
1550        assert!(against(2, "checked"));
1551        assert!(!against(2, "$ordinal"));
1552        assert!(against(1, "$ordinal"));
1553        // a null binding pushes against JSON, not against a property
1554        assert!(against(3, "checked"));
1555        assert!(!against(3, "doc.tags"));
1556        // an absent binding (past the end) is null
1557        assert!(against(9, "checked"));
1558        assert!(!against(9, "doc.tags"));
1559        // a non-scalar binding has no faithful SQL value
1560        assert!(!against(4, "type"));
1561    }
1562
1563    // -- decline (b): handles are not property reads --
1564
1565    #[test]
1566    fn relation_and_handle_names_are_not_property_reads() {
1567        let blocks = TranslateCtx {
1568            target: Target::Blocks,
1569            self_alias: "b",
1570            ..DOCS
1571        };
1572        let nodes = TranslateCtx {
1573            target: Target::Nodes,
1574            self_alias: "n",
1575            ..DOCS
1576        };
1577        let edges = TranslateCtx {
1578            target: Target::Edges,
1579            self_alias: "e",
1580            ..DOCS
1581        };
1582        for (ctx, target) in [
1583            (&DOCS, Target::Docs),
1584            (&blocks, Target::Blocks),
1585            (&nodes, Target::Nodes),
1586            (&edges, Target::Edges),
1587        ] {
1588            for name in non_property_handles(target) {
1589                let src = format!("{name} == null");
1590                assert_eq!(
1591                    translate_predicate(&pred(&src), ctx),
1592                    None,
1593                    "{target:?}: {src}"
1594                );
1595                let src = format!("{name} == \"x\"");
1596                assert_eq!(
1597                    translate_predicate(&pred(&src), ctx),
1598                    None,
1599                    "{target:?}: {src}"
1600                );
1601            }
1602        }
1603        // the fixtures' shapes
1604        assert_eq!(translate_predicate(&pred("nodes == null"), &DOCS), None);
1605        assert_eq!(
1606            translate_predicate(&pred("frontmatter == null"), &DOCS),
1607            None
1608        );
1609        assert_eq!(translate_predicate(&pred("nodes == null"), &blocks), None);
1610        assert_eq!(translate_predicate(&pred("attrs == null"), &blocks), None);
1611        // `doc.<handle>` is the doc's handle, not its property; `doc.<k>` still pushes
1612        assert_eq!(
1613            translate_predicate(&pred("doc.nodes == null"), &blocks),
1614            None
1615        );
1616        assert_eq!(
1617            translate_predicate(&pred("doc.frontmatter == null"), &blocks),
1618            None
1619        );
1620        assert_eq!(translate_predicate(&pred("doc.doc == null"), &DOCS), None);
1621        assert!(translate_predicate(&pred("doc.layer == \"canon\""), &blocks).is_some());
1622        // a plain attribute or property of the same spelling elsewhere still pushes
1623        assert!(translate_predicate(&pred("section == \"x\""), &DOCS).is_some());
1624        assert!(translate_predicate(&pred("frontmatter == \"x\""), &blocks).is_some());
1625    }
1626
1627    /// The handle sets are exactly the keys the store context resolves to
1628    /// rows, a row or a bag before its property fallback: over a small
1629    /// observed corpus, on every row of a target, a name in the set never
1630    /// reads as a scalar, and at least one row resolves it to rows / a row /
1631    /// an object; a name outside the set never does.
1632    #[test]
1633    fn handle_sets_match_the_store_context() {
1634        use std::collections::HashMap;
1635
1636        use omgbase_reconcile::Config;
1637        use omgbase_store::{BatchItem, Store};
1638        use oqx::DataContext;
1639
1640        use crate::context::StoreContext;
1641
1642        let mut store = Store::open_in_memory().expect("store");
1643        let repo = store.create_repo("handles").expect("repo");
1644        let items = [
1645            BatchItem::observed(
1646                "a.md",
1647                "---\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",
1648            ),
1649            BatchItem::observed("b.md", "# B\n\nBack to [a](a.md).\n"),
1650        ];
1651        store
1652            .observe_batch(
1653                &repo,
1654                &items,
1655                "2026-09-26T00:00:00.000Z",
1656                &Config::default(),
1657            )
1658            .expect("observe");
1659        let ctx = StoreContext::new(store.conn(), &repo, HashMap::new());
1660
1661        let mut universe: Vec<&str> = [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges]
1662            .into_iter()
1663            .flat_map(|t| non_property_handles(t).iter().copied())
1664            .collect();
1665        universe.extend([
1666            "layer",
1667            "title",
1668            "verified",
1669            "checked",
1670            "level",
1671            "format",
1672            "type",
1673            "text",
1674            "kind",
1675            "name",
1676            "value",
1677            "predicate",
1678            "key",
1679            "nope",
1680        ]);
1681        universe.sort_unstable();
1682        universe.dedup();
1683
1684        let is_shape = |v: &Value| matches!(v, Value::Array(_) | Value::Object(_));
1685        for target in [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges] {
1686            // a root is a lazy scan marker; `to_rows` reads it
1687            let rows = ctx.to_rows(&ctx.root(target.as_str()));
1688            assert!(!rows.is_empty(), "{target:?} has rows");
1689            let set = non_property_handles(target);
1690            for name in &universe {
1691                let mut shaped = 0;
1692                for row in &rows {
1693                    let v = match ctx.get(row, name) {
1694                        Ok(v) => v,
1695                        // a target name that is not one of this row's
1696                        // relations is the root-row error, never a property
1697                        Err(_) if Target::parse(name).is_some() && !set.contains(name) => continue,
1698                        Err(e) => panic!("{target:?}.{name}: {e}"),
1699                    };
1700                    if set.contains(name) {
1701                        assert!(
1702                            v.is_absent() || is_shape(&v),
1703                            "{target:?}.{name} read as a scalar: {v:?}"
1704                        );
1705                    } else {
1706                        assert!(!is_shape(&v), "{target:?}.{name} is a handle: {v:?}");
1707                    }
1708                    shaped += usize::from(is_shape(&v));
1709                }
1710                if set.contains(name) {
1711                    assert!(
1712                        shaped > 0,
1713                        "{target:?}.{name} never resolved to rows or a bag"
1714                    );
1715                }
1716            }
1717        }
1718    }
1719
1720    // -- declines (left residual) return None --
1721
1722    #[test]
1723    fn reserved_bare_basename_is_not_pushed() {
1724        assert_eq!(translate_predicate(&pred("path == \"x\""), &DOCS), None);
1725        assert_eq!(translate_predicate(&pred("body == \"x\""), &DOCS), None);
1726        assert_eq!(translate_predicate(&pred("doc.path == \"x\""), &DOCS), None);
1727    }
1728
1729    #[test]
1730    fn docs_body_and_computed_intrinsics_are_not_columns() {
1731        assert_eq!(translate_predicate(&pred("$body == \"x\""), &DOCS), None);
1732        assert_eq!(translate_predicate(&pred("$title == \"x\""), &DOCS), None);
1733        assert_eq!(translate_predicate(&pred("$tags == \"x\""), &DOCS), None);
1734    }
1735
1736    #[test]
1737    fn matches_needs_a_regexp_udf() {
1738        assert_eq!(
1739            translate_predicate(&pred("$path.matches(\"^lab/\")"), &DOCS),
1740            None
1741        );
1742    }
1743
1744    #[test]
1745    fn negation_as_a_nested_expr_is_not_and_safe() {
1746        let e = Expr::Unary {
1747            op: oqx::ast::UnaryOp::Not,
1748            expr: ident("$path"),
1749            span: oqx::Span::EMPTY,
1750        };
1751        assert_eq!(translate_predicate(&e, &DOCS), None);
1752    }
1753
1754    #[test]
1755    fn disjunction_as_a_nested_expr_is_declined() {
1756        let e = Expr::Logical {
1757            op: LogicalOp::Or,
1758            left: eq(ident("$path"), lit("a")),
1759            right: eq(ident("$path"), lit("b")),
1760            span: oqx::Span::EMPTY,
1761        };
1762        assert_eq!(translate_predicate(&e, &DOCS), None);
1763    }
1764
1765    #[test]
1766    fn unmapped_node_intrinsic_is_not_pushed() {
1767        let nodes = TranslateCtx {
1768            target: Target::Nodes,
1769            self_alias: "n",
1770            ..DOCS
1771        };
1772        assert_eq!(
1773            translate_predicate(&pred("$locator == \"x\""), &nodes),
1774            None
1775        );
1776        // `$updated_at` is mapped on docs only.
1777        let blocks = TranslateCtx {
1778            target: Target::Blocks,
1779            self_alias: "b",
1780            ..DOCS
1781        };
1782        assert_eq!(
1783            translate_predicate(&pred("$updated_at == \"x\""), &blocks),
1784            None
1785        );
1786    }
1787
1788    #[test]
1789    fn range_membership_in_and_bare_idents_are_declined() {
1790        assert_eq!(translate_predicate(&pred("era in 800..1680"), &DOCS), None);
1791        assert_eq!(
1792            translate_predicate(&pred("\"a\" in list(tags)"), &DOCS),
1793            None
1794        );
1795        assert_eq!(translate_predicate(&pred("verified"), &DOCS), None);
1796        assert_eq!(translate_predicate(&pred("doc.verified"), &DOCS), None);
1797        assert_eq!(translate_predicate(&pred("size(tags) > 1"), &DOCS), None);
1798        assert_eq!(translate_predicate(&pred("$self.text(\"x\")"), &DOCS), None);
1799        assert_eq!(translate_predicate(&pred("$it == \"x\""), &DOCS), None);
1800        assert_eq!(translate_predicate(&pred("^slug == \"x\""), &DOCS), None);
1801        assert_eq!(
1802            translate_predicate(&pred("frontmatter.era == 1"), &DOCS),
1803            None
1804        );
1805    }
1806
1807    // -- conjunction and bindings via constructed AST --
1808
1809    #[test]
1810    fn and_composes_two_pushable_comparisons() {
1811        let e = Expr::Logical {
1812            op: LogicalOp::And,
1813            left: eq(ident("$path"), lit("a")),
1814            right: Box::new(Expr::Binary {
1815                op: BinaryOp::Ne,
1816                left: ident("$id"),
1817                right: lit("d_2"),
1818                span: oqx::Span::EMPTY,
1819            }),
1820            span: oqx::Span::EMPTY,
1821        };
1822        assert_eq!(
1823            translate_predicate(&e, &DOCS),
1824            frag(
1825                "((('/' || d.path) IS ?) AND (d.doc_id IS NOT ?))",
1826                &[text("a"), text("d_2")]
1827            )
1828        );
1829    }
1830
1831    #[test]
1832    fn and_declines_wholesale_if_either_side_is_not_pushable() {
1833        let e = Expr::Logical {
1834            op: LogicalOp::And,
1835            left: eq(ident("$path"), lit("a")),
1836            // $body is reconstructed, not a column → the whole && declines.
1837            right: eq(ident("$body"), lit("x")),
1838            span: oqx::Span::EMPTY,
1839        };
1840        assert_eq!(translate_predicate(&e, &DOCS), None);
1841    }
1842
1843    #[test]
1844    fn resolves_a_binding_to_its_param_value() {
1845        let e = eq(
1846            ident("$path"),
1847            Box::new(Expr::Binding {
1848                index: 0,
1849                span: oqx::Span::EMPTY,
1850            }),
1851        );
1852        let params = [Value::from("from-binding.md")];
1853        let ctx = TranslateCtx {
1854            params: &params,
1855            ..DOCS
1856        };
1857        assert_eq!(
1858            translate_predicate(&e, &ctx),
1859            frag("(('/' || d.path) IS ?)", &[text("from-binding.md")])
1860        );
1861        // A binding past the end reads as absent → NULL.
1862        assert_eq!(
1863            translate_predicate(&e, &DOCS),
1864            frag("(('/' || d.path) IS ?)", &[SqlValue::Null])
1865        );
1866    }
1867
1868    // -- per-target fields --
1869
1870    #[test]
1871    fn blocks_and_nodes_flatten_bare_identifiers_into_attrs() {
1872        let blocks = TranslateCtx {
1873            target: Target::Blocks,
1874            self_alias: "b",
1875            ..DOCS
1876        };
1877        // (a top-level `&&` is a `Where::And` of scalars; the nested form is
1878        // reached through constructed AST, as in the reference's tests)
1879        let both = Expr::Logical {
1880            op: LogicalOp::And,
1881            left: eq(ident("type"), lit("task")),
1882            right: eq(ident("marker"), lit("x")),
1883            span: oqx::Span::EMPTY,
1884        };
1885        assert_eq!(
1886            translate_predicate(&both, &blocks),
1887            frag(
1888                "((b.type IS ?) AND (json_extract(b.attrs, '$.marker') IS ?))",
1889                &[text("task"), text("x")]
1890            )
1891        );
1892        assert_eq!(
1893            translate_predicate(&pred("attrs.marker == \"x\""), &blocks),
1894            frag("(json_extract(b.attrs, '$.marker') IS ?)", &[text("x")])
1895        );
1896        // (a boolean or number against a JSON read pushes typed — see the
1897        // typed-shape tests)
1898        assert_eq!(
1899            translate_predicate(&pred("attrs.checked == true"), &blocks),
1900            frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1901        );
1902        let nodes = TranslateCtx {
1903            target: Target::Nodes,
1904            self_alias: "n",
1905            ..DOCS
1906        };
1907        assert_eq!(
1908            translate_predicate(&pred("kind == \"md:section\""), &nodes),
1909            frag("(n.kind IS ?)", &[text("md:section")])
1910        );
1911        assert_eq!(
1912            translate_predicate(&pred("level == \"1\""), &nodes),
1913            frag("(json_extract(n.attrs, '$.level') IS ?)", &[text("1")])
1914        );
1915        assert_eq!(
1916            translate_predicate(&pred("level == 1"), &nodes),
1917            frag(
1918                "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') = ?) IS 1)",
1919                &[SqlValue::Real(1.0)]
1920            )
1921        );
1922        assert_eq!(
1923            translate_predicate(&pred("attrs.a.b == \"c\""), &nodes),
1924            frag("(json_extract(n.attrs, '$.a.b') IS ?)", &[text("c")])
1925        );
1926        // `attrs.<k>` is a blocks/nodes form; on docs it is not a column.
1927        assert_eq!(
1928            translate_predicate(&pred("attrs.marker == \"x\""), &DOCS),
1929            None
1930        );
1931    }
1932
1933    #[test]
1934    fn doc_and_block_reach_through() {
1935        let blocks = TranslateCtx {
1936            target: Target::Blocks,
1937            self_alias: "b",
1938            ..DOCS
1939        };
1940        let f = translate_predicate(&pred("doc.type == \"lab-note\""), &blocks).expect("pushable");
1941        assert!(
1942            f.sql.contains("p.doc_id = d.doc_id AND p.key = 'type'"),
1943            "{}",
1944            f.sql
1945        );
1946        assert_eq!(
1947            translate_predicate(&pred("doc.$path == \"a.md\""), &blocks),
1948            frag("(('/' || d.path) IS ?)", &[text("a.md")])
1949        );
1950        // a rooted literal (what the runner's rewrite always produces) takes
1951        // the indexed fast path on the storage column
1952        assert_eq!(
1953            translate_predicate(&pred("doc.$path == \"/a.md\""), &blocks),
1954            frag("(d.path IS ?)", &[text("a.md")])
1955        );
1956        assert_eq!(
1957            translate_predicate(&pred("doc.format == \"markdown\""), &blocks),
1958            frag("(d.format IS ?)", &[text("markdown")])
1959        );
1960        assert_eq!(
1961            translate_predicate(&pred("doc.$id == \"d_1\""), &blocks),
1962            None
1963        );
1964        assert_eq!(translate_predicate(&pred("doc.a.b == 1"), &blocks), None);
1965        let nodes = TranslateCtx {
1966            target: Target::Nodes,
1967            self_alias: "n",
1968            ..DOCS
1969        };
1970        assert_eq!(
1971            translate_predicate(&pred("block.type == \"task\""), &nodes),
1972            frag(
1973                "((SELECT bb.type FROM blocks bb WHERE bb.block_id = n.block_id) IS ?)",
1974                &[text("task")]
1975            )
1976        );
1977        assert_eq!(
1978            translate_predicate(&pred("block.type == \"task\""), &blocks),
1979            None
1980        );
1981        assert_eq!(
1982            translate_predicate(&pred("section.level == 1"), &nodes),
1983            None
1984        );
1985    }
1986
1987    #[test]
1988    fn edges_push_their_five_fields_and_intrinsics() {
1989        let edges = TranslateCtx {
1990            target: Target::Edges,
1991            self_alias: "e",
1992            ..DOCS
1993        };
1994        assert_eq!(
1995            translate_predicate(&pred("predicate == \"references\""), &edges),
1996            frag("(e.predicate IS ?)", &[text("references")])
1997        );
1998        assert_eq!(
1999            translate_predicate(&pred("$dst_path == \"index.md\""), &edges),
2000            frag(
2001                "((SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2002                &[text("index.md")]
2003            )
2004        );
2005        assert_eq!(translate_predicate(&pred("weight == 1"), &edges), None);
2006    }
2007
2008    #[test]
2009    fn unsafe_identifier_segments_are_never_inlined() {
2010        assert!(is_seg("layer") && is_seg("_x9"));
2011        assert!(!is_seg("") && !is_seg("9a") && !is_seg("a-b") && !is_seg("a'b"));
2012        assert_eq!(json_path(&["ok", "no-pe"]), None);
2013        assert_eq!(json_path(&["ok", "a_1"]).as_deref(), Some("$.ok.a_1"));
2014        assert_eq!(prop_scalar("d", "x'y"), None);
2015    }
2016}