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"],
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            None
713        }
714        // `.lower()` / `.upper()` are the value-position string methods.
715        Expr::Call {
716            recv: Some(recv),
717            name,
718            args,
719            ..
720        } if args.is_empty() && (name == "lower" || name == "upper") => {
721            let recv = translate_value(recv, ctx)?;
722            Some(Operand {
723                frag: Frag {
724                    sql: format!("{name}({})", recv.sql),
725                    params: recv.params,
726                },
727                ty: Ty::Text,
728                shape: Shape::Plain,
729            })
730        }
731        _ => None,
732    }
733}
734
735// ---- predicate position --------------------------------------------------------------
736
737/// `==` / `!=` → null-safe `IS` / `IS NOT`; the relational ops as plain SQL.
738fn is_op(op: BinaryOp) -> Option<&'static str> {
739    Some(match op {
740        BinaryOp::Eq => "IS",
741        BinaryOp::Ne => "IS NOT",
742        BinaryOp::Lt => "<",
743        BinaryOp::Le => "<=",
744        BinaryOp::Gt => ">",
745        BinaryOp::Ge => ">=",
746        // Identity (`is` / `is not`, SEMANTICS §5b) compares the engine's
747        // structural identities — not provably SQL's `IS` over typed cells —
748        // so it stays residual, like arithmetic in predicate position. A regex
749        // operator (oqx 0.22) is `matches` in disguise and needs the OQX regex
750        // baseline (a `regexp` UDF the store does not register), so it stays
751        // residual exactly as the `matches` call does.
752        BinaryOp::Is
753        | BinaryOp::IsNot
754        | BinaryOp::Match
755        | BinaryOp::MatchI
756        | BinaryOp::NotMatch
757        | BinaryOp::NotMatchI
758        | BinaryOp::Add
759        | BinaryOp::Sub
760        | BinaryOp::Mul
761        | BinaryOp::Div
762        | BinaryOp::Mod => {
763            return None;
764        }
765    })
766}
767
768/// The typed pushes (`spec/surface` §1, 1.2 patch): a bool or num constant
769/// against a JSON or property read, with the stored type tested in SQL
770/// before the value so SQLite cannot conflate JSON `true` with `1` or
771/// `val_bool` with `val_num`. `None` when the pair is not a typed cell (the
772/// plain `IS` / relational form applies) — the gate ([`comparable`]) has
773/// already declined the cells neither form can push.
774///
775/// * json × bool (`==`/`!=`): `(json_type(x) = 'true' | 'false') IS 1`;
776/// * json × num (all six): `(json_type(x) IN ('integer', 'real') AND
777///   json_extract(x) <op> ?) IS 1`;
778/// * prop × bool (`==`/`!=`): the single-scalar-row subquery selecting
779///   `p.type = 'bool' AND p.val_bool = ?` (`spec/properties` §2.1 type
780///   names; booleans bind as 1/0), `(…) IS 1`;
781/// * prop × num (all six): the same subquery selecting `p.type = 'number'
782///   AND p.val_num <op> ?`, `(…) IS 1`.
783///
784/// `!=` wraps `IS NOT 1` around the equality test. `json_type` is NULL for
785/// an absent path and the subquery is NULL for an absent, list-valued or
786/// nested key, so `IS 1` is false and `IS NOT 1` true — the in-memory
787/// absence semantics (unequal, never ordered). The test is normalized to
788/// `read <op> ?`, a relational op flipping when the constant is on the left
789/// (`800 < era` ⇔ `era > 800`), as the reference does.
790fn typed_compare(op: BinaryOp, sql_op: &str, l: &Operand, r: &Operand) -> Option<Frag> {
791    let (read, konst, read_left) = match (&l.shape, &r.shape) {
792        (Shape::Json { .. } | Shape::Prop { .. }, Shape::Const(v)) => (&l.shape, v, true),
793        (Shape::Const(v), Shape::Json { .. } | Shape::Prop { .. }) => (&r.shape, v, false),
794        _ => return None,
795    };
796    let equality = matches!(op, BinaryOp::Eq | BinaryOp::Ne);
797    let wrap = if op == BinaryOp::Ne {
798        "IS NOT 1"
799    } else {
800        "IS 1"
801    };
802    // Normalized to `read <op> ?`: a relational op flips when the constant is
803    // on the left (`800 < era` ⇔ `era > 800`), as in the reference.
804    let inner_op = match (equality, read_left, sql_op) {
805        (true, _, _) => "=",
806        (false, true, _) => sql_op,
807        (false, false, "<") => ">",
808        (false, false, "<=") => ">=",
809        (false, false, ">") => "<",
810        (false, false, ">=") => "<=",
811        (false, false, _) => return None,
812    };
813    let sides = |read_sql: &str| format!("{read_sql} {inner_op} ?");
814    let (sql, params) = match (read, konst) {
815        // Booleans are only ever equal; the gate declines them relational.
816        (_, Value::Bool(_)) if !equality => return None,
817        (Shape::Json { col, path }, Value::Bool(b)) => (
818            format!("(json_type({col}, '{path}') = '{b}') {wrap}"),
819            Vec::new(),
820        ),
821        (Shape::Json { col, path }, Value::Number(_)) => (
822            format!(
823                "(json_type({col}, '{path}') IN ('integer', 'real') AND {}) {wrap}",
824                sides(&format!("json_extract({col}, '{path}')"))
825            ),
826            vec![to_sql(konst)],
827        ),
828        (
829            Shape::Prop {
830                doc_id,
831                key,
832                source,
833            },
834            Value::Bool(_),
835        ) => (
836            format!(
837                "{} {wrap}",
838                prop_row(
839                    doc_id,
840                    key,
841                    &format!("p.type = 'bool' AND {}", sides("p.val_bool")),
842                    *source,
843                )?
844            ),
845            vec![to_sql(konst)],
846        ),
847        (
848            Shape::Prop {
849                doc_id,
850                key,
851                source,
852            },
853            Value::Number(_),
854        ) => (
855            format!(
856                "{} {wrap}",
857                prop_row(
858                    doc_id,
859                    key,
860                    &format!("p.type = 'number' AND {}", sides("p.val_num")),
861                    *source,
862                )?
863            ),
864            vec![to_sql(konst)],
865        ),
866        _ => return None,
867    };
868    Some(Frag {
869        sql: format!("({sql})"),
870        params,
871    })
872}
873
874/// The path equality fast path (`spec/surface` §1 "Paths"): `path == "/a.md"`
875/// (or `!=`, either side) against a ROOTED text constant is `d.path IS ?` with
876/// the constant's storage form, so the `(repo_id, path)` index serves it;
877/// `'/' || d.path IS ?` — what the general form emits — is the same predicate
878/// without the index. Only a rooted constant qualifies (the runner roots every
879/// literal compared with a path read, so that is every literal); a bare
880/// binding stays on the general form, where `'/' || d.path` can never equal
881/// it — exactly the in-memory answer.
882fn path_equality(op: BinaryOp, l: &Operand, r: &Operand) -> Option<Frag> {
883    if !matches!(op, BinaryOp::Eq | BinaryOp::Ne) {
884        return None;
885    }
886    let (col, konst) = match (&l.shape, &r.shape) {
887        (Shape::PathCol(c), Shape::Const(Value::Str(s))) if s.starts_with('/') => (c, s),
888        (Shape::Const(Value::Str(s)), Shape::PathCol(c)) if s.starts_with('/') => (c, s),
889        _ => return None,
890    };
891    Some(Frag {
892        sql: format!("({col} {} ?)", is_op(op)?),
893        params: vec![SqlValue::Text(storage_path(konst).to_owned())],
894    })
895}
896
897/// Translate an expression used as a boolean PREDICATE to a SQL boolean, or
898/// `None` if it cannot be pushed faithfully. Only positive, AND-safe forms
899/// are handled: `unary` (`!`), `in`, bare truthy idents and member
900/// reach-through in predicate position stay residual.
901pub fn translate_predicate(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
902    match e {
903        // Only `&&` composes faithfully in a positive context; `||` is
904        // declined (its NULL / short-circuit interaction stays residual).
905        Expr::Logical {
906            op: LogicalOp::And,
907            left,
908            right,
909            ..
910        } => join2(
911            translate_predicate(left, ctx),
912            translate_predicate(right, ctx),
913            "AND",
914        ),
915        Expr::Binary {
916            op, left, right, ..
917        } => {
918            // An arithmetic operator in predicate position → residual.
919            let sql_op = is_op(*op)?;
920            let l = operand(left, ctx)?;
921            let r = operand(right, ctx)?;
922            if let Some(fast) = path_equality(*op, &l, &r) {
923                return Some(fast);
924            }
925            // The operand-typing gate (§1): the pairs SQLite would compare
926            // with less type than the engine has stay residual.
927            if !comparable(*op, l.ty, r.ty) {
928                return None;
929            }
930            // A bool/num constant against a JSON or property read pushes with
931            // the stored type tested first (the 1.2 typed cells).
932            if let Some(typed) = typed_compare(*op, sql_op, &l, &r) {
933                return Some(typed);
934            }
935            let op = sql_op;
936            // `==`/`!=` → IS / IS NOT (absence-normalized equality, faithful in
937            // any context). Relational ops → plain SQL: a NULL operand yields
938            // NULL, which is excluded in the positive AND context these
939            // fragments are pushed into, matching the absent-operand ⇒ false rule.
940            let mut params = l.frag.params;
941            params.extend(r.frag.params);
942            Some(Frag {
943                sql: format!("({} {op} {})", l.frag.sql, r.frag.sql),
944                params,
945            })
946        }
947        Expr::Call {
948            recv: Some(recv),
949            name,
950            args,
951            ..
952        } if args.len() == 1 => {
953            // startsWith / contains / endsWith — CASE-SENSITIVE, via
954            // substr/instr (never LIKE). `matches` (regex) is declined.
955            let recv = translate_value(recv, ctx)?;
956            let arg = translate_value(&args[0], ctx)?;
957            let mut params = recv.params;
958            let sql = match name.as_str() {
959                "startsWith" => {
960                    // recv begins with arg ⇔ its first length(arg) chars equal arg.
961                    params.extend(arg.params.iter().cloned());
962                    params.extend(arg.params);
963                    format!("(substr({}, 1, length({a})) = {a})", recv.sql, a = arg.sql)
964                }
965                "endsWith" => {
966                    // recv ends with arg ⇔ its last length(arg) chars equal arg.
967                    // When arg is longer than recv, substr clamps to the whole
968                    // (shorter) string, so the equality is false.
969                    params.extend(arg.params.iter().cloned());
970                    params.extend(arg.params);
971                    format!("(substr({}, -length({a})) = {a})", recv.sql, a = arg.sql)
972                }
973                "contains" => {
974                    params.extend(arg.params);
975                    format!("(instr({}, {}) > 0)", recv.sql, arg.sql)
976                }
977                _ => return None,
978            };
979            Some(Frag { sql, params })
980        }
981        _ => None,
982    }
983}
984
985/// Combine two optional fragments with a boolean connective; `None` if either
986/// is untranslatable (the whole conjunct then stays residual).
987fn join2(a: Option<Frag>, b: Option<Frag>, connective: &str) -> Option<Frag> {
988    let (a, b) = (a?, b?);
989    let mut params = a.params;
990    params.extend(b.params);
991    Some(Frag {
992        sql: format!("({} {connective} {})", a.sql, b.sql),
993        params,
994    })
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000    use oqx::ast::Where;
1001
1002    const DOCS: TranslateCtx<'static> = TranslateCtx {
1003        target: Target::Docs,
1004        self_alias: "d",
1005        doc_alias: "d",
1006        params: &[],
1007    };
1008    const BLOCKS: TranslateCtx<'static> = TranslateCtx {
1009        target: Target::Blocks,
1010        self_alias: "b",
1011        doc_alias: "d",
1012        params: &[],
1013    };
1014
1015    fn text(s: &str) -> SqlValue {
1016        SqlValue::Text(s.to_owned())
1017    }
1018
1019    fn frag(sql: &str, params: &[SqlValue]) -> Option<Frag> {
1020        Some(Frag {
1021            sql: sql.to_owned(),
1022            params: params.to_vec(),
1023        })
1024    }
1025
1026    /// Parse `from docs where <src>` and return the single scalar predicate.
1027    fn pred(src: &str) -> Expr {
1028        let q = oqx::parse_string(&format!("from docs where {src}")).expect("parses");
1029        match q.r#where {
1030            Some(Where::Scalar { expr, .. }) => expr,
1031            other => panic!("expected a single scalar predicate, got {other:?}"),
1032        }
1033    }
1034
1035    fn ident(name: &str) -> Box<Expr> {
1036        Box::new(Expr::Ident {
1037            name: name.to_owned(),
1038            span: oqx::Span::EMPTY,
1039        })
1040    }
1041
1042    fn lit(s: &str) -> Box<Expr> {
1043        Box::new(Expr::Lit {
1044            value: Value::from(s),
1045            span: oqx::Span::EMPTY,
1046        })
1047    }
1048
1049    fn eq(l: Box<Expr>, r: Box<Expr>) -> Box<Expr> {
1050        Box::new(Expr::Binary {
1051            op: BinaryOp::Eq,
1052            left: l,
1053            right: r,
1054            span: oqx::Span::EMPTY,
1055        })
1056    }
1057
1058    // -- equality is absence-normalized (IS / IS NOT) --
1059
1060    #[test]
1061    fn equality_is_null_safe_is() {
1062        assert_eq!(
1063            translate_predicate(&pred("path == \"index.md\""), &DOCS),
1064            frag("(('/' || d.path) IS ?)", &[text("index.md")])
1065        );
1066    }
1067
1068    // `spec/surface` §1 "Paths" (2.0): `path` is the reference form,
1069    // `'/' || d.path` in SQL; a ROOTED text constant under `==`/`!=` takes the
1070    // indexed fast path on the bare column with its storage form bound, a bare
1071    // one (never equal to a rooted path) stays on the general form.
1072    #[test]
1073    fn a_rooted_path_literal_is_the_indexed_fast_path() {
1074        assert_eq!(
1075            translate_predicate(&pred("path == \"/index.md\""), &DOCS),
1076            frag("(d.path IS ?)", &[text("index.md")])
1077        );
1078        assert_eq!(
1079            translate_predicate(&pred("\"/x.md\" != path"), &DOCS),
1080            frag("(d.path IS NOT ?)", &[text("x.md")])
1081        );
1082        assert_eq!(
1083            translate_predicate(&pred("path < \"/m\""), &DOCS),
1084            frag("(('/' || d.path) < ?)", &[text("/m")])
1085        );
1086        assert_eq!(
1087            translate_predicate(&pred("path == \"index.md\""), &DOCS),
1088            frag("(('/' || d.path) IS ?)", &[text("index.md")])
1089        );
1090    }
1091
1092    #[test]
1093    fn inequality_is_null_safe_is_not() {
1094        assert_eq!(
1095            translate_predicate(&pred("path != \"x\""), &DOCS),
1096            frag("(('/' || d.path) IS NOT ?)", &[text("x")])
1097        );
1098    }
1099
1100    #[test]
1101    fn intrinsic_column_mapping() {
1102        assert_eq!(
1103            translate_predicate(&pred("id == \"d_1\""), &DOCS),
1104            frag("(d.doc_id IS ?)", &[text("d_1")])
1105        );
1106    }
1107
1108    // -- relational ops (plain SQL) --
1109
1110    #[test]
1111    fn relational_ops_are_plain_comparisons() {
1112        assert_eq!(
1113            translate_predicate(&pred("path < \"m\""), &DOCS),
1114            frag("(('/' || d.path) < ?)", &[text("m")])
1115        );
1116        assert_eq!(
1117            translate_predicate(&pred("path >= \"m\""), &DOCS),
1118            frag("(('/' || d.path) >= ?)", &[text("m")])
1119        );
1120        // arithmetic in predicate position → residual
1121        assert_eq!(translate_predicate(&pred("path + 1"), &DOCS), None);
1122    }
1123
1124    // -- string ops are case-sensitive (substr/instr, never LIKE) --
1125
1126    #[test]
1127    fn starts_with_is_substr_equality() {
1128        assert_eq!(
1129            translate_predicate(&pred("path.startsWith(\"lab/\")"), &DOCS),
1130            frag(
1131                "(substr(('/' || d.path), 1, length(?)) = ?)",
1132                &[text("lab/"), text("lab/")]
1133            )
1134        );
1135    }
1136
1137    #[test]
1138    fn lower_then_starts_with_pushes_with_explicit_lower() {
1139        assert_eq!(
1140            translate_predicate(&pred("path.lower().startsWith(\"lab/\")"), &DOCS),
1141            frag(
1142                "(substr(lower(('/' || d.path)), 1, length(?)) = ?)",
1143                &[text("lab/"), text("lab/")]
1144            )
1145        );
1146    }
1147
1148    #[test]
1149    fn contains_is_instr() {
1150        assert_eq!(
1151            translate_predicate(&pred("path.contains(\"notes\")"), &DOCS),
1152            frag("(instr(('/' || d.path), ?) > 0)", &[text("notes")])
1153        );
1154    }
1155
1156    #[test]
1157    fn ends_with_is_negative_substr_equality() {
1158        assert_eq!(
1159            translate_predicate(&pred("path.endsWith(\".md\")"), &DOCS),
1160            frag(
1161                "(substr(('/' || d.path), -length(?)) = ?)",
1162                &[text(".md"), text(".md")]
1163            )
1164        );
1165    }
1166
1167    #[test]
1168    fn upper_wraps_the_receiver_in_value_position() {
1169        assert_eq!(
1170            translate_value(&pred("path.upper()"), &DOCS),
1171            frag("upper(('/' || d.path))", &[])
1172        );
1173    }
1174
1175    // -- bare document properties push via the properties table --
1176
1177    #[test]
1178    fn bare_doc_property_is_the_scalar_in_scope_subquery() {
1179        let f = translate_predicate(&pred("layer == \"canon\""), &DOCS).expect("pushable");
1180        assert!(f.sql.contains("FROM properties p"), "{}", f.sql);
1181        assert!(f.sql.contains("p.key = 'layer'"), "{}", f.sql);
1182        assert!(f.sql.contains("p.card = 'scalar'"), "{}", f.sql);
1183        assert!(
1184            f.sql.starts_with('(') && f.sql.contains(" IS ?)"),
1185            "{}",
1186            f.sql
1187        );
1188        assert_eq!(f.params, vec![text("canon")]);
1189    }
1190
1191    #[test]
1192    fn updated_at_pushes_as_its_revisions_subquery() {
1193        let f =
1194            translate_predicate(&pred("updated_at >= \"2026-01-01\""), &DOCS).expect("pushable");
1195        assert!(
1196            f.sql.contains("FROM revisions r JOIN commits c"),
1197            "{}",
1198            f.sql
1199        );
1200    }
1201
1202    // `created_at` (4.10): the same shape at `seq = 1`, keyed by the document.
1203    #[test]
1204    fn created_at_pushes_as_its_first_revision_subquery() {
1205        let f = translate_predicate(&pred("created_at < \"2026-01-01\""), &DOCS).expect("pushable");
1206        assert_eq!(
1207            f.sql,
1208            "((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) < ?)"
1209        );
1210        assert_eq!(f.params, vec![text("2026-01-01")]);
1211        // a block's is residual, like its `updated_at`
1212        assert_eq!(
1213            translate_predicate(&pred("created_at == \"x\""), &BLOCKS),
1214            None
1215        );
1216    }
1217
1218    #[test]
1219    fn format_is_a_column_not_a_property() {
1220        assert_eq!(
1221            translate_predicate(&pred("format == \"markdown\""), &DOCS),
1222            frag("(d.format IS ?)", &[text("markdown")])
1223        );
1224    }
1225
1226    #[test]
1227    fn booleans_bind_as_one_and_zero() {
1228        let blocks = TranslateCtx {
1229            target: Target::Blocks,
1230            self_alias: "b",
1231            ..DOCS
1232        };
1233        // (against a text column — against a JSON or property read the
1234        // boolean pushes typed, see the typed-shape tests)
1235        assert_eq!(
1236            translate_predicate(&pred("type == true"), &blocks).map(|f| f.params),
1237            Some(vec![SqlValue::Integer(1)])
1238        );
1239        assert_eq!(
1240            translate_predicate(&pred("type == false"), &blocks).map(|f| f.params),
1241            Some(vec![SqlValue::Integer(0)])
1242        );
1243        assert_eq!(
1244            translate_predicate(&pred("ordinal < 1000"), &blocks).map(|f| f.params),
1245            Some(vec![SqlValue::Real(1000.0)])
1246        );
1247        assert_eq!(
1248            translate_predicate(&pred("path == null"), &DOCS).map(|f| f.params),
1249            Some(vec![SqlValue::Null])
1250        );
1251    }
1252
1253    // -- decline (a): operand typing (spec/surface §1) --
1254
1255    /// One representative expression per [`Ty`] on the blocks target (the only
1256    /// target with an integer column; `doc.<k>` is its property read).
1257    const REPRESENTATIVES: [(Ty, &str); 7] = [
1258        (Ty::Text, "path"),
1259        (Ty::Int, "ordinal"),
1260        (Ty::Num, "1"),
1261        (Ty::Bool, "true"),
1262        (Ty::Null, "null"),
1263        (Ty::Json, "checked"),
1264        (Ty::Prop, "doc.layer"),
1265    ];
1266
1267    #[test]
1268    fn representatives_carry_their_type() {
1269        let blocks = TranslateCtx {
1270            target: Target::Blocks,
1271            self_alias: "b",
1272            ..DOCS
1273        };
1274        for (ty, src) in REPRESENTATIVES {
1275            let (_, got) = typed_value(&pred(src), &blocks).expect(src);
1276            assert_eq!(got, ty, "{src}");
1277        }
1278        assert_eq!(
1279            typed_value(&pred("attrs.a.b"), &blocks).map(|(_, t)| t),
1280            Some(Ty::Json)
1281        );
1282        assert_eq!(
1283            typed_value(&pred("depth"), &blocks).map(|(_, t)| t),
1284            Some(Ty::Int)
1285        );
1286        assert_eq!(
1287            typed_value(&pred("doc_id"), &blocks).map(|(_, t)| t),
1288            Some(Ty::Text)
1289        );
1290        assert_eq!(
1291            typed_value(&pred("type.lower()"), &blocks).map(|(_, t)| t),
1292            Some(Ty::Text)
1293        );
1294        assert_eq!(
1295            typed_value(&pred("checked.upper()"), &blocks).map(|(_, t)| t),
1296            Some(Ty::Text)
1297        );
1298        assert_eq!(
1299            typed_value(&pred("layer"), &DOCS).map(|(_, t)| t),
1300            Some(Ty::Prop)
1301        );
1302        assert_eq!(
1303            typed_value(&pred("format"), &DOCS).map(|(_, t)| t),
1304            Some(Ty::Text)
1305        );
1306    }
1307
1308    #[test]
1309    fn the_comparison_matrix_decides_every_cell() {
1310        // Row/column order: Text Int Num Bool Null Json Prop.
1311        const P: bool = true;
1312        const D: bool = false;
1313        // Typed (1.2): a bool/num constant against a JSON or property read.
1314        const T: bool = true;
1315        // Equality: one side text, or null against a non-property, or both
1316        // numeric, or a typed cell.
1317        #[rustfmt::skip]
1318        const EQUALITY: [[bool; 7]; 7] = [
1319            /* Text */ [P, P, P, P, P, P, P],
1320            /* Int  */ [P, P, P, D, P, D, D],
1321            /* Num  */ [P, P, P, D, P, T, T],
1322            /* Bool */ [P, D, D, D, P, T, T],
1323            /* Null */ [P, P, P, P, P, P, D],
1324            /* Json */ [P, D, T, T, P, D, D],
1325            /* Prop */ [P, D, T, T, D, D, D],
1326        ];
1327        // Relational: both text, both numeric, or a num constant against a
1328        // read (typed); nothing else.
1329        #[rustfmt::skip]
1330        const RELATIONAL: [[bool; 7]; 7] = [
1331            /* Text */ [P, D, D, D, D, D, D],
1332            /* Int  */ [D, P, P, D, D, D, D],
1333            /* Num  */ [D, P, P, D, D, T, T],
1334            /* Bool */ [D, D, D, D, D, D, D],
1335            /* Null */ [D, D, D, D, D, D, D],
1336            /* Json */ [D, D, T, D, D, D, D],
1337            /* Prop */ [D, D, T, D, D, D, D],
1338        ];
1339        let blocks = TranslateCtx {
1340            target: Target::Blocks,
1341            self_alias: "b",
1342            ..DOCS
1343        };
1344        let ops = [
1345            (BinaryOp::Eq, "==", &EQUALITY),
1346            (BinaryOp::Ne, "!=", &EQUALITY),
1347            (BinaryOp::Lt, "<", &RELATIONAL),
1348            (BinaryOp::Le, "<=", &RELATIONAL),
1349            (BinaryOp::Gt, ">", &RELATIONAL),
1350            (BinaryOp::Ge, ">=", &RELATIONAL),
1351        ];
1352        for (i, (a, l)) in REPRESENTATIVES.iter().enumerate() {
1353            for (j, (b, r)) in REPRESENTATIVES.iter().enumerate() {
1354                for (op, spelled, matrix) in ops {
1355                    let want = matrix[i][j];
1356                    assert_eq!(matrix[j][i], want, "the matrix is symmetric ({a:?}, {b:?})");
1357                    assert_eq!(
1358                        comparable(op, *a, *b),
1359                        want,
1360                        "comparable({spelled}, {a:?}, {b:?})"
1361                    );
1362                    let src = format!("{l} {spelled} {r}");
1363                    assert_eq!(
1364                        translate_predicate(&pred(&src), &blocks).is_some(),
1365                        want,
1366                        "{src}"
1367                    );
1368                }
1369            }
1370        }
1371    }
1372
1373    #[test]
1374    fn the_spec_shapes_of_decline_a() {
1375        let blocks = TranslateCtx {
1376            target: Target::Blocks,
1377            self_alias: "b",
1378            ..DOCS
1379        };
1380        // a boolean or number against a JSON read pushes TYPED (1.2): the
1381        // json_type is tested first, so the SQL cannot read JSON `true` as 1
1382        let typed = |src: &str, ctx: &TranslateCtx<'_>| {
1383            let f = translate_predicate(&pred(src), ctx).expect(src);
1384            assert!(
1385                f.sql.contains("json_type(") || f.sql.contains("p.type = "),
1386                "{src}: {}",
1387                f.sql
1388            );
1389            assert!(
1390                f.sql.ends_with(" IS 1)") || f.sql.ends_with(" IS NOT 1)"),
1391                "{src}: {}",
1392                f.sql
1393            );
1394        };
1395        typed("checked == 1", &blocks);
1396        typed("checked == true", &blocks);
1397        typed("attrs.checked == true", &blocks);
1398        // … or a property read (bare on docs, `doc.<k>` elsewhere)
1399        typed("verified == 1", &DOCS);
1400        typed("verified == true", &DOCS);
1401        typed("era < 1000", &DOCS);
1402        typed("doc.era < 1000", &blocks);
1403        // a boolean against an integer column; a number stays pushable
1404        assert_eq!(translate_predicate(&pred("ordinal == true"), &blocks), None);
1405        assert_eq!(
1406            translate_predicate(&pred("ordinal == 1"), &blocks),
1407            frag("(b.ordinal IS ?)", &[SqlValue::Real(1.0)])
1408        );
1409        // null against a property read; against a JSON read or a column it pushes
1410        assert_eq!(translate_predicate(&pred("tags != null"), &DOCS), None);
1411        assert_eq!(translate_predicate(&pred("tags == null"), &DOCS), None);
1412        assert_eq!(
1413            translate_predicate(&pred("doc.tags == null"), &blocks),
1414            None
1415        );
1416        assert_eq!(
1417            translate_predicate(&pred("checked == null"), &blocks),
1418            frag(
1419                "(json_extract(b.attrs, '$.checked') IS ?)",
1420                &[SqlValue::Null]
1421            )
1422        );
1423        assert_eq!(
1424            translate_predicate(&pred("ordinal != null"), &blocks),
1425            frag("(b.ordinal IS NOT ?)", &[SqlValue::Null])
1426        );
1427        // a string literal against anything pushes under equality
1428        assert_eq!(
1429            translate_predicate(&pred("checked == \"x\""), &blocks),
1430            frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1431        );
1432        assert!(translate_predicate(&pred("layer == \"canon\""), &DOCS).is_some());
1433        assert!(translate_predicate(&pred("ordinal == \"1\""), &blocks).is_some());
1434        assert!(translate_predicate(&pred("ordinal != \"1\""), &blocks).is_some());
1435        // … but a relational comparison across text and a number / integer
1436        // declines (the fifth shape): SQLite orders integers before text
1437        assert_eq!(translate_predicate(&pred("ordinal < \"3\""), &blocks), None);
1438        assert_eq!(
1439            translate_predicate(&pred("\"3\" >= ordinal"), &blocks),
1440            None
1441        );
1442        assert_eq!(translate_predicate(&pred("path > 5"), &DOCS), None);
1443        assert_eq!(translate_predicate(&pred("type <= 1"), &blocks), None);
1444        assert_eq!(
1445            translate_predicate(&pred("ordinal < 3"), &blocks),
1446            frag("(b.ordinal < ?)", &[SqlValue::Real(3.0)])
1447        );
1448        assert_eq!(
1449            translate_predicate(&pred("path > \"m\""), &DOCS),
1450            frag("(('/' || d.path) > ?)", &[text("m")])
1451        );
1452        // two reads carry no type at plan time; a boolean equals only text/null
1453        assert_eq!(
1454            translate_predicate(&pred("ordinal == checked"), &blocks),
1455            None
1456        );
1457        assert_eq!(
1458            translate_predicate(&pred("checked == level"), &blocks),
1459            None
1460        );
1461        assert_eq!(
1462            translate_predicate(&pred("doc.era == doc.year"), &blocks),
1463            None
1464        );
1465        assert_eq!(translate_predicate(&pred("level < \"x\""), &blocks), None);
1466        typed("level < 3", &blocks);
1467        assert_eq!(translate_predicate(&pred("true == false"), &blocks), None);
1468        assert_eq!(translate_predicate(&pred("checked < true"), &blocks), None);
1469        assert_eq!(
1470            translate_predicate(&pred("doc.verified >= false"), &blocks),
1471            None
1472        );
1473        assert_eq!(translate_predicate(&pred("ordinal > null"), &blocks), None);
1474        assert!(translate_predicate(&pred("ordinal == depth"), &blocks).is_some());
1475        assert!(translate_predicate(&pred("ordinal <= depth"), &blocks).is_some());
1476        assert!(translate_predicate(&pred("type == null"), &blocks).is_some());
1477    }
1478
1479    // -- the typed pushes (spec/surface §1, 1.2 patch) --
1480
1481    /// The properties subquery's scope: the same single-scalar-row conditions
1482    /// as the scalar read, so a list-valued or nested key yields NULL.
1483    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";
1484
1485    fn prop_sql(key: &str, select: &str, wrap: &str) -> String {
1486        format!(
1487            "((SELECT {select} {}) {wrap})",
1488            PROP_SCOPE.replace('K', key)
1489        )
1490    }
1491
1492    #[test]
1493    fn json_against_a_boolean_tests_json_type_for_the_literal() {
1494        let blocks = TranslateCtx {
1495            target: Target::Blocks,
1496            self_alias: "b",
1497            ..DOCS
1498        };
1499        // The boolean is inlined as the JSON type name; nothing binds.
1500        assert_eq!(
1501            translate_predicate(&pred("checked == true"), &blocks),
1502            frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1503        );
1504        assert_eq!(
1505            translate_predicate(&pred("checked == false"), &blocks),
1506            frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1507        );
1508        assert_eq!(
1509            translate_predicate(&pred("checked != true"), &blocks),
1510            frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1511        );
1512        assert_eq!(
1513            translate_predicate(&pred("false == attrs.checked"), &blocks),
1514            frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1515        );
1516        // a bound boolean is the same shape
1517        let params = [Value::Bool(true)];
1518        let e = Expr::Binary {
1519            op: BinaryOp::Ne,
1520            left: ident("checked"),
1521            right: Box::new(Expr::Binding {
1522                index: 0,
1523                span: oqx::Span::EMPTY,
1524            }),
1525            span: oqx::Span::EMPTY,
1526        };
1527        assert_eq!(
1528            translate_predicate(
1529                &e,
1530                &TranslateCtx {
1531                    params: &params,
1532                    ..blocks
1533                }
1534            ),
1535            frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1536        );
1537    }
1538
1539    #[test]
1540    fn json_against_a_number_tests_the_numeric_types_then_compares() {
1541        let nodes = TranslateCtx {
1542            target: Target::Nodes,
1543            self_alias: "n",
1544            ..DOCS
1545        };
1546        let shape = |op: &str, wrap: &str| {
1547            format!(
1548                "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') {op} ?) {wrap})"
1549            )
1550        };
1551        let two = [SqlValue::Real(2.0)];
1552        for (src, op, wrap) in [
1553            ("level == 2", "=", "IS 1"),
1554            ("level != 2", "=", "IS NOT 1"),
1555            ("level < 2", "<", "IS 1"),
1556            ("level <= 2", "<=", "IS 1"),
1557            ("level > 2", ">", "IS 1"),
1558            ("level >= 2", ">=", "IS 1"),
1559        ] {
1560            assert_eq!(
1561                translate_predicate(&pred(src), &nodes),
1562                frag(&shape(op, wrap), &two),
1563                "{src}"
1564            );
1565        }
1566        // a constant on the left is normalized to the right, the op flipped
1567        assert_eq!(
1568            translate_predicate(&pred("2 <= attrs.level"), &nodes),
1569            frag(&shape(">=", "IS 1"), &two)
1570        );
1571        assert_eq!(
1572            translate_predicate(&pred("2 > level"), &nodes),
1573            frag(&shape("<", "IS 1"), &two)
1574        );
1575        assert_eq!(
1576            translate_predicate(&pred("2 != level"), &nodes),
1577            frag(&shape("=", "IS NOT 1"), &two)
1578        );
1579    }
1580
1581    #[test]
1582    fn property_against_a_boolean_tests_p_type_bool_in_the_scalar_row_subquery() {
1583        let blocks = TranslateCtx {
1584            target: Target::Blocks,
1585            self_alias: "b",
1586            ..DOCS
1587        };
1588        assert_eq!(
1589            translate_predicate(&pred("verified == true"), &DOCS),
1590            frag(
1591                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1592                &[SqlValue::Integer(1)]
1593            )
1594        );
1595        assert_eq!(
1596            translate_predicate(&pred("verified != false"), &DOCS),
1597            frag(
1598                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS NOT 1"),
1599                &[SqlValue::Integer(0)]
1600            )
1601        );
1602        // `doc.<k>` from a block reads the owning document's row
1603        assert_eq!(
1604            translate_predicate(&pred("doc.verified == false"), &blocks),
1605            frag(
1606                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1607                &[SqlValue::Integer(0)]
1608            )
1609        );
1610        assert_eq!(
1611            translate_predicate(&pred("true == verified"), &DOCS),
1612            frag(
1613                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1614                &[SqlValue::Integer(1)]
1615            )
1616        );
1617    }
1618
1619    #[test]
1620    fn property_against_a_number_tests_p_type_number_in_the_scalar_row_subquery() {
1621        let blocks = TranslateCtx {
1622            target: Target::Blocks,
1623            self_alias: "b",
1624            ..DOCS
1625        };
1626        let thousand = [SqlValue::Real(1000.0)];
1627        for (src, op, wrap) in [
1628            ("era == 1000", "=", "IS 1"),
1629            ("era != 1000", "=", "IS NOT 1"),
1630            ("era < 1000", "<", "IS 1"),
1631            ("era <= 1000", "<=", "IS 1"),
1632            ("era > 1000", ">", "IS 1"),
1633            ("era >= 1000", ">=", "IS 1"),
1634        ] {
1635            assert_eq!(
1636                translate_predicate(&pred(src), &DOCS),
1637                frag(
1638                    &prop_sql(
1639                        "era",
1640                        &format!("p.type = 'number' AND p.val_num {op} ?"),
1641                        wrap
1642                    ),
1643                    &thousand
1644                ),
1645                "{src}"
1646            );
1647        }
1648        assert_eq!(
1649            translate_predicate(&pred("doc.era >= 1000"), &blocks),
1650            frag(
1651                &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1652                &thousand
1653            )
1654        );
1655        // a constant on the left is normalized to the right, the op flipped
1656        assert_eq!(
1657            translate_predicate(&pred("1000 > era"), &DOCS),
1658            frag(
1659                &prop_sql("era", "p.type = 'number' AND p.val_num < ?", "IS 1"),
1660                &thousand
1661            )
1662        );
1663        assert_eq!(
1664            translate_predicate(&pred("1000 <= era"), &DOCS),
1665            frag(
1666                &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1667                &thousand
1668            )
1669        );
1670    }
1671
1672    #[test]
1673    fn typed_pushes_compose_under_and_and_keep_the_untyped_forms() {
1674        let blocks = TranslateCtx {
1675            target: Target::Blocks,
1676            self_alias: "b",
1677            ..DOCS
1678        };
1679        let e = Expr::Logical {
1680            op: LogicalOp::And,
1681            left: eq(ident("type"), lit("task")),
1682            right: Box::new(Expr::Binary {
1683                op: BinaryOp::Eq,
1684                left: ident("checked"),
1685                right: Box::new(Expr::Lit {
1686                    value: Value::Bool(false),
1687                    span: oqx::Span::EMPTY,
1688                }),
1689                span: oqx::Span::EMPTY,
1690            }),
1691            span: oqx::Span::EMPTY,
1692        };
1693        assert_eq!(
1694            translate_predicate(&e, &blocks),
1695            frag(
1696                "((b.type IS ?) AND ((json_type(b.attrs, '$.checked') = 'false') IS 1))",
1697                &[text("task")]
1698            )
1699        );
1700        // text and null against a read stay the plain IS form
1701        assert_eq!(
1702            translate_predicate(&pred("checked == \"x\""), &blocks),
1703            frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1704        );
1705        assert_eq!(
1706            translate_predicate(&pred("checked != null"), &blocks),
1707            frag(
1708                "(json_extract(b.attrs, '$.checked') IS NOT ?)",
1709                &[SqlValue::Null]
1710            )
1711        );
1712        // an unsafe key never inlines, typed or not
1713        assert_eq!(
1714            prop_row("d", "x'y", "p.type = 'number' AND p.val_num = ?", None),
1715            None
1716        );
1717    }
1718
1719    #[test]
1720    fn bindings_are_typed_by_their_value() {
1721        let blocks = TranslateCtx {
1722            target: Target::Blocks,
1723            self_alias: "b",
1724            ..DOCS
1725        };
1726        let params = [
1727            Value::from("s"),
1728            Value::from(1.0),
1729            Value::Bool(true),
1730            Value::Null,
1731            Value::Array(vec![]),
1732        ];
1733        let ctx = TranslateCtx {
1734            params: &params,
1735            ..blocks
1736        };
1737        let against = |i: usize, rhs: &str| {
1738            let e = Expr::Binary {
1739                op: BinaryOp::Eq,
1740                left: Box::new(Expr::Binding {
1741                    index: i,
1742                    span: oqx::Span::EMPTY,
1743                }),
1744                right: Box::new(pred(rhs)),
1745                span: oqx::Span::EMPTY,
1746            };
1747            translate_predicate(&e, &ctx).is_some()
1748        };
1749        // text binding pushes against anything; number/boolean push typed
1750        // against JSON (1.2), a boolean not against an integer column
1751        assert!(against(0, "checked"));
1752        assert!(against(1, "checked"));
1753        assert!(against(2, "checked"));
1754        assert!(!against(2, "ordinal"));
1755        assert!(against(1, "ordinal"));
1756        // a null binding pushes against JSON, not against a property
1757        assert!(against(3, "checked"));
1758        assert!(!against(3, "doc.tags"));
1759        // an absent binding (past the end) is null
1760        assert!(against(9, "checked"));
1761        assert!(!against(9, "doc.tags"));
1762        // a non-scalar binding has no faithful SQL value
1763        assert!(!against(4, "type"));
1764    }
1765
1766    // -- decline (b): handles are not property reads --
1767
1768    #[test]
1769    fn relation_and_handle_names_are_not_property_reads() {
1770        let blocks = TranslateCtx {
1771            target: Target::Blocks,
1772            self_alias: "b",
1773            ..DOCS
1774        };
1775        let nodes = TranslateCtx {
1776            target: Target::Nodes,
1777            self_alias: "n",
1778            ..DOCS
1779        };
1780        let edges = TranslateCtx {
1781            target: Target::Links,
1782            self_alias: "e",
1783            ..DOCS
1784        };
1785        for (ctx, target) in [
1786            (&DOCS, Target::Docs),
1787            (&blocks, Target::Blocks),
1788            (&nodes, Target::Nodes),
1789            (&edges, Target::Links),
1790        ] {
1791            for name in non_property_handles(target) {
1792                let src = format!("{name} == null");
1793                assert_eq!(
1794                    translate_predicate(&pred(&src), ctx),
1795                    None,
1796                    "{target:?}: {src}"
1797                );
1798                let src = format!("{name} == \"x\"");
1799                assert_eq!(
1800                    translate_predicate(&pred(&src), ctx),
1801                    None,
1802                    "{target:?}: {src}"
1803                );
1804            }
1805        }
1806        // the fixtures' shapes
1807        assert_eq!(translate_predicate(&pred("nodes == null"), &DOCS), None);
1808        assert_eq!(
1809            translate_predicate(&pred("frontmatter == null"), &DOCS),
1810            None
1811        );
1812        assert_eq!(translate_predicate(&pred("nodes == null"), &blocks), None);
1813        assert_eq!(translate_predicate(&pred("attrs == null"), &blocks), None);
1814        // `doc.<handle>` is the doc's handle, not its property; `doc.<k>` still pushes
1815        assert_eq!(
1816            translate_predicate(&pred("doc.nodes == null"), &blocks),
1817            None
1818        );
1819        assert_eq!(
1820            translate_predicate(&pred("doc.frontmatter == null"), &blocks),
1821            None
1822        );
1823        assert_eq!(translate_predicate(&pred("doc.doc == null"), &DOCS), None);
1824        assert!(translate_predicate(&pred("doc.layer == \"canon\""), &blocks).is_some());
1825        // a plain attribute or property of the same spelling elsewhere still pushes
1826        assert!(translate_predicate(&pred("section == \"x\""), &DOCS).is_some());
1827        assert!(translate_predicate(&pred("frontmatter == \"x\""), &blocks).is_some());
1828    }
1829
1830    /// The handle sets are exactly the keys the store context resolves to
1831    /// rows, a row or a bag before its property fallback: over a small
1832    /// observed corpus, on every row of a target, a name in the set never
1833    /// reads as a scalar, and at least one row resolves it to rows / a row /
1834    /// an object; a name outside the set never does.
1835    #[test]
1836    fn handle_sets_match_the_store_context() {
1837        use std::collections::HashMap;
1838
1839        use omgbase_reconcile::Config;
1840        use omgbase_store::{BatchItem, Store};
1841        use oqx::DataContext;
1842
1843        use crate::context::StoreContext;
1844
1845        let mut store = Store::open_in_memory().expect("store");
1846        let repo = store.create_repo("handles").expect("repo");
1847        let items = [
1848            BatchItem::observed(
1849                "a.md",
1850                "---\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",
1851            ),
1852            BatchItem::observed("b.md", "# B\n\nBack to [a](a.md).\n"),
1853        ];
1854        store
1855            .observe_batch(
1856                &repo,
1857                &items,
1858                "2026-09-26T00:00:00.000Z",
1859                &Config::default(),
1860            )
1861            .expect("observe");
1862        let ctx = StoreContext::new(store.conn(), &repo, HashMap::new());
1863
1864        let mut universe: Vec<&str> = crate::context::ALL_TARGETS
1865            .into_iter()
1866            .flat_map(|t| non_property_handles(t).iter().copied())
1867            .collect();
1868        universe.extend([
1869            "layer",
1870            "title",
1871            "verified",
1872            "checked",
1873            "level",
1874            "format",
1875            "type",
1876            "text",
1877            "kind",
1878            "name",
1879            "value",
1880            "predicate",
1881            "key",
1882            "nope",
1883        ]);
1884        universe.sort_unstable();
1885        universe.dedup();
1886
1887        let is_shape = |v: &Value| matches!(v, Value::Array(_) | Value::Object(_));
1888        for target in crate::context::ALL_TARGETS {
1889            // a root is a lazy scan marker; `to_rows` reads it
1890            let rows = ctx.to_rows(&ctx.root(target.as_str()));
1891            assert!(!rows.is_empty(), "{target:?} has rows");
1892            let set = non_property_handles(target);
1893            for name in &universe {
1894                let mut shaped = 0;
1895                for row in &rows {
1896                    let v = match ctx.get(row, name) {
1897                        Ok(v) => v,
1898                        // a target name that is not one of this row's
1899                        // relations is the root-row error, never a property
1900                        Err(_) if Target::parse(name).is_some() && !set.contains(name) => continue,
1901                        Err(e) => panic!("{target:?}.{name}: {e}"),
1902                    };
1903                    if set.contains(name) {
1904                        assert!(
1905                            v.is_absent() || is_shape(&v),
1906                            "{target:?}.{name} read as a scalar: {v:?}"
1907                        );
1908                    } else {
1909                        assert!(!is_shape(&v), "{target:?}.{name} is a handle: {v:?}");
1910                    }
1911                    shaped += usize::from(is_shape(&v));
1912                }
1913                if set.contains(name) {
1914                    assert!(
1915                        shaped > 0,
1916                        "{target:?}.{name} never resolved to rows or a bag"
1917                    );
1918                }
1919            }
1920        }
1921    }
1922
1923    // -- declines (left residual) return None --
1924
1925    #[test]
1926    fn attributes_that_are_not_columns_decline_and_are_never_properties() {
1927        // 3.0: the attribute wins — declined, never read as a same-named
1928        // property or attr.
1929        assert_eq!(translate_predicate(&pred("title == \"x\""), &DOCS), None);
1930        assert_eq!(translate_predicate(&pred("tags == \"x\""), &DOCS), None);
1931        assert_eq!(translate_predicate(&pred("raw == \"x\""), &DOCS), None);
1932        assert_eq!(
1933            translate_predicate(&pred("doc.title == \"x\""), &BLOCKS),
1934            None
1935        );
1936        assert_eq!(
1937            translate_predicate(&pred("doc.raw == \"x\""), &BLOCKS),
1938            None
1939        );
1940        assert_eq!(translate_predicate(&pred("raw == \"x\""), &BLOCKS), None);
1941        // a source handle is a non-property name on docs
1942        assert_eq!(translate_predicate(&pred("data == null"), &DOCS), None);
1943        // the pushable attributes push, bare or through `doc.`
1944        assert!(translate_predicate(&pred("path == \"x\""), &DOCS).is_some());
1945        assert!(translate_predicate(&pred("hash == \"x\""), &DOCS).is_some());
1946        assert!(translate_predicate(&pred("doc.path == \"x\""), &BLOCKS).is_some());
1947    }
1948
1949    #[test]
1950    fn a_key_read_through_a_source_handle_is_pushed_scoped_to_its_source() {
1951        // `data.<k>` is exactly the bare read (same SQL, no source clause).
1952        let bare = translate_predicate(&pred("layer == \"canon\""), &DOCS).unwrap();
1953        let data = translate_predicate(&pred("data.layer == \"canon\""), &DOCS).unwrap();
1954        assert_eq!(bare, data);
1955        assert!(!bare.sql.contains("p.source"));
1956        // `frontmatter.<k>` / `inline.<k>`: the row AND the count are of that source.
1957        let fm = translate_predicate(&pred("frontmatter.layer == \"canon\""), &DOCS).unwrap();
1958        assert_eq!(
1959            fm.sql,
1960            "((SELECT COALESCE(p.val_text, p.val_num, p.val_bool) FROM properties p \
1961              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' \
1962              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 \
1963              LIMIT 1) IS ?)"
1964        );
1965        let inl = translate_predicate(&pred("doc.inline.element == \"fire\""), &BLOCKS).unwrap();
1966        assert!(
1967            inl.sql.contains("p.source = 'inline'") && inl.sql.contains("p2.source = 'inline'")
1968        );
1969        assert!(translate_predicate(&pred("doc.data.layer == \"canon\""), &BLOCKS).is_some());
1970        // a typed push keeps the scope
1971        let typed = translate_predicate(&pred("frontmatter.verified == true"), &DOCS).unwrap();
1972        assert!(
1973            typed.sql.contains("p.type = 'bool'") && typed.sql.contains("p.source = 'frontmatter'")
1974        );
1975        // a WHOLE handle, a deeper chain and `entries(…)` still decline
1976        assert_eq!(
1977            translate_predicate(&pred("frontmatter == null"), &DOCS),
1978            None
1979        );
1980        assert_eq!(translate_predicate(&pred("data == null"), &DOCS), None);
1981        assert_eq!(
1982            translate_predicate(&pred("frontmatter.meta.owner == \"x\""), &DOCS),
1983            None
1984        );
1985        assert_eq!(
1986            translate_predicate(&pred("doc.frontmatter == null"), &BLOCKS),
1987            None
1988        );
1989        assert_eq!(
1990            translate_predicate(&pred("size(entries(frontmatter)) > 0"), &DOCS),
1991            None
1992        );
1993    }
1994
1995    #[test]
1996    fn docs_raw_and_computed_intrinsics_are_not_columns() {
1997        assert_eq!(translate_predicate(&pred("raw == \"x\""), &DOCS), None);
1998        assert_eq!(translate_predicate(&pred("title == \"x\""), &DOCS), None);
1999        assert_eq!(translate_predicate(&pred("tags == \"x\""), &DOCS), None);
2000    }
2001
2002    #[test]
2003    fn matches_needs_a_regexp_udf() {
2004        assert_eq!(
2005            translate_predicate(&pred("path.matches(\"^lab/\")"), &DOCS),
2006            None
2007        );
2008    }
2009
2010    #[test]
2011    fn negation_as_a_nested_expr_is_not_and_safe() {
2012        let e = Expr::Unary {
2013            op: oqx::ast::UnaryOp::Not,
2014            expr: ident("path"),
2015            span: oqx::Span::EMPTY,
2016        };
2017        assert_eq!(translate_predicate(&e, &DOCS), None);
2018    }
2019
2020    #[test]
2021    fn disjunction_as_a_nested_expr_is_declined() {
2022        let e = Expr::Logical {
2023            op: LogicalOp::Or,
2024            left: eq(ident("path"), lit("a")),
2025            right: eq(ident("path"), lit("b")),
2026            span: oqx::Span::EMPTY,
2027        };
2028        assert_eq!(translate_predicate(&e, &DOCS), None);
2029    }
2030
2031    #[test]
2032    fn unmapped_node_intrinsic_is_not_pushed() {
2033        let nodes = TranslateCtx {
2034            target: Target::Nodes,
2035            self_alias: "n",
2036            ..DOCS
2037        };
2038        assert_eq!(
2039            translate_predicate(&pred("$locator == \"x\""), &nodes),
2040            None
2041        );
2042        // `updated_at` is mapped on docs only.
2043        let blocks = TranslateCtx {
2044            target: Target::Blocks,
2045            self_alias: "b",
2046            ..DOCS
2047        };
2048        assert_eq!(
2049            translate_predicate(&pred("updated_at == \"x\""), &blocks),
2050            None
2051        );
2052    }
2053
2054    #[test]
2055    fn range_membership_in_and_bare_idents_are_declined() {
2056        assert_eq!(translate_predicate(&pred("era in 800..1680"), &DOCS), None);
2057        assert_eq!(
2058            translate_predicate(&pred("\"a\" in list(tags)"), &DOCS),
2059            None
2060        );
2061        assert_eq!(translate_predicate(&pred("verified"), &DOCS), None);
2062        assert_eq!(translate_predicate(&pred("doc.verified"), &DOCS), None);
2063        assert_eq!(translate_predicate(&pred("size(tags) > 1"), &DOCS), None);
2064        assert_eq!(translate_predicate(&pred("$self.text(\"x\")"), &DOCS), None);
2065        assert_eq!(translate_predicate(&pred("$it == \"x\""), &DOCS), None);
2066        assert_eq!(translate_predicate(&pred("^slug == \"x\""), &DOCS), None);
2067        // a WHOLE source handle (3.0: `frontmatter.<k>` itself is pushed)
2068        assert_eq!(
2069            translate_predicate(&pred("frontmatter == null"), &DOCS),
2070            None
2071        );
2072    }
2073
2074    // -- conjunction and bindings via constructed AST --
2075
2076    #[test]
2077    fn and_composes_two_pushable_comparisons() {
2078        let e = Expr::Logical {
2079            op: LogicalOp::And,
2080            left: eq(ident("path"), lit("a")),
2081            right: Box::new(Expr::Binary {
2082                op: BinaryOp::Ne,
2083                left: ident("id"),
2084                right: lit("d_2"),
2085                span: oqx::Span::EMPTY,
2086            }),
2087            span: oqx::Span::EMPTY,
2088        };
2089        assert_eq!(
2090            translate_predicate(&e, &DOCS),
2091            frag(
2092                "((('/' || d.path) IS ?) AND (d.doc_id IS NOT ?))",
2093                &[text("a"), text("d_2")]
2094            )
2095        );
2096    }
2097
2098    #[test]
2099    fn and_declines_wholesale_if_either_side_is_not_pushable() {
2100        let e = Expr::Logical {
2101            op: LogicalOp::And,
2102            left: eq(ident("path"), lit("a")),
2103            // raw is reconstructed, not a column → the whole && declines.
2104            right: eq(ident("raw"), lit("x")),
2105            span: oqx::Span::EMPTY,
2106        };
2107        assert_eq!(translate_predicate(&e, &DOCS), None);
2108    }
2109
2110    #[test]
2111    fn resolves_a_binding_to_its_param_value() {
2112        let e = eq(
2113            ident("path"),
2114            Box::new(Expr::Binding {
2115                index: 0,
2116                span: oqx::Span::EMPTY,
2117            }),
2118        );
2119        let params = [Value::from("from-binding.md")];
2120        let ctx = TranslateCtx {
2121            params: &params,
2122            ..DOCS
2123        };
2124        assert_eq!(
2125            translate_predicate(&e, &ctx),
2126            frag("(('/' || d.path) IS ?)", &[text("from-binding.md")])
2127        );
2128        // A binding past the end reads as absent → NULL.
2129        assert_eq!(
2130            translate_predicate(&e, &DOCS),
2131            frag("(('/' || d.path) IS ?)", &[SqlValue::Null])
2132        );
2133    }
2134
2135    // -- per-target fields --
2136
2137    #[test]
2138    fn blocks_and_nodes_flatten_bare_identifiers_into_attrs() {
2139        let blocks = TranslateCtx {
2140            target: Target::Blocks,
2141            self_alias: "b",
2142            ..DOCS
2143        };
2144        // (a top-level `&&` is a `Where::And` of scalars; the nested form is
2145        // reached through constructed AST, as in the reference's tests)
2146        let both = Expr::Logical {
2147            op: LogicalOp::And,
2148            left: eq(ident("type"), lit("task")),
2149            right: eq(ident("marker"), lit("x")),
2150            span: oqx::Span::EMPTY,
2151        };
2152        assert_eq!(
2153            translate_predicate(&both, &blocks),
2154            frag(
2155                "((b.type IS ?) AND (json_extract(b.attrs, '$.marker') IS ?))",
2156                &[text("task"), text("x")]
2157            )
2158        );
2159        assert_eq!(
2160            translate_predicate(&pred("attrs.marker == \"x\""), &blocks),
2161            frag("(json_extract(b.attrs, '$.marker') IS ?)", &[text("x")])
2162        );
2163        // (a boolean or number against a JSON read pushes typed — see the
2164        // typed-shape tests)
2165        assert_eq!(
2166            translate_predicate(&pred("attrs.checked == true"), &blocks),
2167            frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
2168        );
2169        let nodes = TranslateCtx {
2170            target: Target::Nodes,
2171            self_alias: "n",
2172            ..DOCS
2173        };
2174        assert_eq!(
2175            translate_predicate(&pred("kind == \"md:section\""), &nodes),
2176            frag("(n.kind IS ?)", &[text("md:section")])
2177        );
2178        assert_eq!(
2179            translate_predicate(&pred("level == \"1\""), &nodes),
2180            frag("(json_extract(n.attrs, '$.level') IS ?)", &[text("1")])
2181        );
2182        assert_eq!(
2183            translate_predicate(&pred("level == 1"), &nodes),
2184            frag(
2185                "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') = ?) IS 1)",
2186                &[SqlValue::Real(1.0)]
2187            )
2188        );
2189        assert_eq!(
2190            translate_predicate(&pred("attrs.a.b == \"c\""), &nodes),
2191            frag("(json_extract(n.attrs, '$.a.b') IS ?)", &[text("c")])
2192        );
2193        // `attrs.<k>` is a blocks/nodes form; on docs it is not a column.
2194        assert_eq!(
2195            translate_predicate(&pred("attrs.marker == \"x\""), &DOCS),
2196            None
2197        );
2198    }
2199
2200    #[test]
2201    fn doc_and_block_reach_through() {
2202        let blocks = TranslateCtx {
2203            target: Target::Blocks,
2204            self_alias: "b",
2205            ..DOCS
2206        };
2207        let f = translate_predicate(&pred("doc.type == \"lab-note\""), &blocks).expect("pushable");
2208        assert!(
2209            f.sql.contains("p.doc_id = d.doc_id AND p.key = 'type'"),
2210            "{}",
2211            f.sql
2212        );
2213        assert_eq!(
2214            translate_predicate(&pred("doc.path == \"a.md\""), &blocks),
2215            frag("(('/' || d.path) IS ?)", &[text("a.md")])
2216        );
2217        // a rooted literal (what the runner's rewrite always produces) takes
2218        // the indexed fast path on the storage column
2219        assert_eq!(
2220            translate_predicate(&pred("doc.path == \"/a.md\""), &blocks),
2221            frag("(d.path IS ?)", &[text("a.md")])
2222        );
2223        assert_eq!(
2224            translate_predicate(&pred("doc.format == \"markdown\""), &blocks),
2225            frag("(d.format IS ?)", &[text("markdown")])
2226        );
2227        // FK elimination: `doc.id` is the row's own FK column (3.0)
2228        assert_eq!(
2229            translate_predicate(&pred("doc.id == \"d_1\""), &blocks),
2230            frag("(b.doc_id IS ?)", &[text("d_1")])
2231        );
2232        assert_eq!(translate_predicate(&pred("doc.a.b == 1"), &blocks), None);
2233        let nodes = TranslateCtx {
2234            target: Target::Nodes,
2235            self_alias: "n",
2236            ..DOCS
2237        };
2238        assert_eq!(
2239            translate_predicate(&pred("block.type == \"task\""), &nodes),
2240            frag(
2241                "((SELECT bb.type FROM blocks bb WHERE bb.block_id = n.block_id) IS ?)",
2242                &[text("task")]
2243            )
2244        );
2245        assert_eq!(
2246            translate_predicate(&pred("block.type == \"task\""), &blocks),
2247            None
2248        );
2249        assert_eq!(
2250            translate_predicate(&pred("section.level == 1"), &nodes),
2251            None
2252        );
2253    }
2254
2255    #[test]
2256    fn edges_push_their_plain_fields_under_the_surface_names() {
2257        let edges = TranslateCtx {
2258            target: Target::Links,
2259            self_alias: "e",
2260            ..DOCS
2261        };
2262        assert_eq!(
2263            translate_predicate(&pred("predicate == \"references\""), &edges),
2264            frag("(e.predicate IS ?)", &[text("references")])
2265        );
2266        assert_eq!(
2267            translate_predicate(&pred("dst_path == \"index.md\""), &edges),
2268            frag(
2269                "((SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2270                &[text("index.md")]
2271            )
2272        );
2273        assert_eq!(
2274            translate_predicate(&pred("block_id == \"b_1\""), &edges),
2275            frag("(e.src_block IS ?)", &[text("b_1")])
2276        );
2277        assert_eq!(
2278            translate_predicate(&pred("via_id == null"), &edges),
2279            frag("(e.via_node IS ?)", &[SqlValue::Null])
2280        );
2281        assert_eq!(
2282            translate_predicate(&pred("dst_id == \"d_2\""), &edges),
2283            frag("(e.dst_node IS ?)", &[text("d_2")])
2284        );
2285        assert_eq!(
2286            translate_predicate(&pred("field == \"before\""), &edges),
2287            frag("(e.src_field IS ?)", &[text("before")])
2288        );
2289        assert!(
2290            translate_predicate(&pred("uri == \"https://x\""), &edges)
2291                .is_some_and(|f| f.sql.contains("FROM external_nodes xn"))
2292        );
2293        assert_eq!(translate_predicate(&pred("weight == 1"), &edges), None);
2294        // the dropped names are no longer intrinsics: an unknown `$x`
2295        // declines, `from_commit` is gone, `src_field` is `field`
2296        assert_eq!(translate_predicate(&pred("$src == \"d_1\""), &edges), None);
2297        assert_eq!(
2298            translate_predicate(&pred("$from_commit == \"c_1\""), &edges),
2299            None
2300        );
2301        assert_eq!(
2302            translate_predicate(&pred("src_field == \"x\""), &edges),
2303            None
2304        );
2305        let blocks = TranslateCtx {
2306            target: Target::Blocks,
2307            self_alias: "b",
2308            ..DOCS
2309        };
2310        assert_eq!(translate_predicate(&pred("$doc == \"d_1\""), &blocks), None);
2311        assert_eq!(translate_predicate(&pred("$ordinal == 1"), &blocks), None);
2312    }
2313
2314    /// Foreign-key elimination (`spec/surface` §1, 3.0): a reach-through to
2315    /// the owner's identity is the row's own FK column, byte for byte the SQL
2316    /// the reference emits.
2317    #[test]
2318    fn reach_through_identity_is_the_foreign_key() {
2319        let blocks = TranslateCtx {
2320            target: Target::Blocks,
2321            self_alias: "b",
2322            ..DOCS
2323        };
2324        let nodes = TranslateCtx {
2325            target: Target::Nodes,
2326            self_alias: "n",
2327            ..DOCS
2328        };
2329        let edges = TranslateCtx {
2330            target: Target::Links,
2331            self_alias: "e",
2332            ..DOCS
2333        };
2334        // doc.id is the FK column on every joined target, the same SQL as doc_id
2335        assert_eq!(
2336            translate_predicate(&pred("doc.id == \"d_1\""), &blocks),
2337            frag("(b.doc_id IS ?)", &[text("d_1")])
2338        );
2339        assert_eq!(
2340            translate_predicate(&pred("doc_id == \"d_1\""), &blocks),
2341            frag("(b.doc_id IS ?)", &[text("d_1")])
2342        );
2343        assert_eq!(
2344            translate_predicate(&pred("doc.id == \"d_1\""), &nodes),
2345            frag("(n.doc_id IS ?)", &[text("d_1")])
2346        );
2347        assert_eq!(
2348            translate_predicate(&pred("doc.id == \"d_1\""), &edges),
2349            frag("(e.src_doc IS ?)", &[text("d_1")])
2350        );
2351        assert_eq!(
2352            translate_predicate(&pred("doc_id == \"d_1\""), &edges),
2353            frag("(e.src_doc IS ?)", &[text("d_1")])
2354        );
2355        assert_eq!(
2356            translate_predicate(&pred("doc.id == \"d_1\""), &DOCS),
2357            frag("(d.doc_id IS ?)", &[text("d_1")])
2358        );
2359        // doc.path / doc.hash / doc.updated_at read the joined document columns
2360        assert_eq!(
2361            translate_predicate(&pred("doc.path == \"/a.md\""), &blocks),
2362            frag("(d.path IS ?)", &[text("a.md")])
2363        );
2364        assert_eq!(
2365            translate_predicate(&pred("doc.path == \"/a.md\""), &edges),
2366            frag("(d.path IS ?)", &[text("a.md")])
2367        );
2368        assert_eq!(
2369            translate_predicate(&pred("doc.hash == \"ab\""), &blocks),
2370            frag("(lower(hex(d.file_hash)) IS ?)", &[text("ab")])
2371        );
2372        assert!(
2373            translate_predicate(&pred("doc.updated_at >= \"2026\""), &nodes)
2374                .is_some_and(|f| f.sql.contains("r.rev_id = d.current_rev"))
2375        );
2376        assert!(
2377            translate_predicate(&pred("doc.created_at >= \"2026\""), &nodes)
2378                .is_some_and(|f| f.sql.contains("r.doc_id = d.doc_id AND r.seq = 1"))
2379        );
2380        assert_eq!(
2381            translate_predicate(&pred("doc.raw == \"x\""), &blocks),
2382            None
2383        );
2384        // block.id / block.path / block.doc_id on a node are the node's own columns
2385        assert_eq!(
2386            translate_predicate(&pred("block.id == \"b_1\""), &nodes),
2387            frag("(n.block_id IS ?)", &[text("b_1")])
2388        );
2389        assert_eq!(
2390            translate_predicate(&pred("block_id == \"b_1\""), &nodes),
2391            frag("(n.block_id IS ?)", &[text("b_1")])
2392        );
2393        assert_eq!(
2394            translate_predicate(&pred("block.path == \"/a.md\""), &nodes),
2395            frag("(d.path IS ?)", &[text("a.md")])
2396        );
2397        assert_eq!(
2398            translate_predicate(&pred("block.doc_id == \"d_1\""), &nodes),
2399            frag("(n.doc_id IS ?)", &[text("d_1")])
2400        );
2401        assert!(
2402            translate_predicate(&pred("block.text == \"x\""), &nodes)
2403                .is_some_and(|f| f.sql.contains("SELECT bb.text FROM blocks bb"))
2404        );
2405        assert_eq!(
2406            translate_predicate(&pred("block.hash == \"x\""), &nodes),
2407            None
2408        );
2409    }
2410
2411    /// `dst.<k>` on a link reaches the destination DOCUMENT by the row's own
2412    /// `dst_node` (3.0 follow-up), byte for byte the reference's SQL.
2413    #[test]
2414    fn dst_reach_through_reads_the_destination_by_its_foreign_key() {
2415        let edges = TranslateCtx {
2416            target: Target::Links,
2417            self_alias: "e",
2418            ..DOCS
2419        };
2420        // identity and the document's own columns: a primary-key probe, NULL
2421        // where `dst` is absent in memory
2422        assert_eq!(
2423            translate_predicate(&pred("dst.id == \"d_2\""), &edges),
2424            frag(
2425                "((SELECT dd.doc_id FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2426                &[text("d_2")]
2427            )
2428        );
2429        assert_eq!(
2430            translate_predicate(&pred("dst.path == \"/a.md\""), &edges),
2431            frag(
2432                "((SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2433                &[text("/a.md")]
2434            )
2435        );
2436        assert_eq!(
2437            translate_predicate(&pred("dst.format == \"yaml\""), &edges),
2438            frag(
2439                "((SELECT dd.format FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
2440                &[text("yaml")]
2441            )
2442        );
2443        assert!(
2444            translate_predicate(&pred("dst.hash == \"ab\""), &edges)
2445                .is_some_and(|f| f.sql.contains("lower(hex(dd.file_hash))"))
2446        );
2447        assert!(
2448            translate_predicate(&pred("dst.updated_at == \"t\""), &edges)
2449                .is_some_and(|f| f.sql.contains("WHERE r.rev_id = dd.current_rev"))
2450        );
2451        assert!(
2452            translate_predicate(&pred("dst.created_at == \"t\""), &edges)
2453                .is_some_and(|f| f.sql.contains("WHERE r.doc_id = dd.doc_id AND r.seq = 1"))
2454        );
2455        // a property: the properties scope keyed by e.dst_node itself
2456        let prop = translate_predicate(&pred("dst.layer == \"canon\""), &edges).expect("pushed");
2457        assert!(
2458            prop.sql
2459                .contains("p.doc_id = e.dst_node AND p.key = 'layer'"),
2460            "{}",
2461            prop.sql
2462        );
2463        assert_eq!(prop.params, vec![text("canon")]);
2464        // a typed push keeps the same scope
2465        assert!(
2466            translate_predicate(&pred("dst.era > 1600"), &edges)
2467                .is_some_and(|f| f.sql.contains("p.doc_id = e.dst_node AND p.key = 'era'"))
2468        );
2469        // through a source handle
2470        assert!(
2471            translate_predicate(&pred("dst.frontmatter.layer == \"canon\""), &edges)
2472                .is_some_and(|f| f.sql.contains("p.source = 'frontmatter'"))
2473        );
2474        // the attributes the owner's reach-through declines decline here too,
2475        // and so do the relations, the whole handle and a bare `dst`
2476        for k in [
2477            "raw",
2478            "title",
2479            "tags",
2480            "nodes",
2481            "out_links",
2482            "data",
2483            "docs",
2484            "$x",
2485        ] {
2486            assert_eq!(
2487                translate_predicate(&pred(&format!("dst.{k} == \"x\"")), &edges),
2488                None,
2489                "dst.{k}"
2490            );
2491        }
2492        assert_eq!(translate_predicate(&pred("dst == null"), &edges), None);
2493        // `dst` is a link's name only
2494        assert_eq!(
2495            translate_predicate(&pred("dst.layer == \"canon\""), &DOCS),
2496            None
2497        );
2498    }
2499
2500    #[test]
2501    fn unsafe_identifier_segments_are_never_inlined() {
2502        assert!(is_seg("layer") && is_seg("_x9"));
2503        assert!(!is_seg("") && !is_seg("9a") && !is_seg("a-b") && !is_seg("a'b"));
2504        assert_eq!(json_path(&["ok", "no-pe"]), None);
2505        assert_eq!(json_path(&["ok", "a_1"]).as_deref(), Some("$.ok.a_1"));
2506        assert_eq!(prop_scalar("d", "x'y", None), None);
2507    }
2508}