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        from_table: String::new(),
46        join_on_left_cols: Vec::new(),
47        join_table: None,
48        aggregate: false,
49    };
50    let query = ex.query()?;
51    Ok(Translation {
52        query,
53        notes: ex.notes,
54    })
55}
56
57/// The exporter rewrites `__LEFT__` as an internal placeholder for
58/// the join's left table (and scrapes `__LEFT__.col` occurrences
59/// into the join obligation). Query text containing the marker
60/// would be rewritten inside its own string literals — and could
61/// spoof the obligation — so such queries stay on the scan path.
62fn refuse_marker(quarb: &str) -> Result<(), SqlError> {
63    if quarb.contains("__LEFT__") {
64        return Err(SqlError::Unsupported(
65            "query text contains the reserved marker \"__LEFT__\"".into(),
66        ));
67    }
68    Ok(())
69}
70
71/// A pushdown plan: SQL whose execution is provably identical to
72/// the Quarb query's — plus the table whose primary key must order
73/// the rows (`None` for a single aggregate row, where order is
74/// moot). The driver appends the `ORDER BY`, since the key lives in
75/// its catalog.
76pub struct Pushdown {
77    pub sql: String,
78    pub order_table: Option<String>,
79    /// Present when the plan contains a witness JOIN: the left
80    /// table and the left-side columns its ON equalities bind.
81    /// The plan is only sound if those columns form a unique key
82    /// of the left table (else SQL multiplies rows where Quarb's
83    /// existential binding does not) — the *driver* must verify
84    /// against its catalog before executing, and fall back to the
85    /// scan if it cannot.
86    pub join_left: Option<(String, Vec<String>)>,
87}
88
89/// Attempt the pushdown translation: `Some` only when every
90/// construct in the query is in the verified-safe set. Anything
91/// else — including everything `export` would merely annotate with
92/// a divergence note — returns `None`, and the caller scans.
93pub fn pushdown(quarb: &str) -> Option<Pushdown> {
94    pushdown_explained(quarb).ok()
95}
96
97/// [`pushdown`], keeping the refusal: the error names the first
98/// construct that kept the query on the scan path.
99pub fn pushdown_explained(quarb: &str) -> Result<Pushdown, SqlError> {
100    refuse_marker(quarb)?;
101    let arbor =
102        QueryArbor::parse(quarb).map_err(|e| SqlError::Syntax(format!("parsing Quarb: {e}")))?;
103    refuse_groups(&arbor)?;
104    let mut ex = Exporter {
105        arbor,
106        notes: Vec::new(),
107        strict: true,
108        from_table: String::new(),
109        join_on_left_cols: Vec::new(),
110        join_table: None,
111        aggregate: false,
112    };
113    let sql = ex.query()?;
114    let order_table = if ex.aggregate {
115        None
116    } else {
117        // Rows come back in the result context's document order:
118        // the joined table's under a correlation, else the FROM
119        // table's.
120        Some(ex.join_table.clone().unwrap_or(ex.from_table.clone()))
121    };
122    let join_left = ex
123        .join_table
124        .is_some()
125        .then(|| (ex.from_table.clone(), ex.join_on_left_cols.clone()));
126    Ok(Pushdown {
127        sql,
128        order_table,
129        join_left,
130    })
131}
132
133/// SQL keywords that must not appear as a bare `AS` alias —
134/// quoting them portably differs by dialect, so strict mode
135/// refuses and export mode quotes with double quotes plus a note.
136const ALIAS_KEYWORDS: &[&str] = &[
137    "all",
138    "and",
139    "as",
140    "asc",
141    "by",
142    "case",
143    "cross",
144    "desc",
145    "distinct",
146    "else",
147    "end",
148    "except",
149    "exists",
150    "from",
151    "group",
152    "having",
153    "in",
154    "index",
155    "inner",
156    "intersect",
157    "into",
158    "is",
159    "join",
160    "left",
161    "like",
162    "limit",
163    "not",
164    "null",
165    "offset",
166    "on",
167    "or",
168    "order",
169    "outer",
170    "right",
171    "select",
172    "set",
173    "table",
174    "then",
175    "union",
176    "unique",
177    "update",
178    "using",
179    "values",
180    "when",
181    "where",
182];
183
184/// The SELECT under construction.
185#[derive(Default)]
186struct Select {
187    select: Vec<String>,
188    distinct: bool,
189    from: String,
190    join: Option<(String, String)>, // (table, ON condition)
191    wheres: Vec<String>,
192    group_by: Option<String>,
193    having: Option<String>,
194    order_by: Option<(String, bool)>, // (expr, desc)
195    limit: Option<String>,
196}
197
198impl Select {
199    fn render(&self) -> String {
200        let mut out = String::from("SELECT ");
201        if self.distinct {
202            out.push_str("DISTINCT ");
203        }
204        if self.select.is_empty() {
205            out.push('*');
206        } else {
207            out.push_str(&self.select.join(", "));
208        }
209        out.push_str(&format!(" FROM {}", self.from));
210        if let Some((t, on)) = &self.join {
211            out.push_str(&format!(" JOIN {t} ON {on}"));
212        }
213        if !self.wheres.is_empty() {
214            out.push_str(&format!(" WHERE {}", self.wheres.join(" AND ")));
215        }
216        if let Some(g) = &self.group_by {
217            out.push_str(&format!(" GROUP BY {g}"));
218        }
219        if let Some(h) = &self.having {
220            out.push_str(&format!(" HAVING {h}"));
221        }
222        if let Some((e, desc)) = &self.order_by {
223            out.push_str(&format!(" ORDER BY {e}"));
224            if *desc {
225                out.push_str(" DESC");
226            }
227        }
228        if let Some(n) = &self.limit {
229            out.push_str(&format!(" LIMIT {n}"));
230        }
231        out
232    }
233}
234
235/// A partial-pushdown plan: the leading row predicates as a WHERE
236/// clause for `table`'s fetch. The caller runs the *original* query
237/// against the filtered adapter — the engine re-applies the pushed
238/// predicates (a no-op on pre-filtered rows), so no rewriting.
239pub struct Partial {
240    pub table: String,
241    pub where_sql: String,
242}
243
244/// Attempt a partial pushdown: `Some` when the query's leading row
245/// predicates translate strictly and the rest of the query provably
246/// cannot observe the filtering. The gates, each with its reason:
247/// a single `/table/*` branch with no correlations (other shapes
248/// address other data); only the *leading* run of expression
249/// predicates pushes (a positional predicate before an expression
250/// one sees unfiltered rows on the scan path); the table's name
251/// appears exactly once among the query's steps (a second reach —
252/// `^`-anchored subcontexts, self-references — would see the
253/// filtered subset); no crosslink or resolution axes anywhere
254/// (backlinks and reverse resolution into the table would too);
255/// and no `:::` / `::;` metadata anywhere (a filtered `::;n-rows`
256/// would lie).
257pub fn partial_pushdown(quarb: &str) -> Option<Partial> {
258    partial_pushdown_explained(quarb).ok()
259}
260
261/// [`partial_pushdown`], keeping the refusal reason.
262pub fn partial_pushdown_explained(quarb: &str) -> Result<Partial, SqlError> {
263    let arbor =
264        QueryArbor::parse(quarb).map_err(|e| SqlError::Syntax(format!("parsing Quarb: {e}")))?;
265    refuse_groups(&arbor)?;
266    let mut ex = Exporter {
267        arbor,
268        notes: Vec::new(),
269        strict: true,
270        from_table: String::new(),
271        join_on_left_cols: Vec::new(),
272        join_table: None,
273        aggregate: false,
274    };
275    ex.partial()
276}
277
278/// Refuse a query carrying path-pattern groups: neither the SQL
279/// translation nor the pushdown safe set covers them, and the shape
280/// checks below count only `step` children — an unguarded group
281/// would silently vanish from the translation. Refused queries fall
282/// back to the scan path, which evaluates groups correctly.
283fn refuse_groups(arbor: &QueryArbor) -> Result<(), SqlError> {
284    let mut stack = vec![arbor.root()];
285    while let Some(n) = stack.pop() {
286        if arbor.name(n).as_deref() == Some("group") {
287            return Err(SqlError::Unsupported(
288                "path patterns (groups and quantifiers)".into(),
289            ));
290        }
291        stack.extend(arbor.children(n));
292    }
293    Ok(())
294}
295
296struct Exporter {
297    arbor: QueryArbor,
298    notes: Vec<String>,
299    /// Pushdown mode: refuse every construct whose SQL semantics
300    /// are not provably identical to Quarb's (LIKE case folding,
301    /// truthiness, group/distinct/sort ordering).
302    strict: bool,
303    from_table: String,
304    join_on_left_cols: Vec<String>,
305    join_table: Option<String>,
306    aggregate: bool,
307}
308
309impl Exporter {
310    fn kids(&self, n: NodeId, kind: &str) -> Vec<NodeId> {
311        self.arbor
312            .children(n)
313            .into_iter()
314            .filter(|&c| self.arbor.name(c).as_deref() == Some(kind))
315            .collect()
316    }
317
318    fn kid(&self, n: NodeId, kind: &str) -> Option<NodeId> {
319        self.kids(n, kind).into_iter().next()
320    }
321
322    fn prop(&self, n: NodeId, key: &str) -> Option<Value> {
323        self.arbor.property(n, key)
324    }
325
326    fn prop_s(&self, n: NodeId, key: &str) -> String {
327        self.prop(n, key).map(|v| v.to_string()).unwrap_or_default()
328    }
329
330    fn kind(&self, n: NodeId) -> String {
331        self.arbor.name(n).unwrap_or_default()
332    }
333
334    /// Whether `n` is a bare `null` literal operand.
335    fn is_null_literal(&self, n: NodeId) -> bool {
336        self.kind(n) == "literal" && self.prop_s(n, "type") == "null"
337    }
338
339    /// The partial-pushdown analysis (see [`partial_pushdown`]).
340    fn partial(&mut self) -> Result<Partial, SqlError> {
341        let root = self.arbor.root();
342        let q = self
343            .kid(root, "query")
344            .ok_or_else(|| SqlError::Unsupported("empty query".into()))?;
345        if self.kid(q, "query").is_some() {
346            return Err(SqlError::Unsupported(
347                "partial pushdown: correlations address other tables".into(),
348            ));
349        }
350        let branches = self.kids(q, "branch");
351        if branches.len() != 1 {
352            return Err(SqlError::Unsupported(
353                "partial pushdown: a branch union".into(),
354            ));
355        }
356        let (table, preds) = self.table_branch(branches[0])?;
357
358        // Whole-query gates.
359        let all = self.walk_all(root);
360        let mut table_mentions = 0;
361        for n in &all {
362            match self.kind(*n).as_str() {
363                "step" => {
364                    let axis = self.prop_s(*n, "axis");
365                    if matches!(axis.as_str(), "->" | "<-" | "~>" | "<~") {
366                        return Err(SqlError::Unsupported(
367                            "partial pushdown: crosslink/resolution axes could reach \
368                             the filtered table"
369                                .into(),
370                        ));
371                    }
372                    if self.prop_s(*n, "matcher") == table {
373                        table_mentions += 1;
374                    }
375                }
376                "projection" if self.prop_s(*n, "kind") != "property" => {
377                    return Err(SqlError::Unsupported(
378                        "partial pushdown: metadata would observe the filtering \
379                         (::;n-rows, :::index)"
380                            .into(),
381                    ));
382                }
383                _ => {}
384            }
385        }
386        if table_mentions != 1 {
387            return Err(SqlError::Unsupported(
388                "partial pushdown: the table is reached more than once".into(),
389            ));
390        }
391
392        // The leading run of expression predicates, strictly
393        // translated.
394        let mut conds = Vec::new();
395        for p in preds {
396            if self.prop_s(p, "kind") != "expr" {
397                break;
398            }
399            conds.push(self.predicate_cond(p, None)?);
400        }
401        if conds.is_empty() {
402            return Err(SqlError::Unsupported(
403                "partial pushdown: no leading expression predicates to push".into(),
404            ));
405        }
406        Ok(Partial {
407            table,
408            where_sql: conds.join(" AND "),
409        })
410    }
411
412    fn walk_all(&self, n: NodeId) -> Vec<NodeId> {
413        let mut out = vec![n];
414        let mut i = 0;
415        while i < out.len() {
416            out.extend(self.arbor.children(out[i]));
417            i += 1;
418        }
419        out
420    }
421
422    fn query(&mut self) -> Result<String, SqlError> {
423        let root = self.arbor.root();
424        let q = self
425            .kid(root, "query")
426            .ok_or_else(|| SqlError::Unsupported("empty query".into()))?;
427        let mut sel = Select::default();
428
429        // Correlations: exactly one becomes the JOIN's left table.
430        let corrs = self.kids(q, "query");
431        let branches = self.kids(q, "branch");
432        if branches.len() != 1 {
433            return Err(SqlError::Unsupported(
434                "a branch union (SQL has UNION, but result shapes differ; export one branch)"
435                    .into(),
436            ));
437        }
438
439        if corrs.len() > 1 {
440            return Err(SqlError::Unsupported(
441                "more than one correlation context".into(),
442            ));
443        }
444        if let Some(corr) = corrs.first() {
445            // Left table from the correlation context.
446            let (ltable, lpreds) = self.table_branch(self.kids(*corr, "branch")[0])?;
447            if !lpreds.is_empty() {
448                return Err(SqlError::Unsupported(
449                    "predicates on the correlation context (put them on the joined side)".into(),
450                ));
451            }
452            let (rtable, rpreds) = self.table_branch(branches[0])?;
453            // Split the joined side's predicates: $*1 equalities
454            // form the ON, the rest the WHERE.
455            let mut on = Vec::new();
456            let mut wheres = Vec::new();
457            for p in rpreds {
458                self.split_join_pred(p, &ltable, &rtable, &mut on, &mut wheres)?;
459            }
460            if on.is_empty() {
461                return Err(SqlError::Unsupported(
462                    "a correlation without a '$*1' equality (no JOIN condition)".into(),
463                ));
464            }
465            self.notes.push(
466                "JOIN: Quarb's binding is existential (one row per joined-side row); \
467                 SQL multiplies rows when several left rows match"
468                    .to_string(),
469            );
470            sel.from = ltable.clone();
471            self.from_table = ltable.clone();
472            self.join_table = Some(rtable.clone());
473            sel.join = Some((rtable.clone(), on.join(" AND ")));
474            sel.wheres = wheres;
475            self.pipeline(q, &mut sel, Some((&ltable, &rtable)))?;
476        } else {
477            let (table, preds) = self.table_branch(branches[0])?;
478            sel.from = table.clone();
479            self.from_table = table.clone();
480            for p in preds {
481                let cond = self.predicate_cond(p, None)?;
482                sel.wheres.push(cond);
483            }
484            // A terminal projection is a one-column select.
485            if let Some(proj) = self.kid(branches[0], "projection") {
486                sel.select.push(self.projection_col(proj)?);
487            }
488            self.pipeline(q, &mut sel, None)?;
489        }
490        Ok(sel.render())
491    }
492
493    /// A `/table/*[preds]` branch: the table name and the row-step
494    /// predicate nodes.
495    fn table_branch(&mut self, b: NodeId) -> Result<(String, Vec<NodeId>), SqlError> {
496        let steps = self.kids(b, "step");
497        if steps.len() != 2 {
498            return Err(SqlError::Unsupported(
499                "navigation beyond /table/* (SQL sees tables and rows)".into(),
500            ));
501        }
502        let (t, rows) = (steps[0], steps[1]);
503        if self.prop_s(t, "axis") != "/"
504            || self.prop_s(t, "matcher-kind") != "name"
505            || self.prop_s(rows, "axis") != "/"
506            || self.prop_s(rows, "matcher-kind") != "any"
507        {
508            return Err(SqlError::Unsupported(
509                "navigation beyond /table/* (SQL sees tables and rows)".into(),
510            ));
511        }
512        if self.kid(t, "predicate").is_some() {
513            return Err(SqlError::Unsupported("a predicate on the table hop".into()));
514        }
515        Ok((self.prop_s(t, "matcher"), self.kids(rows, "predicate")))
516    }
517
518    /// One row predicate as a WHERE condition (qualify columns with
519    /// `qualifier` when joining).
520    fn predicate_cond(&mut self, p: NodeId, qual: Option<&str>) -> Result<String, SqlError> {
521        if self.prop_s(p, "kind") != "expr" {
522            return Err(SqlError::Unsupported(
523                "a positional predicate on rows (SQL rows are unordered; ORDER BY + LIMIT)".into(),
524            ));
525        }
526        let parts: Vec<String> = self
527            .arbor
528            .children(p)
529            .into_iter()
530            .map(|c| self.pred_expr(c, qual))
531            .collect::<Result<_, _>>()?;
532        Ok(parts.join(" AND "))
533    }
534
535    fn pred_expr(&mut self, e: NodeId, qual: Option<&str>) -> Result<String, SqlError> {
536        match self.kind(e).as_str() {
537            "and" | "or" => {
538                let op = self.kind(e).to_uppercase();
539                let kids: Vec<String> = self
540                    .arbor
541                    .children(e)
542                    .into_iter()
543                    .map(|c| self.pred_expr(c, qual))
544                    .collect::<Result<_, _>>()?;
545                Ok(format!("({})", kids.join(&format!(" {op} "))))
546            }
547            "not" => {
548                // SQL's `NOT` propagates UNKNOWN: `NOT (x = 5)` is
549                // UNKNOWN for a NULL `x` and drops the row, but Quarb's
550                // negation keeps it (the inner `value_eq` is false, so
551                // its negation is true). Not provably identical without
552                // the schema — the pushdown paths refuse it and scan.
553                if self.strict {
554                    return Err(SqlError::Unsupported(
555                        "pushdown: 'not(...)' drops the NULL rows Quarb keeps \
556                         (SQL NOT propagates UNKNOWN)"
557                            .into(),
558                    ));
559                }
560                let inner: Vec<String> = self
561                    .arbor
562                    .children(e)
563                    .into_iter()
564                    .map(|c| self.pred_expr(c, qual))
565                    .collect::<Result<_, _>>()?;
566                Ok(format!("NOT ({})", inner.join(" AND ")))
567            }
568            "parens" => {
569                let inner: Vec<String> = self
570                    .arbor
571                    .children(e)
572                    .into_iter()
573                    .map(|c| self.pred_expr(c, qual))
574                    .collect::<Result<_, _>>()?;
575                Ok(format!("({})", inner.join(" AND ")))
576            }
577            "compare" => {
578                let op = self.prop_s(e, "op");
579                let kids = self.arbor.children(e);
580                // A comparison against a bare `null` literal. Quarb's
581                // `value_eq` treats NULL as an ordinary value
582                // (`value_eq(NULL, NULL)` is true, `value_eq(NULL, x)`
583                // false), so `= null` keeps exactly the NULL rows and
584                // `!= null` the non-NULL rows. SQL's `= NULL` / `<>
585                // NULL` are always UNKNOWN and drop every row; the
586                // `IS [NOT] NULL` forms are provably identical (and
587                // portable across every target dialect).
588                if matches!(op.as_str(), "=" | "!=")
589                    && (self.is_null_literal(kids[0]) || self.is_null_literal(kids[1]))
590                {
591                    let other = if self.is_null_literal(kids[0]) {
592                        kids[1]
593                    } else {
594                        kids[0]
595                    };
596                    let col = self.operand(other, qual)?;
597                    return Ok(if op == "=" {
598                        format!("{col} IS NULL")
599                    } else {
600                        format!("{col} IS NOT NULL")
601                    });
602                }
603                let l = self.operand(kids[0], qual)?;
604                let r = self.operand(kids[1], qual)?;
605                Ok(match op.as_str() {
606                    "=" => format!("{l} = {r}"),
607                    "!=" => {
608                        // Quarb keeps rows whose operand is NULL (its
609                        // `value_eq` is false there, so `!=` is true);
610                        // SQL's `<>` is UNKNOWN for a NULL operand and
611                        // drops those rows. Not provably identical
612                        // without the schema, so pushdown refuses it;
613                        // the display translation keeps `<>` and notes
614                        // the divergence.
615                        if self.strict {
616                            return Err(SqlError::Unsupported(
617                                "pushdown: '!=' drops the NULL rows Quarb keeps \
618                                 (SQL '<>' is UNKNOWN for NULL; use '!= null' \
619                                 for IS NOT NULL)"
620                                    .into(),
621                            ));
622                        }
623                        self.notes.push(
624                            "'!=' → '<>': Quarb keeps rows whose column is NULL; \
625                             SQL's '<>' drops them (use '!= null' for IS NOT NULL)"
626                                .to_string(),
627                        );
628                        format!("{l} <> {r}")
629                    }
630                    "<" | "<=" | ">" | ">=" => format!("{l} {op} {r}"),
631                    "*=" => {
632                        if self.strict {
633                            return Err(SqlError::Unsupported(
634                                "pushdown: LIKE case folding differs per engine".into(),
635                            ));
636                        }
637                        self.notes.push(
638                            "*= → LIKE: Quarb's substring test is case-sensitive; LIKE \
639                             folds case on SQLite/MySQL but not PostgreSQL"
640                                .to_string(),
641                        );
642                        let pat = r.trim_matches('\'').replace('%', "\\%").replace('_', "\\_");
643                        format!("{l} LIKE '%{pat}%'")
644                    }
645                    "=~" | "!~" => {
646                        return Err(SqlError::Unsupported(
647                            "regex matching (REGEXP dialects disagree; use *= or spell \
648                             the SQL by hand)"
649                                .into(),
650                        ));
651                    }
652                    other => {
653                        return Err(SqlError::Unsupported(format!("the '{other}' comparison")));
654                    }
655                })
656            }
657            // A bare truthy operand.
658            _ => {
659                if self.strict {
660                    return Err(SqlError::Unsupported(
661                        "pushdown: truthiness diverges (0 and '' are falsy in Quarb)".into(),
662                    ));
663                }
664                self.notes.push(
665                    "truthiness: '[::c]' exports as IS NOT NULL, but Quarb also treats \
666                     0 and '' as falsy"
667                        .to_string(),
668                );
669                Ok(format!("{} IS NOT NULL", self.operand(e, qual)?))
670            }
671        }
672    }
673
674    fn operand(&mut self, o: NodeId, qual: Option<&str>) -> Result<String, SqlError> {
675        match self.kind(o).as_str() {
676            "literal" => {
677                let v = self.prop(o, "value").unwrap_or(Value::Null);
678                match self.prop_s(o, "type").as_str() {
679                    "text" => {
680                        let s = v.to_string();
681                        // No single escaping is portable across the
682                        // pushdown's target dialects: MySQL (default
683                        // sql_mode) and BigQuery read `\` as an escape
684                        // while SQLite/PostgreSQL/DuckDB take it
685                        // literally, and BigQuery rejects the `''`
686                        // quote-doubling the others require. A literal
687                        // carrying either character cannot be pushed as
688                        // provably identical SQL, so refuse it and let
689                        // the caller scan. (The display translation
690                        // keeps its best-effort `''`-doubling.)
691                        if self.strict && (s.contains('\'') || s.contains('\\')) {
692                            return Err(SqlError::Unsupported(
693                                "pushdown: a text literal with a quote or backslash \
694                                 has no escaping portable across SQL dialects"
695                                    .into(),
696                            ));
697                        }
698                        Ok(format!("'{}'", s.replace('\'', "''")))
699                    }
700                    "null" => Ok("NULL".to_string()),
701                    _ => Ok(v.to_string()),
702                }
703            }
704            "path" => {
705                if self.kid(o, "step").is_some() {
706                    // A step here is either navigation (refused) or
707                    // a resolution chain (refused with the reason).
708                    let s = self.kids(o, "step")[0];
709                    if self.prop_s(s, "axis") == "~>" {
710                        return Err(SqlError::Unsupported(
711                            "a '~>' resolution chain: the foreign-key targets live in \
712                             the schema, not the query — spell the join with '<=>' to \
713                             export it"
714                                .into(),
715                        ));
716                    }
717                    return Err(SqlError::Unsupported(
718                        "navigation inside a predicate (SQL rows are flat)".into(),
719                    ));
720                }
721                let p = self
722                    .kid(o, "projection")
723                    .ok_or_else(|| SqlError::Unsupported("an empty path operand".into()))?;
724                let col = self.projection_col(p)?;
725                Ok(match qual {
726                    Some(q) => format!("{q}.{col}"),
727                    None => col,
728                })
729            }
730            "context" => {
731                // `$*k::col` — k names the correlation operand: 1 is
732                // the left/FROM side, 2 the joined side (the `qual`
733                // table in a join context). Anything else is outside
734                // the verified-safe set.
735                let p = self
736                    .kid(o, "projection")
737                    .ok_or_else(|| SqlError::Unsupported("a bare '$*' reference".into()))?;
738                let col = self.projection_col(p)?;
739                match self.prop(o, "index") {
740                    None | Some(Value::Int(1)) => Ok(format!("__LEFT__.{col}")),
741                    Some(Value::Int(2)) => match qual {
742                        Some(q) => Ok(format!("{q}.{col}")),
743                        None => Err(SqlError::Unsupported(
744                            "$*2 outside a correlation join".into(),
745                        )),
746                    },
747                    Some(v) => Err(SqlError::Unsupported(format!(
748                        "pushdown: $*{v} beyond a two-branch correlation"
749                    ))),
750                }
751            }
752            "arith" => {
753                let op = self.prop_s(o, "op");
754                let kids = self.arbor.children(o);
755                let l = self.operand(kids[0], qual)?;
756                let r = self.operand(kids[1], qual)?;
757                Ok(match op.as_str() {
758                    "+" | "-" | "*" => format!("({l} {op} {r})"),
759                    "div" => format!("({l} / {r})"),
760                    "mod" => format!("({l} % {r})"),
761                    other => return Err(SqlError::Unsupported(format!("'{other}' arithmetic"))),
762                })
763            }
764            other => Err(SqlError::Unsupported(format!(
765                "the '{other}' operand (registers, topics, and captures are Quarb-side state)"
766            ))),
767        }
768    }
769
770    fn projection_col(&mut self, p: NodeId) -> Result<String, SqlError> {
771        match self.prop_s(p, "kind").as_str() {
772            "property" => match self.prop(p, "key") {
773                Some(k) => Ok(k.to_string()),
774                None => Err(SqlError::Unsupported(
775                    "the bare '::' projection (name the column)".into(),
776                )),
777            },
778            other => Err(SqlError::Unsupported(format!(
779                "the {other} metadata projection"
780            ))),
781        }
782    }
783
784    /// Split a joined-side predicate: `col = $*1::col2` equalities
785    /// become the ON condition; everything else the WHERE.
786    /// A safe `AS` alias: bare when it is a plain identifier and
787    /// not an SQL keyword; otherwise strict mode refuses (quoting
788    /// dialects disagree) and export mode double-quotes, noting it.
789    fn alias(&mut self, name: &str) -> Result<String, SqlError> {
790        let plain = !name.is_empty()
791            && name.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
792            && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
793            && !ALIAS_KEYWORDS.contains(&name.to_ascii_lowercase().as_str());
794        if plain {
795            return Ok(name.to_string());
796        }
797        if self.strict {
798            return Err(SqlError::Unsupported(format!(
799                "pushdown: field name {name:?} needs SQL quoting \
800                 (dialects disagree); rename the field"
801            )));
802        }
803        self.notes
804            .push(format!("field name {name:?} double-quoted (ANSI)"));
805        Ok(format!("\"{}\"", name.replace('"', "\"\"")))
806    }
807
808    fn split_join_pred(
809        &mut self,
810        p: NodeId,
811        ltable: &str,
812        rtable: &str,
813        on: &mut Vec<String>,
814        wheres: &mut Vec<String>,
815    ) -> Result<(), SqlError> {
816        if self.prop_s(p, "kind") != "expr" {
817            return Err(SqlError::Unsupported(
818                "a positional predicate on the joined side".into(),
819            ));
820        }
821        // Flatten top-level ANDs; each conjunct routes to ON or
822        // WHERE.
823        fn conjuncts(ex: &Exporter, e: NodeId, out: &mut Vec<NodeId>) {
824            if ex.kind(e) == "and" {
825                for c in ex.arbor.children(e) {
826                    conjuncts(ex, c, out);
827                }
828            } else {
829                out.push(e);
830            }
831        }
832        let mut parts = Vec::new();
833        for c in self.arbor.children(p) {
834            conjuncts(self, c, &mut parts);
835        }
836        for e in parts {
837            let uses_ctx = self.subtree_has(e, "context");
838            let raw = self.pred_expr(e, Some(rtable))?;
839            let cond = raw.replace("__LEFT__", ltable);
840            if uses_ctx && self.kind(e) == "compare" && self.prop_s(e, "op") == "=" {
841                // Record which left-table columns the ON binds —
842                // the driver's uniqueness obligation (see
843                // Pushdown::join_left). Captured from the
844                // pre-substitution marker so literals can't fake
845                // a match.
846                for cap in raw.split("__LEFT__.").skip(1) {
847                    let col: String = cap
848                        .chars()
849                        .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
850                        .collect();
851                    if !col.is_empty() {
852                        self.join_on_left_cols.push(col);
853                    }
854                }
855                on.push(cond);
856            } else {
857                wheres.push(cond);
858            }
859        }
860        Ok(())
861    }
862
863    fn subtree_has(&self, n: NodeId, kind: &str) -> bool {
864        if self.kind(n) == kind {
865            return true;
866        }
867        self.arbor
868            .children(n)
869            .into_iter()
870            .any(|c| self.subtree_has(c, kind))
871    }
872
873    /// Consume the pipeline into SELECT clauses.
874    fn pipeline(
875        &mut self,
876        q: NodeId,
877        sel: &mut Select,
878        join: Option<(&str, &str)>,
879    ) -> Result<(), SqlError> {
880        let Some(pipe) = self.kid(q, "pipeline") else {
881            return Ok(());
882        };
883        let stages: Vec<NodeId> = self.arbor.children(pipe);
884        let mut i = 0;
885        // A pending per-row value (`| ::col`) feeding an aggregate.
886        let mut pending_col: Option<String> = None;
887        // After a grouped reduction: (key, agg_sql, alias).
888        let mut grouped: Option<(String, String, String)> = None;
889
890        while i < stages.len() {
891            let s = stages[i];
892            match self.kind(s).as_str() {
893                "expr" => {
894                    let kids = self.arbor.children(s);
895                    pending_col = Some(self.operand(kids[0], join.map(|(_, r)| r))?);
896                }
897                "func" => {
898                    let name = self.prop_s(s, "name");
899                    match name.as_str() {
900                        "rec" | "record" => {
901                            sel.select = self.record_fields(s, join)?;
902                        }
903                        // A reducing aggregate on the plain pipe:
904                        // the grouped reduction.
905                        "count" | "sum" | "mean" | "avg" | "min" | "max" => {
906                            let (key, _, _) = grouped.as_ref().ok_or_else(|| {
907                                SqlError::Unsupported(format!(
908                                    "'| {name}' outside a group (use '@| {name}')"
909                                ))
910                            })?;
911                            let agg = sql_agg(&name, pending_col.take());
912                            grouped = Some((key.clone(), agg.clone(), agg));
913                        }
914                        other => {
915                            return Err(SqlError::Unsupported(format!(
916                                "the '{other}' pipeline function"
917                            )));
918                        }
919                    }
920                }
921                "push" => {
922                    // An alias for the grouped aggregate.
923                    if let Some((k, agg, _)) = grouped.take() {
924                        let alias = self.prop_s(s, "name");
925                        grouped = Some((k, agg, alias));
926                    } else {
927                        return Err(SqlError::Unsupported(
928                            "a register push (Quarb-side state)".into(),
929                        ));
930                    }
931                }
932                "filter" => {
933                    if grouped.is_some() {
934                        // HAVING: `$_` / the alias refer to the
935                        // aggregate.
936                        let cond = self.having_cond(s)?;
937                        sel.having = Some(cond);
938                    } else {
939                        return Err(SqlError::Unsupported(
940                            "a mid-pipeline filter (put it in the row predicate)".into(),
941                        ));
942                    }
943                }
944                "recall" => {
945                    // `| %.` finalizes the grouped record.
946                    if self.prop_s(s, "ref") != "%." {
947                        return Err(SqlError::Unsupported(
948                            "a register recall (Quarb-side state)".into(),
949                        ));
950                    }
951                    let (key, agg, alias) = grouped.clone().ok_or_else(|| {
952                        SqlError::Unsupported("'%.', with nothing grouped".into())
953                    })?;
954                    let alias = self.alias(&alias)?;
955                    sel.select = vec![key.clone(), format!("{agg} AS {alias}")];
956                    // The HAVING condition compared the aggregate
957                    // through $_ — substitute the real expression.
958                    if let Some(h) = sel.having.take() {
959                        sel.having = Some(h.replace("__AGG__", &agg));
960                    }
961                }
962                "agg" => {
963                    let name = self.prop_s(s, "name");
964                    match name.as_str() {
965                        "count" | "sum" | "mean" | "avg" | "min" | "max" => {
966                            // Quarb's count counts every row, nulls
967                            // included: COUNT(*) regardless of a
968                            // pending column.
969                            let col = if name == "count" {
970                                pending_col = None;
971                                None
972                            } else {
973                                pending_col.take()
974                            };
975                            sel.select = vec![sql_agg(&name, col)];
976                            self.aggregate = true;
977                        }
978                        "group" => {
979                            if self.strict {
980                                return Err(SqlError::Unsupported(
981                                    "pushdown: GROUP BY result order is unordered in SQL".into(),
982                                ));
983                            }
984                            let key = self.group_key(s, join)?;
985                            sel.group_by = Some(key.clone());
986                            grouped = Some((key, String::new(), String::new()));
987                        }
988                        "sort_by" => {
989                            if self.strict {
990                                return Err(SqlError::Unsupported(
991                                    "pushdown: ORDER BY collations differ per engine".into(),
992                                ));
993                            }
994                            let kids = self.arbor.children(s);
995                            let e = self.operand(kids[0], join.map(|(_, r)| r))?;
996                            sel.order_by = Some((e, false));
997                        }
998                        "reverse" => match &mut sel.order_by {
999                            Some((_, desc)) => *desc = true,
1000                            None => {
1001                                return Err(SqlError::Unsupported(
1002                                    "reverse without an ORDER BY (rows are unordered)".into(),
1003                                ));
1004                            }
1005                        },
1006                        "top" => {
1007                            if self.strict {
1008                                return Err(SqlError::Unsupported(
1009                                    "pushdown: ORDER BY collations differ per engine".into(),
1010                                ));
1011                            }
1012                            let kids = self.arbor.children(s);
1013                            let n = self.prop_s(kids[0], "value");
1014                            let e = self.operand(kids[1], join.map(|(_, r)| r))?;
1015                            sel.order_by = Some((e, true));
1016                            sel.limit = Some(n);
1017                        }
1018                        "unique" => {
1019                            if self.strict {
1020                                return Err(SqlError::Unsupported(
1021                                    "pushdown: DISTINCT result order is unordered in SQL".into(),
1022                                ));
1023                            }
1024                            if let Some(c) = pending_col.take() {
1025                                sel.select = vec![c];
1026                            }
1027                            sel.distinct = true;
1028                        }
1029                        other => {
1030                            return Err(SqlError::Unsupported(format!("the '{other}' aggregate")));
1031                        }
1032                    }
1033                }
1034                "select" => {
1035                    if self.strict {
1036                        return Err(SqlError::Unsupported(
1037                            "pushdown: LIMIT without a guaranteed order".into(),
1038                        ));
1039                    }
1040                    // `@| [..n]` → LIMIT.
1041                    let p = self.arbor.children(s)[0];
1042                    match (self.prop_s(p, "kind").as_str(), self.prop(p, "to")) {
1043                        ("range", Some(Value::Int(n)))
1044                            if self.prop(p, "from").is_none() && n > 0 =>
1045                        {
1046                            sel.limit = Some(n.to_string());
1047                        }
1048                        _ => {
1049                            return Err(SqlError::Unsupported(
1050                                "positional selection beyond '@| [..n]'".into(),
1051                            ));
1052                        }
1053                    }
1054                }
1055                other => {
1056                    return Err(SqlError::Unsupported(format!(
1057                        "the '{other}' stage (windows, subcontexts, and registers are \
1058                         Quarb-side state)"
1059                    )));
1060                }
1061            }
1062            i += 1;
1063        }
1064        // A pending column with no aggregate is a one-column select.
1065        if let Some(c) = pending_col
1066            && sel.select.is_empty()
1067        {
1068            sel.select = vec![c];
1069        }
1070        Ok(())
1071    }
1072
1073    fn group_key(&mut self, s: NodeId, join: Option<(&str, &str)>) -> Result<String, SqlError> {
1074        let kids = self.arbor.children(s);
1075        // group(::k) or group("name", expr) — the key expression is
1076        // the last child; a literal first child is its name.
1077        let key = kids
1078            .iter()
1079            .rev()
1080            .find(|&&k| self.kind(k) != "literal")
1081            .ok_or_else(|| SqlError::Unsupported("a literal group key".into()))?;
1082        self.operand(*key, join.map(|(_, r)| r))
1083    }
1084
1085    /// A HAVING filter: `$_` and `$.name` refer to the aggregate.
1086    fn having_cond(&mut self, s: NodeId) -> Result<String, SqlError> {
1087        let kids = self.arbor.children(s);
1088        if kids.len() != 1 || self.kind(kids[0]) != "compare" {
1089            return Err(SqlError::Unsupported(
1090                "HAVING translates for a single comparison".into(),
1091            ));
1092        }
1093        let e = kids[0];
1094        let op = self.prop_s(e, "op");
1095        let cmp_kids = self.arbor.children(e);
1096        let l = match self.kind(cmp_kids[0]).as_str() {
1097            "topic" | "recall" => "__AGG__".to_string(),
1098            _ => {
1099                return Err(SqlError::Unsupported(
1100                    "HAVING compares the aggregate ($_ or its register)".into(),
1101                ));
1102            }
1103        };
1104        let r = self.operand(cmp_kids[1], None)?;
1105        Ok(format!("{l} {op} {r}"))
1106    }
1107
1108    /// `rec(...)` fields as a select list.
1109    fn record_fields(
1110        &mut self,
1111        s: NodeId,
1112        join: Option<(&str, &str)>,
1113    ) -> Result<Vec<String>, SqlError> {
1114        let kids = self.arbor.children(s);
1115        let mut fields = Vec::new();
1116        let mut i = 0;
1117        while i < kids.len() {
1118            let k = kids[i];
1119            if self.kind(k) == "literal" {
1120                let name = self.prop_s(k, "value");
1121                let value = self.operand(kids[i + 1], join.map(|(_, r)| r))?;
1122                let value = match join {
1123                    Some((l, _)) => value.replace("__LEFT__", l),
1124                    None => value,
1125                };
1126                let name = self.alias(&name)?;
1127                fields.push(format!("{value} AS {name}"));
1128                i += 2;
1129            } else {
1130                let value = self.operand(k, join.map(|(_, r)| r))?;
1131                let value = match join {
1132                    Some((l, _)) => value.replace("__LEFT__", l),
1133                    None => value,
1134                };
1135                fields.push(value);
1136                i += 1;
1137            }
1138        }
1139        Ok(fields)
1140    }
1141}
1142
1143fn sql_agg(name: &str, col: Option<String>) -> String {
1144    let f = match name {
1145        "count" => "COUNT",
1146        "sum" => "SUM",
1147        "mean" | "avg" => "AVG",
1148        "min" => "MIN",
1149        "max" => "MAX",
1150        _ => unreachable!("checked by caller"),
1151    };
1152    match col {
1153        Some(c) => format!("{f}({c})"),
1154        None => {
1155            if f == "COUNT" {
1156                "COUNT(*)".to_string()
1157            } else {
1158                format!("{f}(*)")
1159            }
1160        }
1161    }
1162}
1163
1164#[cfg(test)]
1165mod null_and_literal_tests {
1166    use super::{export, partial_pushdown, pushdown};
1167
1168    // Grouped pipeline that never pushes, so `partial_pushdown` hinges
1169    // only on the leading predicate (mirrors the crate's partial gate).
1170    const GROUPED: &str = " | ::x @| group(\"g\", ::x) | count | .n | %.";
1171
1172    #[test]
1173    fn null_literal_compares_use_is_null() {
1174        // `= null` / `!= null` are provably identical to Quarb's
1175        // value_eq(NULL, …) semantics, never the always-UNKNOWN
1176        // `x = NULL`; they translate — and push — in both modes.
1177        assert_eq!(
1178            export("/t/*[::x = null] | ::x").unwrap().query,
1179            "SELECT x FROM t WHERE x IS NULL"
1180        );
1181        assert_eq!(
1182            export("/t/*[::x != null] | ::x").unwrap().query,
1183            "SELECT x FROM t WHERE x IS NOT NULL"
1184        );
1185        assert_eq!(
1186            pushdown("/t/*[::x = null] | ::x").unwrap().sql,
1187            "SELECT x FROM t WHERE x IS NULL"
1188        );
1189        assert_eq!(
1190            pushdown("/t/*[::x != null] | ::x").unwrap().sql,
1191            "SELECT x FROM t WHERE x IS NOT NULL"
1192        );
1193    }
1194
1195    #[test]
1196    fn ne_and_not_refuse_pushdown_but_display_diverges() {
1197        // `!=` against a non-null value and `not(...)` drop the NULL
1198        // rows Quarb keeps under SQL three-valued logic: the pushdown
1199        // paths refuse (and scan), full and partial alike.
1200        assert!(pushdown("/t/*[::x != 5] | ::x").is_none());
1201        assert!(pushdown("/t/*[!::x = 5] | ::x").is_none());
1202        assert!(partial_pushdown(&format!("/t/*[::x != 5]{GROUPED}")).is_none());
1203        assert!(partial_pushdown(&format!("/t/*[!::x = 5]{GROUPED}")).is_none());
1204        // The display translation still emits `<>`, flagged with a note.
1205        let t = export("/t/*[::x != 5] | ::x").unwrap();
1206        assert_eq!(t.query, "SELECT x FROM t WHERE x <> 5");
1207        assert!(t.notes.iter().any(|n| n.contains("NULL")));
1208    }
1209
1210    #[test]
1211    fn unescapable_text_literal_refuses_pushdown() {
1212        // A backslash (MySQL/BigQuery escape) or an apostrophe
1213        // (BigQuery rejects '' doubling) has no portable escaping, so
1214        // pushdown refuses; a clean literal still pushes.
1215        assert!(pushdown("/files/*[::path = \"C:\\temp\"] | ::path").is_none());
1216        assert!(pushdown("/t/*[::name = \"it's\"] | ::name").is_none());
1217        assert!(partial_pushdown(&format!("/t/*[::name = \"it's\"]{GROUPED}")).is_none());
1218        assert_eq!(
1219            pushdown("/t/*[::name = \"rare\"] | ::name").unwrap().sql,
1220            "SELECT name FROM t WHERE name = 'rare'"
1221        );
1222    }
1223}