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