Skip to main content

omgbase_surface/
translate.rs

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