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