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