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                // `$*1::col` — the correlation (left/FROM) side.
732                let p = self
733                    .kid(o, "projection")
734                    .ok_or_else(|| SqlError::Unsupported("a bare '$*' reference".into()))?;
735                Ok(format!("__LEFT__.{}", self.projection_col(p)?))
736            }
737            "arith" => {
738                let op = self.prop_s(o, "op");
739                let kids = self.arbor.children(o);
740                let l = self.operand(kids[0], qual)?;
741                let r = self.operand(kids[1], qual)?;
742                Ok(match op.as_str() {
743                    "+" | "-" | "*" => format!("({l} {op} {r})"),
744                    "div" => format!("({l} / {r})"),
745                    "mod" => format!("({l} % {r})"),
746                    other => return Err(SqlError::Unsupported(format!("'{other}' arithmetic"))),
747                })
748            }
749            other => Err(SqlError::Unsupported(format!(
750                "the '{other}' operand (registers, topics, and captures are Quarb-side state)"
751            ))),
752        }
753    }
754
755    fn projection_col(&mut self, p: NodeId) -> Result<String, SqlError> {
756        match self.prop_s(p, "kind").as_str() {
757            "property" => match self.prop(p, "key") {
758                Some(k) => Ok(k.to_string()),
759                None => Err(SqlError::Unsupported(
760                    "the bare '::' projection (name the column)".into(),
761                )),
762            },
763            other => Err(SqlError::Unsupported(format!(
764                "the {other} metadata projection"
765            ))),
766        }
767    }
768
769    /// Split a joined-side predicate: `col = $*1::col2` equalities
770    /// become the ON condition; everything else the WHERE.
771    /// A safe `AS` alias: bare when it is a plain identifier and
772    /// not an SQL keyword; otherwise strict mode refuses (quoting
773    /// dialects disagree) and export mode double-quotes, noting it.
774    fn alias(&mut self, name: &str) -> Result<String, SqlError> {
775        let plain = !name.is_empty()
776            && name.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
777            && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
778            && !ALIAS_KEYWORDS.contains(&name.to_ascii_lowercase().as_str());
779        if plain {
780            return Ok(name.to_string());
781        }
782        if self.strict {
783            return Err(SqlError::Unsupported(format!(
784                "pushdown: field name {name:?} needs SQL quoting \
785                 (dialects disagree); rename the field"
786            )));
787        }
788        self.notes
789            .push(format!("field name {name:?} double-quoted (ANSI)"));
790        Ok(format!("\"{}\"", name.replace('"', "\"\"")))
791    }
792
793    fn split_join_pred(
794        &mut self,
795        p: NodeId,
796        ltable: &str,
797        rtable: &str,
798        on: &mut Vec<String>,
799        wheres: &mut Vec<String>,
800    ) -> Result<(), SqlError> {
801        if self.prop_s(p, "kind") != "expr" {
802            return Err(SqlError::Unsupported(
803                "a positional predicate on the joined side".into(),
804            ));
805        }
806        // Flatten top-level ANDs; each conjunct routes to ON or
807        // WHERE.
808        fn conjuncts(ex: &Exporter, e: NodeId, out: &mut Vec<NodeId>) {
809            if ex.kind(e) == "and" {
810                for c in ex.arbor.children(e) {
811                    conjuncts(ex, c, out);
812                }
813            } else {
814                out.push(e);
815            }
816        }
817        let mut parts = Vec::new();
818        for c in self.arbor.children(p) {
819            conjuncts(self, c, &mut parts);
820        }
821        for e in parts {
822            let uses_ctx = self.subtree_has(e, "context");
823            let raw = self.pred_expr(e, Some(rtable))?;
824            let cond = raw.replace("__LEFT__", ltable);
825            if uses_ctx && self.kind(e) == "compare" && self.prop_s(e, "op") == "=" {
826                // Record which left-table columns the ON binds —
827                // the driver's uniqueness obligation (see
828                // Pushdown::join_left). Captured from the
829                // pre-substitution marker so literals can't fake
830                // a match.
831                for cap in raw.split("__LEFT__.").skip(1) {
832                    let col: String = cap
833                        .chars()
834                        .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
835                        .collect();
836                    if !col.is_empty() {
837                        self.join_on_left_cols.push(col);
838                    }
839                }
840                on.push(cond);
841            } else {
842                wheres.push(cond);
843            }
844        }
845        Ok(())
846    }
847
848    fn subtree_has(&self, n: NodeId, kind: &str) -> bool {
849        if self.kind(n) == kind {
850            return true;
851        }
852        self.arbor
853            .children(n)
854            .into_iter()
855            .any(|c| self.subtree_has(c, kind))
856    }
857
858    /// Consume the pipeline into SELECT clauses.
859    fn pipeline(
860        &mut self,
861        q: NodeId,
862        sel: &mut Select,
863        join: Option<(&str, &str)>,
864    ) -> Result<(), SqlError> {
865        let Some(pipe) = self.kid(q, "pipeline") else {
866            return Ok(());
867        };
868        let stages: Vec<NodeId> = self.arbor.children(pipe);
869        let mut i = 0;
870        // A pending per-row value (`| ::col`) feeding an aggregate.
871        let mut pending_col: Option<String> = None;
872        // After a grouped reduction: (key, agg_sql, alias).
873        let mut grouped: Option<(String, String, String)> = None;
874
875        while i < stages.len() {
876            let s = stages[i];
877            match self.kind(s).as_str() {
878                "expr" => {
879                    let kids = self.arbor.children(s);
880                    pending_col = Some(self.operand(kids[0], join.map(|(_, r)| r))?);
881                }
882                "func" => {
883                    let name = self.prop_s(s, "name");
884                    match name.as_str() {
885                        "rec" | "record" => {
886                            sel.select = self.record_fields(s, join)?;
887                        }
888                        // A reducing aggregate on the plain pipe:
889                        // the grouped reduction.
890                        "count" | "sum" | "mean" | "avg" | "min" | "max" => {
891                            let (key, _, _) = grouped.as_ref().ok_or_else(|| {
892                                SqlError::Unsupported(format!(
893                                    "'| {name}' outside a group (use '@| {name}')"
894                                ))
895                            })?;
896                            let agg = sql_agg(&name, pending_col.take());
897                            grouped = Some((key.clone(), agg.clone(), agg));
898                        }
899                        other => {
900                            return Err(SqlError::Unsupported(format!(
901                                "the '{other}' pipeline function"
902                            )));
903                        }
904                    }
905                }
906                "push" => {
907                    // An alias for the grouped aggregate.
908                    if let Some((k, agg, _)) = grouped.take() {
909                        let alias = self.prop_s(s, "name");
910                        grouped = Some((k, agg, alias));
911                    } else {
912                        return Err(SqlError::Unsupported(
913                            "a register push (Quarb-side state)".into(),
914                        ));
915                    }
916                }
917                "filter" => {
918                    if grouped.is_some() {
919                        // HAVING: `$_` / the alias refer to the
920                        // aggregate.
921                        let cond = self.having_cond(s)?;
922                        sel.having = Some(cond);
923                    } else {
924                        return Err(SqlError::Unsupported(
925                            "a mid-pipeline filter (put it in the row predicate)".into(),
926                        ));
927                    }
928                }
929                "recall" => {
930                    // `| %.` finalizes the grouped record.
931                    if self.prop_s(s, "ref") != "%." {
932                        return Err(SqlError::Unsupported(
933                            "a register recall (Quarb-side state)".into(),
934                        ));
935                    }
936                    let (key, agg, alias) = grouped.clone().ok_or_else(|| {
937                        SqlError::Unsupported("'%.', with nothing grouped".into())
938                    })?;
939                    let alias = self.alias(&alias)?;
940                    sel.select = vec![key.clone(), format!("{agg} AS {alias}")];
941                    // The HAVING condition compared the aggregate
942                    // through $_ — substitute the real expression.
943                    if let Some(h) = sel.having.take() {
944                        sel.having = Some(h.replace("__AGG__", &agg));
945                    }
946                }
947                "agg" => {
948                    let name = self.prop_s(s, "name");
949                    match name.as_str() {
950                        "count" | "sum" | "mean" | "avg" | "min" | "max" => {
951                            // Quarb's count counts every row, nulls
952                            // included: COUNT(*) regardless of a
953                            // pending column.
954                            let col = if name == "count" {
955                                pending_col = None;
956                                None
957                            } else {
958                                pending_col.take()
959                            };
960                            sel.select = vec![sql_agg(&name, col)];
961                            self.aggregate = true;
962                        }
963                        "group" => {
964                            if self.strict {
965                                return Err(SqlError::Unsupported(
966                                    "pushdown: GROUP BY result order is unordered in SQL".into(),
967                                ));
968                            }
969                            let key = self.group_key(s, join)?;
970                            sel.group_by = Some(key.clone());
971                            grouped = Some((key, String::new(), String::new()));
972                        }
973                        "sort_by" => {
974                            if self.strict {
975                                return Err(SqlError::Unsupported(
976                                    "pushdown: ORDER BY collations differ per engine".into(),
977                                ));
978                            }
979                            let kids = self.arbor.children(s);
980                            let e = self.operand(kids[0], join.map(|(_, r)| r))?;
981                            sel.order_by = Some((e, false));
982                        }
983                        "reverse" => match &mut sel.order_by {
984                            Some((_, desc)) => *desc = true,
985                            None => {
986                                return Err(SqlError::Unsupported(
987                                    "reverse without an ORDER BY (rows are unordered)".into(),
988                                ));
989                            }
990                        },
991                        "top" => {
992                            if self.strict {
993                                return Err(SqlError::Unsupported(
994                                    "pushdown: ORDER BY collations differ per engine".into(),
995                                ));
996                            }
997                            let kids = self.arbor.children(s);
998                            let n = self.prop_s(kids[0], "value");
999                            let e = self.operand(kids[1], join.map(|(_, r)| r))?;
1000                            sel.order_by = Some((e, true));
1001                            sel.limit = Some(n);
1002                        }
1003                        "unique" => {
1004                            if self.strict {
1005                                return Err(SqlError::Unsupported(
1006                                    "pushdown: DISTINCT result order is unordered in SQL".into(),
1007                                ));
1008                            }
1009                            if let Some(c) = pending_col.take() {
1010                                sel.select = vec![c];
1011                            }
1012                            sel.distinct = true;
1013                        }
1014                        other => {
1015                            return Err(SqlError::Unsupported(format!("the '{other}' aggregate")));
1016                        }
1017                    }
1018                }
1019                "select" => {
1020                    if self.strict {
1021                        return Err(SqlError::Unsupported(
1022                            "pushdown: LIMIT without a guaranteed order".into(),
1023                        ));
1024                    }
1025                    // `@| [..n]` → LIMIT.
1026                    let p = self.arbor.children(s)[0];
1027                    match (self.prop_s(p, "kind").as_str(), self.prop(p, "to")) {
1028                        ("range", Some(Value::Int(n)))
1029                            if self.prop(p, "from").is_none() && n > 0 =>
1030                        {
1031                            sel.limit = Some(n.to_string());
1032                        }
1033                        _ => {
1034                            return Err(SqlError::Unsupported(
1035                                "positional selection beyond '@| [..n]'".into(),
1036                            ));
1037                        }
1038                    }
1039                }
1040                other => {
1041                    return Err(SqlError::Unsupported(format!(
1042                        "the '{other}' stage (windows, subcontexts, and registers are \
1043                         Quarb-side state)"
1044                    )));
1045                }
1046            }
1047            i += 1;
1048        }
1049        // A pending column with no aggregate is a one-column select.
1050        if let Some(c) = pending_col
1051            && sel.select.is_empty()
1052        {
1053            sel.select = vec![c];
1054        }
1055        Ok(())
1056    }
1057
1058    fn group_key(&mut self, s: NodeId, join: Option<(&str, &str)>) -> Result<String, SqlError> {
1059        let kids = self.arbor.children(s);
1060        // group(::k) or group("name", expr) — the key expression is
1061        // the last child; a literal first child is its name.
1062        let key = kids
1063            .iter()
1064            .rev()
1065            .find(|&&k| self.kind(k) != "literal")
1066            .ok_or_else(|| SqlError::Unsupported("a literal group key".into()))?;
1067        self.operand(*key, join.map(|(_, r)| r))
1068    }
1069
1070    /// A HAVING filter: `$_` and `$.name` refer to the aggregate.
1071    fn having_cond(&mut self, s: NodeId) -> Result<String, SqlError> {
1072        let kids = self.arbor.children(s);
1073        if kids.len() != 1 || self.kind(kids[0]) != "compare" {
1074            return Err(SqlError::Unsupported(
1075                "HAVING translates for a single comparison".into(),
1076            ));
1077        }
1078        let e = kids[0];
1079        let op = self.prop_s(e, "op");
1080        let cmp_kids = self.arbor.children(e);
1081        let l = match self.kind(cmp_kids[0]).as_str() {
1082            "topic" | "recall" => "__AGG__".to_string(),
1083            _ => {
1084                return Err(SqlError::Unsupported(
1085                    "HAVING compares the aggregate ($_ or its register)".into(),
1086                ));
1087            }
1088        };
1089        let r = self.operand(cmp_kids[1], None)?;
1090        Ok(format!("{l} {op} {r}"))
1091    }
1092
1093    /// `rec(...)` fields as a select list.
1094    fn record_fields(
1095        &mut self,
1096        s: NodeId,
1097        join: Option<(&str, &str)>,
1098    ) -> Result<Vec<String>, SqlError> {
1099        let kids = self.arbor.children(s);
1100        let mut fields = Vec::new();
1101        let mut i = 0;
1102        while i < kids.len() {
1103            let k = kids[i];
1104            if self.kind(k) == "literal" {
1105                let name = self.prop_s(k, "value");
1106                let value = self.operand(kids[i + 1], join.map(|(_, r)| r))?;
1107                let value = match join {
1108                    Some((l, _)) => value.replace("__LEFT__", l),
1109                    None => value,
1110                };
1111                let name = self.alias(&name)?;
1112                fields.push(format!("{value} AS {name}"));
1113                i += 2;
1114            } else {
1115                let value = self.operand(k, join.map(|(_, r)| r))?;
1116                let value = match join {
1117                    Some((l, _)) => value.replace("__LEFT__", l),
1118                    None => value,
1119                };
1120                fields.push(value);
1121                i += 1;
1122            }
1123        }
1124        Ok(fields)
1125    }
1126}
1127
1128fn sql_agg(name: &str, col: Option<String>) -> String {
1129    let f = match name {
1130        "count" => "COUNT",
1131        "sum" => "SUM",
1132        "mean" | "avg" => "AVG",
1133        "min" => "MIN",
1134        "max" => "MAX",
1135        _ => unreachable!("checked by caller"),
1136    };
1137    match col {
1138        Some(c) => format!("{f}({c})"),
1139        None => {
1140            if f == "COUNT" {
1141                "COUNT(*)".to_string()
1142            } else {
1143                format!("{f}(*)")
1144            }
1145        }
1146    }
1147}
1148
1149#[cfg(test)]
1150mod null_and_literal_tests {
1151    use super::{export, partial_pushdown, pushdown};
1152
1153    // Grouped pipeline that never pushes, so `partial_pushdown` hinges
1154    // only on the leading predicate (mirrors the crate's partial gate).
1155    const GROUPED: &str = " | ::x @| group(\"g\", ::x) | count | .n | %.";
1156
1157    #[test]
1158    fn null_literal_compares_use_is_null() {
1159        // `= null` / `!= null` are provably identical to Quarb's
1160        // value_eq(NULL, …) semantics, never the always-UNKNOWN
1161        // `x = NULL`; they translate — and push — in both modes.
1162        assert_eq!(
1163            export("/t/*[::x = null] | ::x").unwrap().query,
1164            "SELECT x FROM t WHERE x IS NULL"
1165        );
1166        assert_eq!(
1167            export("/t/*[::x != null] | ::x").unwrap().query,
1168            "SELECT x FROM t WHERE x IS NOT NULL"
1169        );
1170        assert_eq!(
1171            pushdown("/t/*[::x = null] | ::x").unwrap().sql,
1172            "SELECT x FROM t WHERE x IS NULL"
1173        );
1174        assert_eq!(
1175            pushdown("/t/*[::x != null] | ::x").unwrap().sql,
1176            "SELECT x FROM t WHERE x IS NOT NULL"
1177        );
1178    }
1179
1180    #[test]
1181    fn ne_and_not_refuse_pushdown_but_display_diverges() {
1182        // `!=` against a non-null value and `not(...)` drop the NULL
1183        // rows Quarb keeps under SQL three-valued logic: the pushdown
1184        // paths refuse (and scan), full and partial alike.
1185        assert!(pushdown("/t/*[::x != 5] | ::x").is_none());
1186        assert!(pushdown("/t/*[!::x = 5] | ::x").is_none());
1187        assert!(partial_pushdown(&format!("/t/*[::x != 5]{GROUPED}")).is_none());
1188        assert!(partial_pushdown(&format!("/t/*[!::x = 5]{GROUPED}")).is_none());
1189        // The display translation still emits `<>`, flagged with a note.
1190        let t = export("/t/*[::x != 5] | ::x").unwrap();
1191        assert_eq!(t.query, "SELECT x FROM t WHERE x <> 5");
1192        assert!(t.notes.iter().any(|n| n.contains("NULL")));
1193    }
1194
1195    #[test]
1196    fn unescapable_text_literal_refuses_pushdown() {
1197        // A backslash (MySQL/BigQuery escape) or an apostrophe
1198        // (BigQuery rejects '' doubling) has no portable escaping, so
1199        // pushdown refuses; a clean literal still pushes.
1200        assert!(pushdown("/files/*[::path = \"C:\\temp\"] | ::path").is_none());
1201        assert!(pushdown("/t/*[::name = \"it's\"] | ::name").is_none());
1202        assert!(partial_pushdown(&format!("/t/*[::name = \"it's\"]{GROUPED}")).is_none());
1203        assert_eq!(
1204            pushdown("/t/*[::name = \"rare\"] | ::name").unwrap().sql,
1205            "SELECT name FROM t WHERE name = 'rare'"
1206        );
1207    }
1208}