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