Skip to main content

quarb_sql/
export.rs

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