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 column (a block's `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_links",
207            "in_links",
208            "revisions",
209            "data",
210            "frontmatter",
211            "inline",
212        ],
213        Target::Blocks => &[
214            "block",
215            "doc",
216            "children",
217            "nodes",
218            "out_links",
219            "section",
220            "attrs",
221        ],
222        Target::Nodes => &[
223            "section",
224            "doc",
225            "block",
226            "blocks",
227            "subsections",
228            "children",
229            "attrs",
230        ],
231        Target::Links => &["doc", "dst", "block"],
232        // a revision's one reach-through: the document's current row (4.7)
233        Target::Revisions => &["doc"],
234    }
235}
236
237// ---- engine attributes -----------------------------------------------------------
238
239/// The identity/bytes/time attributes per kind — the names [`attribute_sql`]
240/// owns (the structural columns are handled per target in [`operand`]).
241/// `spec/surface` §1.2, 3.0: bare, snake_case, winning on collision — so a
242/// name here is translated or declined, never read as a property or an attr.
243pub(crate) fn row_attributes(t: Target) -> &'static [&'static str] {
244    match t {
245        Target::Docs => &[
246            "id",
247            "path",
248            "raw",
249            "hash",
250            "updated_at",
251            "created_at",
252            "title",
253            "tags",
254        ],
255        Target::Blocks => &["id", "path", "raw", "hash", "updated_at", "created_at"],
256        Target::Nodes | Target::Links | Target::Revisions => &["id", "path"],
257    }
258}
259
260/// A row attribute → a param-free SQL scalar and its type, per target.
261/// Foreign keys and columns are translated in [`operand`]'s ident arm
262/// ([`edge_field`] for edges). Anything not mapped (`raw`, reconstructed or a
263/// blob; `title`, computed; `tags`, the union of the authored list and the
264/// computed hashtags; blocks `updated_at`/`created_at`) returns `None` →
265/// residual. `updated_at` and `created_at` (4.10) are correlated subqueries
266/// over the document's revisions (`current_rev` / `seq = 1`). Every mapped
267/// attribute is text.
268/// `path` is rendered by [`Operand::path_col`] (the rooted read,
269/// `spec/surface` §1 "Paths").
270fn attribute_sql(name: &str, ctx: &TranslateCtx<'_>) -> Option<(String, Ty)> {
271    let (s, d) = (ctx.self_alias, ctx.doc_alias);
272    let sql = match (ctx.target, name) {
273        (Target::Docs, "id") => format!("{s}.doc_id"),
274        (Target::Docs, "path") => format!("('/' || {d}.path)"),
275        (Target::Docs, "hash") => format!("lower(hex({s}.file_hash))"),
276        (Target::Docs, "updated_at") => format!(
277            "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = {s}.current_rev)"
278        ),
279        (Target::Docs, "created_at") => format!(
280            "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.doc_id = {s}.doc_id AND r.seq = 1)"
281        ),
282        (Target::Blocks, "id") => format!("{s}.block_id"),
283        (Target::Blocks, "path") => format!("('/' || {d}.path)"),
284        (Target::Blocks, "hash") => format!("lower(hex({s}.raw_hash))"),
285        (Target::Nodes, "id") => format!("{s}.node_id"),
286        (Target::Nodes, "path") => format!("('/' || {d}.path)"),
287        (Target::Links, "id") => format!("{s}.edge_id"),
288        (Target::Links, "path") => format!("('/' || {d}.path)"),
289        _ => return None,
290    };
291    Some((sql, Ty::Text))
292}
293
294/// The links target's plain fields → the store's columns (`spec/surface`
295/// §1.2, 3.0: a foreign key is named for what it is, never `$`-prefixed).
296/// `dst_path` and `uri` are correlated subqueries; `dst_path` is the
297/// reference form.
298fn edge_field(name: &str, s: &str) -> Option<Operand> {
299    let sql = match name {
300        "doc_id" => format!("{s}.src_doc"),
301        "block_id" => format!("{s}.src_block"),
302        "via_id" => format!("{s}.via_node"),
303        "dst_id" => format!("{s}.dst_node"),
304        "dst_path" => {
305            format!("(SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = {s}.dst_node)")
306        }
307        "uri" => format!("(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = {s}.dst_node)"),
308        "field" => format!("{s}.src_field"),
309        "predicate" | "provenance" | "dst_kind" | "anchor" => format!("{s}.{name}"),
310        _ => return None,
311    };
312    Operand::text(sql)
313}
314
315/// An injection-safe inlined identifier: `^[A-Za-z_][A-Za-z0-9_]*$`.
316fn is_seg(s: &str) -> bool {
317    let mut chars = s.chars();
318    chars
319        .next()
320        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
321        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
322}
323
324/// The single-scalar-row property subquery (the scalar-in-scope rule):
325/// `select` evaluated over the property row `p` only when the key has exactly
326/// one row in scope and it is `card = 'scalar'`, else NULL — matching the
327/// context's `doc_prop` for a scalar read. [`prop_scalar`] selects the value;
328/// the typed pushes select a type test ([`typed_compare`]).
329///
330/// `source` narrows the scope to one authored source — the read
331/// `frontmatter.<k>` / `inline.<k>` performs (`spec/surface` §1.2; the
332/// context's `doc_prop(doc_id, key, Some(source))`): both the row and the
333/// count are of that source's rows only. The merged `data.<k>` read is the
334/// bare read, so it passes no source. `doc_id` is the SQL expression naming
335/// the document: the scan's `d.doc_id`, or a link's `e.dst_node` for a
336/// `dst.<k>` read (the destination's rows by its id — a non-document or
337/// phantom destination has none, so the read is NULL exactly where `dst` is
338/// absent in memory). Same text as the reference's `propSubquery`.
339fn prop_row(doc_id: &str, key: &str, select: &str, source: Option<&str>) -> Option<String> {
340    if !is_seg(key) {
341        return None;
342    }
343    let src = source.map_or(String::new(), |s| format!(" AND p.source = '{s}'"));
344    let src2 = source.map_or(String::new(), |s| format!(" AND p2.source = '{s}'"));
345    Some(format!(
346        "(SELECT {select} FROM properties p \
347         WHERE p.doc_id = {doc_id} AND p.key = '{key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL{src} \
348         AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = {doc_id} AND p2.key = '{key}' AND p2.deleted_commit IS NULL{src2}) = 1 \
349         LIMIT 1)"
350    ))
351}
352
353/// A single-valued document property's scalar value (`val_text`, `val_num`
354/// and `val_bool` coalesced), or NULL. `doc_id` as in [`prop_row`].
355fn prop_scalar(doc_id: &str, key: &str, source: Option<&str>) -> Option<String> {
356    prop_row(
357        doc_id,
358        key,
359        "COALESCE(p.val_text, p.val_num, p.val_bool)",
360        source,
361    )
362}
363
364/// The source a handle scopes a property read to (`spec/surface` §1.2, 3.0):
365/// `data` is the merged authored view (no scope — exactly the bare read),
366/// `frontmatter` / `inline` one source each. `None` for a name that is not a
367/// source handle.
368fn handle_source(handle: &str) -> Option<Option<&'static str>> {
369    match handle {
370        "data" => Some(None),
371        "frontmatter" => Some(Some("frontmatter")),
372        "inline" => Some(Some("inline")),
373        _ => None,
374    }
375}
376
377/// The JSON path `$.a.b` from validated segments; `None` if any segment is
378/// unsafe.
379fn json_path(segs: &[&str]) -> Option<String> {
380    if segs.iter().any(|s| !is_seg(s)) {
381        return None;
382    }
383    Some(format!("$.{}", segs.join(".")))
384}
385
386/// What an operand IS, beyond the SQL it renders to — the typed pushes
387/// ([`typed_compare`]) rebuild a stored read as a type test and inline a
388/// constant's value, which a finished [`Frag`] no longer exposes.
389#[derive(Clone, Debug, PartialEq)]
390enum Shape {
391    /// A plain SQL scalar: a column, an intrinsic, `lower()` / `upper()`.
392    Plain,
393    /// The rooted path read over a storage path column (`'/' || d.path`,
394    /// `spec/surface` §1 "Paths"): the bare, indexed column is kept for the
395    /// equality fast path ([`path_equality`]).
396    PathCol(String),
397    /// A literal or binding, bound as `?`.
398    Const(Value),
399    /// `json_extract(col, 'path')`.
400    Json { col: String, path: String },
401    /// A document property read: the SQL expression naming the document
402    /// (`d.doc_id`; a link's `e.dst_node` for `dst.<k>`), the key and the
403    /// source a handle scoped it to (`frontmatter.<k>` / `inline.<k>`;
404    /// `None` for the bare or `data.<k>` read).
405    Prop {
406        doc_id: String,
407        key: String,
408        source: Option<&'static str>,
409    },
410}
411
412/// A translated value-position operand: its fragment, its [`Ty`] for the
413/// gate, and its [`Shape`] for the typed pushes.
414#[derive(Clone, Debug, PartialEq)]
415struct Operand {
416    frag: Frag,
417    ty: Ty,
418    shape: Shape,
419}
420
421impl Operand {
422    fn plain(sql: String, ty: Ty) -> Self {
423        Self {
424            frag: Frag::bare(sql),
425            ty,
426            shape: Shape::Plain,
427        }
428    }
429
430    fn text(sql: String) -> Option<Self> {
431        Some(Self::plain(sql, Ty::Text))
432    }
433
434    /// `path` / `doc.path`: the reference form over the storage column, so
435    /// every comparison, `startsWith`, `contains`, `lower()`… sees exactly the
436    /// string the in-memory intrinsic yields.
437    fn path_col(col: String) -> Option<Self> {
438        Some(Self {
439            frag: Frag::bare(format!("('/' || {col})")),
440            ty: Ty::Text,
441            shape: Shape::PathCol(col),
442        })
443    }
444
445    /// `None` for a non-scalar (an array, an object, a range), which has no
446    /// faithful SQL binding.
447    fn constant(v: &Value) -> Option<Self> {
448        Some(Self {
449            frag: Frag {
450                sql: "?".to_owned(),
451                params: vec![to_sql(v)],
452            },
453            ty: const_ty(v)?,
454            shape: Shape::Const(v.clone()),
455        })
456    }
457
458    fn json(col: String, segs: &[&str]) -> Option<Self> {
459        let path = json_path(segs)?;
460        Some(Self {
461            frag: Frag::bare(format!("json_extract({col}, '{path}')")),
462            ty: Ty::Json,
463            shape: Shape::Json { col, path },
464        })
465    }
466
467    /// A property of the document aliased `doc_alias` (its `doc_id` column).
468    fn prop(doc_alias: &str, key: &str, source: Option<&'static str>) -> Option<Self> {
469        Self::prop_of(&format!("{doc_alias}.doc_id"), key, source)
470    }
471
472    /// A property of the document the SQL expression `doc_id` names.
473    fn prop_of(doc_id: &str, key: &str, source: Option<&'static str>) -> Option<Self> {
474        Some(Self {
475            frag: Frag::bare(prop_scalar(doc_id, key, source)?),
476            ty: Ty::Prop,
477            shape: Shape::Prop {
478                doc_id: doc_id.to_owned(),
479                key: key.to_owned(),
480                source,
481            },
482        })
483    }
484}
485
486/// The dotted `attrs.a.b` / `doc.x` receiver chain as segments, or `None` if
487/// it is not a plain identifier navigation.
488fn member_segments(e: &Expr) -> Option<Vec<&str>> {
489    match e {
490        Expr::Ident { name, .. } => Some(vec![name.as_str()]),
491        Expr::Member { recv, name, .. } => {
492            let mut base = member_segments(recv)?;
493            base.push(name.as_str());
494            Some(base)
495        }
496        _ => None,
497    }
498}
499
500// ---- value position ----------------------------------------------------------------
501
502/// Translate an expression used as a VALUE (comparison operand, method
503/// receiver, function argument) to a SQL scalar. `None` if not faithfully
504/// translatable.
505pub fn translate_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
506    typed_value(e, ctx).map(|(frag, _)| frag)
507}
508
509/// [`translate_value`] plus the operand's [`Ty`], for the comparison gate.
510pub fn typed_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<(Frag, Ty)> {
511    operand(e, ctx).map(|o| (o.frag, o.ty))
512}
513
514/// The full [`Operand`] of a value-position expression.
515fn operand(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Operand> {
516    let (s, d, target) = (ctx.self_alias, ctx.doc_alias, ctx.target);
517    match e {
518        Expr::Lit { value: v, .. } => Operand::constant(v),
519        Expr::Binding { index, .. } => {
520            Operand::constant(ctx.params.get(*index).unwrap_or(&Value::Undefined))
521        }
522        Expr::Ident { name, .. } => {
523            // An OQX `$` name (`$it`, `$key`, the follow intrinsics) is the
524            // language's, never a column; omgbase adds no `$` names (§1.2, 3.0).
525            if name.starts_with('$') {
526                return None;
527            }
528            let name = name.as_str();
529            // The row's own attributes win on collision (§1.2): translated or
530            // declined (`raw`, `title`, `tags`), never read as a property or an attr.
531            if row_attributes(target).contains(&name) {
532                if name == "path" {
533                    return Operand::path_col(format!("{d}.path"));
534                }
535                return attribute_sql(name, ctx).map(|(sql, ty)| Operand::plain(sql, ty));
536            }
537            // A relation, reach-through handle, source handle or bag is not a
538            // property read (§1): rows or an object in memory, never a key. A
539            // bare target name is a relation or a loud error (the root-row
540            // rule, `crate::query`), never a property.
541            if non_property_handles(target).contains(&name) || Target::parse(name).is_some() {
542                return None;
543            }
544            match target {
545                Target::Docs => {
546                    // `format` is a column, not a property.
547                    if name == "format" {
548                        return Operand::text(format!("{s}.format"));
549                    }
550                    Operand::prop(d, name, None)
551                }
552                Target::Blocks => {
553                    if matches!(name, "doc_id" | "type" | "text") {
554                        return Operand::text(format!("{s}.{name}"));
555                    }
556                    if matches!(name, "ordinal" | "depth") {
557                        return Some(Operand::plain(format!("{s}.{name}"), Ty::Int));
558                    }
559                    // A bare non-structural identifier flattens into attrs —
560                    // the same pushdown as the `attrs.<k>` member form.
561                    Operand::json(format!("{s}.attrs"), &[name])
562                }
563                Target::Nodes => {
564                    if matches!(name, "doc_id" | "block_id" | "kind" | "name" | "value") {
565                        return Operand::text(format!("{s}.{name}"));
566                    }
567                    Operand::json(format!("{s}.attrs"), &[name])
568                }
569                Target::Links => edge_field(name, s),
570                // never reached: the planner declines a revisions scan (4.7)
571                Target::Revisions => None,
572            }
573        }
574        Expr::Member { .. } => {
575            let segs = member_segments(e)?;
576            let (head, rest) = segs.split_first()?;
577            if rest.is_empty() {
578                return None;
579            }
580            // attrs.<path> → json_extract on the row's attrs (blocks/nodes).
581            if *head == "attrs" && matches!(target, Target::Blocks | Target::Nodes) {
582                return Operand::json(format!("{s}.attrs"), rest);
583            }
584            // A key read through a source handle (§1.2, 3.0): `data.<k>` is
585            // exactly the bare property read, `frontmatter.<k>` / `inline.<k>`
586            // the same read scoped to one source — on docs, and through `doc.`
587            // on the other targets (`doc.inline.element`). A WHOLE handle
588            // (`frontmatter == null`) stays declined below (decline (b)).
589            if target == Target::Docs
590                && rest.len() == 1
591                && let Some(source) = handle_source(head)
592            {
593                return Operand::prop(d, rest[0], source);
594            }
595            if *head == "doc"
596                && rest.len() == 2
597                && let Some(source) = handle_source(rest[0])
598            {
599                return Operand::prop(d, rest[1], source);
600            }
601            // doc.<x> reach-through — the owning doc (alias `doc`). On the docs
602            // target `doc` is the row itself; either way it resolves against `d`.
603            // FOREIGN-KEY ELIMINATION (`spec/surface` §1, 3.0): `doc.id` is the
604            // row's own FK column (`b.doc_id`, `n.doc_id`, `e.src_doc`) and
605            // `doc.path` / `doc.hash` / `doc.updated_at` the scan's
606            // already-joined document columns — no extra join, the same SQL as
607            // the bare `doc_id`.
608            if *head == "doc" {
609                if rest.len() != 1 {
610                    return None;
611                }
612                let k = rest[0];
613                if k == "id" {
614                    return Operand::text(if target == Target::Links {
615                        format!("{s}.src_doc")
616                    } else {
617                        format!("{s}.doc_id")
618                    });
619                }
620                if k == "path" {
621                    return Operand::path_col(format!("{d}.path"));
622                }
623                if k == "hash" {
624                    return Operand::text(format!("lower(hex({d}.file_hash))"));
625                }
626                if k == "updated_at" {
627                    return Operand::text(format!(
628                        "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = {d}.current_rev)"
629                    ));
630                }
631                if k == "created_at" {
632                    return Operand::text(format!(
633                        "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.doc_id = {d}.doc_id AND r.seq = 1)"
634                    ));
635                }
636                if k == "format" {
637                    return Operand::text(format!("{d}.format"));
638                }
639                // `doc.raw` / `doc.title` / `doc.tags` are the attribute, not
640                // pushable; `doc.nodes`, `doc.data`, `doc.doc`… are the doc's
641                // handles, not its properties; `doc.docs` / `doc.links` raise.
642                if k.starts_with('$')
643                    || row_attributes(Target::Docs).contains(&k)
644                    || non_property_handles(Target::Docs).contains(&k)
645                    || Target::parse(k).is_some()
646                {
647                    return None;
648                }
649                return Operand::prop(d, k, None);
650            }
651            // dst.<x> reach-through from a link (`spec/surface` §1.2, 3.0
652            // follow-up): the destination DOCUMENT, keyed by the row's own
653            // `dst_node` — the lookup `dst_path` already runs. A property read
654            // scopes `properties` by `e.dst_node` directly; the document's own
655            // columns are a primary-key probe `(SELECT … FROM docs dd WHERE
656            // dd.doc_id = e.dst_node)` — NULL wherever `dst` is absent in
657            // memory (a block or external destination, a phantom). `dst.raw` /
658            // `dst.title` / `dst.tags` and the relations stay residual, as
659            // `doc.*`'s do.
660            if *head == "dst" && target == Target::Links {
661                let dst_id = format!("{s}.dst_node");
662                if rest.len() == 2
663                    && let Some(source) = handle_source(rest[0])
664                {
665                    return Operand::prop_of(&dst_id, rest[1], source);
666                }
667                if rest.len() != 1 {
668                    return None;
669                }
670                let k = rest[0];
671                let probe = |col: &str| {
672                    Operand::text(format!(
673                        "(SELECT {col} FROM docs dd WHERE dd.doc_id = {dst_id})"
674                    ))
675                };
676                return match k {
677                    "id" => probe("dd.doc_id"),
678                    "path" => probe("'/' || dd.path"),
679                    "hash" => probe("lower(hex(dd.file_hash))"),
680                    "updated_at" => probe(
681                        "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = dd.current_rev)",
682                    ),
683                    "created_at" => probe(
684                        "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.doc_id = dd.doc_id AND r.seq = 1)",
685                    ),
686                    "format" => probe("dd.format"),
687                    _ if k.starts_with('$')
688                        || row_attributes(Target::Docs).contains(&k)
689                        || non_property_handles(Target::Docs).contains(&k)
690                        || Target::parse(k).is_some() =>
691                    {
692                        None
693                    }
694                    _ => Operand::prop_of(&dst_id, k, None),
695                };
696            }
697            // block.<x> reach-through from a node: the block's identity is the
698            // node's FK (`block.id` → `n.block_id`, `block.path` → the joined
699            // `d.path`, `block.doc_id` → `n.doc_id` — FK elimination again);
700            // `type` / `text` are a correlated read of the block row.
701            if *head == "block" && target == Target::Nodes && rest.len() == 1 {
702                return match rest[0] {
703                    "id" => Operand::text(format!("{s}.block_id")),
704                    "path" => Operand::path_col(format!("{d}.path")),
705                    "doc_id" => Operand::text(format!("{s}.doc_id")),
706                    k @ ("type" | "text") => Operand::text(format!(
707                        "(SELECT bb.{k} FROM blocks bb WHERE bb.block_id = {s}.block_id)"
708                    )),
709                    _ => None,
710                };
711            }
712            // block.<x> reach-through from a link (`spec/surface` §1.2, 4.11):
713            // the SOURCE block, keyed by the row's own `src_block` — `block.id`
714            // is that FK column (NULL for a frontmatter link, exactly where
715            // `block` is absent in memory); everything else is a primary-key
716            // probe of the block row, NULL for the same rows (`block.path`
717            // reads the scan's joined source document inside the probe, so it
718            // too is NULL when there is no block). `raw`, `hash`,
719            // `updated_at`, `attrs` and the relations stay residual.
720            if *head == "block" && target == Target::Links && rest.len() == 1 {
721                let probe = |col: &str| {
722                    Operand::text(format!(
723                        "(SELECT {col} FROM blocks bb WHERE bb.block_id = {s}.src_block)"
724                    ))
725                };
726                return match rest[0] {
727                    "id" => Operand::text(format!("{s}.src_block")),
728                    "path" => probe(&format!("'/' || {d}.path")),
729                    "doc_id" => probe("bb.doc_id"),
730                    k @ ("type" | "text") => probe(&format!("bb.{k}")),
731                    _ => None,
732                };
733            }
734            None
735        }
736        // `.lower()` / `.upper()` are the value-position string methods.
737        Expr::Call {
738            recv: Some(recv),
739            name,
740            args,
741            ..
742        } if args.is_empty() && (name == "lower" || name == "upper") => {
743            let recv = translate_value(recv, ctx)?;
744            Some(Operand {
745                frag: Frag {
746                    sql: format!("{name}({})", recv.sql),
747                    params: recv.params,
748                },
749                ty: Ty::Text,
750                shape: Shape::Plain,
751            })
752        }
753        _ => None,
754    }
755}
756
757// ---- predicate position --------------------------------------------------------------
758
759/// `==` / `!=` → null-safe `IS` / `IS NOT`; the relational ops as plain SQL.
760fn is_op(op: BinaryOp) -> Option<&'static str> {
761    Some(match op {
762        BinaryOp::Eq => "IS",
763        BinaryOp::Ne => "IS NOT",
764        BinaryOp::Lt => "<",
765        BinaryOp::Le => "<=",
766        BinaryOp::Gt => ">",
767        BinaryOp::Ge => ">=",
768        // Identity (`is` / `is not`, SEMANTICS §5b) compares the engine's
769        // structural identities — not provably SQL's `IS` over typed cells —
770        // so it stays residual, like arithmetic in predicate position. A regex
771        // operator (oqx 0.22) is `matches` in disguise and needs the OQX regex
772        // baseline (a `regexp` UDF the store does not register), so it stays
773        // residual exactly as the `matches` call does.
774        BinaryOp::Is
775        | BinaryOp::IsNot
776        | BinaryOp::Match
777        | BinaryOp::MatchI
778        | BinaryOp::NotMatch
779        | BinaryOp::NotMatchI
780        | BinaryOp::Add
781        | BinaryOp::Sub
782        | BinaryOp::Mul
783        | BinaryOp::Div
784        | BinaryOp::Mod => {
785            return None;
786        }
787    })
788}
789
790/// The typed pushes (`spec/surface` §1, 1.2 patch): a bool or num constant
791/// against a JSON or property read, with the stored type tested in SQL
792/// before the value so SQLite cannot conflate JSON `true` with `1` or
793/// `val_bool` with `val_num`. `None` when the pair is not a typed cell (the
794/// plain `IS` / relational form applies) — the gate ([`comparable`]) has
795/// already declined the cells neither form can push.
796///
797/// * json × bool (`==`/`!=`): `(json_type(x) = 'true' | 'false') IS 1`;
798/// * json × num (all six): `(json_type(x) IN ('integer', 'real') AND
799///   json_extract(x) <op> ?) IS 1`;
800/// * prop × bool (`==`/`!=`): the single-scalar-row subquery selecting
801///   `p.type = 'bool' AND p.val_bool = ?` (`spec/properties` §2.1 type
802///   names; booleans bind as 1/0), `(…) IS 1`;
803/// * prop × num (all six): the same subquery selecting `p.type = 'number'
804///   AND p.val_num <op> ?`, `(…) IS 1`.
805///
806/// `!=` wraps `IS NOT 1` around the equality test. `json_type` is NULL for
807/// an absent path and the subquery is NULL for an absent, list-valued or
808/// nested key, so `IS 1` is false and `IS NOT 1` true — the in-memory
809/// absence semantics (unequal, never ordered). The test is normalized to
810/// `read <op> ?`, a relational op flipping when the constant is on the left
811/// (`800 < era` ⇔ `era > 800`), as the reference does.
812fn typed_compare(op: BinaryOp, sql_op: &str, l: &Operand, r: &Operand) -> Option<Frag> {
813    let (read, konst, read_left) = match (&l.shape, &r.shape) {
814        (Shape::Json { .. } | Shape::Prop { .. }, Shape::Const(v)) => (&l.shape, v, true),
815        (Shape::Const(v), Shape::Json { .. } | Shape::Prop { .. }) => (&r.shape, v, false),
816        _ => return None,
817    };
818    let equality = matches!(op, BinaryOp::Eq | BinaryOp::Ne);
819    let wrap = if op == BinaryOp::Ne {
820        "IS NOT 1"
821    } else {
822        "IS 1"
823    };
824    // Normalized to `read <op> ?`: a relational op flips when the constant is
825    // on the left (`800 < era` ⇔ `era > 800`), as in the reference.
826    let inner_op = match (equality, read_left, sql_op) {
827        (true, _, _) => "=",
828        (false, true, _) => sql_op,
829        (false, false, "<") => ">",
830        (false, false, "<=") => ">=",
831        (false, false, ">") => "<",
832        (false, false, ">=") => "<=",
833        (false, false, _) => return None,
834    };
835    let sides = |read_sql: &str| format!("{read_sql} {inner_op} ?");
836    let (sql, params) = match (read, konst) {
837        // Booleans are only ever equal; the gate declines them relational.
838        (_, Value::Bool(_)) if !equality => return None,
839        (Shape::Json { col, path }, Value::Bool(b)) => (
840            format!("(json_type({col}, '{path}') = '{b}') {wrap}"),
841            Vec::new(),
842        ),
843        (Shape::Json { col, path }, Value::Number(_)) => (
844            format!(
845                "(json_type({col}, '{path}') IN ('integer', 'real') AND {}) {wrap}",
846                sides(&format!("json_extract({col}, '{path}')"))
847            ),
848            vec![to_sql(konst)],
849        ),
850        (
851            Shape::Prop {
852                doc_id,
853                key,
854                source,
855            },
856            Value::Bool(_),
857        ) => (
858            format!(
859                "{} {wrap}",
860                prop_row(
861                    doc_id,
862                    key,
863                    &format!("p.type = 'bool' AND {}", sides("p.val_bool")),
864                    *source,
865                )?
866            ),
867            vec![to_sql(konst)],
868        ),
869        (
870            Shape::Prop {
871                doc_id,
872                key,
873                source,
874            },
875            Value::Number(_),
876        ) => (
877            format!(
878                "{} {wrap}",
879                prop_row(
880                    doc_id,
881                    key,
882                    &format!("p.type = 'number' AND {}", sides("p.val_num")),
883                    *source,
884                )?
885            ),
886            vec![to_sql(konst)],
887        ),
888        _ => return None,
889    };
890    Some(Frag {
891        sql: format!("({sql})"),
892        params,
893    })
894}
895
896/// The path equality fast path (`spec/surface` §1 "Paths"): `path == "/a.md"`
897/// (or `!=`, either side) against a ROOTED text constant is `d.path IS ?` with
898/// the constant's storage form, so the `(repo_id, path)` index serves it;
899/// `'/' || d.path IS ?` — what the general form emits — is the same predicate
900/// without the index. Only a rooted constant qualifies (the runner roots every
901/// literal compared with a path read, so that is every literal); a bare
902/// binding stays on the general form, where `'/' || d.path` can never equal
903/// it — exactly the in-memory answer.
904fn path_equality(op: BinaryOp, l: &Operand, r: &Operand) -> Option<Frag> {
905    if !matches!(op, BinaryOp::Eq | BinaryOp::Ne) {
906        return None;
907    }
908    let (col, konst) = match (&l.shape, &r.shape) {
909        (Shape::PathCol(c), Shape::Const(Value::Str(s))) if s.starts_with('/') => (c, s),
910        (Shape::Const(Value::Str(s)), Shape::PathCol(c)) if s.starts_with('/') => (c, s),
911        _ => return None,
912    };
913    Some(Frag {
914        sql: format!("({col} {} ?)", is_op(op)?),
915        params: vec![SqlValue::Text(storage_path(konst).to_owned())],
916    })
917}
918
919/// Translate an expression used as a boolean PREDICATE to a SQL boolean, or
920/// `None` if it cannot be pushed faithfully. Only positive, AND-safe forms
921/// are handled: `unary` (`!`), `in`, bare truthy idents and member
922/// reach-through in predicate position stay residual.
923pub fn translate_predicate(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
924    match e {
925        // Only `&&` composes faithfully in a positive context; `||` is
926        // declined (its NULL / short-circuit interaction stays residual).
927        Expr::Logical {
928            op: LogicalOp::And,
929            left,
930            right,
931            ..
932        } => join2(
933            translate_predicate(left, ctx),
934            translate_predicate(right, ctx),
935            "AND",
936        ),
937        Expr::Binary {
938            op, left, right, ..
939        } => {
940            // An arithmetic operator in predicate position → residual.
941            let sql_op = is_op(*op)?;
942            let l = operand(left, ctx)?;
943            let r = operand(right, ctx)?;
944            if let Some(fast) = path_equality(*op, &l, &r) {
945                return Some(fast);
946            }
947            // The operand-typing gate (§1): the pairs SQLite would compare
948            // with less type than the engine has stay residual.
949            if !comparable(*op, l.ty, r.ty) {
950                return None;
951            }
952            // A bool/num constant against a JSON or property read pushes with
953            // the stored type tested first (the 1.2 typed cells).
954            if let Some(typed) = typed_compare(*op, sql_op, &l, &r) {
955                return Some(typed);
956            }
957            let op = sql_op;
958            // `==`/`!=` → IS / IS NOT (absence-normalized equality, faithful in
959            // any context). Relational ops → plain SQL: a NULL operand yields
960            // NULL, which is excluded in the positive AND context these
961            // fragments are pushed into, matching the absent-operand ⇒ false rule.
962            let mut params = l.frag.params;
963            params.extend(r.frag.params);
964            Some(Frag {
965                sql: format!("({} {op} {})", l.frag.sql, r.frag.sql),
966                params,
967            })
968        }
969        Expr::Call {
970            recv: Some(recv),
971            name,
972            args,
973            ..
974        } if args.len() == 1 => {
975            // startsWith / contains / endsWith — CASE-SENSITIVE, via
976            // substr/instr (never LIKE). `matches` (regex) is declined.
977            let recv = translate_value(recv, ctx)?;
978            let arg = translate_value(&args[0], ctx)?;
979            let mut params = recv.params;
980            let sql = match name.as_str() {
981                "startsWith" => {
982                    // recv begins with arg ⇔ its first length(arg) chars equal arg.
983                    params.extend(arg.params.iter().cloned());
984                    params.extend(arg.params);
985                    format!("(substr({}, 1, length({a})) = {a})", recv.sql, a = arg.sql)
986                }
987                "endsWith" => {
988                    // recv ends with arg ⇔ its last length(arg) chars equal arg.
989                    // When arg is longer than recv, substr clamps to the whole
990                    // (shorter) string, so the equality is false.
991                    params.extend(arg.params.iter().cloned());
992                    params.extend(arg.params);
993                    format!("(substr({}, -length({a})) = {a})", recv.sql, a = arg.sql)
994                }
995                "contains" => {
996                    params.extend(arg.params);
997                    format!("(instr({}, {}) > 0)", recv.sql, arg.sql)
998                }
999                _ => return None,
1000            };
1001            Some(Frag { sql, params })
1002        }
1003        _ => None,
1004    }
1005}
1006
1007/// Combine two optional fragments with a boolean connective; `None` if either
1008/// is untranslatable (the whole conjunct then stays residual).
1009fn join2(a: Option<Frag>, b: Option<Frag>, connective: &str) -> Option<Frag> {
1010    let (a, b) = (a?, b?);
1011    let mut params = a.params;
1012    params.extend(b.params);
1013    Some(Frag {
1014        sql: format!("({} {connective} {})", a.sql, b.sql),
1015        params,
1016    })
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022    use oqx::ast::Where;
1023
1024    const DOCS: TranslateCtx<'static> = TranslateCtx {
1025        target: Target::Docs,
1026        self_alias: "d",
1027        doc_alias: "d",
1028        params: &[],
1029    };
1030    const BLOCKS: TranslateCtx<'static> = TranslateCtx {
1031        target: Target::Blocks,
1032        self_alias: "b",
1033        doc_alias: "d",
1034        params: &[],
1035    };
1036
1037    fn text(s: &str) -> SqlValue {
1038        SqlValue::Text(s.to_owned())
1039    }
1040
1041    fn frag(sql: &str, params: &[SqlValue]) -> Option<Frag> {
1042        Some(Frag {
1043            sql: sql.to_owned(),
1044            params: params.to_vec(),
1045        })
1046    }
1047
1048    /// Parse `from docs where <src>` and return the single scalar predicate.
1049    fn pred(src: &str) -> Expr {
1050        let q = oqx::parse_string(&format!("from docs where {src}")).expect("parses");
1051        match q.r#where {
1052            Some(Where::Scalar { expr, .. }) => expr,
1053            other => panic!("expected a single scalar predicate, got {other:?}"),
1054        }
1055    }
1056
1057    fn ident(name: &str) -> Box<Expr> {
1058        Box::new(Expr::Ident {
1059            name: name.to_owned(),
1060            span: oqx::Span::EMPTY,
1061        })
1062    }
1063
1064    fn lit(s: &str) -> Box<Expr> {
1065        Box::new(Expr::Lit {
1066            value: Value::from(s),
1067            span: oqx::Span::EMPTY,
1068        })
1069    }
1070
1071    fn eq(l: Box<Expr>, r: Box<Expr>) -> Box<Expr> {
1072        Box::new(Expr::Binary {
1073            op: BinaryOp::Eq,
1074            left: l,
1075            right: r,
1076            span: oqx::Span::EMPTY,
1077        })
1078    }
1079
1080    // -- equality is absence-normalized (IS / IS NOT) --
1081
1082    #[test]
1083    fn equality_is_null_safe_is() {
1084        assert_eq!(
1085            translate_predicate(&pred("path == \"index.md\""), &DOCS),
1086            frag("(('/' || d.path) IS ?)", &[text("index.md")])
1087        );
1088    }
1089
1090    // `spec/surface` §1 "Paths" (2.0): `path` is the reference form,
1091    // `'/' || d.path` in SQL; a ROOTED text constant under `==`/`!=` takes the
1092    // indexed fast path on the bare column with its storage form bound, a bare
1093    // one (never equal to a rooted path) stays on the general form.
1094    #[test]
1095    fn a_rooted_path_literal_is_the_indexed_fast_path() {
1096        assert_eq!(
1097            translate_predicate(&pred("path == \"/index.md\""), &DOCS),
1098            frag("(d.path IS ?)", &[text("index.md")])
1099        );
1100        assert_eq!(
1101            translate_predicate(&pred("\"/x.md\" != path"), &DOCS),
1102            frag("(d.path IS NOT ?)", &[text("x.md")])
1103        );
1104        assert_eq!(
1105            translate_predicate(&pred("path < \"/m\""), &DOCS),
1106            frag("(('/' || d.path) < ?)", &[text("/m")])
1107        );
1108        assert_eq!(
1109            translate_predicate(&pred("path == \"index.md\""), &DOCS),
1110            frag("(('/' || d.path) IS ?)", &[text("index.md")])
1111        );
1112    }
1113
1114    #[test]
1115    fn inequality_is_null_safe_is_not() {
1116        assert_eq!(
1117            translate_predicate(&pred("path != \"x\""), &DOCS),
1118            frag("(('/' || d.path) IS NOT ?)", &[text("x")])
1119        );
1120    }
1121
1122    #[test]
1123    fn intrinsic_column_mapping() {
1124        assert_eq!(
1125            translate_predicate(&pred("id == \"d_1\""), &DOCS),
1126            frag("(d.doc_id IS ?)", &[text("d_1")])
1127        );
1128    }
1129
1130    // -- relational ops (plain SQL) --
1131
1132    #[test]
1133    fn relational_ops_are_plain_comparisons() {
1134        assert_eq!(
1135            translate_predicate(&pred("path < \"m\""), &DOCS),
1136            frag("(('/' || d.path) < ?)", &[text("m")])
1137        );
1138        assert_eq!(
1139            translate_predicate(&pred("path >= \"m\""), &DOCS),
1140            frag("(('/' || d.path) >= ?)", &[text("m")])
1141        );
1142        // arithmetic in predicate position → residual
1143        assert_eq!(translate_predicate(&pred("path + 1"), &DOCS), None);
1144    }
1145
1146    // -- string ops are case-sensitive (substr/instr, never LIKE) --
1147
1148    #[test]
1149    fn starts_with_is_substr_equality() {
1150        assert_eq!(
1151            translate_predicate(&pred("path.startsWith(\"lab/\")"), &DOCS),
1152            frag(
1153                "(substr(('/' || d.path), 1, length(?)) = ?)",
1154                &[text("lab/"), text("lab/")]
1155            )
1156        );
1157    }
1158
1159    #[test]
1160    fn lower_then_starts_with_pushes_with_explicit_lower() {
1161        assert_eq!(
1162            translate_predicate(&pred("path.lower().startsWith(\"lab/\")"), &DOCS),
1163            frag(
1164                "(substr(lower(('/' || d.path)), 1, length(?)) = ?)",
1165                &[text("lab/"), text("lab/")]
1166            )
1167        );
1168    }
1169
1170    #[test]
1171    fn contains_is_instr() {
1172        assert_eq!(
1173            translate_predicate(&pred("path.contains(\"notes\")"), &DOCS),
1174            frag("(instr(('/' || d.path), ?) > 0)", &[text("notes")])
1175        );
1176    }
1177
1178    #[test]
1179    fn ends_with_is_negative_substr_equality() {
1180        assert_eq!(
1181            translate_predicate(&pred("path.endsWith(\".md\")"), &DOCS),
1182            frag(
1183                "(substr(('/' || d.path), -length(?)) = ?)",
1184                &[text(".md"), text(".md")]
1185            )
1186        );
1187    }
1188
1189    #[test]
1190    fn upper_wraps_the_receiver_in_value_position() {
1191        assert_eq!(
1192            translate_value(&pred("path.upper()"), &DOCS),
1193            frag("upper(('/' || d.path))", &[])
1194        );
1195    }
1196
1197    // -- bare document properties push via the properties table --
1198
1199    #[test]
1200    fn bare_doc_property_is_the_scalar_in_scope_subquery() {
1201        let f = translate_predicate(&pred("layer == \"canon\""), &DOCS).expect("pushable");
1202        assert!(f.sql.contains("FROM properties p"), "{}", f.sql);
1203        assert!(f.sql.contains("p.key = 'layer'"), "{}", f.sql);
1204        assert!(f.sql.contains("p.card = 'scalar'"), "{}", f.sql);
1205        assert!(
1206            f.sql.starts_with('(') && f.sql.contains(" IS ?)"),
1207            "{}",
1208            f.sql
1209        );
1210        assert_eq!(f.params, vec![text("canon")]);
1211    }
1212
1213    #[test]
1214    fn updated_at_pushes_as_its_revisions_subquery() {
1215        let f =
1216            translate_predicate(&pred("updated_at >= \"2026-01-01\""), &DOCS).expect("pushable");
1217        assert!(
1218            f.sql.contains("FROM revisions r JOIN commits c"),
1219            "{}",
1220            f.sql
1221        );
1222    }
1223
1224    // `created_at` (4.10): the same shape at `seq = 1`, keyed by the document.
1225    #[test]
1226    fn created_at_pushes_as_its_first_revision_subquery() {
1227        let f = translate_predicate(&pred("created_at < \"2026-01-01\""), &DOCS).expect("pushable");
1228        assert_eq!(
1229            f.sql,
1230            "((SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.doc_id = d.doc_id AND r.seq = 1) < ?)"
1231        );
1232        assert_eq!(f.params, vec![text("2026-01-01")]);
1233        // a block's is residual, like its `updated_at`
1234        assert_eq!(
1235            translate_predicate(&pred("created_at == \"x\""), &BLOCKS),
1236            None
1237        );
1238    }
1239
1240    #[test]
1241    fn format_is_a_column_not_a_property() {
1242        assert_eq!(
1243            translate_predicate(&pred("format == \"markdown\""), &DOCS),
1244            frag("(d.format IS ?)", &[text("markdown")])
1245        );
1246    }
1247
1248    #[test]
1249    fn booleans_bind_as_one_and_zero() {
1250        let blocks = TranslateCtx {
1251            target: Target::Blocks,
1252            self_alias: "b",
1253            ..DOCS
1254        };
1255        // (against a text column — against a JSON or property read the
1256        // boolean pushes typed, see the typed-shape tests)
1257        assert_eq!(
1258            translate_predicate(&pred("type == true"), &blocks).map(|f| f.params),
1259            Some(vec![SqlValue::Integer(1)])
1260        );
1261        assert_eq!(
1262            translate_predicate(&pred("type == false"), &blocks).map(|f| f.params),
1263            Some(vec![SqlValue::Integer(0)])
1264        );
1265        assert_eq!(
1266            translate_predicate(&pred("ordinal < 1000"), &blocks).map(|f| f.params),
1267            Some(vec![SqlValue::Real(1000.0)])
1268        );
1269        assert_eq!(
1270            translate_predicate(&pred("path == null"), &DOCS).map(|f| f.params),
1271            Some(vec![SqlValue::Null])
1272        );
1273    }
1274
1275    // -- decline (a): operand typing (spec/surface §1) --
1276
1277    /// One representative expression per [`Ty`] on the blocks target (the only
1278    /// target with an integer column; `doc.<k>` is its property read).
1279    const REPRESENTATIVES: [(Ty, &str); 7] = [
1280        (Ty::Text, "path"),
1281        (Ty::Int, "ordinal"),
1282        (Ty::Num, "1"),
1283        (Ty::Bool, "true"),
1284        (Ty::Null, "null"),
1285        (Ty::Json, "checked"),
1286        (Ty::Prop, "doc.layer"),
1287    ];
1288
1289    #[test]
1290    fn representatives_carry_their_type() {
1291        let blocks = TranslateCtx {
1292            target: Target::Blocks,
1293            self_alias: "b",
1294            ..DOCS
1295        };
1296        for (ty, src) in REPRESENTATIVES {
1297            let (_, got) = typed_value(&pred(src), &blocks).expect(src);
1298            assert_eq!(got, ty, "{src}");
1299        }
1300        assert_eq!(
1301            typed_value(&pred("attrs.a.b"), &blocks).map(|(_, t)| t),
1302            Some(Ty::Json)
1303        );
1304        assert_eq!(
1305            typed_value(&pred("depth"), &blocks).map(|(_, t)| t),
1306            Some(Ty::Int)
1307        );
1308        assert_eq!(
1309            typed_value(&pred("doc_id"), &blocks).map(|(_, t)| t),
1310            Some(Ty::Text)
1311        );
1312        assert_eq!(
1313            typed_value(&pred("type.lower()"), &blocks).map(|(_, t)| t),
1314            Some(Ty::Text)
1315        );
1316        assert_eq!(
1317            typed_value(&pred("checked.upper()"), &blocks).map(|(_, t)| t),
1318            Some(Ty::Text)
1319        );
1320        assert_eq!(
1321            typed_value(&pred("layer"), &DOCS).map(|(_, t)| t),
1322            Some(Ty::Prop)
1323        );
1324        assert_eq!(
1325            typed_value(&pred("format"), &DOCS).map(|(_, t)| t),
1326            Some(Ty::Text)
1327        );
1328    }
1329
1330    #[test]
1331    fn the_comparison_matrix_decides_every_cell() {
1332        // Row/column order: Text Int Num Bool Null Json Prop.
1333        const P: bool = true;
1334        const D: bool = false;
1335        // Typed (1.2): a bool/num constant against a JSON or property read.
1336        const T: bool = true;
1337        // Equality: one side text, or null against a non-property, or both
1338        // numeric, or a typed cell.
1339        #[rustfmt::skip]
1340        const EQUALITY: [[bool; 7]; 7] = [
1341            /* Text */ [P, P, P, P, P, P, P],
1342            /* Int  */ [P, P, P, D, P, D, D],
1343            /* Num  */ [P, P, P, D, P, T, T],
1344            /* Bool */ [P, D, D, D, P, T, T],
1345            /* Null */ [P, P, P, P, P, P, D],
1346            /* Json */ [P, D, T, T, P, D, D],
1347            /* Prop */ [P, D, T, T, D, D, D],
1348        ];
1349        // Relational: both text, both numeric, or a num constant against a
1350        // read (typed); nothing else.
1351        #[rustfmt::skip]
1352        const RELATIONAL: [[bool; 7]; 7] = [
1353            /* Text */ [P, D, D, D, D, D, D],
1354            /* Int  */ [D, P, P, D, D, D, D],
1355            /* Num  */ [D, P, P, D, D, T, T],
1356            /* Bool */ [D, D, D, D, D, D, D],
1357            /* Null */ [D, D, D, D, D, D, D],
1358            /* Json */ [D, D, T, D, D, D, D],
1359            /* Prop */ [D, D, T, D, D, D, D],
1360        ];
1361        let blocks = TranslateCtx {
1362            target: Target::Blocks,
1363            self_alias: "b",
1364            ..DOCS
1365        };
1366        let ops = [
1367            (BinaryOp::Eq, "==", &EQUALITY),
1368            (BinaryOp::Ne, "!=", &EQUALITY),
1369            (BinaryOp::Lt, "<", &RELATIONAL),
1370            (BinaryOp::Le, "<=", &RELATIONAL),
1371            (BinaryOp::Gt, ">", &RELATIONAL),
1372            (BinaryOp::Ge, ">=", &RELATIONAL),
1373        ];
1374        for (i, (a, l)) in REPRESENTATIVES.iter().enumerate() {
1375            for (j, (b, r)) in REPRESENTATIVES.iter().enumerate() {
1376                for (op, spelled, matrix) in ops {
1377                    let want = matrix[i][j];
1378                    assert_eq!(matrix[j][i], want, "the matrix is symmetric ({a:?}, {b:?})");
1379                    assert_eq!(
1380                        comparable(op, *a, *b),
1381                        want,
1382                        "comparable({spelled}, {a:?}, {b:?})"
1383                    );
1384                    let src = format!("{l} {spelled} {r}");
1385                    assert_eq!(
1386                        translate_predicate(&pred(&src), &blocks).is_some(),
1387                        want,
1388                        "{src}"
1389                    );
1390                }
1391            }
1392        }
1393    }
1394
1395    #[test]
1396    fn the_spec_shapes_of_decline_a() {
1397        let blocks = TranslateCtx {
1398            target: Target::Blocks,
1399            self_alias: "b",
1400            ..DOCS
1401        };
1402        // a boolean or number against a JSON read pushes TYPED (1.2): the
1403        // json_type is tested first, so the SQL cannot read JSON `true` as 1
1404        let typed = |src: &str, ctx: &TranslateCtx<'_>| {
1405            let f = translate_predicate(&pred(src), ctx).expect(src);
1406            assert!(
1407                f.sql.contains("json_type(") || f.sql.contains("p.type = "),
1408                "{src}: {}",
1409                f.sql
1410            );
1411            assert!(
1412                f.sql.ends_with(" IS 1)") || f.sql.ends_with(" IS NOT 1)"),
1413                "{src}: {}",
1414                f.sql
1415            );
1416        };
1417        typed("checked == 1", &blocks);
1418        typed("checked == true", &blocks);
1419        typed("attrs.checked == true", &blocks);
1420        // … or a property read (bare on docs, `doc.<k>` elsewhere)
1421        typed("verified == 1", &DOCS);
1422        typed("verified == true", &DOCS);
1423        typed("era < 1000", &DOCS);
1424        typed("doc.era < 1000", &blocks);
1425        // a boolean against an integer column; a number stays pushable
1426        assert_eq!(translate_predicate(&pred("ordinal == true"), &blocks), None);
1427        assert_eq!(
1428            translate_predicate(&pred("ordinal == 1"), &blocks),
1429            frag("(b.ordinal IS ?)", &[SqlValue::Real(1.0)])
1430        );
1431        // null against a property read; against a JSON read or a column it pushes
1432        assert_eq!(translate_predicate(&pred("tags != null"), &DOCS), None);
1433        assert_eq!(translate_predicate(&pred("tags == null"), &DOCS), None);
1434        assert_eq!(
1435            translate_predicate(&pred("doc.tags == null"), &blocks),
1436            None
1437        );
1438        assert_eq!(
1439            translate_predicate(&pred("checked == null"), &blocks),
1440            frag(
1441                "(json_extract(b.attrs, '$.checked') IS ?)",
1442                &[SqlValue::Null]
1443            )
1444        );
1445        assert_eq!(
1446            translate_predicate(&pred("ordinal != null"), &blocks),
1447            frag("(b.ordinal IS NOT ?)", &[SqlValue::Null])
1448        );
1449        // a string literal against anything pushes under equality
1450        assert_eq!(
1451            translate_predicate(&pred("checked == \"x\""), &blocks),
1452            frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1453        );
1454        assert!(translate_predicate(&pred("layer == \"canon\""), &DOCS).is_some());
1455        assert!(translate_predicate(&pred("ordinal == \"1\""), &blocks).is_some());
1456        assert!(translate_predicate(&pred("ordinal != \"1\""), &blocks).is_some());
1457        // … but a relational comparison across text and a number / integer
1458        // declines (the fifth shape): SQLite orders integers before text
1459        assert_eq!(translate_predicate(&pred("ordinal < \"3\""), &blocks), None);
1460        assert_eq!(
1461            translate_predicate(&pred("\"3\" >= ordinal"), &blocks),
1462            None
1463        );
1464        assert_eq!(translate_predicate(&pred("path > 5"), &DOCS), None);
1465        assert_eq!(translate_predicate(&pred("type <= 1"), &blocks), None);
1466        assert_eq!(
1467            translate_predicate(&pred("ordinal < 3"), &blocks),
1468            frag("(b.ordinal < ?)", &[SqlValue::Real(3.0)])
1469        );
1470        assert_eq!(
1471            translate_predicate(&pred("path > \"m\""), &DOCS),
1472            frag("(('/' || d.path) > ?)", &[text("m")])
1473        );
1474        // two reads carry no type at plan time; a boolean equals only text/null
1475        assert_eq!(
1476            translate_predicate(&pred("ordinal == checked"), &blocks),
1477            None
1478        );
1479        assert_eq!(
1480            translate_predicate(&pred("checked == level"), &blocks),
1481            None
1482        );
1483        assert_eq!(
1484            translate_predicate(&pred("doc.era == doc.year"), &blocks),
1485            None
1486        );
1487        assert_eq!(translate_predicate(&pred("level < \"x\""), &blocks), None);
1488        typed("level < 3", &blocks);
1489        assert_eq!(translate_predicate(&pred("true == false"), &blocks), None);
1490        assert_eq!(translate_predicate(&pred("checked < true"), &blocks), None);
1491        assert_eq!(
1492            translate_predicate(&pred("doc.verified >= false"), &blocks),
1493            None
1494        );
1495        assert_eq!(translate_predicate(&pred("ordinal > null"), &blocks), None);
1496        assert!(translate_predicate(&pred("ordinal == depth"), &blocks).is_some());
1497        assert!(translate_predicate(&pred("ordinal <= depth"), &blocks).is_some());
1498        assert!(translate_predicate(&pred("type == null"), &blocks).is_some());
1499    }
1500
1501    // -- the typed pushes (spec/surface §1, 1.2 patch) --
1502
1503    /// The properties subquery's scope: the same single-scalar-row conditions
1504    /// as the scalar read, so a list-valued or nested key yields NULL.
1505    const PROP_SCOPE: &str = "FROM properties p WHERE p.doc_id = d.doc_id AND p.key = 'K' AND p.card = 'scalar' AND p.deleted_commit IS NULL AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = d.doc_id AND p2.key = 'K' AND p2.deleted_commit IS NULL) = 1 LIMIT 1";
1506
1507    fn prop_sql(key: &str, select: &str, wrap: &str) -> String {
1508        format!(
1509            "((SELECT {select} {}) {wrap})",
1510            PROP_SCOPE.replace('K', key)
1511        )
1512    }
1513
1514    #[test]
1515    fn json_against_a_boolean_tests_json_type_for_the_literal() {
1516        let blocks = TranslateCtx {
1517            target: Target::Blocks,
1518            self_alias: "b",
1519            ..DOCS
1520        };
1521        // The boolean is inlined as the JSON type name; nothing binds.
1522        assert_eq!(
1523            translate_predicate(&pred("checked == true"), &blocks),
1524            frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1525        );
1526        assert_eq!(
1527            translate_predicate(&pred("checked == false"), &blocks),
1528            frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1529        );
1530        assert_eq!(
1531            translate_predicate(&pred("checked != true"), &blocks),
1532            frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1533        );
1534        assert_eq!(
1535            translate_predicate(&pred("false == attrs.checked"), &blocks),
1536            frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1537        );
1538        // a bound boolean is the same shape
1539        let params = [Value::Bool(true)];
1540        let e = Expr::Binary {
1541            op: BinaryOp::Ne,
1542            left: ident("checked"),
1543            right: Box::new(Expr::Binding {
1544                index: 0,
1545                span: oqx::Span::EMPTY,
1546            }),
1547            span: oqx::Span::EMPTY,
1548        };
1549        assert_eq!(
1550            translate_predicate(
1551                &e,
1552                &TranslateCtx {
1553                    params: &params,
1554                    ..blocks
1555                }
1556            ),
1557            frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1558        );
1559    }
1560
1561    #[test]
1562    fn json_against_a_number_tests_the_numeric_types_then_compares() {
1563        let nodes = TranslateCtx {
1564            target: Target::Nodes,
1565            self_alias: "n",
1566            ..DOCS
1567        };
1568        let shape = |op: &str, wrap: &str| {
1569            format!(
1570                "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') {op} ?) {wrap})"
1571            )
1572        };
1573        let two = [SqlValue::Real(2.0)];
1574        for (src, op, wrap) in [
1575            ("level == 2", "=", "IS 1"),
1576            ("level != 2", "=", "IS NOT 1"),
1577            ("level < 2", "<", "IS 1"),
1578            ("level <= 2", "<=", "IS 1"),
1579            ("level > 2", ">", "IS 1"),
1580            ("level >= 2", ">=", "IS 1"),
1581        ] {
1582            assert_eq!(
1583                translate_predicate(&pred(src), &nodes),
1584                frag(&shape(op, wrap), &two),
1585                "{src}"
1586            );
1587        }
1588        // a constant on the left is normalized to the right, the op flipped
1589        assert_eq!(
1590            translate_predicate(&pred("2 <= attrs.level"), &nodes),
1591            frag(&shape(">=", "IS 1"), &two)
1592        );
1593        assert_eq!(
1594            translate_predicate(&pred("2 > level"), &nodes),
1595            frag(&shape("<", "IS 1"), &two)
1596        );
1597        assert_eq!(
1598            translate_predicate(&pred("2 != level"), &nodes),
1599            frag(&shape("=", "IS NOT 1"), &two)
1600        );
1601    }
1602
1603    #[test]
1604    fn property_against_a_boolean_tests_p_type_bool_in_the_scalar_row_subquery() {
1605        let blocks = TranslateCtx {
1606            target: Target::Blocks,
1607            self_alias: "b",
1608            ..DOCS
1609        };
1610        assert_eq!(
1611            translate_predicate(&pred("verified == true"), &DOCS),
1612            frag(
1613                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1614                &[SqlValue::Integer(1)]
1615            )
1616        );
1617        assert_eq!(
1618            translate_predicate(&pred("verified != false"), &DOCS),
1619            frag(
1620                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS NOT 1"),
1621                &[SqlValue::Integer(0)]
1622            )
1623        );
1624        // `doc.<k>` from a block reads the owning document's row
1625        assert_eq!(
1626            translate_predicate(&pred("doc.verified == false"), &blocks),
1627            frag(
1628                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1629                &[SqlValue::Integer(0)]
1630            )
1631        );
1632        assert_eq!(
1633            translate_predicate(&pred("true == verified"), &DOCS),
1634            frag(
1635                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1636                &[SqlValue::Integer(1)]
1637            )
1638        );
1639    }
1640
1641    #[test]
1642    fn property_against_a_number_tests_p_type_number_in_the_scalar_row_subquery() {
1643        let blocks = TranslateCtx {
1644            target: Target::Blocks,
1645            self_alias: "b",
1646            ..DOCS
1647        };
1648        let thousand = [SqlValue::Real(1000.0)];
1649        for (src, op, wrap) in [
1650            ("era == 1000", "=", "IS 1"),
1651            ("era != 1000", "=", "IS NOT 1"),
1652            ("era < 1000", "<", "IS 1"),
1653            ("era <= 1000", "<=", "IS 1"),
1654            ("era > 1000", ">", "IS 1"),
1655            ("era >= 1000", ">=", "IS 1"),
1656        ] {
1657            assert_eq!(
1658                translate_predicate(&pred(src), &DOCS),
1659                frag(
1660                    &prop_sql(
1661                        "era",
1662                        &format!("p.type = 'number' AND p.val_num {op} ?"),
1663                        wrap
1664                    ),
1665                    &thousand
1666                ),
1667                "{src}"
1668            );
1669        }
1670        assert_eq!(
1671            translate_predicate(&pred("doc.era >= 1000"), &blocks),
1672            frag(
1673                &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1674                &thousand
1675            )
1676        );
1677        // a constant on the left is normalized to the right, the op flipped
1678        assert_eq!(
1679            translate_predicate(&pred("1000 > era"), &DOCS),
1680            frag(
1681                &prop_sql("era", "p.type = 'number' AND p.val_num < ?", "IS 1"),
1682                &thousand
1683            )
1684        );
1685        assert_eq!(
1686            translate_predicate(&pred("1000 <= era"), &DOCS),
1687            frag(
1688                &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1689                &thousand
1690            )
1691        );
1692    }
1693
1694    #[test]
1695    fn typed_pushes_compose_under_and_and_keep_the_untyped_forms() {
1696        let blocks = TranslateCtx {
1697            target: Target::Blocks,
1698            self_alias: "b",
1699            ..DOCS
1700        };
1701        let e = Expr::Logical {
1702            op: LogicalOp::And,
1703            left: eq(ident("type"), lit("task")),
1704            right: Box::new(Expr::Binary {
1705                op: BinaryOp::Eq,
1706                left: ident("checked"),
1707                right: Box::new(Expr::Lit {
1708                    value: Value::Bool(false),
1709                    span: oqx::Span::EMPTY,
1710                }),
1711                span: oqx::Span::EMPTY,
1712            }),
1713            span: oqx::Span::EMPTY,
1714        };
1715        assert_eq!(
1716            translate_predicate(&e, &blocks),
1717            frag(
1718                "((b.type IS ?) AND ((json_type(b.attrs, '$.checked') = 'false') IS 1))",
1719                &[text("task")]
1720            )
1721        );
1722        // text and null against a read stay the plain IS form
1723        assert_eq!(
1724            translate_predicate(&pred("checked == \"x\""), &blocks),
1725            frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1726        );
1727        assert_eq!(
1728            translate_predicate(&pred("checked != null"), &blocks),
1729            frag(
1730                "(json_extract(b.attrs, '$.checked') IS NOT ?)",
1731                &[SqlValue::Null]
1732            )
1733        );
1734        // an unsafe key never inlines, typed or not
1735        assert_eq!(
1736            prop_row("d", "x'y", "p.type = 'number' AND p.val_num = ?", None),
1737            None
1738        );
1739    }
1740
1741    #[test]
1742    fn bindings_are_typed_by_their_value() {
1743        let blocks = TranslateCtx {
1744            target: Target::Blocks,
1745            self_alias: "b",
1746            ..DOCS
1747        };
1748        let params = [
1749            Value::from("s"),
1750            Value::from(1.0),
1751            Value::Bool(true),
1752            Value::Null,
1753            Value::Array(vec![]),
1754        ];
1755        let ctx = TranslateCtx {
1756            params: &params,
1757            ..blocks
1758        };
1759        let against = |i: usize, rhs: &str| {
1760            let e = Expr::Binary {
1761                op: BinaryOp::Eq,
1762                left: Box::new(Expr::Binding {
1763                    index: i,
1764                    span: oqx::Span::EMPTY,
1765                }),
1766                right: Box::new(pred(rhs)),
1767                span: oqx::Span::EMPTY,
1768            };
1769            translate_predicate(&e, &ctx).is_some()
1770        };
1771        // text binding pushes against anything; number/boolean push typed
1772        // against JSON (1.2), a boolean not against an integer column
1773        assert!(against(0, "checked"));
1774        assert!(against(1, "checked"));
1775        assert!(against(2, "checked"));
1776        assert!(!against(2, "ordinal"));
1777        assert!(against(1, "ordinal"));
1778        // a null binding pushes against JSON, not against a property
1779        assert!(against(3, "checked"));
1780        assert!(!against(3, "doc.tags"));
1781        // an absent binding (past the end) is null
1782        assert!(against(9, "checked"));
1783        assert!(!against(9, "doc.tags"));
1784        // a non-scalar binding has no faithful SQL value
1785        assert!(!against(4, "type"));
1786    }
1787
1788    // -- decline (b): handles are not property reads --
1789
1790    #[test]
1791    fn relation_and_handle_names_are_not_property_reads() {
1792        let blocks = TranslateCtx {
1793            target: Target::Blocks,
1794            self_alias: "b",
1795            ..DOCS
1796        };
1797        let nodes = TranslateCtx {
1798            target: Target::Nodes,
1799            self_alias: "n",
1800            ..DOCS
1801        };
1802        let edges = TranslateCtx {
1803            target: Target::Links,
1804            self_alias: "e",
1805            ..DOCS
1806        };
1807        for (ctx, target) in [
1808            (&DOCS, Target::Docs),
1809            (&blocks, Target::Blocks),
1810            (&nodes, Target::Nodes),
1811            (&edges, Target::Links),
1812        ] {
1813            for name in non_property_handles(target) {
1814                let src = format!("{name} == null");
1815                assert_eq!(
1816                    translate_predicate(&pred(&src), ctx),
1817                    None,
1818                    "{target:?}: {src}"
1819                );
1820                let src = format!("{name} == \"x\"");
1821                assert_eq!(
1822                    translate_predicate(&pred(&src), ctx),
1823                    None,
1824                    "{target:?}: {src}"
1825                );
1826            }
1827        }
1828        // the fixtures' shapes
1829        assert_eq!(translate_predicate(&pred("nodes == null"), &DOCS), None);
1830        assert_eq!(
1831            translate_predicate(&pred("frontmatter == null"), &DOCS),
1832            None
1833        );
1834        assert_eq!(translate_predicate(&pred("nodes == null"), &blocks), None);
1835        assert_eq!(translate_predicate(&pred("attrs == null"), &blocks), None);
1836        // `doc.<handle>` is the doc's handle, not its property; `doc.<k>` still pushes
1837        assert_eq!(
1838            translate_predicate(&pred("doc.nodes == null"), &blocks),
1839            None
1840        );
1841        assert_eq!(
1842            translate_predicate(&pred("doc.frontmatter == null"), &blocks),
1843            None
1844        );
1845        assert_eq!(translate_predicate(&pred("doc.doc == null"), &DOCS), None);
1846        assert!(translate_predicate(&pred("doc.layer == \"canon\""), &blocks).is_some());
1847        // a plain attribute or property of the same spelling elsewhere still pushes
1848        assert!(translate_predicate(&pred("section == \"x\""), &DOCS).is_some());
1849        assert!(translate_predicate(&pred("frontmatter == \"x\""), &blocks).is_some());
1850    }
1851
1852    /// The handle sets are exactly the keys the store context resolves to
1853    /// rows, a row or a bag before its property fallback: over a small
1854    /// observed corpus, on every row of a target, a name in the set never
1855    /// reads as a scalar, and at least one row resolves it to rows / a row /
1856    /// an object; a name outside the set never does.
1857    #[test]
1858    fn handle_sets_match_the_store_context() {
1859        use std::collections::HashMap;
1860
1861        use omgbase_reconcile::Config;
1862        use omgbase_store::{BatchItem, Store};
1863        use oqx::DataContext;
1864
1865        use crate::context::StoreContext;
1866
1867        let mut store = Store::open_in_memory().expect("store");
1868        let repo = store.create_repo("handles").expect("repo");
1869        let items = [
1870            BatchItem::observed(
1871                "a.md",
1872                "---\ntitle: A\nlayer: canon\nverified: true\n---\n# Heading\n\nSee [b](b.md) and [[b]].\n\n- [ ] task\n  - nested\n\nkey:: value\n\n## Sub\n\ntext\n",
1873            ),
1874            BatchItem::observed("b.md", "# B\n\nBack to [a](a.md).\n"),
1875        ];
1876        store
1877            .observe_batch(
1878                &repo,
1879                &items,
1880                "2026-09-26T00:00:00.000Z",
1881                &Config::default(),
1882            )
1883            .expect("observe");
1884        let ctx = StoreContext::new(store.conn(), &repo, HashMap::new());
1885
1886        let mut universe: Vec<&str> = crate::context::ALL_TARGETS
1887            .into_iter()
1888            .flat_map(|t| non_property_handles(t).iter().copied())
1889            .collect();
1890        universe.extend([
1891            "layer",
1892            "title",
1893            "verified",
1894            "checked",
1895            "level",
1896            "format",
1897            "type",
1898            "text",
1899            "kind",
1900            "name",
1901            "value",
1902            "predicate",
1903            "key",
1904            "nope",
1905        ]);
1906        universe.sort_unstable();
1907        universe.dedup();
1908
1909        let is_shape = |v: &Value| matches!(v, Value::Array(_) | Value::Object(_));
1910        for target in crate::context::ALL_TARGETS {
1911            // a root is a lazy scan marker; `to_rows` reads it
1912            let rows = ctx.to_rows(&ctx.root(target.as_str()));
1913            assert!(!rows.is_empty(), "{target:?} has rows");
1914            let set = non_property_handles(target);
1915            for name in &universe {
1916                let mut shaped = 0;
1917                for row in &rows {
1918                    let v = match ctx.get(row, name) {
1919                        Ok(v) => v,
1920                        // a target name that is not one of this row's
1921                        // relations is the root-row error, never a property
1922                        Err(_) if Target::parse(name).is_some() && !set.contains(name) => continue,
1923                        Err(e) => panic!("{target:?}.{name}: {e}"),
1924                    };
1925                    if set.contains(name) {
1926                        assert!(
1927                            v.is_absent() || is_shape(&v),
1928                            "{target:?}.{name} read as a scalar: {v:?}"
1929                        );
1930                    } else {
1931                        assert!(!is_shape(&v), "{target:?}.{name} is a handle: {v:?}");
1932                    }
1933                    shaped += usize::from(is_shape(&v));
1934                }
1935                if set.contains(name) {
1936                    assert!(
1937                        shaped > 0,
1938                        "{target:?}.{name} never resolved to rows or a bag"
1939                    );
1940                }
1941            }
1942        }
1943    }
1944
1945    // -- declines (left residual) return None --
1946
1947    #[test]
1948    fn attributes_that_are_not_columns_decline_and_are_never_properties() {
1949        // 3.0: the attribute wins — declined, never read as a same-named
1950        // property or attr.
1951        assert_eq!(translate_predicate(&pred("title == \"x\""), &DOCS), None);
1952        assert_eq!(translate_predicate(&pred("tags == \"x\""), &DOCS), None);
1953        assert_eq!(translate_predicate(&pred("raw == \"x\""), &DOCS), None);
1954        assert_eq!(
1955            translate_predicate(&pred("doc.title == \"x\""), &BLOCKS),
1956            None
1957        );
1958        assert_eq!(
1959            translate_predicate(&pred("doc.raw == \"x\""), &BLOCKS),
1960            None
1961        );
1962        assert_eq!(translate_predicate(&pred("raw == \"x\""), &BLOCKS), None);
1963        // a source handle is a non-property name on docs
1964        assert_eq!(translate_predicate(&pred("data == null"), &DOCS), None);
1965        // the pushable attributes push, bare or through `doc.`
1966        assert!(translate_predicate(&pred("path == \"x\""), &DOCS).is_some());
1967        assert!(translate_predicate(&pred("hash == \"x\""), &DOCS).is_some());
1968        assert!(translate_predicate(&pred("doc.path == \"x\""), &BLOCKS).is_some());
1969    }
1970
1971    #[test]
1972    fn a_key_read_through_a_source_handle_is_pushed_scoped_to_its_source() {
1973        // `data.<k>` is exactly the bare read (same SQL, no source clause).
1974        let bare = translate_predicate(&pred("layer == \"canon\""), &DOCS).unwrap();
1975        let data = translate_predicate(&pred("data.layer == \"canon\""), &DOCS).unwrap();
1976        assert_eq!(bare, data);
1977        assert!(!bare.sql.contains("p.source"));
1978        // `frontmatter.<k>` / `inline.<k>`: the row AND the count are of that source.
1979        let fm = translate_predicate(&pred("frontmatter.layer == \"canon\""), &DOCS).unwrap();
1980        assert_eq!(
1981            fm.sql,
1982            "((SELECT COALESCE(p.val_text, p.val_num, p.val_bool) FROM properties p \
1983              WHERE p.doc_id = d.doc_id AND p.key = 'layer' AND p.card = 'scalar' AND p.deleted_commit IS NULL AND p.source = 'frontmatter' \
1984              AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = d.doc_id AND p2.key = 'layer' AND p2.deleted_commit IS NULL AND p2.source = 'frontmatter') = 1 \
1985              LIMIT 1) IS ?)"
1986        );
1987        let inl = translate_predicate(&pred("doc.inline.element == \"fire\""), &BLOCKS).unwrap();
1988        assert!(
1989            inl.sql.contains("p.source = 'inline'") && inl.sql.contains("p2.source = 'inline'")
1990        );
1991        assert!(translate_predicate(&pred("doc.data.layer == \"canon\""), &BLOCKS).is_some());
1992        // a typed push keeps the scope
1993        let typed = translate_predicate(&pred("frontmatter.verified == true"), &DOCS).unwrap();
1994        assert!(
1995            typed.sql.contains("p.type = 'bool'") && typed.sql.contains("p.source = 'frontmatter'")
1996        );
1997        // a WHOLE handle, a deeper chain and `entries(…)` still decline
1998        assert_eq!(
1999            translate_predicate(&pred("frontmatter == null"), &DOCS),
2000            None
2001        );
2002        assert_eq!(translate_predicate(&pred("data == null"), &DOCS), None);
2003        assert_eq!(
2004            translate_predicate(&pred("frontmatter.meta.owner == \"x\""), &DOCS),
2005            None
2006        );
2007        assert_eq!(
2008            translate_predicate(&pred("doc.frontmatter == null"), &BLOCKS),
2009            None
2010        );
2011        assert_eq!(
2012            translate_predicate(&pred("size(entries(frontmatter)) > 0"), &DOCS),
2013            None
2014        );
2015    }
2016
2017    #[test]
2018    fn docs_raw_and_computed_intrinsics_are_not_columns() {
2019        assert_eq!(translate_predicate(&pred("raw == \"x\""), &DOCS), None);
2020        assert_eq!(translate_predicate(&pred("title == \"x\""), &DOCS), None);
2021        assert_eq!(translate_predicate(&pred("tags == \"x\""), &DOCS), None);
2022    }
2023
2024    #[test]
2025    fn matches_needs_a_regexp_udf() {
2026        assert_eq!(
2027            translate_predicate(&pred("path.matches(\"^lab/\")"), &DOCS),
2028            None
2029        );
2030    }
2031
2032    #[test]
2033    fn negation_as_a_nested_expr_is_not_and_safe() {
2034        let e = Expr::Unary {
2035            op: oqx::ast::UnaryOp::Not,
2036            expr: ident("path"),
2037            span: oqx::Span::EMPTY,
2038        };
2039        assert_eq!(translate_predicate(&e, &DOCS), None);
2040    }
2041
2042    #[test]
2043    fn disjunction_as_a_nested_expr_is_declined() {
2044        let e = Expr::Logical {
2045            op: LogicalOp::Or,
2046            left: eq(ident("path"), lit("a")),
2047            right: eq(ident("path"), lit("b")),
2048            span: oqx::Span::EMPTY,
2049        };
2050        assert_eq!(translate_predicate(&e, &DOCS), None);
2051    }
2052
2053    #[test]
2054    fn unmapped_node_intrinsic_is_not_pushed() {
2055        let nodes = TranslateCtx {
2056            target: Target::Nodes,
2057            self_alias: "n",
2058            ..DOCS
2059        };
2060        assert_eq!(
2061            translate_predicate(&pred("$locator == \"x\""), &nodes),
2062            None
2063        );
2064        // `updated_at` is mapped on docs only.
2065        let blocks = TranslateCtx {
2066            target: Target::Blocks,
2067            self_alias: "b",
2068            ..DOCS
2069        };
2070        assert_eq!(
2071            translate_predicate(&pred("updated_at == \"x\""), &blocks),
2072            None
2073        );
2074    }
2075
2076    #[test]
2077    fn range_membership_in_and_bare_idents_are_declined() {
2078        assert_eq!(translate_predicate(&pred("era in 800..1680"), &DOCS), None);
2079        assert_eq!(
2080            translate_predicate(&pred("\"a\" in list(tags)"), &DOCS),
2081            None
2082        );
2083        assert_eq!(translate_predicate(&pred("verified"), &DOCS), None);
2084        assert_eq!(translate_predicate(&pred("doc.verified"), &DOCS), None);
2085        assert_eq!(translate_predicate(&pred("size(tags) > 1"), &DOCS), None);
2086        assert_eq!(translate_predicate(&pred("$self.text(\"x\")"), &DOCS), None);
2087        assert_eq!(translate_predicate(&pred("$it == \"x\""), &DOCS), None);
2088        assert_eq!(translate_predicate(&pred("^slug == \"x\""), &DOCS), None);
2089        // a WHOLE source handle (3.0: `frontmatter.<k>` itself is pushed)
2090        assert_eq!(
2091            translate_predicate(&pred("frontmatter == null"), &DOCS),
2092            None
2093        );
2094    }
2095
2096    // -- conjunction and bindings via constructed AST --
2097
2098    #[test]
2099    fn and_composes_two_pushable_comparisons() {
2100        let e = Expr::Logical {
2101            op: LogicalOp::And,
2102            left: eq(ident("path"), lit("a")),
2103            right: Box::new(Expr::Binary {
2104                op: BinaryOp::Ne,
2105                left: ident("id"),
2106                right: lit("d_2"),
2107                span: oqx::Span::EMPTY,
2108            }),
2109            span: oqx::Span::EMPTY,
2110        };
2111        assert_eq!(
2112            translate_predicate(&e, &DOCS),
2113            frag(
2114                "((('/' || d.path) IS ?) AND (d.doc_id IS NOT ?))",
2115                &[text("a"), text("d_2")]
2116            )
2117        );
2118    }
2119
2120    #[test]
2121    fn and_declines_wholesale_if_either_side_is_not_pushable() {
2122        let e = Expr::Logical {
2123            op: LogicalOp::And,
2124            left: eq(ident("path"), lit("a")),
2125            // raw is reconstructed, not a column → the whole && declines.
2126            right: eq(ident("raw"), lit("x")),
2127            span: oqx::Span::EMPTY,
2128        };
2129        assert_eq!(translate_predicate(&e, &DOCS), None);
2130    }
2131
2132    #[test]
2133    fn resolves_a_binding_to_its_param_value() {
2134        let e = eq(
2135            ident("path"),
2136            Box::new(Expr::Binding {
2137                index: 0,
2138                span: oqx::Span::EMPTY,
2139            }),
2140        );
2141        let params = [Value::from("from-binding.md")];
2142        let ctx = TranslateCtx {
2143            params: &params,
2144            ..DOCS
2145        };
2146        assert_eq!(
2147            translate_predicate(&e, &ctx),
2148            frag("(('/' || d.path) IS ?)", &[text("from-binding.md")])
2149        );
2150        // A binding past the end reads as absent → NULL.
2151        assert_eq!(
2152            translate_predicate(&e, &DOCS),
2153            frag("(('/' || d.path) IS ?)", &[SqlValue::Null])
2154        );
2155    }
2156
2157    // -- per-target fields --
2158
2159    #[test]
2160    fn blocks_and_nodes_flatten_bare_identifiers_into_attrs() {
2161        let blocks = TranslateCtx {
2162            target: Target::Blocks,
2163            self_alias: "b",
2164            ..DOCS
2165        };
2166        // (a top-level `&&` is a `Where::And` of scalars; the nested form is
2167        // reached through constructed AST, as in the reference's tests)
2168        let both = Expr::Logical {
2169            op: LogicalOp::And,
2170            left: eq(ident("type"), lit("task")),
2171            right: eq(ident("marker"), lit("x")),
2172            span: oqx::Span::EMPTY,
2173        };
2174        assert_eq!(
2175            translate_predicate(&both, &blocks),
2176            frag(
2177                "((b.type IS ?) AND (json_extract(b.attrs, '$.marker') IS ?))",
2178                &[text("task"), text("x")]
2179            )
2180        );
2181        assert_eq!(
2182            translate_predicate(&pred("attrs.marker == \"x\""), &blocks),
2183            frag("(json_extract(b.attrs, '$.marker') IS ?)", &[text("x")])
2184        );
2185        // (a boolean or number against a JSON read pushes typed — see the
2186        // typed-shape tests)
2187        assert_eq!(
2188            translate_predicate(&pred("attrs.checked == true"), &blocks),
2189            frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
2190        );
2191        let nodes = TranslateCtx {
2192            target: Target::Nodes,
2193            self_alias: "n",
2194            ..DOCS
2195        };
2196        assert_eq!(
2197            translate_predicate(&pred("kind == \"md:section\""), &nodes),
2198            frag("(n.kind IS ?)", &[text("md:section")])
2199        );
2200        assert_eq!(
2201            translate_predicate(&pred("level == \"1\""), &nodes),
2202            frag("(json_extract(n.attrs, '$.level') IS ?)", &[text("1")])
2203        );
2204        assert_eq!(
2205            translate_predicate(&pred("level == 1"), &nodes),
2206            frag(
2207                "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') = ?) IS 1)",
2208                &[SqlValue::Real(1.0)]
2209            )
2210        );
2211        assert_eq!(
2212            translate_predicate(&pred("attrs.a.b == \"c\""), &nodes),
2213            frag("(json_extract(n.attrs, '$.a.b') IS ?)", &[text("c")])
2214        );
2215        // `attrs.<k>` is a blocks/nodes form; on docs it is not a column.
2216        assert_eq!(
2217            translate_predicate(&pred("attrs.marker == \"x\""), &DOCS),
2218            None
2219        );
2220    }
2221
2222    #[test]
2223    fn doc_and_block_reach_through() {
2224        let blocks = TranslateCtx {
2225            target: Target::Blocks,
2226            self_alias: "b",
2227            ..DOCS
2228        };
2229        let f = translate_predicate(&pred("doc.type == \"lab-note\""), &blocks).expect("pushable");
2230        assert!(
2231            f.sql.contains("p.doc_id = d.doc_id AND p.key = 'type'"),
2232            "{}",
2233            f.sql
2234        );
2235        assert_eq!(
2236            translate_predicate(&pred("doc.path == \"a.md\""), &blocks),
2237            frag("(('/' || d.path) IS ?)", &[text("a.md")])
2238        );
2239        // a rooted literal (what the runner's rewrite always produces) takes
2240        // the indexed fast path on the storage column
2241        assert_eq!(
2242            translate_predicate(&pred("doc.path == \"/a.md\""), &blocks),
2243            frag("(d.path IS ?)", &[text("a.md")])
2244        );
2245        assert_eq!(
2246            translate_predicate(&pred("doc.format == \"markdown\""), &blocks),
2247            frag("(d.format IS ?)", &[text("markdown")])
2248        );
2249        // FK elimination: `doc.id` is the row's own FK column (3.0)
2250        assert_eq!(
2251            translate_predicate(&pred("doc.id == \"d_1\""), &blocks),
2252            frag("(b.doc_id IS ?)", &[text("d_1")])
2253        );
2254        assert_eq!(translate_predicate(&pred("doc.a.b == 1"), &blocks), None);
2255        let nodes = TranslateCtx {
2256            target: Target::Nodes,
2257            self_alias: "n",
2258            ..DOCS
2259        };
2260        assert_eq!(
2261            translate_predicate(&pred("block.type == \"task\""), &nodes),
2262            frag(
2263                "((SELECT bb.type FROM blocks bb WHERE bb.block_id = n.block_id) IS ?)",
2264                &[text("task")]
2265            )
2266        );
2267        assert_eq!(
2268            translate_predicate(&pred("block.type == \"task\""), &blocks),
2269            None
2270        );
2271        assert_eq!(
2272            translate_predicate(&pred("section.level == 1"), &nodes),
2273            None
2274        );
2275    }
2276
2277    #[test]
2278    fn edges_push_their_plain_fields_under_the_surface_names() {
2279        let edges = TranslateCtx {
2280            target: Target::Links,
2281            self_alias: "e",
2282            ..DOCS
2283        };
2284        assert_eq!(
2285            translate_predicate(&pred("predicate == \"references\""), &edges),
2286            frag("(e.predicate IS ?)", &[text("references")])
2287        );
2288        assert_eq!(
2289            translate_predicate(&pred("dst_path == \"index.md\""), &edges),
2290            frag(
2291                "((SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2292                &[text("index.md")]
2293            )
2294        );
2295        assert_eq!(
2296            translate_predicate(&pred("block_id == \"b_1\""), &edges),
2297            frag("(e.src_block IS ?)", &[text("b_1")])
2298        );
2299        assert_eq!(
2300            translate_predicate(&pred("via_id == null"), &edges),
2301            frag("(e.via_node IS ?)", &[SqlValue::Null])
2302        );
2303        assert_eq!(
2304            translate_predicate(&pred("dst_id == \"d_2\""), &edges),
2305            frag("(e.dst_node IS ?)", &[text("d_2")])
2306        );
2307        assert_eq!(
2308            translate_predicate(&pred("field == \"before\""), &edges),
2309            frag("(e.src_field IS ?)", &[text("before")])
2310        );
2311        assert!(
2312            translate_predicate(&pred("uri == \"https://x\""), &edges)
2313                .is_some_and(|f| f.sql.contains("FROM external_nodes xn"))
2314        );
2315        assert_eq!(translate_predicate(&pred("weight == 1"), &edges), None);
2316        // the dropped names are no longer intrinsics: an unknown `$x`
2317        // declines, `from_commit` is gone, `src_field` is `field`
2318        assert_eq!(translate_predicate(&pred("$src == \"d_1\""), &edges), None);
2319        assert_eq!(
2320            translate_predicate(&pred("$from_commit == \"c_1\""), &edges),
2321            None
2322        );
2323        assert_eq!(
2324            translate_predicate(&pred("src_field == \"x\""), &edges),
2325            None
2326        );
2327        let blocks = TranslateCtx {
2328            target: Target::Blocks,
2329            self_alias: "b",
2330            ..DOCS
2331        };
2332        assert_eq!(translate_predicate(&pred("$doc == \"d_1\""), &blocks), None);
2333        assert_eq!(translate_predicate(&pred("$ordinal == 1"), &blocks), None);
2334    }
2335
2336    /// Foreign-key elimination (`spec/surface` §1, 3.0): a reach-through to
2337    /// the owner's identity is the row's own FK column, byte for byte the SQL
2338    /// the reference emits.
2339    #[test]
2340    fn reach_through_identity_is_the_foreign_key() {
2341        let blocks = TranslateCtx {
2342            target: Target::Blocks,
2343            self_alias: "b",
2344            ..DOCS
2345        };
2346        let nodes = TranslateCtx {
2347            target: Target::Nodes,
2348            self_alias: "n",
2349            ..DOCS
2350        };
2351        let edges = TranslateCtx {
2352            target: Target::Links,
2353            self_alias: "e",
2354            ..DOCS
2355        };
2356        // doc.id is the FK column on every joined target, the same SQL as doc_id
2357        assert_eq!(
2358            translate_predicate(&pred("doc.id == \"d_1\""), &blocks),
2359            frag("(b.doc_id IS ?)", &[text("d_1")])
2360        );
2361        assert_eq!(
2362            translate_predicate(&pred("doc_id == \"d_1\""), &blocks),
2363            frag("(b.doc_id IS ?)", &[text("d_1")])
2364        );
2365        assert_eq!(
2366            translate_predicate(&pred("doc.id == \"d_1\""), &nodes),
2367            frag("(n.doc_id IS ?)", &[text("d_1")])
2368        );
2369        assert_eq!(
2370            translate_predicate(&pred("doc.id == \"d_1\""), &edges),
2371            frag("(e.src_doc IS ?)", &[text("d_1")])
2372        );
2373        assert_eq!(
2374            translate_predicate(&pred("doc_id == \"d_1\""), &edges),
2375            frag("(e.src_doc IS ?)", &[text("d_1")])
2376        );
2377        assert_eq!(
2378            translate_predicate(&pred("doc.id == \"d_1\""), &DOCS),
2379            frag("(d.doc_id IS ?)", &[text("d_1")])
2380        );
2381        // doc.path / doc.hash / doc.updated_at read the joined document columns
2382        assert_eq!(
2383            translate_predicate(&pred("doc.path == \"/a.md\""), &blocks),
2384            frag("(d.path IS ?)", &[text("a.md")])
2385        );
2386        assert_eq!(
2387            translate_predicate(&pred("doc.path == \"/a.md\""), &edges),
2388            frag("(d.path IS ?)", &[text("a.md")])
2389        );
2390        assert_eq!(
2391            translate_predicate(&pred("doc.hash == \"ab\""), &blocks),
2392            frag("(lower(hex(d.file_hash)) IS ?)", &[text("ab")])
2393        );
2394        assert!(
2395            translate_predicate(&pred("doc.updated_at >= \"2026\""), &nodes)
2396                .is_some_and(|f| f.sql.contains("r.rev_id = d.current_rev"))
2397        );
2398        assert!(
2399            translate_predicate(&pred("doc.created_at >= \"2026\""), &nodes)
2400                .is_some_and(|f| f.sql.contains("r.doc_id = d.doc_id AND r.seq = 1"))
2401        );
2402        assert_eq!(
2403            translate_predicate(&pred("doc.raw == \"x\""), &blocks),
2404            None
2405        );
2406        // block.id / block.path / block.doc_id on a node are the node's own columns
2407        assert_eq!(
2408            translate_predicate(&pred("block.id == \"b_1\""), &nodes),
2409            frag("(n.block_id IS ?)", &[text("b_1")])
2410        );
2411        assert_eq!(
2412            translate_predicate(&pred("block_id == \"b_1\""), &nodes),
2413            frag("(n.block_id IS ?)", &[text("b_1")])
2414        );
2415        assert_eq!(
2416            translate_predicate(&pred("block.path == \"/a.md\""), &nodes),
2417            frag("(d.path IS ?)", &[text("a.md")])
2418        );
2419        assert_eq!(
2420            translate_predicate(&pred("block.doc_id == \"d_1\""), &nodes),
2421            frag("(n.doc_id IS ?)", &[text("d_1")])
2422        );
2423        assert!(
2424            translate_predicate(&pred("block.text == \"x\""), &nodes)
2425                .is_some_and(|f| f.sql.contains("SELECT bb.text FROM blocks bb"))
2426        );
2427        assert_eq!(
2428            translate_predicate(&pred("block.hash == \"x\""), &nodes),
2429            None
2430        );
2431    }
2432
2433    /// `dst.<k>` on a link reaches the destination DOCUMENT by the row's own
2434    /// `dst_node` (3.0 follow-up), byte for byte the reference's SQL.
2435    #[test]
2436    fn dst_reach_through_reads_the_destination_by_its_foreign_key() {
2437        let edges = TranslateCtx {
2438            target: Target::Links,
2439            self_alias: "e",
2440            ..DOCS
2441        };
2442        // identity and the document's own columns: a primary-key probe, NULL
2443        // where `dst` is absent in memory
2444        assert_eq!(
2445            translate_predicate(&pred("dst.id == \"d_2\""), &edges),
2446            frag(
2447                "((SELECT dd.doc_id FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2448                &[text("d_2")]
2449            )
2450        );
2451        assert_eq!(
2452            translate_predicate(&pred("dst.path == \"/a.md\""), &edges),
2453            frag(
2454                "((SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2455                &[text("/a.md")]
2456            )
2457        );
2458        assert_eq!(
2459            translate_predicate(&pred("dst.format == \"yaml\""), &edges),
2460            frag(
2461                "((SELECT dd.format FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2462                &[text("yaml")]
2463            )
2464        );
2465        assert!(
2466            translate_predicate(&pred("dst.hash == \"ab\""), &edges)
2467                .is_some_and(|f| f.sql.contains("lower(hex(dd.file_hash))"))
2468        );
2469        assert!(
2470            translate_predicate(&pred("dst.updated_at == \"t\""), &edges)
2471                .is_some_and(|f| f.sql.contains("WHERE r.rev_id = dd.current_rev"))
2472        );
2473        assert!(
2474            translate_predicate(&pred("dst.created_at == \"t\""), &edges)
2475                .is_some_and(|f| f.sql.contains("WHERE r.doc_id = dd.doc_id AND r.seq = 1"))
2476        );
2477        // a property: the properties scope keyed by e.dst_node itself
2478        let prop = translate_predicate(&pred("dst.layer == \"canon\""), &edges).expect("pushed");
2479        assert!(
2480            prop.sql
2481                .contains("p.doc_id = e.dst_node AND p.key = 'layer'"),
2482            "{}",
2483            prop.sql
2484        );
2485        assert_eq!(prop.params, vec![text("canon")]);
2486        // a typed push keeps the same scope
2487        assert!(
2488            translate_predicate(&pred("dst.era > 1600"), &edges)
2489                .is_some_and(|f| f.sql.contains("p.doc_id = e.dst_node AND p.key = 'era'"))
2490        );
2491        // through a source handle
2492        assert!(
2493            translate_predicate(&pred("dst.frontmatter.layer == \"canon\""), &edges)
2494                .is_some_and(|f| f.sql.contains("p.source = 'frontmatter'"))
2495        );
2496        // the attributes the owner's reach-through declines decline here too,
2497        // and so do the relations, the whole handle and a bare `dst`
2498        for k in [
2499            "raw",
2500            "title",
2501            "tags",
2502            "nodes",
2503            "out_links",
2504            "data",
2505            "docs",
2506            "$x",
2507        ] {
2508            assert_eq!(
2509                translate_predicate(&pred(&format!("dst.{k} == \"x\"")), &edges),
2510                None,
2511                "dst.{k}"
2512            );
2513        }
2514        assert_eq!(translate_predicate(&pred("dst == null"), &edges), None);
2515        // `dst` is a link's name only
2516        assert_eq!(
2517            translate_predicate(&pred("dst.layer == \"canon\""), &DOCS),
2518            None
2519        );
2520    }
2521
2522    /// `block.<k>` on a link (4.11) is the SOURCE block: `block.id` the FK
2523    /// column, the rest a probe by `src_block` (NULL for a frontmatter
2524    /// link), byte for byte the reference's SQL.
2525    #[test]
2526    fn block_reach_through_on_a_link_reads_the_source_block_by_its_foreign_key() {
2527        let edges = TranslateCtx {
2528            target: Target::Links,
2529            self_alias: "e",
2530            ..DOCS
2531        };
2532        assert_eq!(
2533            translate_predicate(&pred("block.id == \"b_1\""), &edges),
2534            frag("(e.src_block IS ?)", &[text("b_1")])
2535        );
2536        assert_eq!(
2537            translate_predicate(&pred("block.type == \"paragraph\""), &edges),
2538            frag(
2539                "((SELECT bb.type FROM blocks bb WHERE bb.block_id = e.src_block) IS ?)",
2540                &[text("paragraph")]
2541            )
2542        );
2543        assert!(
2544            translate_predicate(&pred("block.text == \"x\""), &edges).is_some_and(|f| f
2545                .sql
2546                .contains("SELECT bb.text FROM blocks bb WHERE bb.block_id = e.src_block"))
2547        );
2548        assert_eq!(
2549            translate_predicate(&pred("block.path == \"/a.md\""), &edges),
2550            frag(
2551                "((SELECT '/' || d.path FROM blocks bb WHERE bb.block_id = e.src_block) IS ?)",
2552                &[text("/a.md")]
2553            )
2554        );
2555        assert_eq!(
2556            translate_predicate(&pred("block.doc_id == \"d_1\""), &edges),
2557            frag(
2558                "((SELECT bb.doc_id FROM blocks bb WHERE bb.block_id = e.src_block) IS ?)",
2559                &[text("d_1")]
2560            )
2561        );
2562        // the block's own attributes and relations stay residual
2563        for k in ["hash", "raw", "updated_at", "attrs", "children"] {
2564            assert_eq!(
2565                translate_predicate(&pred(&format!("block.{k} == \"x\"")), &edges),
2566                None,
2567                "block.{k}"
2568            );
2569        }
2570        assert_eq!(translate_predicate(&pred("block == null"), &edges), None);
2571    }
2572
2573    #[test]
2574    fn unsafe_identifier_segments_are_never_inlined() {
2575        assert!(is_seg("layer") && is_seg("_x9"));
2576        assert!(!is_seg("") && !is_seg("9a") && !is_seg("a-b") && !is_seg("a'b"));
2577        assert_eq!(json_path(&["ok", "no-pe"]), None);
2578        assert_eq!(json_path(&["ok", "a_1"]).as_deref(), Some("$.ok.a_1"));
2579        assert_eq!(prop_scalar("d", "x'y", None), None);
2580    }
2581}