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