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