Skip to main content

omgbase_surface/
translate.rs

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