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};
58
59/// The SQL aliases the planner assigned to the current scope's row (`self`)
60/// and its owning document (`doc`); on the `docs` target both are the same
61/// alias. `params` are the query bindings, for `${…}` interpolations.
62#[derive(Clone, Copy, Debug)]
63pub struct TranslateCtx<'a> {
64    pub target: Target,
65    pub self_alias: &'a str,
66    pub doc_alias: &'a str,
67    pub params: &'a [Value],
68}
69
70/// A SQL fragment plus its positional bind params, in statement order.
71#[derive(Clone, Debug, PartialEq)]
72pub struct Frag {
73    pub sql: String,
74    pub params: Vec<SqlValue>,
75}
76
77impl Frag {
78    fn bare(sql: impl Into<String>) -> Self {
79        Self {
80            sql: sql.into(),
81            params: Vec::new(),
82        }
83    }
84}
85
86// ---- operand typing ----------------------------------------------------------------
87
88/// What a translated operand carries in SQL, as far as the translator can
89/// tell statically. The comparison gate ([`comparable`]) declines the pairs
90/// SQLite would compare with less type than the in-memory engine has.
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum Ty {
93    /// A string literal or binding, a text column, a text intrinsic,
94    /// `lower()` / `upper()` output.
95    Text,
96    /// An integer intrinsic (`$ordinal`, `$depth`).
97    Int,
98    /// A number literal or binding.
99    Num,
100    /// A boolean literal or binding.
101    Bool,
102    /// A `json_extract` read (an `attrs` path or a bare attribute name):
103    /// JSON `true` and `1` both surface as `1`, so a bool or num against it
104    /// tests `json_type` first ([`typed_compare`]).
105    Json,
106    /// A document property scalar (bare key on docs, `doc.<k>`): `val_bool`,
107    /// `val_num` and `val_text` coalesce into one column, and a list-valued or
108    /// nested key has no scalar row (`NULL`); a bool or num against it tests
109    /// `p.type` first ([`typed_compare`]).
110    Prop,
111    /// `null` (or an absent binding).
112    Null,
113}
114
115/// The comparison gate (`spec/surface` §1), stated positively: a pair pushes
116/// only when it is provably compared the same way in SQLite and in memory.
117///
118/// **Equality** (`==`, `!=`) pushes iff one operand is `Text` (SQLite's typed
119/// comparison and strict equality agree that a string equals nothing but an
120/// equal string), or one is `Null` and the other is not `Prop` (a list-valued
121/// or nested key has no scalar row, so SQL reads `NULL` where memory has an
122/// array or an object), or both are numeric (`Int` / `Num`), or — the 1.2
123/// **typed** cells — one is a `Bool` / `Num` constant and the other a `Json` /
124/// `Prop` read, pushed with the stored type tested first ([`typed_compare`]):
125///
126/// ```text
127///          Text   Int    Num    Bool   Null   Json   Prop
128///   Text   push   push   push   push   push   push   push
129///   Int    push   push   push   decl   push   decl   decl
130///   Num    push   push   push   decl   push   typed  typed
131///   Bool   push   decl   decl   decl   push   typed  typed
132///   Null   push   push   push   push   push   push   decl
133///   Json   push   decl   typed  typed  push   decl   decl
134///   Prop   push   decl   typed  typed  decl   decl   decl
135/// ```
136///
137/// **Relational** (`<`, `<=`, `>`, `>=`) pushes iff both operands are `Text`,
138/// both are numeric, or one is a `Num` constant against a `Json` / `Prop` read
139/// (typed); every other cell declines (SQLite orders every integer before
140/// every text, `NULL` compares to nothing, booleans are not ordered):
141///
142/// ```text
143///          Text   Int    Num    Bool   Null   Json   Prop
144///   Text   push   decl   decl   decl   decl   decl   decl
145///   Int    decl   push   push   decl   decl   decl   decl
146///   Num    decl   push   push   decl   decl   typed  typed
147///   Bool   decl   decl   decl   decl   decl   decl   decl
148///   Null   decl   decl   decl   decl   decl   decl   decl
149///   Json   decl   decl   typed  decl   decl   decl   decl
150///   Prop   decl   decl   typed  decl   decl   decl   decl
151/// ```
152///
153/// Why the declines: SQLite sees JSON `true` and `1`, `val_bool` and
154/// `val_num` alike (`$ordinal == checked`, `true == 1` binds as `1 IS 1`),
155/// orders every integer before every text (`$ordinal < "3"`, `level <
156/// "x"`), and two JSON or property reads carry no type at plan time.
157#[must_use]
158pub fn comparable(op: BinaryOp, a: Ty, b: Ty) -> bool {
159    use Ty::{Bool, Int, Json, Null, Num, Prop, Text};
160    let numeric = |t: Ty| matches!(t, Int | Num);
161    let read = |t: Ty| matches!(t, Json | Prop);
162    let both_numeric = numeric(a) && numeric(b);
163    // The typed cells: a constant of the given kinds against a stored read.
164    let typed = |konst: fn(Ty) -> bool| (read(a) && konst(b)) || (read(b) && konst(a));
165    if matches!(op, BinaryOp::Eq | BinaryOp::Ne) {
166        a == Text
167            || b == Text
168            || (a == Null && b != Prop)
169            || (b == Null && a != Prop)
170            || both_numeric
171            || typed(|t| matches!(t, Bool | Num))
172    } else {
173        (a == Text && b == Text) || both_numeric || typed(|t| t == Num)
174    }
175}
176
177/// The [`Ty`] of a literal or bound value; `None` for a non-scalar (an array,
178/// an object, a range), which has no faithful SQL binding.
179fn const_ty(v: &Value) -> Option<Ty> {
180    Some(match v {
181        Value::Str(_) => Ty::Text,
182        Value::Number(_) => Ty::Num,
183        Value::Bool(_) => Ty::Bool,
184        Value::Null | Value::Undefined => Ty::Null,
185        Value::Array(_) | Value::Object(_) | Value::Range(_) => return None,
186    })
187}
188
189// ---- handles -----------------------------------------------------------------------
190
191/// The bare names that resolve to something other than a property or
192/// attribute on each target — the self alias, the reach-through handles, the
193/// relations and the bags (`spec/surface` §1.2) — exactly the keys
194/// [`crate::StoreContext`]'s `get` answers before its property fallback. A
195/// comparison against one of these is declined rather than read as a key
196/// (`nodes == null` is not `NULL IS NULL`). A unit test proves the sets
197/// match the context.
198#[must_use]
199pub fn non_property_handles(t: Target) -> &'static [&'static str] {
200    match t {
201        Target::Docs => &[
202            "doc",
203            "blocks",
204            "nodes",
205            "out",
206            "in",
207            "out_edges",
208            "in_edges",
209            "frontmatter",
210            "inline",
211        ],
212        Target::Blocks => &[
213            "block",
214            "doc",
215            "children",
216            "nodes",
217            "out_edges",
218            "section",
219            "attrs",
220        ],
221        Target::Nodes => &[
222            "section",
223            "doc",
224            "block",
225            "blocks",
226            "subsections",
227            "children",
228            "attrs",
229        ],
230        Target::Edges => &["doc"],
231    }
232}
233
234// ---- intrinsics ------------------------------------------------------------------
235
236/// A `$`-namespaced intrinsic → a param-free SQL scalar and its type, per
237/// target. Anything not mapped (docs `$body`, reconstructed; `$title` /
238/// `$tags`, computed; blocks `$updated_at`; nodes `$locator`) returns `None`
239/// → residual. `$updated_at` / `$dst_path` / `$dst_uri` are correlated
240/// subqueries. Only `$ordinal` / `$depth` are integers; every other mapped
241/// intrinsic is text.
242fn intrinsic_sql(name: &str, ctx: &TranslateCtx<'_>) -> Option<(String, Ty)> {
243    let (s, d) = (ctx.self_alias, ctx.doc_alias);
244    let sql = match (ctx.target, name) {
245        (Target::Docs, "$id") => format!("{s}.doc_id"),
246        (Target::Docs, "$path") => format!("{d}.path"),
247        (Target::Docs, "$content_hash") => format!("lower(hex({s}.file_hash))"),
248        (Target::Docs, "$updated_at") => format!(
249            "(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = {s}.current_rev)"
250        ),
251        (Target::Blocks, "$id") => format!("{s}.block_id"),
252        (Target::Blocks, "$doc") => format!("{s}.doc_id"),
253        (Target::Blocks, "$path") => format!("{d}.path"),
254        (Target::Blocks, "$ordinal") => return Some((format!("{s}.ordinal"), Ty::Int)),
255        (Target::Blocks, "$depth") => return Some((format!("{s}.depth"), Ty::Int)),
256        (Target::Blocks, "$body") => format!("{s}.text"),
257        (Target::Blocks, "$content_hash") => format!("lower(hex({s}.raw_hash))"),
258        (Target::Nodes, "$id" | "$node_id") => format!("{s}.node_id"),
259        (Target::Nodes, "$doc_id") => format!("{s}.doc_id"),
260        (Target::Nodes, "$block_id") => format!("{s}.block_id"),
261        (Target::Nodes, "$path") => format!("{d}.path"),
262        (Target::Edges, "$id") => format!("{s}.edge_id"),
263        (Target::Edges, "$src") => format!("{s}.src_doc"),
264        (Target::Edges, "$dst") => format!("{s}.dst_node"),
265        (Target::Edges, "$src_block") => format!("{s}.src_block"),
266        (Target::Edges, "$via") => format!("{s}.via_node"),
267        (Target::Edges, "$from_commit") => format!("{s}.from_commit"),
268        (Target::Edges, "$path") => format!("{d}.path"),
269        (Target::Edges, "$dst_path") => {
270            format!("(SELECT dd.path FROM docs dd WHERE dd.doc_id = {s}.dst_node)")
271        }
272        (Target::Edges, "$dst_uri") => {
273            format!("(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = {s}.dst_node)")
274        }
275        _ => return None,
276    };
277    Some((sql, Ty::Text))
278}
279
280/// docs intrinsics whose BARE (non-`$`) form is a loud error in-memory — not
281/// pushable, so the residual raises the guard (and the planner declines the
282/// whole query when such a read is left residual, see [`crate::planner`]).
283pub(crate) const RESERVED_DOC_BASENAMES: [&str; 5] =
284    ["id", "path", "updated_at", "content_hash", "body"];
285
286/// An injection-safe inlined identifier: `^[A-Za-z_][A-Za-z0-9_]*$`.
287fn is_seg(s: &str) -> bool {
288    let mut chars = s.chars();
289    chars
290        .next()
291        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
292        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
293}
294
295/// The single-scalar-row property subquery (the scalar-in-scope rule):
296/// `select` evaluated over the property row `p` only when the key has exactly
297/// one row in scope and it is `card = 'scalar'`, else NULL — matching the
298/// context's `doc_prop` for a scalar read. [`prop_scalar`] selects the value;
299/// the typed pushes select a type test ([`typed_compare`]).
300fn prop_row(doc_alias: &str, key: &str, select: &str) -> Option<String> {
301    if !is_seg(key) {
302        return None;
303    }
304    Some(format!(
305        "(SELECT {select} FROM properties p \
306         WHERE p.doc_id = {doc_alias}.doc_id AND p.key = '{key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL \
307         AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = {doc_alias}.doc_id AND p2.key = '{key}' AND p2.deleted_commit IS NULL) = 1 \
308         LIMIT 1)"
309    ))
310}
311
312/// A single-valued document property's scalar value (`val_text`, `val_num`
313/// and `val_bool` coalesced), or NULL.
314fn prop_scalar(doc_alias: &str, key: &str) -> Option<String> {
315    prop_row(
316        doc_alias,
317        key,
318        "COALESCE(p.val_text, p.val_num, p.val_bool)",
319    )
320}
321
322/// The JSON path `$.a.b` from validated segments; `None` if any segment is
323/// unsafe.
324fn json_path(segs: &[&str]) -> Option<String> {
325    if segs.iter().any(|s| !is_seg(s)) {
326        return None;
327    }
328    Some(format!("$.{}", segs.join(".")))
329}
330
331/// What an operand IS, beyond the SQL it renders to — the typed pushes
332/// ([`typed_compare`]) rebuild a stored read as a type test and inline a
333/// constant's value, which a finished [`Frag`] no longer exposes.
334#[derive(Clone, Debug, PartialEq)]
335enum Shape {
336    /// A plain SQL scalar: a column, an intrinsic, `lower()` / `upper()`.
337    Plain,
338    /// A literal or binding, bound as `?`.
339    Const(Value),
340    /// `json_extract(col, 'path')`.
341    Json { col: String, path: String },
342    /// A document property read: the owning document's alias and the key.
343    Prop { doc_alias: String, key: String },
344}
345
346/// A translated value-position operand: its fragment, its [`Ty`] for the
347/// gate, and its [`Shape`] for the typed pushes.
348#[derive(Clone, Debug, PartialEq)]
349struct Operand {
350    frag: Frag,
351    ty: Ty,
352    shape: Shape,
353}
354
355impl Operand {
356    fn plain(sql: String, ty: Ty) -> Self {
357        Self {
358            frag: Frag::bare(sql),
359            ty,
360            shape: Shape::Plain,
361        }
362    }
363
364    fn text(sql: String) -> Option<Self> {
365        Some(Self::plain(sql, Ty::Text))
366    }
367
368    /// `None` for a non-scalar (an array, an object, a range), which has no
369    /// faithful SQL binding.
370    fn constant(v: &Value) -> Option<Self> {
371        Some(Self {
372            frag: Frag {
373                sql: "?".to_owned(),
374                params: vec![to_sql(v)],
375            },
376            ty: const_ty(v)?,
377            shape: Shape::Const(v.clone()),
378        })
379    }
380
381    fn json(col: String, segs: &[&str]) -> Option<Self> {
382        let path = json_path(segs)?;
383        Some(Self {
384            frag: Frag::bare(format!("json_extract({col}, '{path}')")),
385            ty: Ty::Json,
386            shape: Shape::Json { col, path },
387        })
388    }
389
390    fn prop(doc_alias: &str, key: &str) -> Option<Self> {
391        Some(Self {
392            frag: Frag::bare(prop_scalar(doc_alias, key)?),
393            ty: Ty::Prop,
394            shape: Shape::Prop {
395                doc_alias: doc_alias.to_owned(),
396                key: key.to_owned(),
397            },
398        })
399    }
400}
401
402/// The dotted `attrs.a.b` / `doc.x` receiver chain as segments, or `None` if
403/// it is not a plain identifier navigation.
404fn member_segments(e: &Expr) -> Option<Vec<&str>> {
405    match e {
406        Expr::Ident { name } => Some(vec![name.as_str()]),
407        Expr::Member { recv, name } => {
408            let mut base = member_segments(recv)?;
409            base.push(name.as_str());
410            Some(base)
411        }
412        _ => None,
413    }
414}
415
416// ---- value position ----------------------------------------------------------------
417
418/// Translate an expression used as a VALUE (comparison operand, method
419/// receiver, function argument) to a SQL scalar. `None` if not faithfully
420/// translatable.
421pub fn translate_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
422    typed_value(e, ctx).map(|(frag, _)| frag)
423}
424
425/// [`translate_value`] plus the operand's [`Ty`], for the comparison gate.
426pub fn typed_value(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<(Frag, Ty)> {
427    operand(e, ctx).map(|o| (o.frag, o.ty))
428}
429
430/// The full [`Operand`] of a value-position expression.
431fn operand(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Operand> {
432    let (s, d, target) = (ctx.self_alias, ctx.doc_alias, ctx.target);
433    match e {
434        Expr::Lit(v) => Operand::constant(v),
435        Expr::Binding { index } => {
436            Operand::constant(ctx.params.get(*index).unwrap_or(&Value::Undefined))
437        }
438        Expr::Ident { name } => {
439            if name.starts_with('$') {
440                return intrinsic_sql(name, ctx).map(|(sql, ty)| Operand::plain(sql, ty));
441            }
442            let name = name.as_str();
443            // A relation, reach-through handle, source handle or bag is not a
444            // property read (§1): rows or an object in memory, never a key.
445            if non_property_handles(target).contains(&name) {
446                return None;
447            }
448            match target {
449                Target::Docs => {
450                    // `format` is a column, not a property.
451                    if name == "format" {
452                        return Operand::text(format!("{s}.format"));
453                    }
454                    // A reserved basename stays residual so the guard fires.
455                    if RESERVED_DOC_BASENAMES.contains(&name) {
456                        return None;
457                    }
458                    Operand::prop(d, name)
459                }
460                Target::Blocks => {
461                    if name == "type" || name == "text" {
462                        return Operand::text(format!("{s}.{name}"));
463                    }
464                    // A bare non-structural identifier flattens into attrs —
465                    // the same pushdown as the `attrs.<k>` member form.
466                    Operand::json(format!("{s}.attrs"), &[name])
467                }
468                Target::Nodes => {
469                    if matches!(name, "kind" | "name" | "value") {
470                        return Operand::text(format!("{s}.{name}"));
471                    }
472                    Operand::json(format!("{s}.attrs"), &[name])
473                }
474                Target::Edges => {
475                    if matches!(
476                        name,
477                        "predicate" | "provenance" | "dst_kind" | "anchor" | "src_field"
478                    ) {
479                        return Operand::text(format!("{s}.{name}"));
480                    }
481                    None
482                }
483            }
484        }
485        Expr::Member { .. } => {
486            let segs = member_segments(e)?;
487            let (head, rest) = segs.split_first()?;
488            if rest.is_empty() {
489                return None;
490            }
491            // attrs.<path> → json_extract on the row's attrs (blocks/nodes).
492            if *head == "attrs" && matches!(target, Target::Blocks | Target::Nodes) {
493                return Operand::json(format!("{s}.attrs"), rest);
494            }
495            // doc.<x> reach-through — the owning doc (alias `doc`). On the docs
496            // target `doc` is the row itself; either way it resolves against `d`.
497            if *head == "doc" {
498                if rest.len() != 1 {
499                    return None;
500                }
501                let k = rest[0];
502                if k == "$path" {
503                    return Operand::text(format!("{d}.path"));
504                }
505                if k == "format" {
506                    return Operand::text(format!("{d}.format"));
507                }
508                // `doc.nodes`, `doc.frontmatter`, `doc.doc`… are the doc's
509                // handles, not its properties.
510                if k.starts_with('$')
511                    || RESERVED_DOC_BASENAMES.contains(&k)
512                    || non_property_handles(Target::Docs).contains(&k)
513                {
514                    return None;
515                }
516                return Operand::prop(d, k);
517            }
518            // block.type / block.text reach-through from a node.
519            if *head == "block"
520                && target == Target::Nodes
521                && rest.len() == 1
522                && matches!(rest[0], "type" | "text")
523            {
524                return Operand::text(format!(
525                    "(SELECT bb.{} FROM blocks bb WHERE bb.block_id = {s}.block_id)",
526                    rest[0]
527                ));
528            }
529            None
530        }
531        // `.lower()` / `.upper()` are the value-position string methods.
532        Expr::Call {
533            recv: Some(recv),
534            name,
535            args,
536        } if args.is_empty() && (name == "lower" || name == "upper") => {
537            let recv = translate_value(recv, ctx)?;
538            Some(Operand {
539                frag: Frag {
540                    sql: format!("{name}({})", recv.sql),
541                    params: recv.params,
542                },
543                ty: Ty::Text,
544                shape: Shape::Plain,
545            })
546        }
547        _ => None,
548    }
549}
550
551// ---- predicate position --------------------------------------------------------------
552
553/// `==` / `!=` → null-safe `IS` / `IS NOT`; the relational ops as plain SQL.
554fn is_op(op: BinaryOp) -> Option<&'static str> {
555    Some(match op {
556        BinaryOp::Eq => "IS",
557        BinaryOp::Ne => "IS NOT",
558        BinaryOp::Lt => "<",
559        BinaryOp::Le => "<=",
560        BinaryOp::Gt => ">",
561        BinaryOp::Ge => ">=",
562        BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => {
563            return None;
564        }
565    })
566}
567
568/// The typed pushes (`spec/surface` §1, 1.2 patch): a bool or num constant
569/// against a JSON or property read, with the stored type tested in SQL
570/// before the value so SQLite cannot conflate JSON `true` with `1` or
571/// `val_bool` with `val_num`. `None` when the pair is not a typed cell (the
572/// plain `IS` / relational form applies) — the gate ([`comparable`]) has
573/// already declined the cells neither form can push.
574///
575/// * json × bool (`==`/`!=`): `(json_type(x) = 'true' | 'false') IS 1`;
576/// * json × num (all six): `(json_type(x) IN ('integer', 'real') AND
577///   json_extract(x) <op> ?) IS 1`;
578/// * prop × bool (`==`/`!=`): the single-scalar-row subquery selecting
579///   `p.type = 'bool' AND p.val_bool = ?` (`spec/properties` §2.1 type
580///   names; booleans bind as 1/0), `(…) IS 1`;
581/// * prop × num (all six): the same subquery selecting `p.type = 'number'
582///   AND p.val_num <op> ?`, `(…) IS 1`.
583///
584/// `!=` wraps `IS NOT 1` around the equality test. `json_type` is NULL for
585/// an absent path and the subquery is NULL for an absent, list-valued or
586/// nested key, so `IS 1` is false and `IS NOT 1` true — the in-memory
587/// absence semantics (unequal, never ordered). The test is normalized to
588/// `read <op> ?`, a relational op flipping when the constant is on the left
589/// (`800 < era` ⇔ `era > 800`), as the reference does.
590fn typed_compare(op: BinaryOp, sql_op: &str, l: &Operand, r: &Operand) -> Option<Frag> {
591    let (read, konst, read_left) = match (&l.shape, &r.shape) {
592        (Shape::Json { .. } | Shape::Prop { .. }, Shape::Const(v)) => (&l.shape, v, true),
593        (Shape::Const(v), Shape::Json { .. } | Shape::Prop { .. }) => (&r.shape, v, false),
594        _ => return None,
595    };
596    let equality = matches!(op, BinaryOp::Eq | BinaryOp::Ne);
597    let wrap = if op == BinaryOp::Ne {
598        "IS NOT 1"
599    } else {
600        "IS 1"
601    };
602    // Normalized to `read <op> ?`: a relational op flips when the constant is
603    // on the left (`800 < era` ⇔ `era > 800`), as in the reference.
604    let inner_op = match (equality, read_left, sql_op) {
605        (true, _, _) => "=",
606        (false, true, _) => sql_op,
607        (false, false, "<") => ">",
608        (false, false, "<=") => ">=",
609        (false, false, ">") => "<",
610        (false, false, ">=") => "<=",
611        (false, false, _) => return None,
612    };
613    let sides = |read_sql: &str| format!("{read_sql} {inner_op} ?");
614    let (sql, params) = match (read, konst) {
615        // Booleans are only ever equal; the gate declines them relational.
616        (_, Value::Bool(_)) if !equality => return None,
617        (Shape::Json { col, path }, Value::Bool(b)) => (
618            format!("(json_type({col}, '{path}') = '{b}') {wrap}"),
619            Vec::new(),
620        ),
621        (Shape::Json { col, path }, Value::Number(_)) => (
622            format!(
623                "(json_type({col}, '{path}') IN ('integer', 'real') AND {}) {wrap}",
624                sides(&format!("json_extract({col}, '{path}')"))
625            ),
626            vec![to_sql(konst)],
627        ),
628        (Shape::Prop { doc_alias, key }, Value::Bool(_)) => (
629            format!(
630                "{} {wrap}",
631                prop_row(
632                    doc_alias,
633                    key,
634                    &format!("p.type = 'bool' AND {}", sides("p.val_bool"))
635                )?
636            ),
637            vec![to_sql(konst)],
638        ),
639        (Shape::Prop { doc_alias, key }, Value::Number(_)) => (
640            format!(
641                "{} {wrap}",
642                prop_row(
643                    doc_alias,
644                    key,
645                    &format!("p.type = 'number' AND {}", sides("p.val_num"))
646                )?
647            ),
648            vec![to_sql(konst)],
649        ),
650        _ => return None,
651    };
652    Some(Frag {
653        sql: format!("({sql})"),
654        params,
655    })
656}
657
658/// Translate an expression used as a boolean PREDICATE to a SQL boolean, or
659/// `None` if it cannot be pushed faithfully. Only positive, AND-safe forms
660/// are handled: `unary` (`!`), `in`, bare truthy idents and member
661/// reach-through in predicate position stay residual.
662pub fn translate_predicate(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
663    match e {
664        // Only `&&` composes faithfully in a positive context; `||` is
665        // declined (its NULL / short-circuit interaction stays residual).
666        Expr::Logical {
667            op: LogicalOp::And,
668            left,
669            right,
670        } => join2(
671            translate_predicate(left, ctx),
672            translate_predicate(right, ctx),
673            "AND",
674        ),
675        Expr::Binary { op, left, right } => {
676            // An arithmetic operator in predicate position → residual.
677            let sql_op = is_op(*op)?;
678            let l = operand(left, ctx)?;
679            let r = operand(right, ctx)?;
680            // The operand-typing gate (§1): the pairs SQLite would compare
681            // with less type than the engine has stay residual.
682            if !comparable(*op, l.ty, r.ty) {
683                return None;
684            }
685            // A bool/num constant against a JSON or property read pushes with
686            // the stored type tested first (the 1.2 typed cells).
687            if let Some(typed) = typed_compare(*op, sql_op, &l, &r) {
688                return Some(typed);
689            }
690            let op = sql_op;
691            // `==`/`!=` → IS / IS NOT (absence-normalized equality, faithful in
692            // any context). Relational ops → plain SQL: a NULL operand yields
693            // NULL, which is excluded in the positive AND context these
694            // fragments are pushed into, matching the absent-operand ⇒ false rule.
695            let mut params = l.frag.params;
696            params.extend(r.frag.params);
697            Some(Frag {
698                sql: format!("({} {op} {})", l.frag.sql, r.frag.sql),
699                params,
700            })
701        }
702        Expr::Call {
703            recv: Some(recv),
704            name,
705            args,
706        } if args.len() == 1 => {
707            // startsWith / contains / endsWith — CASE-SENSITIVE, via
708            // substr/instr (never LIKE). `matches` (regex) is declined.
709            let recv = translate_value(recv, ctx)?;
710            let arg = translate_value(&args[0], ctx)?;
711            let mut params = recv.params;
712            let sql = match name.as_str() {
713                "startsWith" => {
714                    // recv begins with arg ⇔ its first length(arg) chars equal arg.
715                    params.extend(arg.params.iter().cloned());
716                    params.extend(arg.params);
717                    format!("(substr({}, 1, length({a})) = {a})", recv.sql, a = arg.sql)
718                }
719                "endsWith" => {
720                    // recv ends with arg ⇔ its last length(arg) chars equal arg.
721                    // When arg is longer than recv, substr clamps to the whole
722                    // (shorter) string, so the equality is false.
723                    params.extend(arg.params.iter().cloned());
724                    params.extend(arg.params);
725                    format!("(substr({}, -length({a})) = {a})", recv.sql, a = arg.sql)
726                }
727                "contains" => {
728                    params.extend(arg.params);
729                    format!("(instr({}, {}) > 0)", recv.sql, arg.sql)
730                }
731                _ => return None,
732            };
733            Some(Frag { sql, params })
734        }
735        _ => None,
736    }
737}
738
739/// Combine two optional fragments with a boolean connective; `None` if either
740/// is untranslatable (the whole conjunct then stays residual).
741fn join2(a: Option<Frag>, b: Option<Frag>, connective: &str) -> Option<Frag> {
742    let (a, b) = (a?, b?);
743    let mut params = a.params;
744    params.extend(b.params);
745    Some(Frag {
746        sql: format!("({} {connective} {})", a.sql, b.sql),
747        params,
748    })
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754    use oqx::ast::Where;
755
756    const DOCS: TranslateCtx<'static> = TranslateCtx {
757        target: Target::Docs,
758        self_alias: "d",
759        doc_alias: "d",
760        params: &[],
761    };
762
763    fn text(s: &str) -> SqlValue {
764        SqlValue::Text(s.to_owned())
765    }
766
767    fn frag(sql: &str, params: &[SqlValue]) -> Option<Frag> {
768        Some(Frag {
769            sql: sql.to_owned(),
770            params: params.to_vec(),
771        })
772    }
773
774    /// Parse `from docs where <src>` and return the single scalar predicate.
775    fn pred(src: &str) -> Expr {
776        let q = oqx::parse_string(&format!("from docs where {src}")).expect("parses");
777        match q.r#where {
778            Some(Where::Scalar { expr }) => expr,
779            other => panic!("expected a single scalar predicate, got {other:?}"),
780        }
781    }
782
783    fn ident(name: &str) -> Box<Expr> {
784        Box::new(Expr::Ident {
785            name: name.to_owned(),
786        })
787    }
788
789    fn lit(s: &str) -> Box<Expr> {
790        Box::new(Expr::Lit(Value::from(s)))
791    }
792
793    fn eq(l: Box<Expr>, r: Box<Expr>) -> Box<Expr> {
794        Box::new(Expr::Binary {
795            op: BinaryOp::Eq,
796            left: l,
797            right: r,
798        })
799    }
800
801    // -- equality is absence-normalized (IS / IS NOT) --
802
803    #[test]
804    fn equality_is_null_safe_is() {
805        assert_eq!(
806            translate_predicate(&pred("$path == \"index.md\""), &DOCS),
807            frag("(d.path IS ?)", &[text("index.md")])
808        );
809    }
810
811    #[test]
812    fn inequality_is_null_safe_is_not() {
813        assert_eq!(
814            translate_predicate(&pred("$path != \"x\""), &DOCS),
815            frag("(d.path IS NOT ?)", &[text("x")])
816        );
817    }
818
819    #[test]
820    fn intrinsic_column_mapping() {
821        assert_eq!(
822            translate_predicate(&pred("$id == \"d_1\""), &DOCS),
823            frag("(d.doc_id IS ?)", &[text("d_1")])
824        );
825    }
826
827    // -- relational ops (plain SQL) --
828
829    #[test]
830    fn relational_ops_are_plain_comparisons() {
831        assert_eq!(
832            translate_predicate(&pred("$path < \"m\""), &DOCS),
833            frag("(d.path < ?)", &[text("m")])
834        );
835        assert_eq!(
836            translate_predicate(&pred("$path >= \"m\""), &DOCS),
837            frag("(d.path >= ?)", &[text("m")])
838        );
839        // arithmetic in predicate position → residual
840        assert_eq!(translate_predicate(&pred("$path + 1"), &DOCS), None);
841    }
842
843    // -- string ops are case-sensitive (substr/instr, never LIKE) --
844
845    #[test]
846    fn starts_with_is_substr_equality() {
847        assert_eq!(
848            translate_predicate(&pred("$path.startsWith(\"lab/\")"), &DOCS),
849            frag(
850                "(substr(d.path, 1, length(?)) = ?)",
851                &[text("lab/"), text("lab/")]
852            )
853        );
854    }
855
856    #[test]
857    fn lower_then_starts_with_pushes_with_explicit_lower() {
858        assert_eq!(
859            translate_predicate(&pred("$path.lower().startsWith(\"lab/\")"), &DOCS),
860            frag(
861                "(substr(lower(d.path), 1, length(?)) = ?)",
862                &[text("lab/"), text("lab/")]
863            )
864        );
865    }
866
867    #[test]
868    fn contains_is_instr() {
869        assert_eq!(
870            translate_predicate(&pred("$path.contains(\"notes\")"), &DOCS),
871            frag("(instr(d.path, ?) > 0)", &[text("notes")])
872        );
873    }
874
875    #[test]
876    fn ends_with_is_negative_substr_equality() {
877        assert_eq!(
878            translate_predicate(&pred("$path.endsWith(\".md\")"), &DOCS),
879            frag(
880                "(substr(d.path, -length(?)) = ?)",
881                &[text(".md"), text(".md")]
882            )
883        );
884    }
885
886    #[test]
887    fn upper_wraps_the_receiver_in_value_position() {
888        assert_eq!(
889            translate_value(&pred("$path.upper()"), &DOCS),
890            frag("upper(d.path)", &[])
891        );
892    }
893
894    // -- bare document properties push via the properties table --
895
896    #[test]
897    fn bare_doc_property_is_the_scalar_in_scope_subquery() {
898        let f = translate_predicate(&pred("layer == \"canon\""), &DOCS).expect("pushable");
899        assert!(f.sql.contains("FROM properties p"), "{}", f.sql);
900        assert!(f.sql.contains("p.key = 'layer'"), "{}", f.sql);
901        assert!(f.sql.contains("p.card = 'scalar'"), "{}", f.sql);
902        assert!(
903            f.sql.starts_with('(') && f.sql.contains(" IS ?)"),
904            "{}",
905            f.sql
906        );
907        assert_eq!(f.params, vec![text("canon")]);
908    }
909
910    #[test]
911    fn updated_at_pushes_as_its_revisions_subquery() {
912        let f =
913            translate_predicate(&pred("$updated_at >= \"2026-01-01\""), &DOCS).expect("pushable");
914        assert!(
915            f.sql.contains("FROM revisions r JOIN commits c"),
916            "{}",
917            f.sql
918        );
919    }
920
921    #[test]
922    fn format_is_a_column_not_a_property() {
923        assert_eq!(
924            translate_predicate(&pred("format == \"markdown\""), &DOCS),
925            frag("(d.format IS ?)", &[text("markdown")])
926        );
927    }
928
929    #[test]
930    fn booleans_bind_as_one_and_zero() {
931        let blocks = TranslateCtx {
932            target: Target::Blocks,
933            self_alias: "b",
934            ..DOCS
935        };
936        // (against a text column — against a JSON or property read the
937        // boolean pushes typed, see the typed-shape tests)
938        assert_eq!(
939            translate_predicate(&pred("type == true"), &blocks).map(|f| f.params),
940            Some(vec![SqlValue::Integer(1)])
941        );
942        assert_eq!(
943            translate_predicate(&pred("type == false"), &blocks).map(|f| f.params),
944            Some(vec![SqlValue::Integer(0)])
945        );
946        assert_eq!(
947            translate_predicate(&pred("$ordinal < 1000"), &blocks).map(|f| f.params),
948            Some(vec![SqlValue::Real(1000.0)])
949        );
950        assert_eq!(
951            translate_predicate(&pred("$path == null"), &DOCS).map(|f| f.params),
952            Some(vec![SqlValue::Null])
953        );
954    }
955
956    // -- decline (a): operand typing (spec/surface §1) --
957
958    /// One representative expression per [`Ty`] on the blocks target (the only
959    /// target with an integer intrinsic; `doc.<k>` is its property read).
960    const REPRESENTATIVES: [(Ty, &str); 7] = [
961        (Ty::Text, "$path"),
962        (Ty::Int, "$ordinal"),
963        (Ty::Num, "1"),
964        (Ty::Bool, "true"),
965        (Ty::Null, "null"),
966        (Ty::Json, "checked"),
967        (Ty::Prop, "doc.layer"),
968    ];
969
970    #[test]
971    fn representatives_carry_their_type() {
972        let blocks = TranslateCtx {
973            target: Target::Blocks,
974            self_alias: "b",
975            ..DOCS
976        };
977        for (ty, src) in REPRESENTATIVES {
978            let (_, got) = typed_value(&pred(src), &blocks).expect(src);
979            assert_eq!(got, ty, "{src}");
980        }
981        assert_eq!(
982            typed_value(&pred("attrs.a.b"), &blocks).map(|(_, t)| t),
983            Some(Ty::Json)
984        );
985        assert_eq!(
986            typed_value(&pred("$depth"), &blocks).map(|(_, t)| t),
987            Some(Ty::Int)
988        );
989        assert_eq!(
990            typed_value(&pred("type.lower()"), &blocks).map(|(_, t)| t),
991            Some(Ty::Text)
992        );
993        assert_eq!(
994            typed_value(&pred("checked.upper()"), &blocks).map(|(_, t)| t),
995            Some(Ty::Text)
996        );
997        assert_eq!(
998            typed_value(&pred("layer"), &DOCS).map(|(_, t)| t),
999            Some(Ty::Prop)
1000        );
1001        assert_eq!(
1002            typed_value(&pred("format"), &DOCS).map(|(_, t)| t),
1003            Some(Ty::Text)
1004        );
1005    }
1006
1007    #[test]
1008    fn the_comparison_matrix_decides_every_cell() {
1009        // Row/column order: Text Int Num Bool Null Json Prop.
1010        const P: bool = true;
1011        const D: bool = false;
1012        // Typed (1.2): a bool/num constant against a JSON or property read.
1013        const T: bool = true;
1014        // Equality: one side text, or null against a non-property, or both
1015        // numeric, or a typed cell.
1016        #[rustfmt::skip]
1017        const EQUALITY: [[bool; 7]; 7] = [
1018            /* Text */ [P, P, P, P, P, P, P],
1019            /* Int  */ [P, P, P, D, P, D, D],
1020            /* Num  */ [P, P, P, D, P, T, T],
1021            /* Bool */ [P, D, D, D, P, T, T],
1022            /* Null */ [P, P, P, P, P, P, D],
1023            /* Json */ [P, D, T, T, P, D, D],
1024            /* Prop */ [P, D, T, T, D, D, D],
1025        ];
1026        // Relational: both text, both numeric, or a num constant against a
1027        // read (typed); nothing else.
1028        #[rustfmt::skip]
1029        const RELATIONAL: [[bool; 7]; 7] = [
1030            /* Text */ [P, D, D, D, D, D, D],
1031            /* Int  */ [D, P, P, D, D, D, D],
1032            /* Num  */ [D, P, P, D, D, T, T],
1033            /* Bool */ [D, D, D, D, D, D, D],
1034            /* Null */ [D, D, D, D, D, D, D],
1035            /* Json */ [D, D, T, D, D, D, D],
1036            /* Prop */ [D, D, T, D, D, D, D],
1037        ];
1038        let blocks = TranslateCtx {
1039            target: Target::Blocks,
1040            self_alias: "b",
1041            ..DOCS
1042        };
1043        let ops = [
1044            (BinaryOp::Eq, "==", &EQUALITY),
1045            (BinaryOp::Ne, "!=", &EQUALITY),
1046            (BinaryOp::Lt, "<", &RELATIONAL),
1047            (BinaryOp::Le, "<=", &RELATIONAL),
1048            (BinaryOp::Gt, ">", &RELATIONAL),
1049            (BinaryOp::Ge, ">=", &RELATIONAL),
1050        ];
1051        for (i, (a, l)) in REPRESENTATIVES.iter().enumerate() {
1052            for (j, (b, r)) in REPRESENTATIVES.iter().enumerate() {
1053                for (op, spelled, matrix) in ops {
1054                    let want = matrix[i][j];
1055                    assert_eq!(matrix[j][i], want, "the matrix is symmetric ({a:?}, {b:?})");
1056                    assert_eq!(
1057                        comparable(op, *a, *b),
1058                        want,
1059                        "comparable({spelled}, {a:?}, {b:?})"
1060                    );
1061                    let src = format!("{l} {spelled} {r}");
1062                    assert_eq!(
1063                        translate_predicate(&pred(&src), &blocks).is_some(),
1064                        want,
1065                        "{src}"
1066                    );
1067                }
1068            }
1069        }
1070    }
1071
1072    #[test]
1073    fn the_spec_shapes_of_decline_a() {
1074        let blocks = TranslateCtx {
1075            target: Target::Blocks,
1076            self_alias: "b",
1077            ..DOCS
1078        };
1079        // a boolean or number against a JSON read pushes TYPED (1.2): the
1080        // json_type is tested first, so the SQL cannot read JSON `true` as 1
1081        let typed = |src: &str, ctx: &TranslateCtx<'_>| {
1082            let f = translate_predicate(&pred(src), ctx).expect(src);
1083            assert!(
1084                f.sql.contains("json_type(") || f.sql.contains("p.type = "),
1085                "{src}: {}",
1086                f.sql
1087            );
1088            assert!(
1089                f.sql.ends_with(" IS 1)") || f.sql.ends_with(" IS NOT 1)"),
1090                "{src}: {}",
1091                f.sql
1092            );
1093        };
1094        typed("checked == 1", &blocks);
1095        typed("checked == true", &blocks);
1096        typed("attrs.checked == true", &blocks);
1097        // … or a property read (bare on docs, `doc.<k>` elsewhere)
1098        typed("verified == 1", &DOCS);
1099        typed("verified == true", &DOCS);
1100        typed("era < 1000", &DOCS);
1101        typed("doc.era < 1000", &blocks);
1102        // a boolean against an integer intrinsic; a number stays pushable
1103        assert_eq!(
1104            translate_predicate(&pred("$ordinal == true"), &blocks),
1105            None
1106        );
1107        assert_eq!(
1108            translate_predicate(&pred("$ordinal == 1"), &blocks),
1109            frag("(b.ordinal IS ?)", &[SqlValue::Real(1.0)])
1110        );
1111        // null against a property read; against a JSON read or a column it pushes
1112        assert_eq!(translate_predicate(&pred("tags != null"), &DOCS), None);
1113        assert_eq!(translate_predicate(&pred("tags == null"), &DOCS), None);
1114        assert_eq!(
1115            translate_predicate(&pred("doc.tags == null"), &blocks),
1116            None
1117        );
1118        assert_eq!(
1119            translate_predicate(&pred("checked == null"), &blocks),
1120            frag(
1121                "(json_extract(b.attrs, '$.checked') IS ?)",
1122                &[SqlValue::Null]
1123            )
1124        );
1125        assert_eq!(
1126            translate_predicate(&pred("$ordinal != null"), &blocks),
1127            frag("(b.ordinal IS NOT ?)", &[SqlValue::Null])
1128        );
1129        // a string literal against anything pushes under equality
1130        assert_eq!(
1131            translate_predicate(&pred("checked == \"x\""), &blocks),
1132            frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1133        );
1134        assert!(translate_predicate(&pred("layer == \"canon\""), &DOCS).is_some());
1135        assert!(translate_predicate(&pred("$ordinal == \"1\""), &blocks).is_some());
1136        assert!(translate_predicate(&pred("$ordinal != \"1\""), &blocks).is_some());
1137        // … but a relational comparison across text and a number / integer
1138        // declines (the fifth shape): SQLite orders integers before text
1139        assert_eq!(
1140            translate_predicate(&pred("$ordinal < \"3\""), &blocks),
1141            None
1142        );
1143        assert_eq!(
1144            translate_predicate(&pred("\"3\" >= $ordinal"), &blocks),
1145            None
1146        );
1147        assert_eq!(translate_predicate(&pred("$path > 5"), &DOCS), None);
1148        assert_eq!(translate_predicate(&pred("type <= 1"), &blocks), None);
1149        assert_eq!(
1150            translate_predicate(&pred("$ordinal < 3"), &blocks),
1151            frag("(b.ordinal < ?)", &[SqlValue::Real(3.0)])
1152        );
1153        assert_eq!(
1154            translate_predicate(&pred("$path > \"m\""), &DOCS),
1155            frag("(d.path > ?)", &[text("m")])
1156        );
1157        // two reads carry no type at plan time; a boolean equals only text/null
1158        assert_eq!(
1159            translate_predicate(&pred("$ordinal == checked"), &blocks),
1160            None
1161        );
1162        assert_eq!(
1163            translate_predicate(&pred("checked == level"), &blocks),
1164            None
1165        );
1166        assert_eq!(
1167            translate_predicate(&pred("doc.era == doc.year"), &blocks),
1168            None
1169        );
1170        assert_eq!(translate_predicate(&pred("level < \"x\""), &blocks), None);
1171        typed("level < 3", &blocks);
1172        assert_eq!(translate_predicate(&pred("true == false"), &blocks), None);
1173        assert_eq!(translate_predicate(&pred("checked < true"), &blocks), None);
1174        assert_eq!(
1175            translate_predicate(&pred("doc.verified >= false"), &blocks),
1176            None
1177        );
1178        assert_eq!(translate_predicate(&pred("$ordinal > null"), &blocks), None);
1179        assert!(translate_predicate(&pred("$ordinal == $depth"), &blocks).is_some());
1180        assert!(translate_predicate(&pred("$ordinal <= $depth"), &blocks).is_some());
1181        assert!(translate_predicate(&pred("type == null"), &blocks).is_some());
1182    }
1183
1184    // -- the typed pushes (spec/surface §1, 1.2 patch) --
1185
1186    /// The properties subquery's scope: the same single-scalar-row conditions
1187    /// as the scalar read, so a list-valued or nested key yields NULL.
1188    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";
1189
1190    fn prop_sql(key: &str, select: &str, wrap: &str) -> String {
1191        format!(
1192            "((SELECT {select} {}) {wrap})",
1193            PROP_SCOPE.replace('K', key)
1194        )
1195    }
1196
1197    #[test]
1198    fn json_against_a_boolean_tests_json_type_for_the_literal() {
1199        let blocks = TranslateCtx {
1200            target: Target::Blocks,
1201            self_alias: "b",
1202            ..DOCS
1203        };
1204        // The boolean is inlined as the JSON type name; nothing binds.
1205        assert_eq!(
1206            translate_predicate(&pred("checked == true"), &blocks),
1207            frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1208        );
1209        assert_eq!(
1210            translate_predicate(&pred("checked == false"), &blocks),
1211            frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1212        );
1213        assert_eq!(
1214            translate_predicate(&pred("checked != true"), &blocks),
1215            frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1216        );
1217        assert_eq!(
1218            translate_predicate(&pred("false == attrs.checked"), &blocks),
1219            frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1220        );
1221        // a bound boolean is the same shape
1222        let params = [Value::Bool(true)];
1223        let e = Expr::Binary {
1224            op: BinaryOp::Ne,
1225            left: ident("checked"),
1226            right: Box::new(Expr::Binding { index: 0 }),
1227        };
1228        assert_eq!(
1229            translate_predicate(
1230                &e,
1231                &TranslateCtx {
1232                    params: &params,
1233                    ..blocks
1234                }
1235            ),
1236            frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1237        );
1238    }
1239
1240    #[test]
1241    fn json_against_a_number_tests_the_numeric_types_then_compares() {
1242        let nodes = TranslateCtx {
1243            target: Target::Nodes,
1244            self_alias: "n",
1245            ..DOCS
1246        };
1247        let shape = |op: &str, wrap: &str| {
1248            format!(
1249                "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') {op} ?) {wrap})"
1250            )
1251        };
1252        let two = [SqlValue::Real(2.0)];
1253        for (src, op, wrap) in [
1254            ("level == 2", "=", "IS 1"),
1255            ("level != 2", "=", "IS NOT 1"),
1256            ("level < 2", "<", "IS 1"),
1257            ("level <= 2", "<=", "IS 1"),
1258            ("level > 2", ">", "IS 1"),
1259            ("level >= 2", ">=", "IS 1"),
1260        ] {
1261            assert_eq!(
1262                translate_predicate(&pred(src), &nodes),
1263                frag(&shape(op, wrap), &two),
1264                "{src}"
1265            );
1266        }
1267        // a constant on the left is normalized to the right, the op flipped
1268        assert_eq!(
1269            translate_predicate(&pred("2 <= attrs.level"), &nodes),
1270            frag(&shape(">=", "IS 1"), &two)
1271        );
1272        assert_eq!(
1273            translate_predicate(&pred("2 > level"), &nodes),
1274            frag(&shape("<", "IS 1"), &two)
1275        );
1276        assert_eq!(
1277            translate_predicate(&pred("2 != level"), &nodes),
1278            frag(&shape("=", "IS NOT 1"), &two)
1279        );
1280    }
1281
1282    #[test]
1283    fn property_against_a_boolean_tests_p_type_bool_in_the_scalar_row_subquery() {
1284        let blocks = TranslateCtx {
1285            target: Target::Blocks,
1286            self_alias: "b",
1287            ..DOCS
1288        };
1289        assert_eq!(
1290            translate_predicate(&pred("verified == true"), &DOCS),
1291            frag(
1292                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1293                &[SqlValue::Integer(1)]
1294            )
1295        );
1296        assert_eq!(
1297            translate_predicate(&pred("verified != false"), &DOCS),
1298            frag(
1299                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS NOT 1"),
1300                &[SqlValue::Integer(0)]
1301            )
1302        );
1303        // `doc.<k>` from a block reads the owning document's row
1304        assert_eq!(
1305            translate_predicate(&pred("doc.verified == false"), &blocks),
1306            frag(
1307                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1308                &[SqlValue::Integer(0)]
1309            )
1310        );
1311        assert_eq!(
1312            translate_predicate(&pred("true == verified"), &DOCS),
1313            frag(
1314                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1315                &[SqlValue::Integer(1)]
1316            )
1317        );
1318    }
1319
1320    #[test]
1321    fn property_against_a_number_tests_p_type_number_in_the_scalar_row_subquery() {
1322        let blocks = TranslateCtx {
1323            target: Target::Blocks,
1324            self_alias: "b",
1325            ..DOCS
1326        };
1327        let thousand = [SqlValue::Real(1000.0)];
1328        for (src, op, wrap) in [
1329            ("era == 1000", "=", "IS 1"),
1330            ("era != 1000", "=", "IS NOT 1"),
1331            ("era < 1000", "<", "IS 1"),
1332            ("era <= 1000", "<=", "IS 1"),
1333            ("era > 1000", ">", "IS 1"),
1334            ("era >= 1000", ">=", "IS 1"),
1335        ] {
1336            assert_eq!(
1337                translate_predicate(&pred(src), &DOCS),
1338                frag(
1339                    &prop_sql(
1340                        "era",
1341                        &format!("p.type = 'number' AND p.val_num {op} ?"),
1342                        wrap
1343                    ),
1344                    &thousand
1345                ),
1346                "{src}"
1347            );
1348        }
1349        assert_eq!(
1350            translate_predicate(&pred("doc.era >= 1000"), &blocks),
1351            frag(
1352                &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1353                &thousand
1354            )
1355        );
1356        // a constant on the left is normalized to the right, the op flipped
1357        assert_eq!(
1358            translate_predicate(&pred("1000 > era"), &DOCS),
1359            frag(
1360                &prop_sql("era", "p.type = 'number' AND p.val_num < ?", "IS 1"),
1361                &thousand
1362            )
1363        );
1364        assert_eq!(
1365            translate_predicate(&pred("1000 <= era"), &DOCS),
1366            frag(
1367                &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1368                &thousand
1369            )
1370        );
1371    }
1372
1373    #[test]
1374    fn typed_pushes_compose_under_and_and_keep_the_untyped_forms() {
1375        let blocks = TranslateCtx {
1376            target: Target::Blocks,
1377            self_alias: "b",
1378            ..DOCS
1379        };
1380        let e = Expr::Logical {
1381            op: LogicalOp::And,
1382            left: eq(ident("type"), lit("task")),
1383            right: Box::new(Expr::Binary {
1384                op: BinaryOp::Eq,
1385                left: ident("checked"),
1386                right: Box::new(Expr::Lit(Value::Bool(false))),
1387            }),
1388        };
1389        assert_eq!(
1390            translate_predicate(&e, &blocks),
1391            frag(
1392                "((b.type IS ?) AND ((json_type(b.attrs, '$.checked') = 'false') IS 1))",
1393                &[text("task")]
1394            )
1395        );
1396        // text and null against a read stay the plain IS form
1397        assert_eq!(
1398            translate_predicate(&pred("checked == \"x\""), &blocks),
1399            frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1400        );
1401        assert_eq!(
1402            translate_predicate(&pred("checked != null"), &blocks),
1403            frag(
1404                "(json_extract(b.attrs, '$.checked') IS NOT ?)",
1405                &[SqlValue::Null]
1406            )
1407        );
1408        // an unsafe key never inlines, typed or not
1409        assert_eq!(
1410            prop_row("d", "x'y", "p.type = 'number' AND p.val_num = ?"),
1411            None
1412        );
1413    }
1414
1415    #[test]
1416    fn bindings_are_typed_by_their_value() {
1417        let blocks = TranslateCtx {
1418            target: Target::Blocks,
1419            self_alias: "b",
1420            ..DOCS
1421        };
1422        let params = [
1423            Value::from("s"),
1424            Value::from(1.0),
1425            Value::Bool(true),
1426            Value::Null,
1427            Value::Array(vec![]),
1428        ];
1429        let ctx = TranslateCtx {
1430            params: &params,
1431            ..blocks
1432        };
1433        let against = |i: usize, rhs: &str| {
1434            let e = Expr::Binary {
1435                op: BinaryOp::Eq,
1436                left: Box::new(Expr::Binding { index: i }),
1437                right: Box::new(pred(rhs)),
1438            };
1439            translate_predicate(&e, &ctx).is_some()
1440        };
1441        // text binding pushes against anything; number/boolean push typed
1442        // against JSON (1.2), a boolean not against an integer intrinsic
1443        assert!(against(0, "checked"));
1444        assert!(against(1, "checked"));
1445        assert!(against(2, "checked"));
1446        assert!(!against(2, "$ordinal"));
1447        assert!(against(1, "$ordinal"));
1448        // a null binding pushes against JSON, not against a property
1449        assert!(against(3, "checked"));
1450        assert!(!against(3, "doc.tags"));
1451        // an absent binding (past the end) is null
1452        assert!(against(9, "checked"));
1453        assert!(!against(9, "doc.tags"));
1454        // a non-scalar binding has no faithful SQL value
1455        assert!(!against(4, "type"));
1456    }
1457
1458    // -- decline (b): handles are not property reads --
1459
1460    #[test]
1461    fn relation_and_handle_names_are_not_property_reads() {
1462        let blocks = TranslateCtx {
1463            target: Target::Blocks,
1464            self_alias: "b",
1465            ..DOCS
1466        };
1467        let nodes = TranslateCtx {
1468            target: Target::Nodes,
1469            self_alias: "n",
1470            ..DOCS
1471        };
1472        let edges = TranslateCtx {
1473            target: Target::Edges,
1474            self_alias: "e",
1475            ..DOCS
1476        };
1477        for (ctx, target) in [
1478            (&DOCS, Target::Docs),
1479            (&blocks, Target::Blocks),
1480            (&nodes, Target::Nodes),
1481            (&edges, Target::Edges),
1482        ] {
1483            for name in non_property_handles(target) {
1484                let src = format!("{name} == null");
1485                assert_eq!(
1486                    translate_predicate(&pred(&src), ctx),
1487                    None,
1488                    "{target:?}: {src}"
1489                );
1490                let src = format!("{name} == \"x\"");
1491                assert_eq!(
1492                    translate_predicate(&pred(&src), ctx),
1493                    None,
1494                    "{target:?}: {src}"
1495                );
1496            }
1497        }
1498        // the fixtures' shapes
1499        assert_eq!(translate_predicate(&pred("nodes == null"), &DOCS), None);
1500        assert_eq!(
1501            translate_predicate(&pred("frontmatter == null"), &DOCS),
1502            None
1503        );
1504        assert_eq!(translate_predicate(&pred("nodes == null"), &blocks), None);
1505        assert_eq!(translate_predicate(&pred("attrs == null"), &blocks), None);
1506        // `doc.<handle>` is the doc's handle, not its property; `doc.<k>` still pushes
1507        assert_eq!(
1508            translate_predicate(&pred("doc.nodes == null"), &blocks),
1509            None
1510        );
1511        assert_eq!(
1512            translate_predicate(&pred("doc.frontmatter == null"), &blocks),
1513            None
1514        );
1515        assert_eq!(translate_predicate(&pred("doc.doc == null"), &DOCS), None);
1516        assert!(translate_predicate(&pred("doc.layer == \"canon\""), &blocks).is_some());
1517        // a plain attribute or property of the same spelling elsewhere still pushes
1518        assert!(translate_predicate(&pred("section == \"x\""), &DOCS).is_some());
1519        assert!(translate_predicate(&pred("frontmatter == \"x\""), &blocks).is_some());
1520    }
1521
1522    /// The handle sets are exactly the keys the store context resolves to
1523    /// rows, a row or a bag before its property fallback: over a small
1524    /// observed corpus, on every row of a target, a name in the set never
1525    /// reads as a scalar, and at least one row resolves it to rows / a row /
1526    /// an object; a name outside the set never does.
1527    #[test]
1528    fn handle_sets_match_the_store_context() {
1529        use std::collections::HashMap;
1530
1531        use omgbase_reconcile::Config;
1532        use omgbase_store::{BatchItem, Store};
1533        use oqx::DataContext;
1534
1535        use crate::context::StoreContext;
1536
1537        let mut store = Store::open_in_memory().expect("store");
1538        let repo = store.create_repo("handles").expect("repo");
1539        let items = [
1540            BatchItem::observed(
1541                "a.md",
1542                "---\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",
1543            ),
1544            BatchItem::observed("b.md", "# B\n\nBack to [a](a.md).\n"),
1545        ];
1546        store
1547            .observe_batch(
1548                &repo,
1549                &items,
1550                "2026-09-26T00:00:00.000Z",
1551                &Config::default(),
1552            )
1553            .expect("observe");
1554        let ctx = StoreContext::new(store.conn(), &repo, HashMap::new());
1555
1556        let mut universe: Vec<&str> = [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges]
1557            .into_iter()
1558            .flat_map(|t| non_property_handles(t).iter().copied())
1559            .collect();
1560        universe.extend([
1561            "layer",
1562            "title",
1563            "verified",
1564            "checked",
1565            "level",
1566            "format",
1567            "type",
1568            "text",
1569            "kind",
1570            "name",
1571            "value",
1572            "predicate",
1573            "key",
1574            "nope",
1575        ]);
1576        universe.sort_unstable();
1577        universe.dedup();
1578
1579        let is_shape = |v: &Value| matches!(v, Value::Array(_) | Value::Object(_));
1580        for target in [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges] {
1581            let Value::Array(rows) = ctx.root(target.as_str()) else {
1582                panic!("{target:?} root is not an array");
1583            };
1584            assert!(!rows.is_empty(), "{target:?} has rows");
1585            let set = non_property_handles(target);
1586            for name in &universe {
1587                let mut shaped = 0;
1588                for row in &rows {
1589                    let v = ctx.get(row, name).expect("get");
1590                    if set.contains(name) {
1591                        assert!(
1592                            v.is_absent() || is_shape(&v),
1593                            "{target:?}.{name} read as a scalar: {v:?}"
1594                        );
1595                    } else {
1596                        assert!(!is_shape(&v), "{target:?}.{name} is a handle: {v:?}");
1597                    }
1598                    shaped += usize::from(is_shape(&v));
1599                }
1600                if set.contains(name) {
1601                    assert!(
1602                        shaped > 0,
1603                        "{target:?}.{name} never resolved to rows or a bag"
1604                    );
1605                }
1606            }
1607        }
1608    }
1609
1610    // -- declines (left residual) return None --
1611
1612    #[test]
1613    fn reserved_bare_basename_is_not_pushed() {
1614        assert_eq!(translate_predicate(&pred("path == \"x\""), &DOCS), None);
1615        assert_eq!(translate_predicate(&pred("body == \"x\""), &DOCS), None);
1616        assert_eq!(translate_predicate(&pred("doc.path == \"x\""), &DOCS), None);
1617    }
1618
1619    #[test]
1620    fn docs_body_and_computed_intrinsics_are_not_columns() {
1621        assert_eq!(translate_predicate(&pred("$body == \"x\""), &DOCS), None);
1622        assert_eq!(translate_predicate(&pred("$title == \"x\""), &DOCS), None);
1623        assert_eq!(translate_predicate(&pred("$tags == \"x\""), &DOCS), None);
1624    }
1625
1626    #[test]
1627    fn matches_needs_a_regexp_udf() {
1628        assert_eq!(
1629            translate_predicate(&pred("$path.matches(\"^lab/\")"), &DOCS),
1630            None
1631        );
1632    }
1633
1634    #[test]
1635    fn negation_as_a_nested_expr_is_not_and_safe() {
1636        let e = Expr::Unary {
1637            op: oqx::ast::UnaryOp::Not,
1638            expr: ident("$path"),
1639        };
1640        assert_eq!(translate_predicate(&e, &DOCS), None);
1641    }
1642
1643    #[test]
1644    fn disjunction_as_a_nested_expr_is_declined() {
1645        let e = Expr::Logical {
1646            op: LogicalOp::Or,
1647            left: eq(ident("$path"), lit("a")),
1648            right: eq(ident("$path"), lit("b")),
1649        };
1650        assert_eq!(translate_predicate(&e, &DOCS), None);
1651    }
1652
1653    #[test]
1654    fn unmapped_node_intrinsic_is_not_pushed() {
1655        let nodes = TranslateCtx {
1656            target: Target::Nodes,
1657            self_alias: "n",
1658            ..DOCS
1659        };
1660        assert_eq!(
1661            translate_predicate(&pred("$locator == \"x\""), &nodes),
1662            None
1663        );
1664        // `$updated_at` is mapped on docs only.
1665        let blocks = TranslateCtx {
1666            target: Target::Blocks,
1667            self_alias: "b",
1668            ..DOCS
1669        };
1670        assert_eq!(
1671            translate_predicate(&pred("$updated_at == \"x\""), &blocks),
1672            None
1673        );
1674    }
1675
1676    #[test]
1677    fn range_membership_in_and_bare_idents_are_declined() {
1678        assert_eq!(translate_predicate(&pred("era in 800..1680"), &DOCS), None);
1679        assert_eq!(
1680            translate_predicate(&pred("\"a\" in list(tags)"), &DOCS),
1681            None
1682        );
1683        assert_eq!(translate_predicate(&pred("verified"), &DOCS), None);
1684        assert_eq!(translate_predicate(&pred("doc.verified"), &DOCS), None);
1685        assert_eq!(translate_predicate(&pred("size(tags) > 1"), &DOCS), None);
1686        assert_eq!(translate_predicate(&pred("$self.text(\"x\")"), &DOCS), None);
1687        assert_eq!(translate_predicate(&pred("$value == \"x\""), &DOCS), None);
1688        assert_eq!(translate_predicate(&pred("^slug == \"x\""), &DOCS), None);
1689        assert_eq!(
1690            translate_predicate(&pred("frontmatter.era == 1"), &DOCS),
1691            None
1692        );
1693    }
1694
1695    // -- conjunction and bindings via constructed AST --
1696
1697    #[test]
1698    fn and_composes_two_pushable_comparisons() {
1699        let e = Expr::Logical {
1700            op: LogicalOp::And,
1701            left: eq(ident("$path"), lit("a")),
1702            right: Box::new(Expr::Binary {
1703                op: BinaryOp::Ne,
1704                left: ident("$id"),
1705                right: lit("d_2"),
1706            }),
1707        };
1708        assert_eq!(
1709            translate_predicate(&e, &DOCS),
1710            frag(
1711                "((d.path IS ?) AND (d.doc_id IS NOT ?))",
1712                &[text("a"), text("d_2")]
1713            )
1714        );
1715    }
1716
1717    #[test]
1718    fn and_declines_wholesale_if_either_side_is_not_pushable() {
1719        let e = Expr::Logical {
1720            op: LogicalOp::And,
1721            left: eq(ident("$path"), lit("a")),
1722            // $body is reconstructed, not a column → the whole && declines.
1723            right: eq(ident("$body"), lit("x")),
1724        };
1725        assert_eq!(translate_predicate(&e, &DOCS), None);
1726    }
1727
1728    #[test]
1729    fn resolves_a_binding_to_its_param_value() {
1730        let e = eq(ident("$path"), Box::new(Expr::Binding { index: 0 }));
1731        let params = [Value::from("from-binding.md")];
1732        let ctx = TranslateCtx {
1733            params: &params,
1734            ..DOCS
1735        };
1736        assert_eq!(
1737            translate_predicate(&e, &ctx),
1738            frag("(d.path IS ?)", &[text("from-binding.md")])
1739        );
1740        // A binding past the end reads as absent → NULL.
1741        assert_eq!(
1742            translate_predicate(&e, &DOCS),
1743            frag("(d.path IS ?)", &[SqlValue::Null])
1744        );
1745    }
1746
1747    // -- per-target fields --
1748
1749    #[test]
1750    fn blocks_and_nodes_flatten_bare_identifiers_into_attrs() {
1751        let blocks = TranslateCtx {
1752            target: Target::Blocks,
1753            self_alias: "b",
1754            ..DOCS
1755        };
1756        // (a top-level `&&` is a `Where::And` of scalars; the nested form is
1757        // reached through constructed AST, as in the reference's tests)
1758        let both = Expr::Logical {
1759            op: LogicalOp::And,
1760            left: eq(ident("type"), lit("task")),
1761            right: eq(ident("marker"), lit("x")),
1762        };
1763        assert_eq!(
1764            translate_predicate(&both, &blocks),
1765            frag(
1766                "((b.type IS ?) AND (json_extract(b.attrs, '$.marker') IS ?))",
1767                &[text("task"), text("x")]
1768            )
1769        );
1770        assert_eq!(
1771            translate_predicate(&pred("attrs.marker == \"x\""), &blocks),
1772            frag("(json_extract(b.attrs, '$.marker') IS ?)", &[text("x")])
1773        );
1774        // (a boolean or number against a JSON read pushes typed — see the
1775        // typed-shape tests)
1776        assert_eq!(
1777            translate_predicate(&pred("attrs.checked == true"), &blocks),
1778            frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1779        );
1780        let nodes = TranslateCtx {
1781            target: Target::Nodes,
1782            self_alias: "n",
1783            ..DOCS
1784        };
1785        assert_eq!(
1786            translate_predicate(&pred("kind == \"md:section\""), &nodes),
1787            frag("(n.kind IS ?)", &[text("md:section")])
1788        );
1789        assert_eq!(
1790            translate_predicate(&pred("level == \"1\""), &nodes),
1791            frag("(json_extract(n.attrs, '$.level') IS ?)", &[text("1")])
1792        );
1793        assert_eq!(
1794            translate_predicate(&pred("level == 1"), &nodes),
1795            frag(
1796                "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') = ?) IS 1)",
1797                &[SqlValue::Real(1.0)]
1798            )
1799        );
1800        assert_eq!(
1801            translate_predicate(&pred("attrs.a.b == \"c\""), &nodes),
1802            frag("(json_extract(n.attrs, '$.a.b') IS ?)", &[text("c")])
1803        );
1804        // `attrs.<k>` is a blocks/nodes form; on docs it is not a column.
1805        assert_eq!(
1806            translate_predicate(&pred("attrs.marker == \"x\""), &DOCS),
1807            None
1808        );
1809    }
1810
1811    #[test]
1812    fn doc_and_block_reach_through() {
1813        let blocks = TranslateCtx {
1814            target: Target::Blocks,
1815            self_alias: "b",
1816            ..DOCS
1817        };
1818        let f = translate_predicate(&pred("doc.type == \"lab-note\""), &blocks).expect("pushable");
1819        assert!(
1820            f.sql.contains("p.doc_id = d.doc_id AND p.key = 'type'"),
1821            "{}",
1822            f.sql
1823        );
1824        assert_eq!(
1825            translate_predicate(&pred("doc.$path == \"a.md\""), &blocks),
1826            frag("(d.path IS ?)", &[text("a.md")])
1827        );
1828        assert_eq!(
1829            translate_predicate(&pred("doc.format == \"markdown\""), &blocks),
1830            frag("(d.format IS ?)", &[text("markdown")])
1831        );
1832        assert_eq!(
1833            translate_predicate(&pred("doc.$id == \"d_1\""), &blocks),
1834            None
1835        );
1836        assert_eq!(translate_predicate(&pred("doc.a.b == 1"), &blocks), None);
1837        let nodes = TranslateCtx {
1838            target: Target::Nodes,
1839            self_alias: "n",
1840            ..DOCS
1841        };
1842        assert_eq!(
1843            translate_predicate(&pred("block.type == \"task\""), &nodes),
1844            frag(
1845                "((SELECT bb.type FROM blocks bb WHERE bb.block_id = n.block_id) IS ?)",
1846                &[text("task")]
1847            )
1848        );
1849        assert_eq!(
1850            translate_predicate(&pred("block.type == \"task\""), &blocks),
1851            None
1852        );
1853        assert_eq!(
1854            translate_predicate(&pred("section.level == 1"), &nodes),
1855            None
1856        );
1857    }
1858
1859    #[test]
1860    fn edges_push_their_five_fields_and_intrinsics() {
1861        let edges = TranslateCtx {
1862            target: Target::Edges,
1863            self_alias: "e",
1864            ..DOCS
1865        };
1866        assert_eq!(
1867            translate_predicate(&pred("predicate == \"references\""), &edges),
1868            frag("(e.predicate IS ?)", &[text("references")])
1869        );
1870        assert_eq!(
1871            translate_predicate(&pred("$dst_path == \"index.md\""), &edges),
1872            frag(
1873                "((SELECT dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
1874                &[text("index.md")]
1875            )
1876        );
1877        assert_eq!(translate_predicate(&pred("weight == 1"), &edges), None);
1878    }
1879
1880    #[test]
1881    fn unsafe_identifier_segments_are_never_inlined() {
1882        assert!(is_seg("layer") && is_seg("_x9"));
1883        assert!(!is_seg("") && !is_seg("9a") && !is_seg("a-b") && !is_seg("a'b"));
1884        assert_eq!(json_path(&["ok", "no-pe"]), None);
1885        assert_eq!(json_path(&["ok", "a_1"]).as_deref(), Some("$.ok.a_1"));
1886        assert_eq!(prop_scalar("d", "x'y"), None);
1887    }
1888}