Skip to main content

quarb_sql/
export.rs

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