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    /// A numeric reducer (`sum`, `mean`, `min`, `max`) pushed over a
1594    /// column is the scan's only on a numeric-typed column: the
1595    /// scan lifts unit text (`'18 parasang'` under a declared unit)
1596    /// to quantities and orders texts as texts, where SQL sums a
1597    /// text column by its leading digits and orders it by
1598    /// collation. Recorded for the caller's catalog as a
1599    /// comparison's assumption is.
1600    fn assume_numeric(&mut self, column: Option<String>) {
1601        if !self.strict {
1602            return;
1603        }
1604        if let Some(c) = column
1605            && !self.column_types.numeric.contains(&c)
1606        {
1607            self.column_types.numeric.push(c);
1608        }
1609    }
1610
1611    fn column_operand(&self, o: NodeId) -> Option<String> {
1612        match self.kind(o).as_str() {
1613            "path" if self.kid(o, "step").is_none() => {}
1614            "context" => {}
1615            _ => return None,
1616        }
1617        let p = self.kid(o, "projection")?;
1618        if self.prop_s(p, "kind") != "property" {
1619            return None;
1620        }
1621        self.prop(p, "key").map(|k| k.to_string())
1622    }
1623
1624    /// Whether `o` is a text string literal (`'London'`).
1625    fn is_text_literal(&self, o: NodeId) -> bool {
1626        self.kind(o) == "literal" && self.prop_s(o, "type") == "text"
1627    }
1628
1629    /// If `o` is the one JSON-column-path shape pushdown handles —
1630    /// `/col/seg/seg…::`, a plain-navigation path (every hop `/`
1631    /// and a plain-identifier object key, no wildcards, no nested
1632    /// predicate), at least one segment past the column, ending in
1633    /// the bare `::` projection — return `(column, [json segments])`.
1634    /// Anything else is `None` (and falls back to the graft).
1635    fn json_path(&self, o: NodeId) -> Option<(String, Vec<String>)> {
1636        if self.kind(o) != "path" {
1637            return None;
1638        }
1639        let steps = self.kids(o, "step");
1640        if steps.len() < 2 {
1641            return None;
1642        }
1643        // The projection must be the bare `::` (default value of
1644        // the JSON leaf), not `::key` or a `:::`/`:` metadata form.
1645        let proj = self.kid(o, "projection")?;
1646        if self.prop_s(proj, "kind") != "property" || self.prop(proj, "key").is_some() {
1647            return None;
1648        }
1649        let mut names = Vec::with_capacity(steps.len());
1650        for s in &steps {
1651            if self.prop_s(*s, "axis") != "/"
1652                || self.prop_s(*s, "matcher-kind") != "name"
1653                || self.kid(*s, "predicate").is_some()
1654            {
1655                return None;
1656            }
1657            let name = self.prop_s(*s, "matcher");
1658            // Plain object keys only — no array indices, no
1659            // characters that would need per-dialect path escaping.
1660            let plain = !name.is_empty()
1661                && name
1662                    .chars()
1663                    .next()
1664                    .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
1665                && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
1666            if !plain {
1667                return None;
1668            }
1669            names.push(name);
1670        }
1671        let col = names.remove(0);
1672        Some((col, names))
1673    }
1674
1675    /// A safe `AS` alias: bare when it is a plain identifier and
1676    /// not an SQL keyword; otherwise strict mode refuses (quoting
1677    /// dialects disagree) and export mode double-quotes, noting it.
1678    fn alias(&mut self, name: &str) -> Result<String, SqlError> {
1679        if is_plain_ident(name) {
1680            return Ok(name.to_string());
1681        }
1682        if self.strict {
1683            return Err(SqlError::Unsupported(format!(
1684                "pushdown: field name {name:?} needs SQL quoting \
1685                 (dialects disagree); rename the field"
1686            )));
1687        }
1688        self.notes
1689            .push(format!("field name {name:?} double-quoted (ANSI)"));
1690        Ok(format!("\"{}\"", name.replace('"', "\"\"")))
1691    }
1692
1693    /// A table or column name rendered into SQL. Bare when it is a
1694    /// plain identifier; otherwise strict mode refuses — a name
1695    /// like `a OR b` would silently rewrite the emitted SQL's
1696    /// meaning, breaking the provably-identical guarantee (and a
1697    /// portable quoting does not exist) — and export mode
1698    /// double-quotes with a note.
1699    fn sql_ident(&mut self, name: &str, what: &str) -> Result<String, SqlError> {
1700        if is_plain_ident(name) {
1701            return Ok(name.to_string());
1702        }
1703        if self.strict {
1704            return Err(SqlError::Unsupported(format!(
1705                "pushdown: {what} {name:?} is not a plain SQL identifier"
1706            )));
1707        }
1708        self.notes
1709            .push(format!("{what} {name:?} double-quoted (ANSI)"));
1710        Ok(format!("\"{}\"", name.replace('"', "\"\"")))
1711    }
1712
1713    /// Split a joined-side predicate: `col = $*1::col2` equalities
1714    /// become the ON condition; everything else the WHERE.
1715    /// `ltable` and `rtable` are the tables' SQL renderings.
1716    fn split_join_pred(
1717        &mut self,
1718        p: NodeId,
1719        rtable: &str,
1720        on: &mut Vec<String>,
1721        wheres: &mut Vec<String>,
1722    ) -> Result<(), SqlError> {
1723        if self.prop_s(p, "kind") != "expr" {
1724            return Err(SqlError::Unsupported(
1725                "a positional predicate on the joined side".into(),
1726            ));
1727        }
1728        // Flatten top-level ANDs; each conjunct routes to ON or
1729        // WHERE.
1730        fn conjuncts(ex: &Exporter, e: NodeId, out: &mut Vec<NodeId>) {
1731            if ex.kind(e) == "and" {
1732                for c in ex.arbor.children(e) {
1733                    conjuncts(ex, c, out);
1734                }
1735            } else {
1736                out.push(e);
1737            }
1738        }
1739        let mut parts = Vec::new();
1740        for c in self.arbor.children(p) {
1741            conjuncts(self, c, &mut parts);
1742        }
1743        for e in parts {
1744            let uses_driver = self.subtree_has_driver(e);
1745            self.in_on = true;
1746            let cond = self.pred_expr(e, Some(rtable));
1747            self.in_on = false;
1748            let cond = cond?;
1749            if uses_driver && self.kind(e) == "compare" && self.prop_s(e, "op") == "=" {
1750                // Record which joined-table columns the ON binds —
1751                // the uniqueness obligation (see
1752                // Pushdown::join_left). Collected from the arbor's
1753                // bare-column operand nodes, never from the
1754                // rendered SQL text, so neither a literal nor an
1755                // unusual column name can corrupt the obligation.
1756                self.collect_join_cols(e)?;
1757                on.push(cond);
1758            } else {
1759                wheres.push(cond);
1760            }
1761        }
1762        Ok(())
1763    }
1764
1765    /// The joined-table columns the ON equality binds: every bare
1766    /// column operand (`::col`) in `e`'s subtree, appended to the
1767    /// join obligation with their raw (catalog) names.
1768    fn collect_join_cols(&mut self, e: NodeId) -> Result<(), SqlError> {
1769        // The `$$0` side is the driver — not part of the joined
1770        // table's obligation.
1771        if self.is_driver(e) {
1772            return Ok(());
1773        }
1774        if self.kind(e) == "path"
1775            && self.kid(e, "step").is_none()
1776            && let Some(p) = self.kid(e, "projection")
1777        {
1778            let col = self.projection_col(p)?;
1779            self.join_on_cols.push(col);
1780        }
1781        for c in self.arbor.children(e) {
1782            self.collect_join_cols(c)?;
1783        }
1784        Ok(())
1785    }
1786
1787    /// `$$0`: a context operand of index 0 (the driver).
1788    fn is_driver(&self, n: NodeId) -> bool {
1789        self.kind(n) == "context" && matches!(self.prop(n, "index"), Some(Value::Int(0)))
1790    }
1791
1792    fn subtree_has_driver(&self, n: NodeId) -> bool {
1793        self.is_driver(n)
1794            || self
1795                .arbor
1796                .children(n)
1797                .into_iter()
1798                .any(|c| self.subtree_has_driver(c))
1799    }
1800
1801    /// Consume the pipeline into SELECT clauses.
1802    fn pipeline(
1803        &mut self,
1804        q: NodeId,
1805        sel: &mut Select,
1806        join: Option<(&str, &str)>,
1807    ) -> Result<(), SqlError> {
1808        let Some(pipe) = self.kid(q, "pipeline") else {
1809            return Ok(());
1810        };
1811        let commands: Vec<NodeId> = self.arbor.children(pipe);
1812        let mut i = 0;
1813        // A pending per-row value (`| ::col`) feeding an aggregate,
1814        // and the column it names where it is one.
1815        let mut pending_col: Option<String> = None;
1816        let mut pending_name: Option<String> = None;
1817        // After a grouped reduction: (key, agg_sql, alias).
1818        let mut grouped: Option<(String, String, String)> = None;
1819
1820        while i < commands.len() {
1821            let s = commands[i];
1822            match self.kind(s).as_str() {
1823                "expr" => {
1824                    let kids = self.arbor.children(s);
1825                    pending_col = Some(self.operand(kids[0], join.map(|(l, _)| l))?);
1826                    pending_name = self.column_operand(kids[0]);
1827                }
1828                "cmd" => {
1829                    let name = self.prop_s(s, "name");
1830                    match name.as_str() {
1831                        "rec" | "record" => {
1832                            sel.select = self.record_fields(s, join)?;
1833                        }
1834                        // A reducing aggregate on the plain pipe:
1835                        // the grouped reduction.
1836                        "count" | "sum" | "mean" | "avg" | "min" | "max" => {
1837                            let (key, _, _) = grouped.as_ref().ok_or_else(|| {
1838                                SqlError::Unsupported(format!(
1839                                    "'| {name}' outside a group (use '@| {name}')"
1840                                ))
1841                            })?;
1842                            // Quarb's count counts every member,
1843                            // nulls included: COUNT(*), never the
1844                            // NULL-skipping COUNT(col).
1845                            let col = if name == "count" {
1846                                pending_col = None;
1847                                None
1848                            } else {
1849                                self.assume_numeric(pending_name.take());
1850                                pending_col.take()
1851                            };
1852                            let agg = sql_agg(&name, col)?;
1853                            grouped = Some((key.clone(), agg.clone(), agg));
1854                        }
1855                        other => {
1856                            // sort/unique/top and kin are spellable
1857                            // in SQL but with the backend's own
1858                            // collation and duplicate semantics —
1859                            // name the divergence, not a missing
1860                            // feature.
1861                            if matches!(other, "sort" | "sort_by" | "unique" | "top" | "bottom") {
1862                                return Err(SqlError::Semantics(format!(
1863                                    "'{other}' means the backend's collation and \
1864                                     duplicate semantics; one engine-side ordering \
1865                                     keeps it identical everywhere"
1866                                )));
1867                            }
1868                            return Err(SqlError::Unsupported(format!(
1869                                "the '{other}' pipeline command"
1870                            )));
1871                        }
1872                    }
1873                }
1874                "push" => {
1875                    // An alias for the grouped aggregate.
1876                    if let Some((k, agg, _)) = grouped.take() {
1877                        let alias = self.prop_s(s, "name");
1878                        grouped = Some((k, agg, alias));
1879                    } else {
1880                        return Err(SqlError::Unsupported(
1881                            "a register push (Quarb-side state)".into(),
1882                        ));
1883                    }
1884                }
1885                "filter" => {
1886                    if grouped.is_some() {
1887                        // HAVING: `$_` / the alias refer to the
1888                        // aggregate.
1889                        let cond = self.having_cond(s)?;
1890                        sel.having = Some(cond);
1891                    } else {
1892                        return Err(SqlError::Unsupported(
1893                            "a mid-pipeline filter (put it in the row predicate)".into(),
1894                        ));
1895                    }
1896                }
1897                "recall" => {
1898                    // `| %.` finalizes the grouped record.
1899                    if self.prop_s(s, "ref") != "%." {
1900                        return Err(SqlError::Unsupported(
1901                            "a register recall (Quarb-side state)".into(),
1902                        ));
1903                    }
1904                    let (key, agg, alias) = grouped.clone().ok_or_else(|| {
1905                        SqlError::Unsupported("'%.', with nothing grouped".into())
1906                    })?;
1907                    let alias = self.alias(&alias)?;
1908                    sel.select = vec![key.clone(), format!("{agg} AS {alias}")];
1909                    // The HAVING condition compared the aggregate
1910                    // through $_ — substitute the real expression.
1911                    if let Some(h) = sel.having.take() {
1912                        sel.having = Some(h.replace("__AGG__", &agg));
1913                    }
1914                }
1915                "agg" => {
1916                    let name = self.prop_s(s, "name");
1917                    match name.as_str() {
1918                        "count" | "sum" | "mean" | "avg" | "min" | "max" => {
1919                            // Quarb's count counts every row, nulls
1920                            // included: COUNT(*) regardless of a
1921                            // pending column.
1922                            let col = if name == "count" {
1923                                pending_col = None;
1924                                None
1925                            } else {
1926                                self.assume_numeric(pending_name.take());
1927                                pending_col.take()
1928                            };
1929                            sel.select = vec![sql_agg(&name, col)?];
1930                            self.aggregate = true;
1931                        }
1932                        "group" => {
1933                            if self.strict {
1934                                return Err(SqlError::Semantics(
1935                                    "GROUP BY result order is unordered in SQL".into(),
1936                                ));
1937                            }
1938                            self.notes.push(
1939                                "GROUP BY: SQL keeps a NULL-key group; Quarb's group \
1940                                 drops null keys"
1941                                    .to_string(),
1942                            );
1943                            let key = self.group_key(s, join)?;
1944                            sel.group_by = Some(key.clone());
1945                            grouped = Some((key, String::new(), String::new()));
1946                        }
1947                        "sort_by" => {
1948                            if self.strict {
1949                                return Err(SqlError::Semantics(
1950                                    "ORDER BY collations differ per engine".into(),
1951                                ));
1952                            }
1953                            // A sort after a positional selection
1954                            // (or a second sort) cannot render: the
1955                            // fixed SELECT shape orders before
1956                            // LIMIT, and has one ORDER BY.
1957                            if sel.limit.is_some() {
1958                                return Err(SqlError::Unsupported(
1959                                    "a sort after a positional selection (SQL orders \
1960                                     before LIMIT)"
1961                                        .into(),
1962                                ));
1963                            }
1964                            if sel.order_by.is_some() {
1965                                return Err(SqlError::Unsupported(
1966                                    "a second sort (SQL has a single ORDER BY)".into(),
1967                                ));
1968                            }
1969                            let kids = self.arbor.children(s);
1970                            let e = self.operand(kids[0], join.map(|(l, _)| l))?;
1971                            sel.order_by = Some((e, false));
1972                        }
1973                        "reverse" => match &mut sel.order_by {
1974                            Some((_, desc)) => *desc = true,
1975                            None => {
1976                                return Err(SqlError::Unsupported(
1977                                    "reverse without an ORDER BY (rows are unordered)".into(),
1978                                ));
1979                            }
1980                        },
1981                        "top" => {
1982                            if self.strict {
1983                                return Err(SqlError::Semantics(
1984                                    "ORDER BY collations differ per engine".into(),
1985                                ));
1986                            }
1987                            if sel.limit.is_some() {
1988                                return Err(SqlError::Unsupported(
1989                                    "'top' after a positional selection (SQL orders \
1990                                     before LIMIT)"
1991                                        .into(),
1992                                ));
1993                            }
1994                            if sel.order_by.is_some() {
1995                                return Err(SqlError::Unsupported(
1996                                    "a second sort (SQL has a single ORDER BY)".into(),
1997                                ));
1998                            }
1999                            let kids = self.arbor.children(s);
2000                            let n = self.prop_s(kids[0], "value");
2001                            let e = self.operand(kids[1], join.map(|(l, _)| l))?;
2002                            sel.order_by = Some((e, true));
2003                            sel.limit = Some(n);
2004                        }
2005                        "unique" => {
2006                            if self.strict {
2007                                return Err(SqlError::Unsupported(
2008                                    "pushdown: DISTINCT result order is unordered in SQL".into(),
2009                                ));
2010                            }
2011                            // Quarb dedups the limited rows; SQL
2012                            // applies DISTINCT before LIMIT.
2013                            if sel.limit.is_some() {
2014                                return Err(SqlError::Unsupported(
2015                                    "'unique' after a positional selection (SQL applies \
2016                                     DISTINCT before LIMIT)"
2017                                        .into(),
2018                                ));
2019                            }
2020                            if let Some(c) = pending_col.take() {
2021                                sel.select = vec![c];
2022                            }
2023                            sel.distinct = true;
2024                        }
2025                        other => {
2026                            return Err(SqlError::Unsupported(format!("the '{other}' aggregate")));
2027                        }
2028                    }
2029                }
2030                "select" => {
2031                    if self.strict {
2032                        return Err(SqlError::Unsupported(
2033                            "pushdown: LIMIT without a guaranteed order".into(),
2034                        ));
2035                    }
2036                    // `@| [..n]` → LIMIT.
2037                    if sel.limit.is_some() {
2038                        return Err(SqlError::Unsupported(
2039                            "a second positional selection".into(),
2040                        ));
2041                    }
2042                    let p = self.arbor.children(s)[0];
2043                    match (self.prop_s(p, "kind").as_str(), self.prop(p, "to")) {
2044                        ("range", Some(Value::Int(n)))
2045                            if self.prop(p, "from").is_none() && n > 0 =>
2046                        {
2047                            sel.limit = Some(n.to_string());
2048                        }
2049                        _ => {
2050                            return Err(SqlError::Unsupported(
2051                                "positional selection beyond '@| [..n]'".into(),
2052                            ));
2053                        }
2054                    }
2055                }
2056                other => {
2057                    return Err(SqlError::Unsupported(format!(
2058                        "the '{other}' command (windows, subcontexts, and registers are \
2059                         Quarb-side state)"
2060                    )));
2061                }
2062            }
2063            i += 1;
2064        }
2065        // A pending column with no aggregate is a one-column select.
2066        if let Some(c) = pending_col
2067            && sel.select.is_empty()
2068        {
2069            sel.select = vec![c];
2070        }
2071        Ok(())
2072    }
2073
2074    fn group_key(&mut self, s: NodeId, join: Option<(&str, &str)>) -> Result<String, SqlError> {
2075        let kids = self.arbor.children(s);
2076        // group(::k) or group("name", expr) — the key expression is
2077        // the last child; a literal first child is its name.
2078        let key = kids
2079            .iter()
2080            .rev()
2081            .find(|&&k| self.kind(k) != "literal")
2082            .ok_or_else(|| SqlError::Unsupported("a literal group key".into()))?;
2083        self.operand(*key, join.map(|(l, _)| l))
2084    }
2085
2086    /// A HAVING filter: `$_` and `$.name` refer to the aggregate.
2087    fn having_cond(&mut self, s: NodeId) -> Result<String, SqlError> {
2088        let kids = self.arbor.children(s);
2089        if kids.len() != 1 || self.kind(kids[0]) != "compare" {
2090            return Err(SqlError::Unsupported(
2091                "HAVING translates for a single comparison".into(),
2092            ));
2093        }
2094        let e = kids[0];
2095        let op = self.prop_s(e, "op");
2096        let cmp_kids = self.arbor.children(e);
2097        let l = match self.kind(cmp_kids[0]).as_str() {
2098            "topic" | "recall" => "__AGG__".to_string(),
2099            _ => {
2100                return Err(SqlError::Unsupported(
2101                    "HAVING compares the aggregate ($_ or its register)".into(),
2102                ));
2103            }
2104        };
2105        let r = self.operand(cmp_kids[1], None)?;
2106        Ok(format!("{l} {op} {r}"))
2107    }
2108
2109    /// `rec(...)` fields as a select list.
2110    fn record_fields(
2111        &mut self,
2112        s: NodeId,
2113        join: Option<(&str, &str)>,
2114    ) -> Result<Vec<String>, SqlError> {
2115        let kids = self.arbor.children(s);
2116        let mut fields = Vec::new();
2117        let mut i = 0;
2118        while i < kids.len() {
2119            let k = kids[i];
2120            if self.kind(k) == "literal" {
2121                let name = self.prop_s(k, "value");
2122                let value = self.operand(kids[i + 1], join.map(|(l, _)| l))?;
2123                let name = self.alias(&name)?;
2124                fields.push(format!("{value} AS {name}"));
2125                i += 2;
2126            } else {
2127                let value = self.operand(k, join.map(|(l, _)| l))?;
2128                fields.push(value);
2129                i += 1;
2130            }
2131        }
2132        Ok(fields)
2133    }
2134}
2135
2136fn sql_agg(name: &str, col: Option<String>) -> Result<String, SqlError> {
2137    let f = match name {
2138        "count" => "COUNT",
2139        "sum" => "SUM",
2140        "mean" | "avg" => "AVG",
2141        "min" => "MIN",
2142        "max" => "MAX",
2143        _ => unreachable!("checked by caller"),
2144    };
2145    match col {
2146        Some(c) => Ok(format!("{f}({c})")),
2147        // Only COUNT aggregates bare rows; SUM(*) and friends are
2148        // not SQL.
2149        None if f == "COUNT" => Ok("COUNT(*)".to_string()),
2150        None => Err(SqlError::Unsupported(format!(
2151            "'{name}' over row nodes (project a column first: '| ::col @| {name}')"
2152        ))),
2153    }
2154}
2155
2156#[cfg(test)]
2157mod null_and_literal_tests {
2158    use super::{Dialect, export, partial_pushdown, pushdown};
2159
2160    // Grouped pipeline that never pushes, so `partial_pushdown` hinges
2161    // only on the leading predicate (mirrors the crate's partial gate).
2162    const GROUPED: &str = " | ::x @| group(\"g\", ::x) | count | .n | %.";
2163
2164    #[test]
2165    fn pattern_literal_pushes_as_like() {
2166        // Ruling #33: segments in order, a `%` per star, LIKE's
2167        // metacharacters escaped. Strict push only where LIKE is
2168        // provably case-sensitive (PostgreSQL); everywhere else the
2169        // pattern rides the scan.
2170        let q = "/users/*[::name == 'app-'*]::id";
2171        let p = pushdown(q, Some(Dialect::Postgres)).unwrap();
2172        assert_eq!(
2173            p.sql,
2174            "SELECT id FROM users WHERE name LIKE 'app-%' ESCAPE '\\'"
2175        );
2176        assert!(pushdown(q, Some(Dialect::Sqlite)).is_none());
2177        assert!(pushdown(q, Some(Dialect::MySql)).is_none());
2178        // contains and multi-segment shapes
2179        let p = pushdown("/users/*[::name == *'web'*]::id", Some(Dialect::Postgres)).unwrap();
2180        assert!(p.sql.contains("LIKE '%web%'"), "{}", p.sql);
2181        let p = pushdown(
2182            "/users/*[::name == *'a'*'.gz']::id",
2183            Some(Dialect::Postgres),
2184        )
2185        .unwrap();
2186        assert!(p.sql.contains("LIKE '%a%.gz'"), "{}", p.sql);
2187        // LIKE metacharacters in a segment are escaped
2188        let p = pushdown("/users/*[::name == *'50%_x'*]::id", Some(Dialect::Postgres)).unwrap();
2189        assert!(p.sql.contains("LIKE '%50\\%\\_x%'"), "{}", p.sql);
2190        // `!=` with a pattern rides the scan
2191        assert!(pushdown("/users/*[::name !== *'web'*]::id", Some(Dialect::Postgres)).is_none());
2192        // the `*=` alias gains the same PostgreSQL rung
2193        let p = pushdown("/users/*[::name *= 'web']::id", Some(Dialect::Postgres)).unwrap();
2194        assert!(p.sql.contains("LIKE '%web%'"), "{}", p.sql);
2195        assert!(pushdown("/users/*[::name *= 'web']::id", Some(Dialect::Sqlite)).is_none());
2196    }
2197
2198    #[test]
2199    fn json_column_path_pushdown_per_dialect() {
2200        // `[/col/a/b:: = 'lit']` navigates into a JSON column; each
2201        // dialect extracts the fixed path to text and compares.
2202        // Verified live against all five engines to match the graft.
2203        let q = "/orders/*[/data/meta/tier:: = 'gold']::id";
2204        let sql = |d| pushdown(q, Some(d)).unwrap().sql;
2205        assert_eq!(
2206            sql(Dialect::Postgres),
2207            "SELECT id FROM orders WHERE ((data::jsonb #>> '{meta,tier}') = 'gold')"
2208        );
2209        assert_eq!(
2210            sql(Dialect::MySql),
2211            "SELECT id FROM orders WHERE (JSON_VALID(data) AND \
2212             JSON_UNQUOTE(JSON_EXTRACT(data, '$.meta.tier')) = 'gold')"
2213        );
2214        assert_eq!(
2215            sql(Dialect::Sqlite),
2216            "SELECT id FROM orders WHERE (json_valid(data) AND \
2217             json_extract(data, '$.meta.tier') = 'gold')"
2218        );
2219        assert_eq!(
2220            sql(Dialect::Mssql),
2221            "SELECT id FROM orders WHERE (ISJSON(data) = 1 AND \
2222             JSON_VALUE(data, '$.meta.tier') = 'gold')"
2223        );
2224        assert_eq!(
2225            sql(Dialect::Oracle),
2226            "SELECT id FROM orders WHERE (JSON_VALUE(data, '$.meta.tier') = 'gold')"
2227        );
2228        // With no dialect, the JSON navigation is not pushable — it
2229        // falls back to the client-side graft.
2230        assert!(pushdown(q, None).is_none());
2231    }
2232
2233    #[test]
2234    fn json_pushdown_only_string_equality() {
2235        // Numeric comparison, `!=`, and a wildcard hop stay off the
2236        // pushdown path (they are not provably identical to the
2237        // graft), so they refuse and scan even with a dialect set.
2238        let d = Some(Dialect::Sqlite);
2239        assert!(pushdown("/o/*[/data/n:: > 2]::id", d).is_none());
2240        assert!(pushdown("/o/*[/data/tier:: != 'gold']::id", d).is_none());
2241        assert!(pushdown("/o/*[/data/items/*/sku:: = 'A1']::id", d).is_none());
2242    }
2243
2244    #[test]
2245    fn json_pushdown_refuses_numeric_looking_literal() {
2246        // value_eq coerces numeric-looking strings ('150' matches
2247        // the JSON number 150 and the string "150.0"); no engine's
2248        // extractor does, so the push would drop rows.
2249        let q = "/orders/*[/data/total:: = '150']::id";
2250        assert!(pushdown(q, Some(Dialect::Sqlite)).is_none());
2251        assert!(pushdown(q, Some(Dialect::Postgres)).is_none());
2252    }
2253
2254    #[test]
2255    fn partial_json_prefilters_sqlite() {
2256        // Exact string equality rides the strict gate.
2257        let p = partial_pushdown(
2258            "/orders/*[/payload/customer/geo/city:: = 'Lyon']::id",
2259            Some(Dialect::Sqlite),
2260        )
2261        .unwrap();
2262        assert_eq!(p.table, "orders");
2263        assert_eq!(
2264            p.where_sql,
2265            "(json_valid(payload) AND \
2266             json_extract(payload, '$.customer.geo.city') = 'Lyon')"
2267        );
2268        // Numeric comparison: typed compare, text values escape.
2269        let p = partial_pushdown(
2270            "/orders/*[/payload/total:: > 150]::id",
2271            Some(Dialect::Sqlite),
2272        )
2273        .unwrap();
2274        assert_eq!(
2275            p.where_sql,
2276            "(json_valid(payload) AND (json_extract(payload, '$.total') > 150 \
2277             OR json_type(payload, '$.total') = 'text'))"
2278        );
2279        // Bare existence.
2280        let p = partial_pushdown("/orders/*[/payload/gift]::id", Some(Dialect::Sqlite)).unwrap();
2281        assert_eq!(
2282            p.where_sql,
2283            "(json_valid(payload) AND json_type(payload, '$.gift') IS NOT NULL)"
2284        );
2285        // != floors at existence.
2286        let p = partial_pushdown(
2287            "/orders/*[/payload/status:: != 'x']::id",
2288            Some(Dialect::Sqlite),
2289        )
2290        .unwrap();
2291        assert_eq!(
2292            p.where_sql,
2293            "(json_valid(payload) AND json_type(payload, '$.status') IS NOT NULL)"
2294        );
2295        // A flipped literal flips the operator.
2296        let p = partial_pushdown(
2297            "/orders/*[150 < /payload/total::]::id",
2298            Some(Dialect::Sqlite),
2299        )
2300        .unwrap();
2301        assert!(p.where_sql.contains("> 150"), "{}", p.where_sql);
2302        // Without a dialect, JSON predicates stay on the engine.
2303        assert!(partial_pushdown("/orders/*[/payload/gift]::id", None).is_none());
2304    }
2305
2306    #[test]
2307    fn partial_json_prefilters_postgres() {
2308        let p = partial_pushdown(
2309            "/orders/*[/payload/total:: > 150]::id",
2310            Some(Dialect::Postgres),
2311        )
2312        .unwrap();
2313        assert_eq!(
2314            p.where_sql,
2315            "(CASE WHEN jsonb_typeof(payload::jsonb #> '{total}') = 'number' \
2316             THEN ((payload::jsonb #>> '{total}'))::float8 > 150 \
2317             WHEN jsonb_typeof(payload::jsonb #> '{total}') = 'string' \
2318             THEN true ELSE false END)"
2319        );
2320        let p = partial_pushdown("/orders/*[/payload/gift]::id", Some(Dialect::Postgres)).unwrap();
2321        assert_eq!(
2322            p.where_sql,
2323            "(jsonb_typeof(payload::jsonb #> '{gift}') IS NOT NULL)"
2324        );
2325    }
2326
2327    #[test]
2328    fn partial_skips_untranslatable_keeps_prefix() {
2329        // A refused predicate no prefilter covers is skipped — the
2330        // shorter conjunction is still a superset (the engine
2331        // re-applies the original) — rather than aborting the plan.
2332        let p = partial_pushdown(
2333            "/orders/*[::status = 'shipped'][/payload/line_items/*/sku:: = 'X']::id",
2334            Some(Dialect::Sqlite),
2335        )
2336        .unwrap();
2337        assert_eq!(p.where_sql, "status = 'shipped'");
2338    }
2339
2340    #[test]
2341    fn null_literal_compares_use_is_null() {
2342        // `= null` / `!= null` are provably identical to Quarb's
2343        // value_eq(NULL, …) semantics, never the always-UNKNOWN
2344        // `x = NULL`; they translate — and push — in both modes.
2345        assert_eq!(
2346            export("/t/*[::x = null] | ::x").unwrap().query,
2347            "SELECT x FROM t WHERE x IS NULL"
2348        );
2349        assert_eq!(
2350            export("/t/*[::x != null] | ::x").unwrap().query,
2351            "SELECT x FROM t WHERE x IS NOT NULL"
2352        );
2353        assert_eq!(
2354            pushdown("/t/*[::x = null] | ::x", None).unwrap().sql,
2355            "SELECT x FROM t WHERE x IS NULL"
2356        );
2357        assert_eq!(
2358            pushdown("/t/*[::x != null] | ::x", None).unwrap().sql,
2359            "SELECT x FROM t WHERE x IS NOT NULL"
2360        );
2361    }
2362
2363    #[test]
2364    fn ne_and_not_refuse_pushdown_but_display_diverges() {
2365        // `!=` against a non-null value and `not(...)` drop the NULL
2366        // rows Quarb keeps under SQL three-valued logic: the pushdown
2367        // paths refuse (and scan), full and partial alike.
2368        assert!(pushdown("/t/*[::x != 5] | ::x", None).is_none());
2369        assert!(pushdown("/t/*[!::x = 5] | ::x", None).is_none());
2370        assert!(partial_pushdown(&format!("/t/*[::x != 5]{GROUPED}"), None).is_none());
2371        assert!(partial_pushdown(&format!("/t/*[!::x = 5]{GROUPED}"), None).is_none());
2372        // The display translation still emits `<>`, flagged with a note.
2373        let t = export("/t/*[::x != 5] | ::x").unwrap();
2374        assert_eq!(t.query, "SELECT x FROM t WHERE x <> 5");
2375        assert!(t.notes.iter().any(|n| n.contains("NULL")));
2376    }
2377
2378    #[test]
2379    fn unescapable_text_literal_refuses_pushdown() {
2380        // A backslash (MySQL/BigQuery escape) or an apostrophe
2381        // (BigQuery rejects '' doubling) has no portable escaping, so
2382        // pushdown refuses; a clean literal still pushes.
2383        assert!(pushdown("/files/*[::path = \"C:\\temp\"] | ::path", None).is_none());
2384        assert!(pushdown("/t/*[::name = \"it's\"] | ::name", None).is_none());
2385        assert!(partial_pushdown(&format!("/t/*[::name = \"it's\"]{GROUPED}"), None).is_none());
2386        assert_eq!(
2387            pushdown("/t/*[::name = \"rare\"] | ::name", None)
2388                .unwrap()
2389                .sql,
2390            "SELECT name FROM t WHERE name = 'rare'"
2391        );
2392    }
2393}