Skip to main content

quarb_sql/
export.rs

1//! The reverse direction: Quarb → SQL.
2//!
3//! Walks the query's *reflection arbor* — the locked vocabulary is
4//! the stable surface for query-rewriting tooling — and emits an
5//! equivalent `SELECT` statement, refusing what the SQL query core
6//! cannot express. Fragments and pure macros expand before
7//! reflection, so they translate for free.
8//!
9//! The translatable subset mirrors the importer: `/table/*`
10//! branches with predicates (→ `WHERE`), witness joins (`<=>` with
11//! `$*1` equality → `JOIN ... ON`, remaining conditions → `WHERE`,
12//! `$*k` select fields qualified), `rec(...)` select lists,
13//! whole-table and grouped aggregates (`GROUP BY`, a filter after
14//! the reduction → `HAVING`), `sort_by`/`reverse`/`top` →
15//! `ORDER BY`/`LIMIT`, `@| [..n]` → `LIMIT`, and single-column
16//! `unique` → `DISTINCT`.
17//!
18//! Deliberately refused: `~>` resolution chains — the foreign-key
19//! targets live in the *schema*, not the query text, so a chain
20//! cannot be compiled to a join without a database at hand; spell
21//! the join explicitly with `<=>` to export it. Also refused:
22//! registers beyond the grouped-aggregate pattern, windows,
23//! captures, and regex matching (`LIKE` dialects disagree).
24//!
25//! Notes carry the standing divergences: truthiness (`[::c]` →
26//! `IS NOT NULL`, but Quarb also treats `0` and `''` as falsy),
27//! `*=` → `LIKE` case-folding dialects, and join cardinality
28//! (Quarb's existential binding never multiplies rows; SQL `JOIN`
29//! does when several left rows match).
30
31use crate::{SqlError, Translation};
32use quarb::reflect::QueryArbor;
33use quarb::{AstAdapter, NodeId, Value};
34
35/// Translate a Quarb query to a SQL `SELECT` statement.
36pub fn export(quarb: &str) -> Result<Translation, SqlError> {
37    refuse_marker(quarb)?;
38    let arbor =
39        QueryArbor::parse(quarb).map_err(|e| SqlError::Syntax(format!("parsing Quarb: {e}")))?;
40    refuse_groups(&arbor)?;
41    let mut ex = Exporter {
42        arbor,
43        notes: Vec::new(),
44        strict: false,
45        dialect: None,
46        from_table: String::new(),
47        join_on_cols: Vec::new(),
48        join_table: None,
49        from_sql: String::new(),
50        join_sql: None,
51        in_on: false,
52        aggregate: false,
53    };
54    let query = ex.query()?;
55    Ok(Translation {
56        query,
57        notes: ex.notes,
58    })
59}
60
61/// The exporter rewrites `__LEFT__` (the join's left table) and
62/// `__AGG__` (the HAVING aggregate) as internal placeholders.
63/// Query text containing either marker would be rewritten inside
64/// its own string literals — and could spoof the emitted SQL — so
65/// such queries stay on the scan path.
66fn refuse_marker(quarb: &str) -> Result<(), SqlError> {
67    for marker in ["__LEFT__", "__AGG__"] {
68        if quarb.contains(marker) {
69            return Err(SqlError::Unsupported(format!(
70                "query text contains the reserved marker \"{marker}\""
71            )));
72        }
73    }
74    Ok(())
75}
76
77/// A pushdown plan: SQL whose execution is provably identical to
78/// the Quarb query's — plus the table whose primary key must order
79/// the rows (`None` for a single aggregate row, where order is
80/// moot). The driver appends the `ORDER BY`, since the key lives in
81/// its catalog.
82pub struct Pushdown {
83    pub sql: String,
84    pub order_table: Option<String>,
85    /// Present when the plan contains a witness JOIN: the joined
86    /// table and the columns its ON equalities bind on it
87    /// (collected structurally from the arbor, so query text
88    /// cannot spoof them).
89    /// The plan is only sound if those columns form a unique key
90    /// of the joined table — each FROM row must find at most one
91    /// witness, else SQL multiplies rows where Quarb's existential
92    /// binding does not. The *driver* must verify against its
93    /// catalog before executing, and fall back to the scan if it
94    /// cannot.
95    pub join_left: Option<(String, Vec<String>)>,
96}
97
98/// The target SQL dialect, for the one construct whose emitted
99/// SQL is not portable: a filter that navigates *into* a JSON
100/// column, which each engine extracts with its own operator.
101/// The rest of a pushdown is dialect-agnostic. A query with no
102/// JSON-column filter emits identical SQL regardless of dialect.
103#[derive(Clone, Copy, PartialEq, Eq, Debug)]
104pub enum Dialect {
105    Postgres,
106    MySql,
107    Sqlite,
108    Mssql,
109    Oracle,
110}
111
112/// Attempt the pushdown translation: `Some` only when every
113/// construct in the query is in the verified-safe set. Anything
114/// else — including everything `export` would merely annotate with
115/// a divergence note — returns `None`, and the caller scans.
116///
117/// `dialect` enables JSON-column-path pushdown for that engine
118/// (fixed-path string equality only); `None` keeps such filters
119/// on the client-side graft.
120pub fn pushdown(quarb: &str, dialect: Option<Dialect>) -> Option<Pushdown> {
121    pushdown_explained(quarb, dialect).ok()
122}
123
124/// [`pushdown`], keeping the refusal: the error names the first
125/// construct that kept the query on the scan path.
126pub fn pushdown_explained(quarb: &str, dialect: Option<Dialect>) -> Result<Pushdown, SqlError> {
127    refuse_marker(quarb)?;
128    let arbor =
129        QueryArbor::parse(quarb).map_err(|e| SqlError::Syntax(format!("parsing Quarb: {e}")))?;
130    refuse_groups(&arbor)?;
131    let mut ex = Exporter {
132        arbor,
133        notes: Vec::new(),
134        strict: true,
135        dialect,
136        from_table: String::new(),
137        join_on_cols: Vec::new(),
138        join_table: None,
139        from_sql: String::new(),
140        join_sql: None,
141        in_on: false,
142        aggregate: false,
143    };
144    let sql = ex.query()?;
145    let order_table = if ex.aggregate {
146        None
147    } else {
148        // Rows come back in the driver's document order — the FROM
149        // table's (driver-first correlation).
150        Some(ex.from_table.clone())
151    };
152    let join_left = ex
153        .join_table
154        .clone()
155        .map(|t| (t, ex.join_on_cols.clone()));
156    Ok(Pushdown {
157        sql,
158        order_table,
159        join_left,
160    })
161}
162
163/// The dialect-specific SQL that extracts a JSON scalar at a
164/// fixed object path, unquoted to text. `path` holds plain
165/// object keys (identifier-safe, per `json_path`), so no
166/// per-dialect path-escaping is needed. Each engine's operator
167/// returns the value as text and yields NULL for an absent path
168/// or a non-scalar — matching the graft, which excludes those
169/// rows too.
170fn json_extract(dialect: Dialect, qual: Option<&str>, col: &str, path: &[String]) -> String {
171    let qcol = match qual {
172        Some(q) => format!("{q}.{col}"),
173        None => col.to_string(),
174    };
175    match dialect {
176        // `#>>` takes a text-array path and returns text; the
177        // `::jsonb` cast lets it work on json, jsonb, and
178        // text-holding-JSON columns alike (an invalid-JSON row
179        // errors, and the driver falls back to the scan).
180        Dialect::Postgres => format!("({qcol}::jsonb #>> '{{{}}}')", path.join(",")),
181        // JSON_UNQUOTE(JSON_EXTRACT(...)) is the `->>` shorthand
182        // spelled out — portable across MySQL and MariaDB, where
183        // the `->>` operator itself is not.
184        Dialect::MySql => {
185            format!("JSON_UNQUOTE(JSON_EXTRACT({qcol}, '$.{}'))", path.join("."))
186        }
187        Dialect::Sqlite => format!("json_extract({qcol}, '$.{}')", path.join(".")),
188        // JSON_VALUE returns a scalar as text (lax mode: NULL on a
189        // missing path or a non-scalar, no error).
190        Dialect::Mssql | Dialect::Oracle => format!("JSON_VALUE({qcol}, '$.{}')", path.join(".")),
191    }
192}
193
194/// Wrap a JSON-path condition in the dialect's validity guard,
195/// where a cheap one exists. SQLite and MySQL error the whole
196/// statement on one malformed-JSON row — which turned every
197/// pushdown over a dirty table into a silent fallback scan — and
198/// the graft excludes such rows anyway (no parse, no subtree), so
199/// the guard is observationally identical. SQL Server gets ISJSON;
200/// Oracle's JSON_VALUE is lax already; PostgreSQL has no guard
201/// short of the ::jsonb cast itself (an invalid row errors and the
202/// caller falls back to the scan).
203fn json_valid_guard(dialect: Dialect, qual: Option<&str>, col: &str, cond: String) -> String {
204    let qcol = match qual {
205        Some(q) => format!("{q}.{col}"),
206        None => col.to_string(),
207    };
208    match dialect {
209        Dialect::Sqlite => format!("(json_valid({qcol}) AND {cond})"),
210        Dialect::MySql => format!("(JSON_VALID({qcol}) AND {cond})"),
211        Dialect::Mssql => format!("(ISJSON({qcol}) = 1 AND {cond})"),
212        Dialect::Postgres | Dialect::Oracle => format!("({cond})"),
213    }
214}
215
216/// The dialect's JSON type probe at a fixed path — non-NULL iff
217/// the path exists. A JSON `null` value reports its type ('null'),
218/// so existence still sees it, matching the graft, where a
219/// null-valued key is a node. Only the dialects with a native
220/// probe participate.
221fn json_type_probe(
222    dialect: Dialect,
223    qual: Option<&str>,
224    col: &str,
225    path: &[String],
226) -> Option<String> {
227    let qcol = match qual {
228        Some(q) => format!("{q}.{col}"),
229        None => col.to_string(),
230    };
231    match dialect {
232        Dialect::Sqlite => Some(format!("json_type({qcol}, '$.{}')", path.join("."))),
233        Dialect::Postgres => Some(format!(
234            "jsonb_typeof({qcol}::jsonb #> '{{{}}}')",
235            path.join(",")
236        )),
237        Dialect::MySql | Dialect::Mssql | Dialect::Oracle => None,
238    }
239}
240
241/// Flip a comparison for a swapped operand order (`lit OP path` →
242/// `path OP' lit`).
243fn flip_op(op: &str) -> String {
244    match op {
245        "<" => ">".into(),
246        "<=" => ">=".into(),
247        ">" => "<".into(),
248        ">=" => "<=".into(),
249        other => other.into(),
250    }
251}
252
253/// SQL keywords that must not appear as a bare identifier or `AS`
254/// alias — quoting them portably differs by dialect, so strict
255/// mode refuses and export mode quotes with double quotes plus a
256/// note.
257const SQL_KEYWORDS: &[&str] = &[
258    "all",
259    "and",
260    "as",
261    "asc",
262    "by",
263    "case",
264    "cross",
265    "desc",
266    "distinct",
267    "else",
268    "end",
269    "except",
270    "exists",
271    "from",
272    "group",
273    "having",
274    "in",
275    "index",
276    "inner",
277    "intersect",
278    "into",
279    "is",
280    "join",
281    "left",
282    "like",
283    "limit",
284    "not",
285    "null",
286    "offset",
287    "on",
288    "or",
289    "order",
290    "outer",
291    "right",
292    "select",
293    "set",
294    "table",
295    "then",
296    "union",
297    "unique",
298    "update",
299    "using",
300    "values",
301    "when",
302    "where",
303];
304
305/// A bare SQL identifier, portable across the target dialects: a
306/// letter or underscore, then letters, digits, and underscores,
307/// and not a reserved word.
308fn is_plain_ident(name: &str) -> bool {
309    !name.is_empty()
310        && name
311            .chars()
312            .next()
313            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
314        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
315        && !SQL_KEYWORDS.contains(&name.to_ascii_lowercase().as_str())
316}
317
318/// The SELECT under construction.
319#[derive(Default)]
320struct Select {
321    select: Vec<String>,
322    distinct: bool,
323    from: String,
324    join: Option<(String, String)>, // (table, ON condition)
325    wheres: Vec<String>,
326    group_by: Option<String>,
327    having: Option<String>,
328    order_by: Option<(String, bool)>, // (expr, desc)
329    limit: Option<String>,
330}
331
332impl Select {
333    fn render(&self) -> String {
334        let mut out = String::from("SELECT ");
335        if self.distinct {
336            out.push_str("DISTINCT ");
337        }
338        if self.select.is_empty() {
339            out.push('*');
340        } else {
341            out.push_str(&self.select.join(", "));
342        }
343        out.push_str(&format!(" FROM {}", self.from));
344        if let Some((t, on)) = &self.join {
345            out.push_str(&format!(" JOIN {t} ON {on}"));
346        }
347        if !self.wheres.is_empty() {
348            out.push_str(&format!(" WHERE {}", self.wheres.join(" AND ")));
349        }
350        if let Some(g) = &self.group_by {
351            out.push_str(&format!(" GROUP BY {g}"));
352        }
353        if let Some(h) = &self.having {
354            out.push_str(&format!(" HAVING {h}"));
355        }
356        if let Some((e, desc)) = &self.order_by {
357            out.push_str(&format!(" ORDER BY {e}"));
358            if *desc {
359                out.push_str(" DESC");
360            }
361        }
362        if let Some(n) = &self.limit {
363            out.push_str(&format!(" LIMIT {n}"));
364        }
365        out
366    }
367}
368
369/// A partial-pushdown plan: the leading row predicates as a WHERE
370/// clause for `table`'s fetch. The caller runs the *original* query
371/// against the filtered adapter — the engine re-applies the pushed
372/// predicates (a no-op on pre-filtered rows), so no rewriting.
373pub struct Partial {
374    pub table: String,
375    pub where_sql: String,
376}
377
378/// Attempt a partial pushdown: `Some` when the query's leading row
379/// predicates translate strictly and the rest of the query provably
380/// cannot observe the filtering. The gates, each with its reason:
381/// a single `/table/*` branch with no correlations (other shapes
382/// address other data); only the *leading* run of expression
383/// predicates pushes (a positional predicate before an expression
384/// one sees unfiltered rows on the scan path); the table's name
385/// appears exactly once among the query's steps (a second reach —
386/// `^`-anchored subcontexts, self-references — would see the
387/// filtered subset); no crosslink or resolution axes anywhere
388/// (backlinks and reverse resolution into the table would too);
389/// and no `:::` / `;;;` metadata anywhere (a filtered `;;;n-rows`
390/// would lie).
391pub fn partial_pushdown(quarb: &str, dialect: Option<Dialect>) -> Option<Partial> {
392    partial_pushdown_explained(quarb, dialect).ok()
393}
394
395/// [`partial_pushdown`], keeping the refusal reason. `dialect`
396/// enables the JSON-column prefilters — the strict fixed-path
397/// string equality, plus the relaxed superset forms (numeric
398/// comparisons, existence) that are safe here and only here,
399/// because the caller re-runs the original query over the
400/// fetched subset.
401pub fn partial_pushdown_explained(
402    quarb: &str,
403    dialect: Option<Dialect>,
404) -> Result<Partial, SqlError> {
405    refuse_marker(quarb)?;
406    let arbor =
407        QueryArbor::parse(quarb).map_err(|e| SqlError::Syntax(format!("parsing Quarb: {e}")))?;
408    refuse_groups(&arbor)?;
409    let mut ex = Exporter {
410        arbor,
411        notes: Vec::new(),
412        strict: true,
413        dialect,
414        from_table: String::new(),
415        join_on_cols: Vec::new(),
416        join_table: None,
417        from_sql: String::new(),
418        join_sql: None,
419        in_on: false,
420        aggregate: false,
421    };
422    ex.partial()
423}
424
425/// Refuse a query carrying path-pattern groups: neither the SQL
426/// translation nor the pushdown safe set covers them, and the shape
427/// checks below count only `step` children — an unguarded group
428/// would silently vanish from the translation. Refused queries fall
429/// back to the scan path, which evaluates groups correctly.
430fn refuse_groups(arbor: &QueryArbor) -> Result<(), SqlError> {
431    let mut stack = vec![arbor.root()];
432    while let Some(n) = stack.pop() {
433        if arbor.name(n).as_deref() == Some("group") {
434            return Err(SqlError::Unsupported(
435                "path patterns (groups and quantifiers)".into(),
436            ));
437        }
438        stack.extend(arbor.children(n));
439    }
440    Ok(())
441}
442
443struct Exporter {
444    arbor: QueryArbor,
445    notes: Vec<String>,
446    /// Pushdown mode: refuse every construct whose SQL semantics
447    /// are not provably identical to Quarb's (LIKE case folding,
448    /// truthiness, group/distinct/sort ordering).
449    strict: bool,
450    /// The target dialect for JSON-column-path pushdown; `None`
451    /// leaves such filters on the client-side graft.
452    dialect: Option<Dialect>,
453    from_table: String,
454    join_on_cols: Vec<String>,
455    join_table: Option<String>,
456    from_sql: String,
457    join_sql: Option<String>,
458    in_on: bool,
459    aggregate: bool,
460}
461
462impl Exporter {
463    fn kids(&self, n: NodeId, kind: &str) -> Vec<NodeId> {
464        self.arbor
465            .children(n)
466            .into_iter()
467            .filter(|&c| self.arbor.name(c).as_deref() == Some(kind))
468            .collect()
469    }
470
471    fn kid(&self, n: NodeId, kind: &str) -> Option<NodeId> {
472        self.kids(n, kind).into_iter().next()
473    }
474
475    fn prop(&self, n: NodeId, key: &str) -> Option<Value> {
476        self.arbor.property(n, key)
477    }
478
479    fn prop_s(&self, n: NodeId, key: &str) -> String {
480        self.prop(n, key).map(|v| v.to_string()).unwrap_or_default()
481    }
482
483    fn kind(&self, n: NodeId) -> String {
484        self.arbor.name(n).unwrap_or_default()
485    }
486
487    /// Whether `n` is a bare `null` literal operand.
488    fn is_null_literal(&self, n: NodeId) -> bool {
489        self.kind(n) == "literal" && self.prop_s(n, "type") == "null"
490    }
491
492    /// The partial-pushdown analysis (see [`partial_pushdown`]).
493    fn partial(&mut self) -> Result<Partial, SqlError> {
494        let root = self.arbor.root();
495        let q = self
496            .kid(root, "query")
497            .ok_or_else(|| SqlError::Unsupported("empty query".into()))?;
498        if self.kid(q, "query").is_some() {
499            return Err(SqlError::Unsupported(
500                "correlations address other tables".into(),
501            ));
502        }
503        let branches = self.kids(q, "branch");
504        if branches.len() != 1 {
505            return Err(SqlError::Unsupported(
506                "a branch union".into(),
507            ));
508        }
509        let (table, preds) = self.table_branch(branches[0])?;
510
511        // Whole-query gates.
512        let all = self.walk_all(root);
513        let mut table_mentions = 0;
514        for n in &all {
515            match self.kind(*n).as_str() {
516                "step" => {
517                    let axis = self.prop_s(*n, "axis");
518                    if matches!(axis.as_str(), "->" | "<-" | "--" | "-->" | "<--") {
519                        return Err(SqlError::Unsupported(
520                            "crosslink/resolution axes could reach \
521                             the filtered table"
522                                .into(),
523                        ));
524                    }
525                    if self.prop_s(*n, "matcher") == table {
526                        table_mentions += 1;
527                    }
528                }
529                "projection" if self.prop_s(*n, "kind") != "property" => {
530                    return Err(SqlError::Unsupported(
531                        "metadata would observe the filtering \
532                         (;;;n-rows, :::index)"
533                            .into(),
534                    ));
535                }
536                _ => {}
537            }
538        }
539        if table_mentions != 1 {
540            return Err(SqlError::Unsupported(
541                "the table is reached more than once".into(),
542            ));
543        }
544
545        // The leading run of expression predicates. Strict
546        // translation first; a predicate the strict translator
547        // refuses tries the relaxed prefilter ladder — superset
548        // semantics: the fetched set may hold extra rows, because
549        // the caller re-runs the ORIGINAL query, which re-applies
550        // every predicate; it must never lose a matching row. An
551        // untranslatable predicate is skipped, not fatal — a
552        // shorter conjunction is still a superset filter. A
553        // positional predicate (index/range) ends the run:
554        // predicates after it filter a positionally-selected
555        // subsequence.
556        let mut conds = Vec::new();
557        for p in preds {
558            if self.prop_s(p, "kind") != "expr" {
559                break;
560            }
561            match self.predicate_cond(p, None) {
562                Ok(c) => conds.push(c),
563                Err(_) => {
564                    if let Some(c) = self.prefilter_cond(p) {
565                        conds.push(c);
566                    }
567                }
568            }
569        }
570        if conds.is_empty() {
571            return Err(SqlError::Unsupported(
572                "no leading expression predicates to push".into(),
573            ));
574        }
575        Ok(Partial {
576            table,
577            where_sql: conds.join(" AND "),
578        })
579    }
580
581    /// The relaxed prefilter for one refused expression predicate:
582    /// the AND of whatever conjuncts translate, each a superset of
583    /// the rows its Quarb counterpart keeps. None when nothing
584    /// does — the predicate stays entirely on the engine.
585    fn prefilter_cond(&mut self, p: NodeId) -> Option<String> {
586        let parts: Vec<String> = self
587            .arbor
588            .children(p)
589            .into_iter()
590            .filter_map(|c| {
591                self.pred_expr(c, None)
592                    .ok()
593                    .or_else(|| self.prefilter_expr(c))
594            })
595            .collect();
596        if parts.is_empty() {
597            None
598        } else {
599            Some(parts.join(" AND "))
600        }
601    }
602
603    /// One conjunct's superset prefilter — fixed-path JSON shapes
604    /// only, per dialect. Every form here is proven to keep every
605    /// row the graft would match (extras are fine; the engine
606    /// re-checks). The proofs lean on three graft facts: absent
607    /// paths match no comparison; `value_eq`/`value_cmp` coerce
608    /// numeric-looking strings numerically; and Str-to-Str
609    /// ordering is bytewise.
610    fn prefilter_expr(&mut self, e: NodeId) -> Option<String> {
611        let dialect = self.dialect?;
612        match self.kind(e).as_str() {
613            // A bare truthy path: existence (with `::`, value
614            // truthiness — existence is its superset). The type
615            // probe sees JSON null too, as the graft does.
616            "path" => {
617                let (col, path) = self.json_path_loose(e)?;
618                let probe = json_type_probe(dialect, None, &col, &path)?;
619                Some(json_valid_guard(
620                    dialect,
621                    None,
622                    &col,
623                    format!("{probe} IS NOT NULL"),
624                ))
625            }
626            "compare" => {
627                let op = self.prop_s(e, "op");
628                let kids = self.arbor.children(e);
629                if kids.len() != 2 {
630                    return None;
631                }
632                for (pi, li) in [(0usize, 1usize), (1, 0)] {
633                    let Some((col, path)) = self.json_path(kids[pi]) else {
634                        continue;
635                    };
636                    if self.kind(kids[li]) != "literal" {
637                        continue;
638                    }
639                    let ty = self.prop_s(kids[li], "type");
640                    if ty == "null" {
641                        return None;
642                    }
643                    let raw = self
644                        .prop(kids[li], "value")
645                        .map(|v| v.to_string())
646                        .unwrap_or_default();
647                    let op = if pi == 0 { op.clone() } else { flip_op(&op) };
648                    return self.json_prefilter(dialect, &col, &path, &op, &ty, &raw);
649                }
650                None
651            }
652            _ => None,
653        }
654    }
655
656    /// Build the dialect's superset prefilter for
657    /// `json_path OP literal`.
658    fn json_prefilter(
659        &self,
660        d: Dialect,
661        col: &str,
662        path: &[String],
663        op: &str,
664        lit_ty: &str,
665        raw: &str,
666    ) -> Option<String> {
667        if !matches!(op, "=" | "!=" | "<" | "<=" | ">" | ">=") {
668            return None;
669        }
670        // `!=` matches every present path whose value isn't the
671        // literal — including JSON null and cross-typed values —
672        // so its floor is existence.
673        if op == "!=" {
674            let probe = json_type_probe(d, None, col, path)?;
675            return Some(json_valid_guard(
676                d,
677                None,
678                col,
679                format!("{probe} IS NOT NULL"),
680            ));
681        }
682        let numeric_lit = raw.trim().parse::<f64>().is_ok_and(|f| f.is_finite())
683            && (lit_ty != "text" || !raw.trim().is_empty());
684        if numeric_lit {
685            let num: f64 = raw.trim().parse().ok()?;
686            // Past 2^53 the engines' exact integers and the
687            // graft's f64 part ways at the boundary.
688            if num.abs() >= 9_007_199_254_740_992.0 {
689                return None;
690            }
691            let lit = raw.trim();
692            match d {
693                // Typed extract: JSON numbers compare numerically
694                // (both sides parse the same source text to f64);
695                // string values escape through the type probe —
696                // the graft may coerce them, the server must not
697                // drop them.
698                Dialect::Sqlite => {
699                    let ex = json_extract(d, None, col, path);
700                    let ty = json_type_probe(d, None, col, path)?;
701                    Some(json_valid_guard(
702                        d,
703                        None,
704                        col,
705                        format!("({ex} {op} {lit} OR {ty} = 'text')"),
706                    ))
707                }
708                // float8 is the graft's f64; jsonb numbers print
709                // exactly, so the cast parses the same decimal.
710                // Strings escape; everything else can't match.
711                Dialect::Postgres => {
712                    let text = json_extract(d, None, col, path);
713                    let ty = json_type_probe(d, None, col, path)?;
714                    Some(format!(
715                        "(CASE WHEN {ty} = 'number' THEN ({text})::float8 {op} {lit} \
716                         WHEN {ty} = 'string' THEN true ELSE false END)"
717                    ))
718                }
719                Dialect::MySql | Dialect::Mssql | Dialect::Oracle => None,
720            }
721        } else if lit_ty == "text" {
722            if raw.contains('\'') || raw.contains('\\') {
723                return None;
724            }
725            match d {
726                // Typed extract again: string values order bytewise
727                // on both sides; numbers and booleans sit below
728                // text in SQLite's cross-type order, so `<` keeps
729                // them (extras, re-checked) and `>` drops them
730                // (the graft refuses to order Str against them).
731                Dialect::Sqlite => {
732                    let ex = json_extract(d, None, col, path);
733                    Some(json_valid_guard(
734                        d,
735                        None,
736                        col,
737                        format!("{ex} {op} '{raw}'"),
738                    ))
739                }
740                // Text ordering elsewhere runs into collation; the
741                // engine keeps those.
742                _ => None,
743            }
744        } else {
745            None
746        }
747    }
748
749    /// [`json_path`], loosened for existence: the projection may
750    /// be absent (a bare structural path) as well as the bare
751    /// `::`.
752    fn json_path_loose(&self, o: NodeId) -> Option<(String, Vec<String>)> {
753        if self.json_path(o).is_some() {
754            return self.json_path(o);
755        }
756        if self.kind(o) != "path" || self.kid(o, "projection").is_some() {
757            return None;
758        }
759        let steps = self.kids(o, "step");
760        if steps.len() < 2 {
761            return None;
762        }
763        let mut names = Vec::with_capacity(steps.len());
764        for s in &steps {
765            if self.prop_s(*s, "axis") != "/"
766                || self.prop_s(*s, "matcher-kind") != "name"
767                || self.kid(*s, "predicate").is_some()
768            {
769                return None;
770            }
771            let name = self.prop_s(*s, "matcher");
772            let plain = !name.is_empty()
773                && name
774                    .chars()
775                    .next()
776                    .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
777                && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
778            if !plain {
779                return None;
780            }
781            names.push(name);
782        }
783        let col = names.remove(0);
784        Some((col, names))
785    }
786
787    fn walk_all(&self, n: NodeId) -> Vec<NodeId> {
788        let mut out = vec![n];
789        let mut i = 0;
790        while i < out.len() {
791            out.extend(self.arbor.children(out[i]));
792            i += 1;
793        }
794        out
795    }
796
797    fn query(&mut self) -> Result<String, SqlError> {
798        let root = self.arbor.root();
799        let q = self
800            .kid(root, "query")
801            .ok_or_else(|| SqlError::Unsupported("empty query".into()))?;
802        let mut sel = Select::default();
803
804        // Correlations: exactly one becomes the JOIN's left table.
805        let corrs = self.kids(q, "query");
806        let branches = self.kids(q, "branch");
807        if branches.len() != 1 {
808            return Err(SqlError::Unsupported(
809                "a branch union (SQL has UNION, but result shapes differ; export one branch)"
810                    .into(),
811            ));
812        }
813
814        if corrs.len() > 1 {
815            return Err(SqlError::Unsupported(
816                "more than one correlation context".into(),
817            ));
818        }
819        if let Some(corr) = corrs.first() {
820            // Driver-first: the query's own branch is the FROM
821            // table (the driver); the correlation entry is the
822            // joined table, whose `$$` equalities form the ON.
823            let (ltable, lpreds) = self.table_branch(branches[0])?;
824            let (rtable, rpreds) = self.table_branch(self.kids(*corr, "branch")[0])?;
825            // The SQL renderings of the two table names (validated
826            // or quoted); the raw names stay in the plan metadata,
827            // which the driver matches against its catalog.
828            let lsql = self.sql_ident(&ltable, "table name")?;
829            let rsql = self.sql_ident(&rtable, "table name")?;
830            self.from_sql = lsql.clone();
831            self.join_sql = Some(rsql.clone());
832            // The driver's own predicates are plain WHERE.
833            let driver_conds: Vec<NodeId> = lpreds;
834            // Split the joined expression's predicates: `$$`
835            // equalities form the ON, the rest the WHERE.
836            let mut on = Vec::new();
837            let mut wheres = Vec::new();
838            for p in rpreds {
839                self.split_join_pred(p, &rsql, &mut on, &mut wheres)?;
840            }
841            if on.is_empty() {
842                return Err(SqlError::Unsupported(
843                    "a correlation without a '$$' equality (no JOIN condition)".into(),
844                ));
845            }
846            for p in driver_conds {
847                let cond = self.predicate_cond(p, Some(&lsql.clone()))?;
848                wheres.push(cond);
849            }
850            self.notes.push(
851                "JOIN: Quarb's binding is existential (one row per FROM row, \
852                 bound to its first witness); SQL multiplies rows when \
853                 several joined rows match"
854                    .to_string(),
855            );
856            sel.from = lsql.clone();
857            self.from_table = ltable;
858            self.join_table = Some(rtable);
859            sel.join = Some((rsql.clone(), on.join(" AND ")));
860            sel.wheres = wheres;
861            // A terminal projection on the driving branch is a
862            // one-column select of the FROM table.
863            if let Some(proj) = self.kid(branches[0], "projection") {
864                let col = self.projection_col(proj)?;
865                let col = self.sql_ident(&col, "column name")?;
866                sel.select.push(format!("{lsql}.{col}"));
867            }
868            if self.kid(self.kids(*corr, "branch")[0], "projection").is_some() {
869                return Err(SqlError::Unsupported(
870                    "a projection on the joined expression (project the witness \
871                     in the pipeline: '$*1::col')"
872                        .into(),
873                ));
874            }
875            self.pipeline(q, &mut sel, Some((&lsql, &rsql)))?;
876        } else {
877            let (table, preds) = self.table_branch(branches[0])?;
878            sel.from = self.sql_ident(&table, "table name")?;
879            self.from_table = table;
880            for p in preds {
881                let cond = self.predicate_cond(p, None)?;
882                sel.wheres.push(cond);
883            }
884            // A terminal projection is a one-column select.
885            if let Some(proj) = self.kid(branches[0], "projection") {
886                let col = self.projection_col(proj)?;
887                sel.select.push(self.sql_ident(&col, "column name")?);
888            }
889            self.pipeline(q, &mut sel, None)?;
890        }
891        Ok(sel.render())
892    }
893
894    /// A `/table/*[preds]` branch: the table name and the row-step
895    /// predicate nodes.
896    fn table_branch(&mut self, b: NodeId) -> Result<(String, Vec<NodeId>), SqlError> {
897        let steps = self.kids(b, "step");
898        if steps.len() != 2 {
899            return Err(SqlError::Unsupported(
900                "navigation beyond /table/* (SQL sees tables and rows)".into(),
901            ));
902        }
903        let (t, rows) = (steps[0], steps[1]);
904        if self.prop_s(t, "axis") != "/"
905            || self.prop_s(t, "matcher-kind") != "name"
906            || self.prop_s(rows, "axis") != "/"
907            || self.prop_s(rows, "matcher-kind") != "any"
908        {
909            return Err(SqlError::Unsupported(
910                "navigation beyond /table/* (SQL sees tables and rows)".into(),
911            ));
912        }
913        if self.kid(t, "predicate").is_some() {
914            return Err(SqlError::Unsupported("a predicate on the table hop".into()));
915        }
916        Ok((self.prop_s(t, "matcher"), self.kids(rows, "predicate")))
917    }
918
919    /// One row predicate as a WHERE condition (qualify columns with
920    /// `qualifier` when joining).
921    fn predicate_cond(&mut self, p: NodeId, qual: Option<&str>) -> Result<String, SqlError> {
922        if self.prop_s(p, "kind") != "expr" {
923            return Err(SqlError::Unsupported(
924                "a positional predicate on rows (SQL rows are unordered; ORDER BY + LIMIT)".into(),
925            ));
926        }
927        let parts: Vec<String> = self
928            .arbor
929            .children(p)
930            .into_iter()
931            .map(|c| self.pred_expr(c, qual))
932            .collect::<Result<_, _>>()?;
933        Ok(parts.join(" AND "))
934    }
935
936    fn pred_expr(&mut self, e: NodeId, qual: Option<&str>) -> Result<String, SqlError> {
937        match self.kind(e).as_str() {
938            "and" | "or" => {
939                let op = self.kind(e).to_uppercase();
940                let kids: Vec<String> = self
941                    .arbor
942                    .children(e)
943                    .into_iter()
944                    .map(|c| self.pred_expr(c, qual))
945                    .collect::<Result<_, _>>()?;
946                Ok(format!("({})", kids.join(&format!(" {op} "))))
947            }
948            "not" => {
949                // SQL's `NOT` propagates UNKNOWN: `NOT (x = 5)` is
950                // UNKNOWN for a NULL `x` and drops the row, but Quarb's
951                // negation keeps it (the inner `value_eq` is false, so
952                // its negation is true). Not provably identical without
953                // the schema — the pushdown paths refuse it and scan.
954                if self.strict {
955                    return Err(SqlError::Semantics(
956                        "SQL NOT propagates UNKNOWN, dropping the NULL rows \
957                         Quarb keeps"
958                            .into(),
959                    ));
960                }
961                let inner: Vec<String> = self
962                    .arbor
963                    .children(e)
964                    .into_iter()
965                    .map(|c| self.pred_expr(c, qual))
966                    .collect::<Result<_, _>>()?;
967                Ok(format!("NOT ({})", inner.join(" AND ")))
968            }
969            "parens" => {
970                let inner: Vec<String> = self
971                    .arbor
972                    .children(e)
973                    .into_iter()
974                    .map(|c| self.pred_expr(c, qual))
975                    .collect::<Result<_, _>>()?;
976                Ok(format!("({})", inner.join(" AND ")))
977            }
978            "compare" => {
979                let op = self.prop_s(e, "op");
980                let kids = self.arbor.children(e);
981                // A comparison against a bare `null` literal. Quarb's
982                // `value_eq` treats NULL as an ordinary value
983                // (`value_eq(NULL, NULL)` is true, `value_eq(NULL, x)`
984                // false), so `= null` keeps exactly the NULL rows and
985                // `!= null` the non-NULL rows. SQL's `= NULL` / `<>
986                // NULL` are always UNKNOWN and drop every row; the
987                // `IS [NOT] NULL` forms are provably identical (and
988                // portable across every target dialect).
989                if matches!(op.as_str(), "=" | "!=")
990                    && (self.is_null_literal(kids[0]) || self.is_null_literal(kids[1]))
991                {
992                    let other = if self.is_null_literal(kids[0]) {
993                        kids[1]
994                    } else {
995                        kids[0]
996                    };
997                    let col = self.operand(other, qual)?;
998                    return Ok(if op == "=" {
999                        format!("{col} IS NULL")
1000                    } else {
1001                        format!("{col} IS NOT NULL")
1002                    });
1003                }
1004                // JSON-column-path pushdown: `[/col/a/b:: = 'lit']`
1005                // navigates into a JSON column and compares a fixed
1006                // path to a string literal. Only this exact shape —
1007                // fixed object path, string equality, and a literal
1008                // that does NOT look numeric — is provably identical
1009                // to the client-side graft (each engine's scalar
1010                // extractor unquotes to text, and an absent path or
1011                // non-string value excludes the row on both sides,
1012                // matching Quarb's `value_eq` on non-numeric text).
1013                // Numeric casts, `!=`, wildcards, and deeper
1014                // predicates fall through to the ordinary operand
1015                // logic below, which refuses the navigation; the
1016                // partial-pushdown prefilter ladder picks those up
1017                // with superset semantics. Enabled only when a
1018                // dialect is set.
1019                if op == "="
1020                    && let Some(dialect) = self.dialect
1021                {
1022                    for (pi, li) in [(0usize, 1usize), (1, 0)] {
1023                        if self.is_text_literal(kids[li])
1024                            && let Some((col, path)) = self.json_path(kids[pi])
1025                        {
1026                            // Quarb's value_eq compares numeric-looking
1027                            // strings numerically — '150' matches the
1028                            // JSON number 150, and the string "150.0".
1029                            // No engine's text extractor coerces that
1030                            // way, so the pushed compare would drop
1031                            // rows the graft keeps. Refuse; the
1032                            // prefilter ladder handles it as a
1033                            // superset.
1034                            let raw = self
1035                                .prop(kids[li], "value")
1036                                .map(|v| v.to_string())
1037                                .unwrap_or_default();
1038                            if raw.trim().parse::<f64>().is_ok() {
1039                                return Err(SqlError::Semantics(
1040                                    "a numeric-looking text literal against a \
1041                                     JSON path compares numerically in Quarb \
1042                                     (value coercion); no SQL extractor \
1043                                     matches that"
1044                                        .into(),
1045                                ));
1046                            }
1047                            let lit = self.operand(kids[li], qual)?;
1048                            let extract = json_extract(dialect, qual, &col, &path);
1049                            return Ok(json_valid_guard(
1050                                dialect,
1051                                qual,
1052                                &col,
1053                                format!("{extract} = {lit}"),
1054                            ));
1055                        }
1056                    }
1057                }
1058                // Any other JSON-path comparison: name the real
1059                // reason, not the generic flat-rows refusal — only
1060                // fixed-path string equality is provably identical
1061                // (Quarb coerces numeric-looking values; no
1062                // engine's extractor matches that). The partial
1063                // prefilter ladder covers these shapes.
1064                if self.dialect.is_some() {
1065                    for side in [kids[0], kids[1]] {
1066                        if self.json_path(side).is_some()
1067                            || self.json_path_loose(side).is_some()
1068                        {
1069                            return Err(SqlError::Semantics(
1070                                "a JSON-path comparison pushes whole only as \
1071                                 fixed-path string equality (Quarb coerces \
1072                                 numeric-looking values; no extractor matches \
1073                                 that); this shape rides the partial prefilter"
1074                                    .into(),
1075                            ));
1076                        }
1077                    }
1078                }
1079                let l = self.operand(kids[0], qual)?;
1080                // Ruling #33: a pattern literal on `=` pushes as
1081                // LIKE — a `%` per star, each literal segment
1082                // escaped, in order. Anchoring matches exactly:
1083                // LIKE anchors both ends, a flanking star lifts
1084                // one. The case-folding caveat is `*=`'s; `!=`
1085                // with a pattern rides the scan (NOT LIKE drops
1086                // the NULL rows Quarb keeps).
1087                if self.kind(kids[1]) == "pattern" {
1088                    if op.as_str() != "=" {
1089                        return Err(SqlError::Unsupported(format!(
1090                            "a pattern literal on '{op}' (only '=' pushes)"
1091                        )));
1092                    }
1093                    // PostgreSQL's LIKE is case-sensitive under its
1094                    // deterministic collations — provably what the
1095                    // pattern test does — so the strict push is
1096                    // sound there (and a prefix pattern is
1097                    // index-sargable server-side). Everywhere else
1098                    // case folding is ambient state (SQLite pragma,
1099                    // MySQL/MSSQL collation), so strict refuses.
1100                    if self.strict && self.dialect != Some(Dialect::Postgres) {
1101                        return Err(SqlError::Semantics(
1102                            "LIKE case folding differs per engine \
1103                             (provably case-sensitive only on PostgreSQL)"
1104                                .into(),
1105                        ));
1106                    }
1107                    if !self.strict {
1108                        self.notes.push(
1109                            "pattern → LIKE: Quarb's pattern test is \
1110                             case-sensitive; LIKE folds case on \
1111                             SQLite/MySQL but not PostgreSQL"
1112                                .to_string(),
1113                        );
1114                    }
1115                    let mut like = String::new();
1116                    for kid in self.arbor.children(kids[1]) {
1117                        match self.kind(kid).as_str() {
1118                            "star" => like.push('%'),
1119                            _ => {
1120                                let raw = self
1121                                    .prop(kid, "value")
1122                                    .map(|v| v.to_string())
1123                                    .unwrap_or_default();
1124                                like.push_str(
1125                                    &raw.replace('\\', "\\\\")
1126                                        .replace('%', "\\%")
1127                                        .replace('_', "\\_")
1128                                        .replace('\'', "''"),
1129                                );
1130                            }
1131                        }
1132                    }
1133                    return Ok(format!("{l} LIKE '{like}' ESCAPE '\\'"));
1134                }
1135                let r = self.operand(kids[1], qual)?;
1136                Ok(match op.as_str() {
1137                    "=" => format!("{l} = {r}"),
1138                    "!=" => {
1139                        // Quarb keeps rows whose operand is NULL (its
1140                        // `value_eq` is false there, so `!=` is true);
1141                        // SQL's `<>` is UNKNOWN for a NULL operand and
1142                        // drops those rows. Not provably identical
1143                        // without the schema, so pushdown refuses it;
1144                        // the display translation keeps `<>` and notes
1145                        // the divergence.
1146                        if self.strict {
1147                            return Err(SqlError::Semantics(
1148                                "'!=' drops the NULL rows Quarb keeps \
1149                                 (SQL '<>' is UNKNOWN for NULL; use '!= null' \
1150                                 for IS NOT NULL)"
1151                                    .into(),
1152                            ));
1153                        }
1154                        self.notes.push(
1155                            "'!=' → '<>': Quarb keeps rows whose column is NULL; \
1156                             SQL's '<>' drops them (use '!= null' for IS NOT NULL)"
1157                                .to_string(),
1158                        );
1159                        format!("{l} <> {r}")
1160                    }
1161                    "<" | "<=" | ">" | ">=" => format!("{l} {op} {r}"),
1162                    "*=" => {
1163                        // The same PostgreSQL rung as the pattern
1164                        // literal above: LIKE is provably
1165                        // case-sensitive there, so `*=` pushes
1166                        // strict too.
1167                        if self.strict && self.dialect != Some(Dialect::Postgres) {
1168                            return Err(SqlError::Semantics(
1169                                "LIKE case folding differs per engine \
1170                                 (provably case-sensitive only on PostgreSQL)"
1171                                    .into(),
1172                            ));
1173                        }
1174                        // The pattern must be a text literal: a
1175                        // column or computed operand holds a value,
1176                        // not a pattern, and SQL LIKE cannot express
1177                        // "contains that value" portably.
1178                        if !self.is_text_literal(kids[1]) {
1179                            return Err(SqlError::Unsupported(
1180                                "'*=' with a non-literal pattern".into(),
1181                            ));
1182                        }
1183                        self.notes.push(
1184                            "*= → LIKE: Quarb's substring test is case-sensitive; LIKE \
1185                             folds case on SQLite/MySQL but not PostgreSQL"
1186                                .to_string(),
1187                        );
1188                        // Escape LIKE's metacharacters, then quote.
1189                        // The explicit ESCAPE makes '\' the escape
1190                        // everywhere — SQLite, MSSQL, and Oracle
1191                        // have no default escape character.
1192                        let raw = self
1193                            .prop(kids[1], "value")
1194                            .map(|v| v.to_string())
1195                            .unwrap_or_default();
1196                        let pat = raw
1197                            .replace('\\', "\\\\")
1198                            .replace('%', "\\%")
1199                            .replace('_', "\\_")
1200                            .replace('\'', "''");
1201                        format!("{l} LIKE '%{pat}%' ESCAPE '\\'")
1202                    }
1203                    "=~" | "!~" => {
1204                        return Err(SqlError::Semantics(
1205                            "regex matching means a different REGEXP engine per \
1206                             backend; regexes run engine-side"
1207                                .into(),
1208                        ));
1209                    }
1210                    other => {
1211                        return Err(SqlError::Unsupported(format!("the '{other}' comparison")));
1212                    }
1213                })
1214            }
1215            // A bare truthy operand.
1216            _ => {
1217                if self.strict {
1218                    return Err(SqlError::Semantics(
1219                        "truthiness diverges (0 and '' are falsy in Quarb)".into(),
1220                    ));
1221                }
1222                self.notes.push(
1223                    "truthiness: '[::c]' exports as IS NOT NULL, but Quarb also treats \
1224                     0 and '' as falsy"
1225                        .to_string(),
1226                );
1227                Ok(format!("{} IS NOT NULL", self.operand(e, qual)?))
1228            }
1229        }
1230    }
1231
1232    fn operand(&mut self, o: NodeId, qual: Option<&str>) -> Result<String, SqlError> {
1233        match self.kind(o).as_str() {
1234            "literal" => {
1235                let v = self.prop(o, "value").unwrap_or(Value::Null);
1236                match self.prop_s(o, "type").as_str() {
1237                    "text" => {
1238                        let s = v.to_string();
1239                        // No single escaping is portable across the
1240                        // pushdown's target dialects: MySQL (default
1241                        // sql_mode) and BigQuery read `\` as an escape
1242                        // while SQLite/PostgreSQL/DuckDB take it
1243                        // literally, and BigQuery rejects the `''`
1244                        // quote-doubling the others require. A literal
1245                        // carrying either character cannot be pushed as
1246                        // provably identical SQL, so refuse it and let
1247                        // the caller scan. (The display translation
1248                        // keeps its best-effort `''`-doubling.)
1249                        if self.strict && (s.contains('\'') || s.contains('\\')) {
1250                            return Err(SqlError::Semantics(
1251                                "a text literal with a quote or backslash \
1252                                 has no escaping portable across SQL dialects"
1253                                    .into(),
1254                            ));
1255                        }
1256                        Ok(format!("'{}'", s.replace('\'', "''")))
1257                    }
1258                    "null" => Ok("NULL".to_string()),
1259                    _ => Ok(v.to_string()),
1260                }
1261            }
1262            "path" => {
1263                if self.kid(o, "step").is_some() {
1264                    // A step here is either navigation (refused) or
1265                    // a resolution chain (refused with the reason).
1266                    let s = self.kids(o, "step")[0];
1267                    if self.prop_s(s, "axis") == "-->" {
1268                        return Err(SqlError::Unsupported(
1269                            "a '~>' resolution chain: the foreign-key targets live in \
1270                             the schema, not the query — spell the join with '<=>' to \
1271                             export it"
1272                                .into(),
1273                        ));
1274                    }
1275                    return Err(SqlError::Unsupported(
1276                        "navigation inside a predicate (SQL rows are flat)".into(),
1277                    ));
1278                }
1279                let p = self
1280                    .kid(o, "projection")
1281                    .ok_or_else(|| SqlError::Unsupported("an empty path operand".into()))?;
1282                let col = self.projection_col(p)?;
1283                let col = self.sql_ident(&col, "column name")?;
1284                Ok(match qual {
1285                    Some(q) => format!("{q}.{col}"),
1286                    None => col,
1287                })
1288            }
1289            "context" => {
1290                // `$*1::col` — the joined expression's witness, in
1291                // pipeline position: the joined table's column.
1292                // Anything else is outside the verified-safe set.
1293                let p = self
1294                    .kid(o, "projection")
1295                    .ok_or_else(|| SqlError::Unsupported("a bare '$*' reference".into()))?;
1296                let col = self.projection_col(p)?;
1297                let col = self.sql_ident(&col, "column name")?;
1298                if self.in_on {
1299                    return Err(SqlError::Unsupported(
1300                        "a '$*' reference inside the ON (the driver is '$$')".into(),
1301                    ));
1302                }
1303                let Some(join) = self.join_sql.clone() else {
1304                    return Err(SqlError::Unsupported(
1305                        "a '$*' reference outside a correlation join".into(),
1306                    ));
1307                };
1308                match self.prop(o, "index") {
1309                    Some(Value::Int(1)) => Ok(format!("{join}.{col}")),
1310                    None => Err(SqlError::Unsupported("a bare '$*' reference".into())),
1311                    Some(v) => Err(SqlError::Unsupported(format!(
1312                        "pushdown: $*{v} beyond a two-branch correlation"
1313                    ))),
1314                }
1315            }
1316            "outer" => {
1317                // `$$::col` — the driver, legal only inside the
1318                // joined expression's ON bracket (elsewhere the
1319                // engine reads an enclosing subcontext scope).
1320                if !self.in_on {
1321                    return Err(SqlError::Unsupported(
1322                        "a '$$' reference outside the join's ON".into(),
1323                    ));
1324                }
1325                let kids = self.arbor.children(o);
1326                let inner = *kids
1327                    .first()
1328                    .ok_or_else(|| SqlError::Unsupported("an empty '$$' reference".into()))?;
1329                if self.kind(inner) != "path" || self.kid(inner, "step").is_some() {
1330                    return Err(SqlError::Unsupported(
1331                        "a '$$' reference beyond a plain column ($$::col)".into(),
1332                    ));
1333                }
1334                let p = self
1335                    .kid(inner, "projection")
1336                    .ok_or_else(|| SqlError::Unsupported("a bare '$$' reference".into()))?;
1337                let col = self.projection_col(p)?;
1338                let col = self.sql_ident(&col, "column name")?;
1339                Ok(format!("{}.{col}", self.from_sql.clone()))
1340            }
1341            "arith" => {
1342                let op = self.prop_s(o, "op");
1343                let kids = self.arbor.children(o);
1344                let l = self.operand(kids[0], qual)?;
1345                let r = self.operand(kids[1], qual)?;
1346                Ok(match op.as_str() {
1347                    "+" | "-" | "*" => format!("({l} {op} {r})"),
1348                    "div" => format!("({l} / {r})"),
1349                    "mod" => format!("({l} % {r})"),
1350                    other => return Err(SqlError::Unsupported(format!("'{other}' arithmetic"))),
1351                })
1352            }
1353            other => Err(SqlError::Unsupported(format!(
1354                "the '{other}' operand (registers, topics, and captures are Quarb-side state)"
1355            ))),
1356        }
1357    }
1358
1359    fn projection_col(&mut self, p: NodeId) -> Result<String, SqlError> {
1360        match self.prop_s(p, "kind").as_str() {
1361            "property" => match self.prop(p, "key") {
1362                Some(k) => Ok(k.to_string()),
1363                None => Err(SqlError::Unsupported(
1364                    "the bare '::' projection (name the column)".into(),
1365                )),
1366            },
1367            other => Err(SqlError::Unsupported(format!(
1368                "the {other} metadata projection"
1369            ))),
1370        }
1371    }
1372
1373    /// Whether `o` is a text string literal (`'London'`).
1374    fn is_text_literal(&self, o: NodeId) -> bool {
1375        self.kind(o) == "literal" && self.prop_s(o, "type") == "text"
1376    }
1377
1378    /// If `o` is the one JSON-column-path shape pushdown handles —
1379    /// `/col/seg/seg…::`, a plain-navigation path (every hop `/`
1380    /// and a plain-identifier object key, no wildcards, no nested
1381    /// predicate), at least one segment past the column, ending in
1382    /// the bare `::` projection — return `(column, [json segments])`.
1383    /// Anything else is `None` (and falls back to the graft).
1384    fn json_path(&self, o: NodeId) -> Option<(String, Vec<String>)> {
1385        if self.kind(o) != "path" {
1386            return None;
1387        }
1388        let steps = self.kids(o, "step");
1389        if steps.len() < 2 {
1390            return None;
1391        }
1392        // The projection must be the bare `::` (default value of
1393        // the JSON leaf), not `::key` or a `;;;`/`:::` metadata form.
1394        let proj = self.kid(o, "projection")?;
1395        if self.prop_s(proj, "kind") != "property" || self.prop(proj, "key").is_some() {
1396            return None;
1397        }
1398        let mut names = Vec::with_capacity(steps.len());
1399        for s in &steps {
1400            if self.prop_s(*s, "axis") != "/"
1401                || self.prop_s(*s, "matcher-kind") != "name"
1402                || self.kid(*s, "predicate").is_some()
1403            {
1404                return None;
1405            }
1406            let name = self.prop_s(*s, "matcher");
1407            // Plain object keys only — no array indices, no
1408            // characters that would need per-dialect path escaping.
1409            let plain = !name.is_empty()
1410                && name.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
1411                && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
1412            if !plain {
1413                return None;
1414            }
1415            names.push(name);
1416        }
1417        let col = names.remove(0);
1418        Some((col, names))
1419    }
1420
1421    /// A safe `AS` alias: bare when it is a plain identifier and
1422    /// not an SQL keyword; otherwise strict mode refuses (quoting
1423    /// dialects disagree) and export mode double-quotes, noting it.
1424    fn alias(&mut self, name: &str) -> Result<String, SqlError> {
1425        if is_plain_ident(name) {
1426            return Ok(name.to_string());
1427        }
1428        if self.strict {
1429            return Err(SqlError::Unsupported(format!(
1430                "pushdown: field name {name:?} needs SQL quoting \
1431                 (dialects disagree); rename the field"
1432            )));
1433        }
1434        self.notes
1435            .push(format!("field name {name:?} double-quoted (ANSI)"));
1436        Ok(format!("\"{}\"", name.replace('"', "\"\"")))
1437    }
1438
1439    /// A table or column name rendered into SQL. Bare when it is a
1440    /// plain identifier; otherwise strict mode refuses — a name
1441    /// like `a OR b` would silently rewrite the emitted SQL's
1442    /// meaning, breaking the provably-identical guarantee (and a
1443    /// portable quoting does not exist) — and export mode
1444    /// double-quotes with a note.
1445    fn sql_ident(&mut self, name: &str, what: &str) -> Result<String, SqlError> {
1446        if is_plain_ident(name) {
1447            return Ok(name.to_string());
1448        }
1449        if self.strict {
1450            return Err(SqlError::Unsupported(format!(
1451                "pushdown: {what} {name:?} is not a plain SQL identifier"
1452            )));
1453        }
1454        self.notes
1455            .push(format!("{what} {name:?} double-quoted (ANSI)"));
1456        Ok(format!("\"{}\"", name.replace('"', "\"\"")))
1457    }
1458
1459    /// Split a joined-side predicate: `col = $*1::col2` equalities
1460    /// become the ON condition; everything else the WHERE.
1461    /// `ltable` and `rtable` are the tables' SQL renderings.
1462    fn split_join_pred(
1463        &mut self,
1464        p: NodeId,
1465        rtable: &str,
1466        on: &mut Vec<String>,
1467        wheres: &mut Vec<String>,
1468    ) -> Result<(), SqlError> {
1469        if self.prop_s(p, "kind") != "expr" {
1470            return Err(SqlError::Unsupported(
1471                "a positional predicate on the joined side".into(),
1472            ));
1473        }
1474        // Flatten top-level ANDs; each conjunct routes to ON or
1475        // WHERE.
1476        fn conjuncts(ex: &Exporter, e: NodeId, out: &mut Vec<NodeId>) {
1477            if ex.kind(e) == "and" {
1478                for c in ex.arbor.children(e) {
1479                    conjuncts(ex, c, out);
1480                }
1481            } else {
1482                out.push(e);
1483            }
1484        }
1485        let mut parts = Vec::new();
1486        for c in self.arbor.children(p) {
1487            conjuncts(self, c, &mut parts);
1488        }
1489        for e in parts {
1490            let uses_driver = self.subtree_has(e, "outer");
1491            self.in_on = true;
1492            let cond = self.pred_expr(e, Some(rtable));
1493            self.in_on = false;
1494            let cond = cond?;
1495            if uses_driver && self.kind(e) == "compare" && self.prop_s(e, "op") == "=" {
1496                // Record which joined-table columns the ON binds —
1497                // the uniqueness obligation (see
1498                // Pushdown::join_left). Collected from the arbor's
1499                // bare-column operand nodes, never from the
1500                // rendered SQL text, so neither a literal nor an
1501                // unusual column name can corrupt the obligation.
1502                self.collect_join_cols(e)?;
1503                on.push(cond);
1504            } else {
1505                wheres.push(cond);
1506            }
1507        }
1508        Ok(())
1509    }
1510
1511    /// The joined-table columns the ON equality binds: every bare
1512    /// column operand (`::col`) in `e`'s subtree, appended to the
1513    /// join obligation with their raw (catalog) names.
1514    fn collect_join_cols(&mut self, e: NodeId) -> Result<(), SqlError> {
1515        // The `$$…` side is the driver — not part of the joined
1516        // table's obligation.
1517        if self.kind(e) == "outer" {
1518            return Ok(());
1519        }
1520        if self.kind(e) == "path"
1521            && self.kid(e, "step").is_none()
1522            && let Some(p) = self.kid(e, "projection")
1523        {
1524            let col = self.projection_col(p)?;
1525            self.join_on_cols.push(col);
1526        }
1527        for c in self.arbor.children(e) {
1528            self.collect_join_cols(c)?;
1529        }
1530        Ok(())
1531    }
1532
1533    fn subtree_has(&self, n: NodeId, kind: &str) -> bool {
1534        if self.kind(n) == kind {
1535            return true;
1536        }
1537        self.arbor
1538            .children(n)
1539            .into_iter()
1540            .any(|c| self.subtree_has(c, kind))
1541    }
1542
1543    /// Consume the pipeline into SELECT clauses.
1544    fn pipeline(
1545        &mut self,
1546        q: NodeId,
1547        sel: &mut Select,
1548        join: Option<(&str, &str)>,
1549    ) -> Result<(), SqlError> {
1550        let Some(pipe) = self.kid(q, "pipeline") else {
1551            return Ok(());
1552        };
1553        let stages: Vec<NodeId> = self.arbor.children(pipe);
1554        let mut i = 0;
1555        // A pending per-row value (`| ::col`) feeding an aggregate.
1556        let mut pending_col: Option<String> = None;
1557        // After a grouped reduction: (key, agg_sql, alias).
1558        let mut grouped: Option<(String, String, String)> = None;
1559
1560        while i < stages.len() {
1561            let s = stages[i];
1562            match self.kind(s).as_str() {
1563                "expr" => {
1564                    let kids = self.arbor.children(s);
1565                    pending_col = Some(self.operand(kids[0], join.map(|(l, _)| l))?);
1566                }
1567                "func" => {
1568                    let name = self.prop_s(s, "name");
1569                    match name.as_str() {
1570                        "rec" | "record" => {
1571                            sel.select = self.record_fields(s, join)?;
1572                        }
1573                        // A reducing aggregate on the plain pipe:
1574                        // the grouped reduction.
1575                        "count" | "sum" | "mean" | "avg" | "min" | "max" => {
1576                            let (key, _, _) = grouped.as_ref().ok_or_else(|| {
1577                                SqlError::Unsupported(format!(
1578                                    "'| {name}' outside a group (use '@| {name}')"
1579                                ))
1580                            })?;
1581                            // Quarb's count counts every member,
1582                            // nulls included: COUNT(*), never the
1583                            // NULL-skipping COUNT(col).
1584                            let col = if name == "count" {
1585                                pending_col = None;
1586                                None
1587                            } else {
1588                                pending_col.take()
1589                            };
1590                            let agg = sql_agg(&name, col)?;
1591                            grouped = Some((key.clone(), agg.clone(), agg));
1592                        }
1593                        other => {
1594                            // sort/unique/top and kin are spellable
1595                            // in SQL but with the backend's own
1596                            // collation and duplicate semantics —
1597                            // name the divergence, not a missing
1598                            // feature.
1599                            if matches!(other, "sort" | "sort_by" | "unique" | "top" | "bottom") {
1600                                return Err(SqlError::Semantics(format!(
1601                                    "'{other}' means the backend's collation and \
1602                                     duplicate semantics; one engine-side ordering \
1603                                     keeps it identical everywhere"
1604                                )));
1605                            }
1606                            return Err(SqlError::Unsupported(format!(
1607                                "the '{other}' pipeline function"
1608                            )));
1609                        }
1610                    }
1611                }
1612                "push" => {
1613                    // An alias for the grouped aggregate.
1614                    if let Some((k, agg, _)) = grouped.take() {
1615                        let alias = self.prop_s(s, "name");
1616                        grouped = Some((k, agg, alias));
1617                    } else {
1618                        return Err(SqlError::Unsupported(
1619                            "a register push (Quarb-side state)".into(),
1620                        ));
1621                    }
1622                }
1623                "filter" => {
1624                    if grouped.is_some() {
1625                        // HAVING: `$_` / the alias refer to the
1626                        // aggregate.
1627                        let cond = self.having_cond(s)?;
1628                        sel.having = Some(cond);
1629                    } else {
1630                        return Err(SqlError::Unsupported(
1631                            "a mid-pipeline filter (put it in the row predicate)".into(),
1632                        ));
1633                    }
1634                }
1635                "recall" => {
1636                    // `| %.` finalizes the grouped record.
1637                    if self.prop_s(s, "ref") != "%." {
1638                        return Err(SqlError::Unsupported(
1639                            "a register recall (Quarb-side state)".into(),
1640                        ));
1641                    }
1642                    let (key, agg, alias) = grouped.clone().ok_or_else(|| {
1643                        SqlError::Unsupported("'%.', with nothing grouped".into())
1644                    })?;
1645                    let alias = self.alias(&alias)?;
1646                    sel.select = vec![key.clone(), format!("{agg} AS {alias}")];
1647                    // The HAVING condition compared the aggregate
1648                    // through $_ — substitute the real expression.
1649                    if let Some(h) = sel.having.take() {
1650                        sel.having = Some(h.replace("__AGG__", &agg));
1651                    }
1652                }
1653                "agg" => {
1654                    let name = self.prop_s(s, "name");
1655                    match name.as_str() {
1656                        "count" | "sum" | "mean" | "avg" | "min" | "max" => {
1657                            // Quarb's count counts every row, nulls
1658                            // included: COUNT(*) regardless of a
1659                            // pending column.
1660                            let col = if name == "count" {
1661                                pending_col = None;
1662                                None
1663                            } else {
1664                                pending_col.take()
1665                            };
1666                            sel.select = vec![sql_agg(&name, col)?];
1667                            self.aggregate = true;
1668                        }
1669                        "group" => {
1670                            if self.strict {
1671                                return Err(SqlError::Semantics(
1672                                    "GROUP BY result order is unordered in SQL".into(),
1673                                ));
1674                            }
1675                            self.notes.push(
1676                                "GROUP BY: SQL keeps a NULL-key group; Quarb's group \
1677                                 drops null keys"
1678                                    .to_string(),
1679                            );
1680                            let key = self.group_key(s, join)?;
1681                            sel.group_by = Some(key.clone());
1682                            grouped = Some((key, String::new(), String::new()));
1683                        }
1684                        "sort_by" => {
1685                            if self.strict {
1686                                return Err(SqlError::Semantics(
1687                                    "ORDER BY collations differ per engine".into(),
1688                                ));
1689                            }
1690                            // A sort after a positional selection
1691                            // (or a second sort) cannot render: the
1692                            // fixed SELECT shape orders before
1693                            // LIMIT, and has one ORDER BY.
1694                            if sel.limit.is_some() {
1695                                return Err(SqlError::Unsupported(
1696                                    "a sort after a positional selection (SQL orders \
1697                                     before LIMIT)"
1698                                        .into(),
1699                                ));
1700                            }
1701                            if sel.order_by.is_some() {
1702                                return Err(SqlError::Unsupported(
1703                                    "a second sort (SQL has a single ORDER BY)".into(),
1704                                ));
1705                            }
1706                            let kids = self.arbor.children(s);
1707                            let e = self.operand(kids[0], join.map(|(l, _)| l))?;
1708                            sel.order_by = Some((e, false));
1709                        }
1710                        "reverse" => match &mut sel.order_by {
1711                            Some((_, desc)) => *desc = true,
1712                            None => {
1713                                return Err(SqlError::Unsupported(
1714                                    "reverse without an ORDER BY (rows are unordered)".into(),
1715                                ));
1716                            }
1717                        },
1718                        "top" => {
1719                            if self.strict {
1720                                return Err(SqlError::Semantics(
1721                                    "ORDER BY collations differ per engine".into(),
1722                                ));
1723                            }
1724                            if sel.limit.is_some() {
1725                                return Err(SqlError::Unsupported(
1726                                    "'top' after a positional selection (SQL orders \
1727                                     before LIMIT)"
1728                                        .into(),
1729                                ));
1730                            }
1731                            if sel.order_by.is_some() {
1732                                return Err(SqlError::Unsupported(
1733                                    "a second sort (SQL has a single ORDER BY)".into(),
1734                                ));
1735                            }
1736                            let kids = self.arbor.children(s);
1737                            let n = self.prop_s(kids[0], "value");
1738                            let e = self.operand(kids[1], join.map(|(l, _)| l))?;
1739                            sel.order_by = Some((e, true));
1740                            sel.limit = Some(n);
1741                        }
1742                        "unique" => {
1743                            if self.strict {
1744                                return Err(SqlError::Unsupported(
1745                                    "pushdown: DISTINCT result order is unordered in SQL".into(),
1746                                ));
1747                            }
1748                            // Quarb dedups the limited rows; SQL
1749                            // applies DISTINCT before LIMIT.
1750                            if sel.limit.is_some() {
1751                                return Err(SqlError::Unsupported(
1752                                    "'unique' after a positional selection (SQL applies \
1753                                     DISTINCT before LIMIT)"
1754                                        .into(),
1755                                ));
1756                            }
1757                            if let Some(c) = pending_col.take() {
1758                                sel.select = vec![c];
1759                            }
1760                            sel.distinct = true;
1761                        }
1762                        other => {
1763                            return Err(SqlError::Unsupported(format!("the '{other}' aggregate")));
1764                        }
1765                    }
1766                }
1767                "select" => {
1768                    if self.strict {
1769                        return Err(SqlError::Unsupported(
1770                            "pushdown: LIMIT without a guaranteed order".into(),
1771                        ));
1772                    }
1773                    // `@| [..n]` → LIMIT.
1774                    if sel.limit.is_some() {
1775                        return Err(SqlError::Unsupported(
1776                            "a second positional selection".into(),
1777                        ));
1778                    }
1779                    let p = self.arbor.children(s)[0];
1780                    match (self.prop_s(p, "kind").as_str(), self.prop(p, "to")) {
1781                        ("range", Some(Value::Int(n)))
1782                            if self.prop(p, "from").is_none() && n > 0 =>
1783                        {
1784                            sel.limit = Some(n.to_string());
1785                        }
1786                        _ => {
1787                            return Err(SqlError::Unsupported(
1788                                "positional selection beyond '@| [..n]'".into(),
1789                            ));
1790                        }
1791                    }
1792                }
1793                other => {
1794                    return Err(SqlError::Unsupported(format!(
1795                        "the '{other}' stage (windows, subcontexts, and registers are \
1796                         Quarb-side state)"
1797                    )));
1798                }
1799            }
1800            i += 1;
1801        }
1802        // A pending column with no aggregate is a one-column select.
1803        if let Some(c) = pending_col
1804            && sel.select.is_empty()
1805        {
1806            sel.select = vec![c];
1807        }
1808        Ok(())
1809    }
1810
1811    fn group_key(&mut self, s: NodeId, join: Option<(&str, &str)>) -> Result<String, SqlError> {
1812        let kids = self.arbor.children(s);
1813        // group(::k) or group("name", expr) — the key expression is
1814        // the last child; a literal first child is its name.
1815        let key = kids
1816            .iter()
1817            .rev()
1818            .find(|&&k| self.kind(k) != "literal")
1819            .ok_or_else(|| SqlError::Unsupported("a literal group key".into()))?;
1820        self.operand(*key, join.map(|(l, _)| l))
1821    }
1822
1823    /// A HAVING filter: `$_` and `$.name` refer to the aggregate.
1824    fn having_cond(&mut self, s: NodeId) -> Result<String, SqlError> {
1825        let kids = self.arbor.children(s);
1826        if kids.len() != 1 || self.kind(kids[0]) != "compare" {
1827            return Err(SqlError::Unsupported(
1828                "HAVING translates for a single comparison".into(),
1829            ));
1830        }
1831        let e = kids[0];
1832        let op = self.prop_s(e, "op");
1833        let cmp_kids = self.arbor.children(e);
1834        let l = match self.kind(cmp_kids[0]).as_str() {
1835            "topic" | "recall" => "__AGG__".to_string(),
1836            _ => {
1837                return Err(SqlError::Unsupported(
1838                    "HAVING compares the aggregate ($_ or its register)".into(),
1839                ));
1840            }
1841        };
1842        let r = self.operand(cmp_kids[1], None)?;
1843        Ok(format!("{l} {op} {r}"))
1844    }
1845
1846    /// `rec(...)` fields as a select list.
1847    fn record_fields(
1848        &mut self,
1849        s: NodeId,
1850        join: Option<(&str, &str)>,
1851    ) -> Result<Vec<String>, SqlError> {
1852        let kids = self.arbor.children(s);
1853        let mut fields = Vec::new();
1854        let mut i = 0;
1855        while i < kids.len() {
1856            let k = kids[i];
1857            if self.kind(k) == "literal" {
1858                let name = self.prop_s(k, "value");
1859                let value = self.operand(kids[i + 1], join.map(|(l, _)| l))?;
1860                let name = self.alias(&name)?;
1861                fields.push(format!("{value} AS {name}"));
1862                i += 2;
1863            } else {
1864                let value = self.operand(k, join.map(|(l, _)| l))?;
1865                fields.push(value);
1866                i += 1;
1867            }
1868        }
1869        Ok(fields)
1870    }
1871}
1872
1873fn sql_agg(name: &str, col: Option<String>) -> Result<String, SqlError> {
1874    let f = match name {
1875        "count" => "COUNT",
1876        "sum" => "SUM",
1877        "mean" | "avg" => "AVG",
1878        "min" => "MIN",
1879        "max" => "MAX",
1880        _ => unreachable!("checked by caller"),
1881    };
1882    match col {
1883        Some(c) => Ok(format!("{f}({c})")),
1884        // Only COUNT aggregates bare rows; SUM(*) and friends are
1885        // not SQL.
1886        None if f == "COUNT" => Ok("COUNT(*)".to_string()),
1887        None => Err(SqlError::Unsupported(format!(
1888            "'{name}' over row nodes (project a column first: '| ::col @| {name}')"
1889        ))),
1890    }
1891}
1892
1893#[cfg(test)]
1894mod null_and_literal_tests {
1895    use super::{Dialect, export, partial_pushdown, pushdown};
1896
1897    // Grouped pipeline that never pushes, so `partial_pushdown` hinges
1898    // only on the leading predicate (mirrors the crate's partial gate).
1899    const GROUPED: &str = " | ::x @| group(\"g\", ::x) | count | .n | %.";
1900
1901    #[test]
1902    fn pattern_literal_pushes_as_like() {
1903        // Ruling #33: segments in order, a `%` per star, LIKE's
1904        // metacharacters escaped. Strict push only where LIKE is
1905        // provably case-sensitive (PostgreSQL); everywhere else the
1906        // pattern rides the scan.
1907        let q = "/users/*[::name = 'app-'*]::id";
1908        let p = pushdown(q, Some(Dialect::Postgres)).unwrap();
1909        assert_eq!(
1910            p.sql,
1911            "SELECT id FROM users WHERE name LIKE 'app-%' ESCAPE '\\'"
1912        );
1913        assert!(pushdown(q, Some(Dialect::Sqlite)).is_none());
1914        assert!(pushdown(q, Some(Dialect::MySql)).is_none());
1915        // contains and multi-segment shapes
1916        let p = pushdown("/users/*[::name = *'web'*]::id", Some(Dialect::Postgres)).unwrap();
1917        assert!(p.sql.contains("LIKE '%web%'"), "{}", p.sql);
1918        let p = pushdown("/users/*[::name = *'a'*'.gz']::id", Some(Dialect::Postgres)).unwrap();
1919        assert!(p.sql.contains("LIKE '%a%.gz'"), "{}", p.sql);
1920        // LIKE metacharacters in a segment are escaped
1921        let p = pushdown("/users/*[::name = *'50%_x'*]::id", Some(Dialect::Postgres)).unwrap();
1922        assert!(p.sql.contains("LIKE '%50\\%\\_x%'"), "{}", p.sql);
1923        // `!=` with a pattern rides the scan
1924        assert!(
1925            pushdown("/users/*[::name != *'web'*]::id", Some(Dialect::Postgres)).is_none()
1926        );
1927        // the `*=` alias gains the same PostgreSQL rung
1928        let p = pushdown("/users/*[::name *= 'web']::id", Some(Dialect::Postgres)).unwrap();
1929        assert!(p.sql.contains("LIKE '%web%'"), "{}", p.sql);
1930        assert!(pushdown("/users/*[::name *= 'web']::id", Some(Dialect::Sqlite)).is_none());
1931    }
1932
1933    #[test]
1934    fn json_column_path_pushdown_per_dialect() {
1935        // `[/col/a/b:: = 'lit']` navigates into a JSON column; each
1936        // dialect extracts the fixed path to text and compares.
1937        // Verified live against all five engines to match the graft.
1938        let q = "/orders/*[/data/meta/tier:: = 'gold']::id";
1939        let sql = |d| pushdown(q, Some(d)).unwrap().sql;
1940        assert_eq!(
1941            sql(Dialect::Postgres),
1942            "SELECT id FROM orders WHERE ((data::jsonb #>> '{meta,tier}') = 'gold')"
1943        );
1944        assert_eq!(
1945            sql(Dialect::MySql),
1946            "SELECT id FROM orders WHERE (JSON_VALID(data) AND \
1947             JSON_UNQUOTE(JSON_EXTRACT(data, '$.meta.tier')) = 'gold')"
1948        );
1949        assert_eq!(
1950            sql(Dialect::Sqlite),
1951            "SELECT id FROM orders WHERE (json_valid(data) AND \
1952             json_extract(data, '$.meta.tier') = 'gold')"
1953        );
1954        assert_eq!(
1955            sql(Dialect::Mssql),
1956            "SELECT id FROM orders WHERE (ISJSON(data) = 1 AND \
1957             JSON_VALUE(data, '$.meta.tier') = 'gold')"
1958        );
1959        assert_eq!(
1960            sql(Dialect::Oracle),
1961            "SELECT id FROM orders WHERE (JSON_VALUE(data, '$.meta.tier') = 'gold')"
1962        );
1963        // With no dialect, the JSON navigation is not pushable — it
1964        // falls back to the client-side graft.
1965        assert!(pushdown(q, None).is_none());
1966    }
1967
1968    #[test]
1969    fn json_pushdown_only_string_equality() {
1970        // Numeric comparison, `!=`, and a wildcard hop stay off the
1971        // pushdown path (they are not provably identical to the
1972        // graft), so they refuse and scan even with a dialect set.
1973        let d = Some(Dialect::Sqlite);
1974        assert!(pushdown("/o/*[/data/n:: > 2]::id", d).is_none());
1975        assert!(pushdown("/o/*[/data/tier:: != 'gold']::id", d).is_none());
1976        assert!(pushdown("/o/*[/data/items/*/sku:: = 'A1']::id", d).is_none());
1977    }
1978
1979    #[test]
1980    fn json_pushdown_refuses_numeric_looking_literal() {
1981        // value_eq coerces numeric-looking strings ('150' matches
1982        // the JSON number 150 and the string "150.0"); no engine's
1983        // extractor does, so the push would drop rows.
1984        let q = "/orders/*[/data/total:: = '150']::id";
1985        assert!(pushdown(q, Some(Dialect::Sqlite)).is_none());
1986        assert!(pushdown(q, Some(Dialect::Postgres)).is_none());
1987    }
1988
1989    #[test]
1990    fn partial_json_prefilters_sqlite() {
1991        // Exact string equality rides the strict gate.
1992        let p = partial_pushdown(
1993            "/orders/*[/payload/customer/geo/city:: = 'Lyon']::id",
1994            Some(Dialect::Sqlite),
1995        )
1996        .unwrap();
1997        assert_eq!(p.table, "orders");
1998        assert_eq!(
1999            p.where_sql,
2000            "(json_valid(payload) AND \
2001             json_extract(payload, '$.customer.geo.city') = 'Lyon')"
2002        );
2003        // Numeric comparison: typed compare, text values escape.
2004        let p = partial_pushdown(
2005            "/orders/*[/payload/total:: > 150]::id",
2006            Some(Dialect::Sqlite),
2007        )
2008        .unwrap();
2009        assert_eq!(
2010            p.where_sql,
2011            "(json_valid(payload) AND (json_extract(payload, '$.total') > 150 \
2012             OR json_type(payload, '$.total') = 'text'))"
2013        );
2014        // Bare existence.
2015        let p = partial_pushdown("/orders/*[/payload/gift]::id", Some(Dialect::Sqlite)).unwrap();
2016        assert_eq!(
2017            p.where_sql,
2018            "(json_valid(payload) AND json_type(payload, '$.gift') IS NOT NULL)"
2019        );
2020        // != floors at existence.
2021        let p = partial_pushdown(
2022            "/orders/*[/payload/status:: != 'x']::id",
2023            Some(Dialect::Sqlite),
2024        )
2025        .unwrap();
2026        assert_eq!(
2027            p.where_sql,
2028            "(json_valid(payload) AND json_type(payload, '$.status') IS NOT NULL)"
2029        );
2030        // A flipped literal flips the operator.
2031        let p = partial_pushdown(
2032            "/orders/*[150 < /payload/total::]::id",
2033            Some(Dialect::Sqlite),
2034        )
2035        .unwrap();
2036        assert!(p.where_sql.contains("> 150"), "{}", p.where_sql);
2037        // Without a dialect, JSON predicates stay on the engine.
2038        assert!(partial_pushdown("/orders/*[/payload/gift]::id", None).is_none());
2039    }
2040
2041    #[test]
2042    fn partial_json_prefilters_postgres() {
2043        let p = partial_pushdown(
2044            "/orders/*[/payload/total:: > 150]::id",
2045            Some(Dialect::Postgres),
2046        )
2047        .unwrap();
2048        assert_eq!(
2049            p.where_sql,
2050            "(CASE WHEN jsonb_typeof(payload::jsonb #> '{total}') = 'number' \
2051             THEN ((payload::jsonb #>> '{total}'))::float8 > 150 \
2052             WHEN jsonb_typeof(payload::jsonb #> '{total}') = 'string' \
2053             THEN true ELSE false END)"
2054        );
2055        let p =
2056            partial_pushdown("/orders/*[/payload/gift]::id", Some(Dialect::Postgres)).unwrap();
2057        assert_eq!(
2058            p.where_sql,
2059            "(jsonb_typeof(payload::jsonb #> '{gift}') IS NOT NULL)"
2060        );
2061    }
2062
2063    #[test]
2064    fn partial_skips_untranslatable_keeps_prefix() {
2065        // A refused predicate no prefilter covers is skipped — the
2066        // shorter conjunction is still a superset (the engine
2067        // re-applies the original) — rather than aborting the plan.
2068        let p = partial_pushdown(
2069            "/orders/*[::status = 'shipped'][/payload/line_items/*/sku:: = 'X']::id",
2070            Some(Dialect::Sqlite),
2071        )
2072        .unwrap();
2073        assert_eq!(p.where_sql, "status = 'shipped'");
2074    }
2075
2076    #[test]
2077    fn null_literal_compares_use_is_null() {
2078        // `= null` / `!= null` are provably identical to Quarb's
2079        // value_eq(NULL, …) semantics, never the always-UNKNOWN
2080        // `x = NULL`; they translate — and push — in both modes.
2081        assert_eq!(
2082            export("/t/*[::x = null] | ::x").unwrap().query,
2083            "SELECT x FROM t WHERE x IS NULL"
2084        );
2085        assert_eq!(
2086            export("/t/*[::x != null] | ::x").unwrap().query,
2087            "SELECT x FROM t WHERE x IS NOT NULL"
2088        );
2089        assert_eq!(
2090            pushdown("/t/*[::x = null] | ::x", None).unwrap().sql,
2091            "SELECT x FROM t WHERE x IS NULL"
2092        );
2093        assert_eq!(
2094            pushdown("/t/*[::x != null] | ::x", None).unwrap().sql,
2095            "SELECT x FROM t WHERE x IS NOT NULL"
2096        );
2097    }
2098
2099    #[test]
2100    fn ne_and_not_refuse_pushdown_but_display_diverges() {
2101        // `!=` against a non-null value and `not(...)` drop the NULL
2102        // rows Quarb keeps under SQL three-valued logic: the pushdown
2103        // paths refuse (and scan), full and partial alike.
2104        assert!(pushdown("/t/*[::x != 5] | ::x", None).is_none());
2105        assert!(pushdown("/t/*[!::x = 5] | ::x", None).is_none());
2106        assert!(partial_pushdown(&format!("/t/*[::x != 5]{GROUPED}"), None).is_none());
2107        assert!(partial_pushdown(&format!("/t/*[!::x = 5]{GROUPED}"), None).is_none());
2108        // The display translation still emits `<>`, flagged with a note.
2109        let t = export("/t/*[::x != 5] | ::x").unwrap();
2110        assert_eq!(t.query, "SELECT x FROM t WHERE x <> 5");
2111        assert!(t.notes.iter().any(|n| n.contains("NULL")));
2112    }
2113
2114    #[test]
2115    fn unescapable_text_literal_refuses_pushdown() {
2116        // A backslash (MySQL/BigQuery escape) or an apostrophe
2117        // (BigQuery rejects '' doubling) has no portable escaping, so
2118        // pushdown refuses; a clean literal still pushes.
2119        assert!(pushdown("/files/*[::path = \"C:\\temp\"] | ::path", None).is_none());
2120        assert!(pushdown("/t/*[::name = \"it's\"] | ::name", None).is_none());
2121        assert!(partial_pushdown(&format!("/t/*[::name = \"it's\"]{GROUPED}"), None).is_none());
2122        assert_eq!(
2123            pushdown("/t/*[::name = \"rare\"] | ::name", None).unwrap().sql,
2124            "SELECT name FROM t WHERE name = 'rare'"
2125        );
2126    }
2127}