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 { value: 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            ..
537        } if args.is_empty() && (name == "lower" || name == "upper") => {
538            let recv = translate_value(recv, ctx)?;
539            Some(Operand {
540                frag: Frag {
541                    sql: format!("{name}({})", recv.sql),
542                    params: recv.params,
543                },
544                ty: Ty::Text,
545                shape: Shape::Plain,
546            })
547        }
548        _ => None,
549    }
550}
551
552// ---- predicate position --------------------------------------------------------------
553
554/// `==` / `!=` → null-safe `IS` / `IS NOT`; the relational ops as plain SQL.
555fn is_op(op: BinaryOp) -> Option<&'static str> {
556    Some(match op {
557        BinaryOp::Eq => "IS",
558        BinaryOp::Ne => "IS NOT",
559        BinaryOp::Lt => "<",
560        BinaryOp::Le => "<=",
561        BinaryOp::Gt => ">",
562        BinaryOp::Ge => ">=",
563        BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => {
564            return None;
565        }
566    })
567}
568
569/// The typed pushes (`spec/surface` §1, 1.2 patch): a bool or num constant
570/// against a JSON or property read, with the stored type tested in SQL
571/// before the value so SQLite cannot conflate JSON `true` with `1` or
572/// `val_bool` with `val_num`. `None` when the pair is not a typed cell (the
573/// plain `IS` / relational form applies) — the gate ([`comparable`]) has
574/// already declined the cells neither form can push.
575///
576/// * json × bool (`==`/`!=`): `(json_type(x) = 'true' | 'false') IS 1`;
577/// * json × num (all six): `(json_type(x) IN ('integer', 'real') AND
578///   json_extract(x) <op> ?) IS 1`;
579/// * prop × bool (`==`/`!=`): the single-scalar-row subquery selecting
580///   `p.type = 'bool' AND p.val_bool = ?` (`spec/properties` §2.1 type
581///   names; booleans bind as 1/0), `(…) IS 1`;
582/// * prop × num (all six): the same subquery selecting `p.type = 'number'
583///   AND p.val_num <op> ?`, `(…) IS 1`.
584///
585/// `!=` wraps `IS NOT 1` around the equality test. `json_type` is NULL for
586/// an absent path and the subquery is NULL for an absent, list-valued or
587/// nested key, so `IS 1` is false and `IS NOT 1` true — the in-memory
588/// absence semantics (unequal, never ordered). The test is normalized to
589/// `read <op> ?`, a relational op flipping when the constant is on the left
590/// (`800 < era` ⇔ `era > 800`), as the reference does.
591fn typed_compare(op: BinaryOp, sql_op: &str, l: &Operand, r: &Operand) -> Option<Frag> {
592    let (read, konst, read_left) = match (&l.shape, &r.shape) {
593        (Shape::Json { .. } | Shape::Prop { .. }, Shape::Const(v)) => (&l.shape, v, true),
594        (Shape::Const(v), Shape::Json { .. } | Shape::Prop { .. }) => (&r.shape, v, false),
595        _ => return None,
596    };
597    let equality = matches!(op, BinaryOp::Eq | BinaryOp::Ne);
598    let wrap = if op == BinaryOp::Ne {
599        "IS NOT 1"
600    } else {
601        "IS 1"
602    };
603    // Normalized to `read <op> ?`: a relational op flips when the constant is
604    // on the left (`800 < era` ⇔ `era > 800`), as in the reference.
605    let inner_op = match (equality, read_left, sql_op) {
606        (true, _, _) => "=",
607        (false, true, _) => sql_op,
608        (false, false, "<") => ">",
609        (false, false, "<=") => ">=",
610        (false, false, ">") => "<",
611        (false, false, ">=") => "<=",
612        (false, false, _) => return None,
613    };
614    let sides = |read_sql: &str| format!("{read_sql} {inner_op} ?");
615    let (sql, params) = match (read, konst) {
616        // Booleans are only ever equal; the gate declines them relational.
617        (_, Value::Bool(_)) if !equality => return None,
618        (Shape::Json { col, path }, Value::Bool(b)) => (
619            format!("(json_type({col}, '{path}') = '{b}') {wrap}"),
620            Vec::new(),
621        ),
622        (Shape::Json { col, path }, Value::Number(_)) => (
623            format!(
624                "(json_type({col}, '{path}') IN ('integer', 'real') AND {}) {wrap}",
625                sides(&format!("json_extract({col}, '{path}')"))
626            ),
627            vec![to_sql(konst)],
628        ),
629        (Shape::Prop { doc_alias, key }, Value::Bool(_)) => (
630            format!(
631                "{} {wrap}",
632                prop_row(
633                    doc_alias,
634                    key,
635                    &format!("p.type = 'bool' AND {}", sides("p.val_bool"))
636                )?
637            ),
638            vec![to_sql(konst)],
639        ),
640        (Shape::Prop { doc_alias, key }, Value::Number(_)) => (
641            format!(
642                "{} {wrap}",
643                prop_row(
644                    doc_alias,
645                    key,
646                    &format!("p.type = 'number' AND {}", sides("p.val_num"))
647                )?
648            ),
649            vec![to_sql(konst)],
650        ),
651        _ => return None,
652    };
653    Some(Frag {
654        sql: format!("({sql})"),
655        params,
656    })
657}
658
659/// Translate an expression used as a boolean PREDICATE to a SQL boolean, or
660/// `None` if it cannot be pushed faithfully. Only positive, AND-safe forms
661/// are handled: `unary` (`!`), `in`, bare truthy idents and member
662/// reach-through in predicate position stay residual.
663pub fn translate_predicate(e: &Expr, ctx: &TranslateCtx<'_>) -> Option<Frag> {
664    match e {
665        // Only `&&` composes faithfully in a positive context; `||` is
666        // declined (its NULL / short-circuit interaction stays residual).
667        Expr::Logical {
668            op: LogicalOp::And,
669            left,
670            right,
671            ..
672        } => join2(
673            translate_predicate(left, ctx),
674            translate_predicate(right, ctx),
675            "AND",
676        ),
677        Expr::Binary {
678            op, left, right, ..
679        } => {
680            // An arithmetic operator in predicate position → residual.
681            let sql_op = is_op(*op)?;
682            let l = operand(left, ctx)?;
683            let r = operand(right, ctx)?;
684            // The operand-typing gate (§1): the pairs SQLite would compare
685            // with less type than the engine has stay residual.
686            if !comparable(*op, l.ty, r.ty) {
687                return None;
688            }
689            // A bool/num constant against a JSON or property read pushes with
690            // the stored type tested first (the 1.2 typed cells).
691            if let Some(typed) = typed_compare(*op, sql_op, &l, &r) {
692                return Some(typed);
693            }
694            let op = sql_op;
695            // `==`/`!=` → IS / IS NOT (absence-normalized equality, faithful in
696            // any context). Relational ops → plain SQL: a NULL operand yields
697            // NULL, which is excluded in the positive AND context these
698            // fragments are pushed into, matching the absent-operand ⇒ false rule.
699            let mut params = l.frag.params;
700            params.extend(r.frag.params);
701            Some(Frag {
702                sql: format!("({} {op} {})", l.frag.sql, r.frag.sql),
703                params,
704            })
705        }
706        Expr::Call {
707            recv: Some(recv),
708            name,
709            args,
710            ..
711        } if args.len() == 1 => {
712            // startsWith / contains / endsWith — CASE-SENSITIVE, via
713            // substr/instr (never LIKE). `matches` (regex) is declined.
714            let recv = translate_value(recv, ctx)?;
715            let arg = translate_value(&args[0], ctx)?;
716            let mut params = recv.params;
717            let sql = match name.as_str() {
718                "startsWith" => {
719                    // recv begins with arg ⇔ its first length(arg) chars equal arg.
720                    params.extend(arg.params.iter().cloned());
721                    params.extend(arg.params);
722                    format!("(substr({}, 1, length({a})) = {a})", recv.sql, a = arg.sql)
723                }
724                "endsWith" => {
725                    // recv ends with arg ⇔ its last length(arg) chars equal arg.
726                    // When arg is longer than recv, substr clamps to the whole
727                    // (shorter) string, so the equality is false.
728                    params.extend(arg.params.iter().cloned());
729                    params.extend(arg.params);
730                    format!("(substr({}, -length({a})) = {a})", recv.sql, a = arg.sql)
731                }
732                "contains" => {
733                    params.extend(arg.params);
734                    format!("(instr({}, {}) > 0)", recv.sql, arg.sql)
735                }
736                _ => return None,
737            };
738            Some(Frag { sql, params })
739        }
740        _ => None,
741    }
742}
743
744/// Combine two optional fragments with a boolean connective; `None` if either
745/// is untranslatable (the whole conjunct then stays residual).
746fn join2(a: Option<Frag>, b: Option<Frag>, connective: &str) -> Option<Frag> {
747    let (a, b) = (a?, b?);
748    let mut params = a.params;
749    params.extend(b.params);
750    Some(Frag {
751        sql: format!("({} {connective} {})", a.sql, b.sql),
752        params,
753    })
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759    use oqx::ast::Where;
760
761    const DOCS: TranslateCtx<'static> = TranslateCtx {
762        target: Target::Docs,
763        self_alias: "d",
764        doc_alias: "d",
765        params: &[],
766    };
767
768    fn text(s: &str) -> SqlValue {
769        SqlValue::Text(s.to_owned())
770    }
771
772    fn frag(sql: &str, params: &[SqlValue]) -> Option<Frag> {
773        Some(Frag {
774            sql: sql.to_owned(),
775            params: params.to_vec(),
776        })
777    }
778
779    /// Parse `from docs where <src>` and return the single scalar predicate.
780    fn pred(src: &str) -> Expr {
781        let q = oqx::parse_string(&format!("from docs where {src}")).expect("parses");
782        match q.r#where {
783            Some(Where::Scalar { expr, .. }) => expr,
784            other => panic!("expected a single scalar predicate, got {other:?}"),
785        }
786    }
787
788    fn ident(name: &str) -> Box<Expr> {
789        Box::new(Expr::Ident {
790            name: name.to_owned(),
791            span: oqx::Span::EMPTY,
792        })
793    }
794
795    fn lit(s: &str) -> Box<Expr> {
796        Box::new(Expr::Lit {
797            value: Value::from(s),
798            span: oqx::Span::EMPTY,
799        })
800    }
801
802    fn eq(l: Box<Expr>, r: Box<Expr>) -> Box<Expr> {
803        Box::new(Expr::Binary {
804            op: BinaryOp::Eq,
805            left: l,
806            right: r,
807            span: oqx::Span::EMPTY,
808        })
809    }
810
811    // -- equality is absence-normalized (IS / IS NOT) --
812
813    #[test]
814    fn equality_is_null_safe_is() {
815        assert_eq!(
816            translate_predicate(&pred("$path == \"index.md\""), &DOCS),
817            frag("(d.path IS ?)", &[text("index.md")])
818        );
819    }
820
821    #[test]
822    fn inequality_is_null_safe_is_not() {
823        assert_eq!(
824            translate_predicate(&pred("$path != \"x\""), &DOCS),
825            frag("(d.path IS NOT ?)", &[text("x")])
826        );
827    }
828
829    #[test]
830    fn intrinsic_column_mapping() {
831        assert_eq!(
832            translate_predicate(&pred("$id == \"d_1\""), &DOCS),
833            frag("(d.doc_id IS ?)", &[text("d_1")])
834        );
835    }
836
837    // -- relational ops (plain SQL) --
838
839    #[test]
840    fn relational_ops_are_plain_comparisons() {
841        assert_eq!(
842            translate_predicate(&pred("$path < \"m\""), &DOCS),
843            frag("(d.path < ?)", &[text("m")])
844        );
845        assert_eq!(
846            translate_predicate(&pred("$path >= \"m\""), &DOCS),
847            frag("(d.path >= ?)", &[text("m")])
848        );
849        // arithmetic in predicate position → residual
850        assert_eq!(translate_predicate(&pred("$path + 1"), &DOCS), None);
851    }
852
853    // -- string ops are case-sensitive (substr/instr, never LIKE) --
854
855    #[test]
856    fn starts_with_is_substr_equality() {
857        assert_eq!(
858            translate_predicate(&pred("$path.startsWith(\"lab/\")"), &DOCS),
859            frag(
860                "(substr(d.path, 1, length(?)) = ?)",
861                &[text("lab/"), text("lab/")]
862            )
863        );
864    }
865
866    #[test]
867    fn lower_then_starts_with_pushes_with_explicit_lower() {
868        assert_eq!(
869            translate_predicate(&pred("$path.lower().startsWith(\"lab/\")"), &DOCS),
870            frag(
871                "(substr(lower(d.path), 1, length(?)) = ?)",
872                &[text("lab/"), text("lab/")]
873            )
874        );
875    }
876
877    #[test]
878    fn contains_is_instr() {
879        assert_eq!(
880            translate_predicate(&pred("$path.contains(\"notes\")"), &DOCS),
881            frag("(instr(d.path, ?) > 0)", &[text("notes")])
882        );
883    }
884
885    #[test]
886    fn ends_with_is_negative_substr_equality() {
887        assert_eq!(
888            translate_predicate(&pred("$path.endsWith(\".md\")"), &DOCS),
889            frag(
890                "(substr(d.path, -length(?)) = ?)",
891                &[text(".md"), text(".md")]
892            )
893        );
894    }
895
896    #[test]
897    fn upper_wraps_the_receiver_in_value_position() {
898        assert_eq!(
899            translate_value(&pred("$path.upper()"), &DOCS),
900            frag("upper(d.path)", &[])
901        );
902    }
903
904    // -- bare document properties push via the properties table --
905
906    #[test]
907    fn bare_doc_property_is_the_scalar_in_scope_subquery() {
908        let f = translate_predicate(&pred("layer == \"canon\""), &DOCS).expect("pushable");
909        assert!(f.sql.contains("FROM properties p"), "{}", f.sql);
910        assert!(f.sql.contains("p.key = 'layer'"), "{}", f.sql);
911        assert!(f.sql.contains("p.card = 'scalar'"), "{}", f.sql);
912        assert!(
913            f.sql.starts_with('(') && f.sql.contains(" IS ?)"),
914            "{}",
915            f.sql
916        );
917        assert_eq!(f.params, vec![text("canon")]);
918    }
919
920    #[test]
921    fn updated_at_pushes_as_its_revisions_subquery() {
922        let f =
923            translate_predicate(&pred("$updated_at >= \"2026-01-01\""), &DOCS).expect("pushable");
924        assert!(
925            f.sql.contains("FROM revisions r JOIN commits c"),
926            "{}",
927            f.sql
928        );
929    }
930
931    #[test]
932    fn format_is_a_column_not_a_property() {
933        assert_eq!(
934            translate_predicate(&pred("format == \"markdown\""), &DOCS),
935            frag("(d.format IS ?)", &[text("markdown")])
936        );
937    }
938
939    #[test]
940    fn booleans_bind_as_one_and_zero() {
941        let blocks = TranslateCtx {
942            target: Target::Blocks,
943            self_alias: "b",
944            ..DOCS
945        };
946        // (against a text column — against a JSON or property read the
947        // boolean pushes typed, see the typed-shape tests)
948        assert_eq!(
949            translate_predicate(&pred("type == true"), &blocks).map(|f| f.params),
950            Some(vec![SqlValue::Integer(1)])
951        );
952        assert_eq!(
953            translate_predicate(&pred("type == false"), &blocks).map(|f| f.params),
954            Some(vec![SqlValue::Integer(0)])
955        );
956        assert_eq!(
957            translate_predicate(&pred("$ordinal < 1000"), &blocks).map(|f| f.params),
958            Some(vec![SqlValue::Real(1000.0)])
959        );
960        assert_eq!(
961            translate_predicate(&pred("$path == null"), &DOCS).map(|f| f.params),
962            Some(vec![SqlValue::Null])
963        );
964    }
965
966    // -- decline (a): operand typing (spec/surface §1) --
967
968    /// One representative expression per [`Ty`] on the blocks target (the only
969    /// target with an integer intrinsic; `doc.<k>` is its property read).
970    const REPRESENTATIVES: [(Ty, &str); 7] = [
971        (Ty::Text, "$path"),
972        (Ty::Int, "$ordinal"),
973        (Ty::Num, "1"),
974        (Ty::Bool, "true"),
975        (Ty::Null, "null"),
976        (Ty::Json, "checked"),
977        (Ty::Prop, "doc.layer"),
978    ];
979
980    #[test]
981    fn representatives_carry_their_type() {
982        let blocks = TranslateCtx {
983            target: Target::Blocks,
984            self_alias: "b",
985            ..DOCS
986        };
987        for (ty, src) in REPRESENTATIVES {
988            let (_, got) = typed_value(&pred(src), &blocks).expect(src);
989            assert_eq!(got, ty, "{src}");
990        }
991        assert_eq!(
992            typed_value(&pred("attrs.a.b"), &blocks).map(|(_, t)| t),
993            Some(Ty::Json)
994        );
995        assert_eq!(
996            typed_value(&pred("$depth"), &blocks).map(|(_, t)| t),
997            Some(Ty::Int)
998        );
999        assert_eq!(
1000            typed_value(&pred("type.lower()"), &blocks).map(|(_, t)| t),
1001            Some(Ty::Text)
1002        );
1003        assert_eq!(
1004            typed_value(&pred("checked.upper()"), &blocks).map(|(_, t)| t),
1005            Some(Ty::Text)
1006        );
1007        assert_eq!(
1008            typed_value(&pred("layer"), &DOCS).map(|(_, t)| t),
1009            Some(Ty::Prop)
1010        );
1011        assert_eq!(
1012            typed_value(&pred("format"), &DOCS).map(|(_, t)| t),
1013            Some(Ty::Text)
1014        );
1015    }
1016
1017    #[test]
1018    fn the_comparison_matrix_decides_every_cell() {
1019        // Row/column order: Text Int Num Bool Null Json Prop.
1020        const P: bool = true;
1021        const D: bool = false;
1022        // Typed (1.2): a bool/num constant against a JSON or property read.
1023        const T: bool = true;
1024        // Equality: one side text, or null against a non-property, or both
1025        // numeric, or a typed cell.
1026        #[rustfmt::skip]
1027        const EQUALITY: [[bool; 7]; 7] = [
1028            /* Text */ [P, P, P, P, P, P, P],
1029            /* Int  */ [P, P, P, D, P, D, D],
1030            /* Num  */ [P, P, P, D, P, T, T],
1031            /* Bool */ [P, D, D, D, P, T, T],
1032            /* Null */ [P, P, P, P, P, P, D],
1033            /* Json */ [P, D, T, T, P, D, D],
1034            /* Prop */ [P, D, T, T, D, D, D],
1035        ];
1036        // Relational: both text, both numeric, or a num constant against a
1037        // read (typed); nothing else.
1038        #[rustfmt::skip]
1039        const RELATIONAL: [[bool; 7]; 7] = [
1040            /* Text */ [P, D, D, D, D, D, D],
1041            /* Int  */ [D, P, P, D, D, D, D],
1042            /* Num  */ [D, P, P, D, D, T, T],
1043            /* Bool */ [D, D, D, D, D, D, D],
1044            /* Null */ [D, D, D, D, D, D, D],
1045            /* Json */ [D, D, T, D, D, D, D],
1046            /* Prop */ [D, D, T, D, D, D, D],
1047        ];
1048        let blocks = TranslateCtx {
1049            target: Target::Blocks,
1050            self_alias: "b",
1051            ..DOCS
1052        };
1053        let ops = [
1054            (BinaryOp::Eq, "==", &EQUALITY),
1055            (BinaryOp::Ne, "!=", &EQUALITY),
1056            (BinaryOp::Lt, "<", &RELATIONAL),
1057            (BinaryOp::Le, "<=", &RELATIONAL),
1058            (BinaryOp::Gt, ">", &RELATIONAL),
1059            (BinaryOp::Ge, ">=", &RELATIONAL),
1060        ];
1061        for (i, (a, l)) in REPRESENTATIVES.iter().enumerate() {
1062            for (j, (b, r)) in REPRESENTATIVES.iter().enumerate() {
1063                for (op, spelled, matrix) in ops {
1064                    let want = matrix[i][j];
1065                    assert_eq!(matrix[j][i], want, "the matrix is symmetric ({a:?}, {b:?})");
1066                    assert_eq!(
1067                        comparable(op, *a, *b),
1068                        want,
1069                        "comparable({spelled}, {a:?}, {b:?})"
1070                    );
1071                    let src = format!("{l} {spelled} {r}");
1072                    assert_eq!(
1073                        translate_predicate(&pred(&src), &blocks).is_some(),
1074                        want,
1075                        "{src}"
1076                    );
1077                }
1078            }
1079        }
1080    }
1081
1082    #[test]
1083    fn the_spec_shapes_of_decline_a() {
1084        let blocks = TranslateCtx {
1085            target: Target::Blocks,
1086            self_alias: "b",
1087            ..DOCS
1088        };
1089        // a boolean or number against a JSON read pushes TYPED (1.2): the
1090        // json_type is tested first, so the SQL cannot read JSON `true` as 1
1091        let typed = |src: &str, ctx: &TranslateCtx<'_>| {
1092            let f = translate_predicate(&pred(src), ctx).expect(src);
1093            assert!(
1094                f.sql.contains("json_type(") || f.sql.contains("p.type = "),
1095                "{src}: {}",
1096                f.sql
1097            );
1098            assert!(
1099                f.sql.ends_with(" IS 1)") || f.sql.ends_with(" IS NOT 1)"),
1100                "{src}: {}",
1101                f.sql
1102            );
1103        };
1104        typed("checked == 1", &blocks);
1105        typed("checked == true", &blocks);
1106        typed("attrs.checked == true", &blocks);
1107        // … or a property read (bare on docs, `doc.<k>` elsewhere)
1108        typed("verified == 1", &DOCS);
1109        typed("verified == true", &DOCS);
1110        typed("era < 1000", &DOCS);
1111        typed("doc.era < 1000", &blocks);
1112        // a boolean against an integer intrinsic; a number stays pushable
1113        assert_eq!(
1114            translate_predicate(&pred("$ordinal == true"), &blocks),
1115            None
1116        );
1117        assert_eq!(
1118            translate_predicate(&pred("$ordinal == 1"), &blocks),
1119            frag("(b.ordinal IS ?)", &[SqlValue::Real(1.0)])
1120        );
1121        // null against a property read; against a JSON read or a column it pushes
1122        assert_eq!(translate_predicate(&pred("tags != null"), &DOCS), None);
1123        assert_eq!(translate_predicate(&pred("tags == null"), &DOCS), None);
1124        assert_eq!(
1125            translate_predicate(&pred("doc.tags == null"), &blocks),
1126            None
1127        );
1128        assert_eq!(
1129            translate_predicate(&pred("checked == null"), &blocks),
1130            frag(
1131                "(json_extract(b.attrs, '$.checked') IS ?)",
1132                &[SqlValue::Null]
1133            )
1134        );
1135        assert_eq!(
1136            translate_predicate(&pred("$ordinal != null"), &blocks),
1137            frag("(b.ordinal IS NOT ?)", &[SqlValue::Null])
1138        );
1139        // a string literal against anything pushes under equality
1140        assert_eq!(
1141            translate_predicate(&pred("checked == \"x\""), &blocks),
1142            frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1143        );
1144        assert!(translate_predicate(&pred("layer == \"canon\""), &DOCS).is_some());
1145        assert!(translate_predicate(&pred("$ordinal == \"1\""), &blocks).is_some());
1146        assert!(translate_predicate(&pred("$ordinal != \"1\""), &blocks).is_some());
1147        // … but a relational comparison across text and a number / integer
1148        // declines (the fifth shape): SQLite orders integers before text
1149        assert_eq!(
1150            translate_predicate(&pred("$ordinal < \"3\""), &blocks),
1151            None
1152        );
1153        assert_eq!(
1154            translate_predicate(&pred("\"3\" >= $ordinal"), &blocks),
1155            None
1156        );
1157        assert_eq!(translate_predicate(&pred("$path > 5"), &DOCS), None);
1158        assert_eq!(translate_predicate(&pred("type <= 1"), &blocks), None);
1159        assert_eq!(
1160            translate_predicate(&pred("$ordinal < 3"), &blocks),
1161            frag("(b.ordinal < ?)", &[SqlValue::Real(3.0)])
1162        );
1163        assert_eq!(
1164            translate_predicate(&pred("$path > \"m\""), &DOCS),
1165            frag("(d.path > ?)", &[text("m")])
1166        );
1167        // two reads carry no type at plan time; a boolean equals only text/null
1168        assert_eq!(
1169            translate_predicate(&pred("$ordinal == checked"), &blocks),
1170            None
1171        );
1172        assert_eq!(
1173            translate_predicate(&pred("checked == level"), &blocks),
1174            None
1175        );
1176        assert_eq!(
1177            translate_predicate(&pred("doc.era == doc.year"), &blocks),
1178            None
1179        );
1180        assert_eq!(translate_predicate(&pred("level < \"x\""), &blocks), None);
1181        typed("level < 3", &blocks);
1182        assert_eq!(translate_predicate(&pred("true == false"), &blocks), None);
1183        assert_eq!(translate_predicate(&pred("checked < true"), &blocks), None);
1184        assert_eq!(
1185            translate_predicate(&pred("doc.verified >= false"), &blocks),
1186            None
1187        );
1188        assert_eq!(translate_predicate(&pred("$ordinal > null"), &blocks), None);
1189        assert!(translate_predicate(&pred("$ordinal == $depth"), &blocks).is_some());
1190        assert!(translate_predicate(&pred("$ordinal <= $depth"), &blocks).is_some());
1191        assert!(translate_predicate(&pred("type == null"), &blocks).is_some());
1192    }
1193
1194    // -- the typed pushes (spec/surface §1, 1.2 patch) --
1195
1196    /// The properties subquery's scope: the same single-scalar-row conditions
1197    /// as the scalar read, so a list-valued or nested key yields NULL.
1198    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";
1199
1200    fn prop_sql(key: &str, select: &str, wrap: &str) -> String {
1201        format!(
1202            "((SELECT {select} {}) {wrap})",
1203            PROP_SCOPE.replace('K', key)
1204        )
1205    }
1206
1207    #[test]
1208    fn json_against_a_boolean_tests_json_type_for_the_literal() {
1209        let blocks = TranslateCtx {
1210            target: Target::Blocks,
1211            self_alias: "b",
1212            ..DOCS
1213        };
1214        // The boolean is inlined as the JSON type name; nothing binds.
1215        assert_eq!(
1216            translate_predicate(&pred("checked == true"), &blocks),
1217            frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1218        );
1219        assert_eq!(
1220            translate_predicate(&pred("checked == false"), &blocks),
1221            frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1222        );
1223        assert_eq!(
1224            translate_predicate(&pred("checked != true"), &blocks),
1225            frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1226        );
1227        assert_eq!(
1228            translate_predicate(&pred("false == attrs.checked"), &blocks),
1229            frag("((json_type(b.attrs, '$.checked') = 'false') IS 1)", &[])
1230        );
1231        // a bound boolean is the same shape
1232        let params = [Value::Bool(true)];
1233        let e = Expr::Binary {
1234            op: BinaryOp::Ne,
1235            left: ident("checked"),
1236            right: Box::new(Expr::Binding {
1237                index: 0,
1238                span: oqx::Span::EMPTY,
1239            }),
1240            span: oqx::Span::EMPTY,
1241        };
1242        assert_eq!(
1243            translate_predicate(
1244                &e,
1245                &TranslateCtx {
1246                    params: &params,
1247                    ..blocks
1248                }
1249            ),
1250            frag("((json_type(b.attrs, '$.checked') = 'true') IS NOT 1)", &[])
1251        );
1252    }
1253
1254    #[test]
1255    fn json_against_a_number_tests_the_numeric_types_then_compares() {
1256        let nodes = TranslateCtx {
1257            target: Target::Nodes,
1258            self_alias: "n",
1259            ..DOCS
1260        };
1261        let shape = |op: &str, wrap: &str| {
1262            format!(
1263                "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') {op} ?) {wrap})"
1264            )
1265        };
1266        let two = [SqlValue::Real(2.0)];
1267        for (src, op, wrap) in [
1268            ("level == 2", "=", "IS 1"),
1269            ("level != 2", "=", "IS NOT 1"),
1270            ("level < 2", "<", "IS 1"),
1271            ("level <= 2", "<=", "IS 1"),
1272            ("level > 2", ">", "IS 1"),
1273            ("level >= 2", ">=", "IS 1"),
1274        ] {
1275            assert_eq!(
1276                translate_predicate(&pred(src), &nodes),
1277                frag(&shape(op, wrap), &two),
1278                "{src}"
1279            );
1280        }
1281        // a constant on the left is normalized to the right, the op flipped
1282        assert_eq!(
1283            translate_predicate(&pred("2 <= attrs.level"), &nodes),
1284            frag(&shape(">=", "IS 1"), &two)
1285        );
1286        assert_eq!(
1287            translate_predicate(&pred("2 > level"), &nodes),
1288            frag(&shape("<", "IS 1"), &two)
1289        );
1290        assert_eq!(
1291            translate_predicate(&pred("2 != level"), &nodes),
1292            frag(&shape("=", "IS NOT 1"), &two)
1293        );
1294    }
1295
1296    #[test]
1297    fn property_against_a_boolean_tests_p_type_bool_in_the_scalar_row_subquery() {
1298        let blocks = TranslateCtx {
1299            target: Target::Blocks,
1300            self_alias: "b",
1301            ..DOCS
1302        };
1303        assert_eq!(
1304            translate_predicate(&pred("verified == true"), &DOCS),
1305            frag(
1306                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1307                &[SqlValue::Integer(1)]
1308            )
1309        );
1310        assert_eq!(
1311            translate_predicate(&pred("verified != false"), &DOCS),
1312            frag(
1313                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS NOT 1"),
1314                &[SqlValue::Integer(0)]
1315            )
1316        );
1317        // `doc.<k>` from a block reads the owning document's row
1318        assert_eq!(
1319            translate_predicate(&pred("doc.verified == false"), &blocks),
1320            frag(
1321                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1322                &[SqlValue::Integer(0)]
1323            )
1324        );
1325        assert_eq!(
1326            translate_predicate(&pred("true == verified"), &DOCS),
1327            frag(
1328                &prop_sql("verified", "p.type = 'bool' AND p.val_bool = ?", "IS 1"),
1329                &[SqlValue::Integer(1)]
1330            )
1331        );
1332    }
1333
1334    #[test]
1335    fn property_against_a_number_tests_p_type_number_in_the_scalar_row_subquery() {
1336        let blocks = TranslateCtx {
1337            target: Target::Blocks,
1338            self_alias: "b",
1339            ..DOCS
1340        };
1341        let thousand = [SqlValue::Real(1000.0)];
1342        for (src, op, wrap) in [
1343            ("era == 1000", "=", "IS 1"),
1344            ("era != 1000", "=", "IS NOT 1"),
1345            ("era < 1000", "<", "IS 1"),
1346            ("era <= 1000", "<=", "IS 1"),
1347            ("era > 1000", ">", "IS 1"),
1348            ("era >= 1000", ">=", "IS 1"),
1349        ] {
1350            assert_eq!(
1351                translate_predicate(&pred(src), &DOCS),
1352                frag(
1353                    &prop_sql(
1354                        "era",
1355                        &format!("p.type = 'number' AND p.val_num {op} ?"),
1356                        wrap
1357                    ),
1358                    &thousand
1359                ),
1360                "{src}"
1361            );
1362        }
1363        assert_eq!(
1364            translate_predicate(&pred("doc.era >= 1000"), &blocks),
1365            frag(
1366                &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1367                &thousand
1368            )
1369        );
1370        // a constant on the left is normalized to the right, the op flipped
1371        assert_eq!(
1372            translate_predicate(&pred("1000 > era"), &DOCS),
1373            frag(
1374                &prop_sql("era", "p.type = 'number' AND p.val_num < ?", "IS 1"),
1375                &thousand
1376            )
1377        );
1378        assert_eq!(
1379            translate_predicate(&pred("1000 <= era"), &DOCS),
1380            frag(
1381                &prop_sql("era", "p.type = 'number' AND p.val_num >= ?", "IS 1"),
1382                &thousand
1383            )
1384        );
1385    }
1386
1387    #[test]
1388    fn typed_pushes_compose_under_and_and_keep_the_untyped_forms() {
1389        let blocks = TranslateCtx {
1390            target: Target::Blocks,
1391            self_alias: "b",
1392            ..DOCS
1393        };
1394        let e = Expr::Logical {
1395            op: LogicalOp::And,
1396            left: eq(ident("type"), lit("task")),
1397            right: Box::new(Expr::Binary {
1398                op: BinaryOp::Eq,
1399                left: ident("checked"),
1400                right: Box::new(Expr::Lit {
1401                    value: Value::Bool(false),
1402                    span: oqx::Span::EMPTY,
1403                }),
1404                span: oqx::Span::EMPTY,
1405            }),
1406            span: oqx::Span::EMPTY,
1407        };
1408        assert_eq!(
1409            translate_predicate(&e, &blocks),
1410            frag(
1411                "((b.type IS ?) AND ((json_type(b.attrs, '$.checked') = 'false') IS 1))",
1412                &[text("task")]
1413            )
1414        );
1415        // text and null against a read stay the plain IS form
1416        assert_eq!(
1417            translate_predicate(&pred("checked == \"x\""), &blocks),
1418            frag("(json_extract(b.attrs, '$.checked') IS ?)", &[text("x")])
1419        );
1420        assert_eq!(
1421            translate_predicate(&pred("checked != null"), &blocks),
1422            frag(
1423                "(json_extract(b.attrs, '$.checked') IS NOT ?)",
1424                &[SqlValue::Null]
1425            )
1426        );
1427        // an unsafe key never inlines, typed or not
1428        assert_eq!(
1429            prop_row("d", "x'y", "p.type = 'number' AND p.val_num = ?"),
1430            None
1431        );
1432    }
1433
1434    #[test]
1435    fn bindings_are_typed_by_their_value() {
1436        let blocks = TranslateCtx {
1437            target: Target::Blocks,
1438            self_alias: "b",
1439            ..DOCS
1440        };
1441        let params = [
1442            Value::from("s"),
1443            Value::from(1.0),
1444            Value::Bool(true),
1445            Value::Null,
1446            Value::Array(vec![]),
1447        ];
1448        let ctx = TranslateCtx {
1449            params: &params,
1450            ..blocks
1451        };
1452        let against = |i: usize, rhs: &str| {
1453            let e = Expr::Binary {
1454                op: BinaryOp::Eq,
1455                left: Box::new(Expr::Binding {
1456                    index: i,
1457                    span: oqx::Span::EMPTY,
1458                }),
1459                right: Box::new(pred(rhs)),
1460                span: oqx::Span::EMPTY,
1461            };
1462            translate_predicate(&e, &ctx).is_some()
1463        };
1464        // text binding pushes against anything; number/boolean push typed
1465        // against JSON (1.2), a boolean not against an integer intrinsic
1466        assert!(against(0, "checked"));
1467        assert!(against(1, "checked"));
1468        assert!(against(2, "checked"));
1469        assert!(!against(2, "$ordinal"));
1470        assert!(against(1, "$ordinal"));
1471        // a null binding pushes against JSON, not against a property
1472        assert!(against(3, "checked"));
1473        assert!(!against(3, "doc.tags"));
1474        // an absent binding (past the end) is null
1475        assert!(against(9, "checked"));
1476        assert!(!against(9, "doc.tags"));
1477        // a non-scalar binding has no faithful SQL value
1478        assert!(!against(4, "type"));
1479    }
1480
1481    // -- decline (b): handles are not property reads --
1482
1483    #[test]
1484    fn relation_and_handle_names_are_not_property_reads() {
1485        let blocks = TranslateCtx {
1486            target: Target::Blocks,
1487            self_alias: "b",
1488            ..DOCS
1489        };
1490        let nodes = TranslateCtx {
1491            target: Target::Nodes,
1492            self_alias: "n",
1493            ..DOCS
1494        };
1495        let edges = TranslateCtx {
1496            target: Target::Edges,
1497            self_alias: "e",
1498            ..DOCS
1499        };
1500        for (ctx, target) in [
1501            (&DOCS, Target::Docs),
1502            (&blocks, Target::Blocks),
1503            (&nodes, Target::Nodes),
1504            (&edges, Target::Edges),
1505        ] {
1506            for name in non_property_handles(target) {
1507                let src = format!("{name} == null");
1508                assert_eq!(
1509                    translate_predicate(&pred(&src), ctx),
1510                    None,
1511                    "{target:?}: {src}"
1512                );
1513                let src = format!("{name} == \"x\"");
1514                assert_eq!(
1515                    translate_predicate(&pred(&src), ctx),
1516                    None,
1517                    "{target:?}: {src}"
1518                );
1519            }
1520        }
1521        // the fixtures' shapes
1522        assert_eq!(translate_predicate(&pred("nodes == null"), &DOCS), None);
1523        assert_eq!(
1524            translate_predicate(&pred("frontmatter == null"), &DOCS),
1525            None
1526        );
1527        assert_eq!(translate_predicate(&pred("nodes == null"), &blocks), None);
1528        assert_eq!(translate_predicate(&pred("attrs == null"), &blocks), None);
1529        // `doc.<handle>` is the doc's handle, not its property; `doc.<k>` still pushes
1530        assert_eq!(
1531            translate_predicate(&pred("doc.nodes == null"), &blocks),
1532            None
1533        );
1534        assert_eq!(
1535            translate_predicate(&pred("doc.frontmatter == null"), &blocks),
1536            None
1537        );
1538        assert_eq!(translate_predicate(&pred("doc.doc == null"), &DOCS), None);
1539        assert!(translate_predicate(&pred("doc.layer == \"canon\""), &blocks).is_some());
1540        // a plain attribute or property of the same spelling elsewhere still pushes
1541        assert!(translate_predicate(&pred("section == \"x\""), &DOCS).is_some());
1542        assert!(translate_predicate(&pred("frontmatter == \"x\""), &blocks).is_some());
1543    }
1544
1545    /// The handle sets are exactly the keys the store context resolves to
1546    /// rows, a row or a bag before its property fallback: over a small
1547    /// observed corpus, on every row of a target, a name in the set never
1548    /// reads as a scalar, and at least one row resolves it to rows / a row /
1549    /// an object; a name outside the set never does.
1550    #[test]
1551    fn handle_sets_match_the_store_context() {
1552        use std::collections::HashMap;
1553
1554        use omgbase_reconcile::Config;
1555        use omgbase_store::{BatchItem, Store};
1556        use oqx::DataContext;
1557
1558        use crate::context::StoreContext;
1559
1560        let mut store = Store::open_in_memory().expect("store");
1561        let repo = store.create_repo("handles").expect("repo");
1562        let items = [
1563            BatchItem::observed(
1564                "a.md",
1565                "---\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",
1566            ),
1567            BatchItem::observed("b.md", "# B\n\nBack to [a](a.md).\n"),
1568        ];
1569        store
1570            .observe_batch(
1571                &repo,
1572                &items,
1573                "2026-09-26T00:00:00.000Z",
1574                &Config::default(),
1575            )
1576            .expect("observe");
1577        let ctx = StoreContext::new(store.conn(), &repo, HashMap::new());
1578
1579        let mut universe: Vec<&str> = [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges]
1580            .into_iter()
1581            .flat_map(|t| non_property_handles(t).iter().copied())
1582            .collect();
1583        universe.extend([
1584            "layer",
1585            "title",
1586            "verified",
1587            "checked",
1588            "level",
1589            "format",
1590            "type",
1591            "text",
1592            "kind",
1593            "name",
1594            "value",
1595            "predicate",
1596            "key",
1597            "nope",
1598        ]);
1599        universe.sort_unstable();
1600        universe.dedup();
1601
1602        let is_shape = |v: &Value| matches!(v, Value::Array(_) | Value::Object(_));
1603        for target in [Target::Docs, Target::Blocks, Target::Nodes, Target::Edges] {
1604            // a root is a lazy scan marker; `to_rows` reads it
1605            let rows = ctx.to_rows(&ctx.root(target.as_str()));
1606            assert!(!rows.is_empty(), "{target:?} has rows");
1607            let set = non_property_handles(target);
1608            for name in &universe {
1609                let mut shaped = 0;
1610                for row in &rows {
1611                    let v = ctx.get(row, name).expect("get");
1612                    if set.contains(name) {
1613                        assert!(
1614                            v.is_absent() || is_shape(&v),
1615                            "{target:?}.{name} read as a scalar: {v:?}"
1616                        );
1617                    } else {
1618                        assert!(!is_shape(&v), "{target:?}.{name} is a handle: {v:?}");
1619                    }
1620                    shaped += usize::from(is_shape(&v));
1621                }
1622                if set.contains(name) {
1623                    assert!(
1624                        shaped > 0,
1625                        "{target:?}.{name} never resolved to rows or a bag"
1626                    );
1627                }
1628            }
1629        }
1630    }
1631
1632    // -- declines (left residual) return None --
1633
1634    #[test]
1635    fn reserved_bare_basename_is_not_pushed() {
1636        assert_eq!(translate_predicate(&pred("path == \"x\""), &DOCS), None);
1637        assert_eq!(translate_predicate(&pred("body == \"x\""), &DOCS), None);
1638        assert_eq!(translate_predicate(&pred("doc.path == \"x\""), &DOCS), None);
1639    }
1640
1641    #[test]
1642    fn docs_body_and_computed_intrinsics_are_not_columns() {
1643        assert_eq!(translate_predicate(&pred("$body == \"x\""), &DOCS), None);
1644        assert_eq!(translate_predicate(&pred("$title == \"x\""), &DOCS), None);
1645        assert_eq!(translate_predicate(&pred("$tags == \"x\""), &DOCS), None);
1646    }
1647
1648    #[test]
1649    fn matches_needs_a_regexp_udf() {
1650        assert_eq!(
1651            translate_predicate(&pred("$path.matches(\"^lab/\")"), &DOCS),
1652            None
1653        );
1654    }
1655
1656    #[test]
1657    fn negation_as_a_nested_expr_is_not_and_safe() {
1658        let e = Expr::Unary {
1659            op: oqx::ast::UnaryOp::Not,
1660            expr: ident("$path"),
1661            span: oqx::Span::EMPTY,
1662        };
1663        assert_eq!(translate_predicate(&e, &DOCS), None);
1664    }
1665
1666    #[test]
1667    fn disjunction_as_a_nested_expr_is_declined() {
1668        let e = Expr::Logical {
1669            op: LogicalOp::Or,
1670            left: eq(ident("$path"), lit("a")),
1671            right: eq(ident("$path"), lit("b")),
1672            span: oqx::Span::EMPTY,
1673        };
1674        assert_eq!(translate_predicate(&e, &DOCS), None);
1675    }
1676
1677    #[test]
1678    fn unmapped_node_intrinsic_is_not_pushed() {
1679        let nodes = TranslateCtx {
1680            target: Target::Nodes,
1681            self_alias: "n",
1682            ..DOCS
1683        };
1684        assert_eq!(
1685            translate_predicate(&pred("$locator == \"x\""), &nodes),
1686            None
1687        );
1688        // `$updated_at` is mapped on docs only.
1689        let blocks = TranslateCtx {
1690            target: Target::Blocks,
1691            self_alias: "b",
1692            ..DOCS
1693        };
1694        assert_eq!(
1695            translate_predicate(&pred("$updated_at == \"x\""), &blocks),
1696            None
1697        );
1698    }
1699
1700    #[test]
1701    fn range_membership_in_and_bare_idents_are_declined() {
1702        assert_eq!(translate_predicate(&pred("era in 800..1680"), &DOCS), None);
1703        assert_eq!(
1704            translate_predicate(&pred("\"a\" in list(tags)"), &DOCS),
1705            None
1706        );
1707        assert_eq!(translate_predicate(&pred("verified"), &DOCS), None);
1708        assert_eq!(translate_predicate(&pred("doc.verified"), &DOCS), None);
1709        assert_eq!(translate_predicate(&pred("size(tags) > 1"), &DOCS), None);
1710        assert_eq!(translate_predicate(&pred("$self.text(\"x\")"), &DOCS), None);
1711        assert_eq!(translate_predicate(&pred("$it == \"x\""), &DOCS), None);
1712        assert_eq!(translate_predicate(&pred("^slug == \"x\""), &DOCS), None);
1713        assert_eq!(
1714            translate_predicate(&pred("frontmatter.era == 1"), &DOCS),
1715            None
1716        );
1717    }
1718
1719    // -- conjunction and bindings via constructed AST --
1720
1721    #[test]
1722    fn and_composes_two_pushable_comparisons() {
1723        let e = Expr::Logical {
1724            op: LogicalOp::And,
1725            left: eq(ident("$path"), lit("a")),
1726            right: Box::new(Expr::Binary {
1727                op: BinaryOp::Ne,
1728                left: ident("$id"),
1729                right: lit("d_2"),
1730                span: oqx::Span::EMPTY,
1731            }),
1732            span: oqx::Span::EMPTY,
1733        };
1734        assert_eq!(
1735            translate_predicate(&e, &DOCS),
1736            frag(
1737                "((d.path IS ?) AND (d.doc_id IS NOT ?))",
1738                &[text("a"), text("d_2")]
1739            )
1740        );
1741    }
1742
1743    #[test]
1744    fn and_declines_wholesale_if_either_side_is_not_pushable() {
1745        let e = Expr::Logical {
1746            op: LogicalOp::And,
1747            left: eq(ident("$path"), lit("a")),
1748            // $body is reconstructed, not a column → the whole && declines.
1749            right: eq(ident("$body"), lit("x")),
1750            span: oqx::Span::EMPTY,
1751        };
1752        assert_eq!(translate_predicate(&e, &DOCS), None);
1753    }
1754
1755    #[test]
1756    fn resolves_a_binding_to_its_param_value() {
1757        let e = eq(
1758            ident("$path"),
1759            Box::new(Expr::Binding {
1760                index: 0,
1761                span: oqx::Span::EMPTY,
1762            }),
1763        );
1764        let params = [Value::from("from-binding.md")];
1765        let ctx = TranslateCtx {
1766            params: &params,
1767            ..DOCS
1768        };
1769        assert_eq!(
1770            translate_predicate(&e, &ctx),
1771            frag("(d.path IS ?)", &[text("from-binding.md")])
1772        );
1773        // A binding past the end reads as absent → NULL.
1774        assert_eq!(
1775            translate_predicate(&e, &DOCS),
1776            frag("(d.path IS ?)", &[SqlValue::Null])
1777        );
1778    }
1779
1780    // -- per-target fields --
1781
1782    #[test]
1783    fn blocks_and_nodes_flatten_bare_identifiers_into_attrs() {
1784        let blocks = TranslateCtx {
1785            target: Target::Blocks,
1786            self_alias: "b",
1787            ..DOCS
1788        };
1789        // (a top-level `&&` is a `Where::And` of scalars; the nested form is
1790        // reached through constructed AST, as in the reference's tests)
1791        let both = Expr::Logical {
1792            op: LogicalOp::And,
1793            left: eq(ident("type"), lit("task")),
1794            right: eq(ident("marker"), lit("x")),
1795            span: oqx::Span::EMPTY,
1796        };
1797        assert_eq!(
1798            translate_predicate(&both, &blocks),
1799            frag(
1800                "((b.type IS ?) AND (json_extract(b.attrs, '$.marker') IS ?))",
1801                &[text("task"), text("x")]
1802            )
1803        );
1804        assert_eq!(
1805            translate_predicate(&pred("attrs.marker == \"x\""), &blocks),
1806            frag("(json_extract(b.attrs, '$.marker') IS ?)", &[text("x")])
1807        );
1808        // (a boolean or number against a JSON read pushes typed — see the
1809        // typed-shape tests)
1810        assert_eq!(
1811            translate_predicate(&pred("attrs.checked == true"), &blocks),
1812            frag("((json_type(b.attrs, '$.checked') = 'true') IS 1)", &[])
1813        );
1814        let nodes = TranslateCtx {
1815            target: Target::Nodes,
1816            self_alias: "n",
1817            ..DOCS
1818        };
1819        assert_eq!(
1820            translate_predicate(&pred("kind == \"md:section\""), &nodes),
1821            frag("(n.kind IS ?)", &[text("md:section")])
1822        );
1823        assert_eq!(
1824            translate_predicate(&pred("level == \"1\""), &nodes),
1825            frag("(json_extract(n.attrs, '$.level') IS ?)", &[text("1")])
1826        );
1827        assert_eq!(
1828            translate_predicate(&pred("level == 1"), &nodes),
1829            frag(
1830                "((json_type(n.attrs, '$.level') IN ('integer', 'real') AND json_extract(n.attrs, '$.level') = ?) IS 1)",
1831                &[SqlValue::Real(1.0)]
1832            )
1833        );
1834        assert_eq!(
1835            translate_predicate(&pred("attrs.a.b == \"c\""), &nodes),
1836            frag("(json_extract(n.attrs, '$.a.b') IS ?)", &[text("c")])
1837        );
1838        // `attrs.<k>` is a blocks/nodes form; on docs it is not a column.
1839        assert_eq!(
1840            translate_predicate(&pred("attrs.marker == \"x\""), &DOCS),
1841            None
1842        );
1843    }
1844
1845    #[test]
1846    fn doc_and_block_reach_through() {
1847        let blocks = TranslateCtx {
1848            target: Target::Blocks,
1849            self_alias: "b",
1850            ..DOCS
1851        };
1852        let f = translate_predicate(&pred("doc.type == \"lab-note\""), &blocks).expect("pushable");
1853        assert!(
1854            f.sql.contains("p.doc_id = d.doc_id AND p.key = 'type'"),
1855            "{}",
1856            f.sql
1857        );
1858        assert_eq!(
1859            translate_predicate(&pred("doc.$path == \"a.md\""), &blocks),
1860            frag("(d.path IS ?)", &[text("a.md")])
1861        );
1862        assert_eq!(
1863            translate_predicate(&pred("doc.format == \"markdown\""), &blocks),
1864            frag("(d.format IS ?)", &[text("markdown")])
1865        );
1866        assert_eq!(
1867            translate_predicate(&pred("doc.$id == \"d_1\""), &blocks),
1868            None
1869        );
1870        assert_eq!(translate_predicate(&pred("doc.a.b == 1"), &blocks), None);
1871        let nodes = TranslateCtx {
1872            target: Target::Nodes,
1873            self_alias: "n",
1874            ..DOCS
1875        };
1876        assert_eq!(
1877            translate_predicate(&pred("block.type == \"task\""), &nodes),
1878            frag(
1879                "((SELECT bb.type FROM blocks bb WHERE bb.block_id = n.block_id) IS ?)",
1880                &[text("task")]
1881            )
1882        );
1883        assert_eq!(
1884            translate_predicate(&pred("block.type == \"task\""), &blocks),
1885            None
1886        );
1887        assert_eq!(
1888            translate_predicate(&pred("section.level == 1"), &nodes),
1889            None
1890        );
1891    }
1892
1893    #[test]
1894    fn edges_push_their_five_fields_and_intrinsics() {
1895        let edges = TranslateCtx {
1896            target: Target::Edges,
1897            self_alias: "e",
1898            ..DOCS
1899        };
1900        assert_eq!(
1901            translate_predicate(&pred("predicate == \"references\""), &edges),
1902            frag("(e.predicate IS ?)", &[text("references")])
1903        );
1904        assert_eq!(
1905            translate_predicate(&pred("$dst_path == \"index.md\""), &edges),
1906            frag(
1907                "((SELECT dd.path FROM docs dd WHERE dd.doc_id = e.dst_node) IS ?)",
1908                &[text("index.md")]
1909            )
1910        );
1911        assert_eq!(translate_predicate(&pred("weight == 1"), &edges), None);
1912    }
1913
1914    #[test]
1915    fn unsafe_identifier_segments_are_never_inlined() {
1916        assert!(is_seg("layer") && is_seg("_x9"));
1917        assert!(!is_seg("") && !is_seg("9a") && !is_seg("a-b") && !is_seg("a'b"));
1918        assert_eq!(json_path(&["ok", "no-pe"]), None);
1919        assert_eq!(json_path(&["ok", "a_1"]).as_deref(), Some("$.ok.a_1"));
1920        assert_eq!(prop_scalar("d", "x'y"), None);
1921    }
1922}