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