Skip to main content

quarb_sql/
lib.rs

1//! SQL importer for Quarb.
2//!
3//! Translates a SQL `SELECT` statement into an equivalent Quarb
4//! query, following the mapping in the SQL cookbook — in reverse.
5//! The translatable subset covers the single-statement query core;
6//! everything else is refused with an
7//! [`SqlError::Unsupported`] naming the construct, never silently
8//! approximated.
9//!
10//! - `SELECT cols FROM t` → `/t/* | rec(::col, …)`; `SELECT *` →
11//!   the row nodes; `SELECT DISTINCT col` → `/t/*::col @| unique`.
12//! - `WHERE` → a predicate: comparisons, `AND`/`OR`/`NOT`,
13//!   `IS [NOT] NULL` → `= null` / `!= null` (exact: Quarb's
14//!   `value_eq` treats NULL as an ordinary value), `IN (…)` → an
15//!   `or`-chain, `LIKE` → anchored `=~` regexes.
16//! - Aggregates (`COUNT(*)`, `COUNT(col)`, `SUM`/`AVG`/`MIN`/`MAX`)
17//!   → `@|` reductions; `GROUP BY k` → `@| group(::k)` with the
18//!   aggregate riding the plain pipe and `HAVING` a filter after
19//!   it.
20//! - `JOIN t2 ON t2.b = t1.a` → correlation (driver-first): `/t1/*
21//!   <=> /t2/*[::b = $$::a]` — the FROM table drives, the ON rides
22//!   the joined expression — with the joined side's select columns
23//!   projected through the witness (`$*1::col`).
24//! - `ORDER BY` → `@| sort_by(…)` (`DESC` via `@| reverse`, or a
25//!   numeric `-` key when mixed); `LIMIT n` → `@| [..n]`.
26//!
27//! Refused: subqueries, `UNION`, window functions, `CASE`,
28//! outer and `CROSS` joins (Quarb's `<=>` correlation is
29//! inner/existential), multi-join chains (one `JOIN` translates),
30//! `HAVING` without `GROUP BY`, aggregate arithmetic
31//! (`SUM(a) + 1`), and any non-`SELECT` statement.
32//!
33//! Known semantic divergences are reported as
34//! [`Translation::notes`] rather than errors:
35//!
36//! - Quarb's `count` counts all rows (SQL `COUNT(col)` skips NULLs;
37//!   the translation adds the `[::col != null]` filter, and says
38//!   so).
39//! - Quarb streams records (JSONL), not a result table.
40//! - `LIKE` translates to a case-insensitive regex (matching
41//!   MySQL's and SQLite's default folding; PostgreSQL's `LIKE`
42//!   is case-sensitive).
43//! - SQL keeps a NULL-key group under `GROUP BY`; Quarb's `group`
44//!   drops null keys.
45
46mod export;
47pub use export::{
48    Dialect, Partial, Pushdown, export, partial_pushdown, partial_pushdown_explained, pushdown,
49    pushdown_explained,
50};
51
52use std::fmt::Write as _;
53
54/// An error translating a SQL statement.
55#[derive(Debug, thiserror::Error)]
56pub enum SqlError {
57    #[error("SQL syntax error: {0}")]
58    Syntax(String),
59    #[error("unsupported SQL construct: {0}")]
60    Unsupported(String),
61    /// SQL can spell the construct, but with provably different
62    /// semantics (NULL propagation, collation, coercion) — the
63    /// refusal names the divergence, not a missing feature.
64    #[error("semantic mismatch: {0}")]
65    Semantics(String),
66}
67
68/// A successful translation: the Quarb query, plus notes on known
69/// semantic divergences that apply to it.
70#[derive(Debug)]
71pub struct Translation {
72    pub query: String,
73    pub notes: Vec<String>,
74}
75
76// ---------------------------------------------------------------
77// Lexer
78// ---------------------------------------------------------------
79
80#[derive(Debug, Clone, PartialEq)]
81enum Tok {
82    /// A bare identifier or keyword (uppercased for keywords).
83    Word(String),
84    /// A 'string literal'.
85    Str(String),
86    Num(String),
87    Sym(char),
88    /// `<=`, `>=`, `<>`, `!=`
89    Op2(String),
90}
91
92fn lex(input: &str) -> Result<Vec<Tok>, SqlError> {
93    let chars: Vec<char> = input.chars().collect();
94    let mut out = Vec::new();
95    let mut i = 0;
96    while i < chars.len() {
97        let c = chars[i];
98        if c.is_whitespace() {
99            i += 1;
100            continue;
101        }
102        if c == '\'' {
103            let mut s = String::new();
104            i += 1;
105            loop {
106                match chars.get(i) {
107                    Some('\'') if chars.get(i + 1) == Some(&'\'') => {
108                        s.push('\'');
109                        i += 2;
110                    }
111                    Some('\'') => {
112                        i += 1;
113                        break;
114                    }
115                    Some(&ch) => {
116                        s.push(ch);
117                        i += 1;
118                    }
119                    None => return Err(SqlError::Syntax("unterminated string".into())),
120                }
121            }
122            out.push(Tok::Str(s));
123            continue;
124        }
125        if c.is_ascii_digit() {
126            let mut s = String::new();
127            let mut dotted = false;
128            while let Some(&ch) = chars.get(i) {
129                if ch.is_ascii_digit() || (ch == '.' && !dotted) {
130                    dotted |= ch == '.';
131                    s.push(ch);
132                    i += 1;
133                } else {
134                    break;
135                }
136            }
137            out.push(Tok::Num(s));
138            continue;
139        }
140        if c.is_alphabetic() || c == '_' || c == '"' || c == '`' {
141            // Quoted identifiers keep their case; bare words are
142            // identifiers-or-keywords (keywords match uppercased).
143            if c == '"' || c == '`' {
144                let quote = c;
145                let mut s = String::new();
146                let mut closed = false;
147                i += 1;
148                while let Some(&ch) = chars.get(i) {
149                    i += 1;
150                    if ch == quote {
151                        closed = true;
152                        break;
153                    }
154                    s.push(ch);
155                }
156                if !closed {
157                    return Err(SqlError::Syntax("unterminated quoted identifier".into()));
158                }
159                out.push(Tok::Word(s));
160                continue;
161            }
162            let mut s = String::new();
163            while let Some(&ch) = chars.get(i) {
164                if ch.is_alphanumeric() || ch == '_' {
165                    s.push(ch);
166                    i += 1;
167                } else {
168                    break;
169                }
170            }
171            out.push(Tok::Word(s));
172            continue;
173        }
174        if (c == '<' && matches!(chars.get(i + 1), Some('=') | Some('>')))
175            || (c == '>' && chars.get(i + 1) == Some(&'='))
176            || (c == '!' && chars.get(i + 1) == Some(&'='))
177        {
178            out.push(Tok::Op2(format!("{c}{}", chars[i + 1])));
179            i += 2;
180            continue;
181        }
182        if "(),.*=<>;-".contains(c) {
183            out.push(Tok::Sym(c));
184            i += 1;
185            continue;
186        }
187        return Err(SqlError::Syntax(format!("unexpected character '{c}'")));
188    }
189    Ok(out)
190}
191
192// ---------------------------------------------------------------
193// Parser / translator
194// ---------------------------------------------------------------
195
196/// A column reference: optionally table-qualified.
197#[derive(Debug, Clone)]
198struct ColRef {
199    table: Option<String>,
200    column: String,
201}
202
203#[derive(Debug, Clone)]
204enum Scalar {
205    Col(ColRef),
206    Str(String),
207    Num(String),
208    Null,
209}
210
211#[derive(Debug)]
212enum Cond {
213    Cmp(ColRef, String, Scalar),
214    /// An aggregate call compared to a scalar — HAVING only.
215    AggCmp(Agg, Option<ColRef>, String, Scalar),
216    Like(ColRef, String),
217    IsNull(ColRef, bool),
218    In(ColRef, Vec<Scalar>),
219    And(Box<Cond>, Box<Cond>),
220    Or(Box<Cond>, Box<Cond>),
221    Not(Box<Cond>),
222}
223
224#[derive(Debug, Clone, PartialEq)]
225enum Agg {
226    Count,
227    CountCol,
228    Sum,
229    Avg,
230    Min,
231    Max,
232}
233
234/// The aggregate a keyword names, if any.
235fn agg_kw(w: &str) -> Option<Agg> {
236    match w.to_ascii_uppercase().as_str() {
237        "COUNT" => Some(Agg::Count),
238        "SUM" => Some(Agg::Sum),
239        "AVG" => Some(Agg::Avg),
240        "MIN" => Some(Agg::Min),
241        "MAX" => Some(Agg::Max),
242        _ => None,
243    }
244}
245
246#[derive(Debug)]
247enum SelectItem {
248    Star,
249    Col(ColRef, Option<String>),
250    Agg(Agg, Option<ColRef>, Option<String>),
251}
252
253struct Parser {
254    toks: Vec<Tok>,
255    pos: usize,
256}
257
258impl Parser {
259    fn peek(&self) -> Option<&Tok> {
260        self.toks.get(self.pos)
261    }
262
263    fn kw(&mut self, word: &str) -> bool {
264        if matches!(self.peek(), Some(Tok::Word(w)) if w.eq_ignore_ascii_case(word)) {
265            self.pos += 1;
266            true
267        } else {
268            false
269        }
270    }
271
272    fn expect_kw(&mut self, word: &str) -> Result<(), SqlError> {
273        if self.kw(word) {
274            Ok(())
275        } else {
276            Err(SqlError::Syntax(format!("expected {word}")))
277        }
278    }
279
280    fn sym(&mut self, c: char) -> bool {
281        if self.peek() == Some(&Tok::Sym(c)) {
282            self.pos += 1;
283            true
284        } else {
285            false
286        }
287    }
288
289    fn ident(&mut self) -> Result<String, SqlError> {
290        match self.peek() {
291            Some(Tok::Word(w)) => {
292                let w = w.clone();
293                self.pos += 1;
294                Ok(w)
295            }
296            other => Err(SqlError::Syntax(format!(
297                "expected an identifier, found {other:?}"
298            ))),
299        }
300    }
301
302    fn col_ref(&mut self) -> Result<ColRef, SqlError> {
303        let first = self.ident()?;
304        if self.sym('.') {
305            let column = self.ident()?;
306            Ok(ColRef {
307                table: Some(first),
308                column,
309            })
310        } else {
311            Ok(ColRef {
312                table: None,
313                column: first,
314            })
315        }
316    }
317
318    fn scalar(&mut self) -> Result<Scalar, SqlError> {
319        match self.peek().cloned() {
320            Some(Tok::Str(s)) => {
321                self.pos += 1;
322                Ok(Scalar::Str(s))
323            }
324            Some(Tok::Num(n)) => {
325                self.pos += 1;
326                Ok(Scalar::Num(n))
327            }
328            Some(Tok::Sym('-')) => {
329                self.pos += 1;
330                match self.peek().cloned() {
331                    Some(Tok::Num(n)) => {
332                        self.pos += 1;
333                        Ok(Scalar::Num(format!("-{n}")))
334                    }
335                    _ => Err(SqlError::Syntax("expected a number after '-'".into())),
336                }
337            }
338            Some(Tok::Word(w)) if w.eq_ignore_ascii_case("NULL") => {
339                self.pos += 1;
340                Ok(Scalar::Null)
341            }
342            Some(Tok::Word(_)) => Ok(Scalar::Col(self.col_ref()?)),
343            other => Err(SqlError::Syntax(format!(
344                "expected a value, found {other:?}"
345            ))),
346        }
347    }
348
349    // cond := and_cond (OR and_cond)*
350    fn cond(&mut self) -> Result<Cond, SqlError> {
351        let mut left = self.and_cond()?;
352        while self.kw("OR") {
353            let right = self.and_cond()?;
354            left = Cond::Or(Box::new(left), Box::new(right));
355        }
356        Ok(left)
357    }
358
359    fn and_cond(&mut self) -> Result<Cond, SqlError> {
360        let mut left = self.not_cond()?;
361        while self.kw("AND") {
362            let right = self.not_cond()?;
363            left = Cond::And(Box::new(left), Box::new(right));
364        }
365        Ok(left)
366    }
367
368    fn not_cond(&mut self) -> Result<Cond, SqlError> {
369        if self.kw("NOT") {
370            return Ok(Cond::Not(Box::new(self.not_cond()?)));
371        }
372        if self.sym('(') {
373            let inner = self.cond()?;
374            if !self.sym(')') {
375                return Err(SqlError::Syntax("expected ')'".into()));
376            }
377            return Ok(inner);
378        }
379        self.comparison()
380    }
381
382    /// An aggregate call's argument list: the parser is past the
383    /// name, at `(`. Returns the aggregate (COUNT refined to
384    /// CountCol) and its column, `None` for `(*)`.
385    fn agg_call(&mut self, mut a: Agg) -> Result<(Agg, Option<ColRef>), SqlError> {
386        self.pos += 1; // '('
387        let col = if self.sym('*') {
388            None
389        } else {
390            let c = self.col_ref()?;
391            if a == Agg::Count {
392                a = Agg::CountCol;
393            }
394            Some(c)
395        };
396        if !self.sym(')') {
397            return Err(SqlError::Syntax("expected ')' after aggregate".into()));
398        }
399        Ok((a, col))
400    }
401
402    fn cmp_op(&mut self) -> Result<String, SqlError> {
403        Ok(match self.peek().cloned() {
404            Some(Tok::Sym('=')) => {
405                self.pos += 1;
406                "=".to_string()
407            }
408            Some(Tok::Sym('<')) => {
409                self.pos += 1;
410                "<".to_string()
411            }
412            Some(Tok::Sym('>')) => {
413                self.pos += 1;
414                ">".to_string()
415            }
416            Some(Tok::Op2(o)) => {
417                self.pos += 1;
418                match o.as_str() {
419                    "<>" | "!=" => "!=".to_string(),
420                    other => other.to_string(),
421                }
422            }
423            other => {
424                return Err(SqlError::Syntax(format!(
425                    "expected an operator, found {other:?}"
426                )));
427            }
428        })
429    }
430
431    fn comparison(&mut self) -> Result<Cond, SqlError> {
432        // An aggregate call (`SUM(x) > 5`): meaningful in HAVING,
433        // refused with its own message in WHERE.
434        if let Some(Tok::Word(w)) = self.peek().cloned()
435            && let Some(a) = agg_kw(&w)
436            && matches!(self.toks.get(self.pos + 1), Some(Tok::Sym('(')))
437        {
438            self.pos += 1; // the name
439            let (a, col) = self.agg_call(a)?;
440            let op = self.cmp_op()?;
441            let rhs = self.scalar()?;
442            return Ok(Cond::AggCmp(a, col, op, rhs));
443        }
444        let col = self.col_ref()?;
445        if self.kw("IS") {
446            let not = self.kw("NOT");
447            self.expect_kw("NULL")?;
448            return Ok(Cond::IsNull(col, !not));
449        }
450        if self.kw("LIKE") {
451            match self.peek().cloned() {
452                Some(Tok::Str(p)) => {
453                    self.pos += 1;
454                    return Ok(Cond::Like(col, p));
455                }
456                _ => return Err(SqlError::Syntax("LIKE takes a string pattern".into())),
457            }
458        }
459        if self.kw("IN") {
460            if !self.sym('(') {
461                return Err(SqlError::Syntax("IN takes a parenthesized list".into()));
462            }
463            let mut items = Vec::new();
464            loop {
465                items.push(self.scalar()?);
466                if !self.sym(',') {
467                    break;
468                }
469            }
470            if !self.sym(')') {
471                return Err(SqlError::Syntax("expected ')' after IN list".into()));
472            }
473            return Ok(Cond::In(col, items));
474        }
475        let op = self.cmp_op()?;
476        let rhs = self.scalar()?;
477        Ok(Cond::Cmp(col, op, rhs))
478    }
479}
480
481// ---------------------------------------------------------------
482// Emission
483// ---------------------------------------------------------------
484
485/// The tables in scope: (name, alias). The first is the FROM table
486/// — the driver under driver-first correlation; the second the
487/// JOIN table, whose witness the pipeline reads as `$*1`.
488struct Scope {
489    from: (String, Option<String>),
490    join: Option<(String, Option<String>)>,
491    /// Whether operands are being emitted inside the joined
492    /// expression's ON bracket (driver = `$$::col`, joined side
493    /// bare) rather than in pipeline position (driver bare,
494    /// joined side = `$*1::col`).
495    on_clause: bool,
496}
497
498impl Scope {
499    /// Whether `col` belongs to the FROM (left) table: qualified by
500    /// its name or alias, or unqualified with no join.
501    fn is_left(&self, col: &ColRef) -> Result<bool, SqlError> {
502        let Some(q) = &col.table else {
503            if self.join.is_some() {
504                return Err(SqlError::Unsupported(format!(
505                    "unqualified column '{}' in a JOIN (qualify it)",
506                    col.column
507                )));
508            }
509            return Ok(true);
510        };
511        let matches = |(name, alias): &(String, Option<String>)| {
512            q.eq_ignore_ascii_case(name)
513                || alias.as_ref().is_some_and(|a| q.eq_ignore_ascii_case(a))
514        };
515        if matches(&self.from) {
516            Ok(true)
517        } else if self.join.as_ref().is_some_and(matches) {
518            Ok(false)
519        } else {
520            Err(SqlError::Syntax(format!("unknown table qualifier '{q}'")))
521        }
522    }
523
524    /// The operand for `col`. In pipeline position the driver's
525    /// columns are bare and the joined table's ride its witness
526    /// (`$*1::col`); inside the ON bracket the joined table's are
527    /// bare and the driver is reached as `$$::col`.
528    fn operand(&self, col: &ColRef) -> Result<String, SqlError> {
529        let key = quarb_key(&col.column)?;
530        Ok(match (self.join.is_some(), self.on_clause) {
531            (false, _) => format!("::{key}"),
532            (true, false) if self.is_left(col)? => format!("::{key}"),
533            (true, false) => format!("$*1::{key}"),
534            (true, true) if self.is_left(col)? => format!("$$::{key}"),
535            (true, true) => format!("::{key}"),
536        })
537    }
538
539    /// This scope, switched to ON-bracket emission.
540    fn on(&self) -> Scope {
541        Scope {
542            from: self.from.clone(),
543            join: self.join.clone(),
544            on_clause: true,
545        }
546    }
547}
548
549/// A SQL string as a Quarb string literal: double-quoted, with the
550/// Quarb lexer's `\"`, `\\`, `\$`, and `` \` `` escapes applied so
551/// the content survives verbatim (`$` would otherwise read as an
552/// interpolation).
553fn quarb_str(s: &str) -> String {
554    let mut out = String::with_capacity(s.len() + 2);
555    out.push('"');
556    for c in s.chars() {
557        if matches!(c, '"' | '\\' | '$' | '`') {
558            out.push('\\');
559        }
560        out.push(c);
561    }
562    out.push('"');
563    out
564}
565
566/// A SQL identifier as a Quarb name (a step matcher or projection
567/// key): bare when it lexes as one, single-quoted otherwise (a
568/// quoted SQL identifier can hold spaces or keywords). A name with
569/// a quote in it does not translate.
570fn quarb_key(name: &str) -> Result<String, SqlError> {
571    let bare = !name.is_empty()
572        && name
573            .chars()
574            .all(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
575        && !name.starts_with('.')
576        && !matches!(name, "and" | "or" | "not");
577    if bare {
578        return Ok(name.to_string());
579    }
580    if name.contains('\'') {
581        return Err(SqlError::Unsupported(format!(
582            "the identifier {name:?} (a quote inside a quoted identifier)"
583        )));
584    }
585    Ok(format!("'{name}'"))
586}
587
588/// A scalar as a Quarb operand; columns resolve through the scope.
589fn scalar_text(s: &Scalar, scope: &Scope) -> Result<String, SqlError> {
590    Ok(match s {
591        Scalar::Col(c) => scope.operand(c)?,
592        Scalar::Str(v) => quarb_str(v),
593        Scalar::Num(n) => n.clone(),
594        Scalar::Null => "null".to_string(),
595    })
596}
597
598fn emit_cond(c: &Cond, scope: &Scope, notes: &mut Vec<String>) -> Result<String, SqlError> {
599    Ok(match c {
600        Cond::Cmp(col, op, rhs) => {
601            let lhs = scope.operand(col)?;
602            format!("{lhs} {op} {}", scalar_text(rhs, scope)?)
603        }
604        Cond::AggCmp(..) => {
605            return Err(SqlError::Unsupported(
606                "an aggregate in WHERE (SQL puts it in HAVING)".into(),
607            ));
608        }
609        Cond::Like(col, pat) => {
610            let lhs = scope.operand(col)?;
611            let inner = pat.trim_matches('%');
612            if inner.contains('%') || inner.contains('_') {
613                return Err(SqlError::Unsupported(format!(
614                    "LIKE pattern '{pat}' (only simple %x%, x%, %x forms translate)"
615                )));
616            }
617            notes.push(
618                "LIKE: translated to a case-insensitive regex (SQL's default \
619                 ASCII case folding)"
620                    .to_string(),
621            );
622            let esc = regex_escape(inner);
623            match (pat.starts_with('%'), pat.ends_with('%')) {
624                (true, true) => format!("{lhs} =~ /(?i){esc}/"),
625                (false, true) => format!("{lhs} =~ /(?i)^{esc}/"),
626                (true, false) => format!("{lhs} =~ /(?i){esc}$/"),
627                (false, false) => format!("{lhs} =~ /(?i)^{esc}$/"),
628            }
629        }
630        Cond::IsNull(col, is_null) => {
631            // `= null` / `!= null`: Quarb's `value_eq` treats NULL
632            // as an ordinary value, so these are exactly SQL's
633            // `IS [NOT] NULL` — truthiness (`not ::c`) would also
634            // drop 0 and ''.
635            let lhs = scope.operand(col)?;
636            if *is_null {
637                format!("{lhs} = null")
638            } else {
639                format!("{lhs} != null")
640            }
641        }
642        Cond::In(col, items) => {
643            let lhs = scope.operand(col)?;
644            let parts: Vec<String> = items
645                .iter()
646                .map(|s| Ok(format!("{lhs} = {}", scalar_text(s, scope)?)))
647                .collect::<Result<_, SqlError>>()?;
648            format!("({})", parts.join(" or "))
649        }
650        Cond::And(a, b) => format!(
651            "{} and {}",
652            emit_cond(a, scope, notes)?,
653            emit_cond(b, scope, notes)?
654        ),
655        Cond::Or(a, b) => format!(
656            "({} or {})",
657            emit_cond(a, scope, notes)?,
658            emit_cond(b, scope, notes)?
659        ),
660        Cond::Not(a) => format!("not ({})", emit_cond(a, scope, notes)?),
661    })
662}
663
664fn regex_escape(s: &str) -> String {
665    s.chars()
666        .flat_map(|c| {
667            if "\\.+*?()|[]{}^$/".contains(c) {
668                vec!['\\', c]
669            } else {
670                vec![c]
671            }
672        })
673        .collect()
674}
675
676fn agg_fn(a: &Agg) -> &'static str {
677    match a {
678        Agg::Count | Agg::CountCol => "count",
679        Agg::Sum => "sum",
680        Agg::Avg => "mean",
681        Agg::Min => "min",
682        Agg::Max => "max",
683    }
684}
685
686/// Translate one SQL `SELECT` statement to a Quarb query.
687pub fn translate(sql: &str) -> Result<Translation, SqlError> {
688    let toks = lex(sql.trim().trim_end_matches(';'))?;
689    let mut p = Parser { toks, pos: 0 };
690    let mut notes = Vec::new();
691
692    p.expect_kw("SELECT")
693        .map_err(|_| SqlError::Unsupported("only SELECT statements translate".into()))?;
694    let distinct = p.kw("DISTINCT");
695
696    // The select list.
697    let mut items = Vec::new();
698    loop {
699        if p.sym('*') {
700            items.push(SelectItem::Star);
701        } else if let Some(Tok::Word(w)) = p.peek().cloned() {
702            if let Some(a) = agg_kw(&w)
703                && matches!(p.toks.get(p.pos + 1), Some(Tok::Sym('(')))
704            {
705                p.pos += 1; // the name
706                let (a, col) = p.agg_call(a)?;
707                let alias = p.kw("AS").then(|| p.ident()).transpose()?;
708                items.push(SelectItem::Agg(a, col, alias));
709            } else {
710                let col = p.col_ref()?;
711                let alias = p.kw("AS").then(|| p.ident()).transpose()?;
712                items.push(SelectItem::Col(col, alias));
713            }
714        } else {
715            return Err(SqlError::Syntax("expected a select item".into()));
716        }
717        if !p.sym(',') {
718            break;
719        }
720    }
721
722    p.expect_kw("FROM")?;
723    let from_table = p.ident()?;
724    // Keywords that end the FROM clause and so cannot be a bare
725    // table alias.
726    const CLAUSE_KEYWORDS: &[&str] = &[
727        "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "OUTER", "ON", "WHERE", "GROUP",
728        "ORDER", "LIMIT", "HAVING", "UNION",
729    ];
730    let from_alias = if p.kw("AS") {
731        Some(p.ident()?)
732    } else {
733        match p.peek() {
734            Some(Tok::Word(w)) if !CLAUSE_KEYWORDS.contains(&w.to_ascii_uppercase().as_str()) => {
735                Some(p.ident()?)
736            }
737            _ => None,
738        }
739    };
740
741    // One optional inner JOIN ... ON a = b; the outer forms have
742    // no existential-correlation equivalent, so they refuse rather
743    // than silently translating as inner.
744    if p.kw("LEFT") || p.kw("RIGHT") || p.kw("FULL") {
745        return Err(SqlError::Unsupported(
746            "an outer JOIN (Quarb's '<=>' correlation is inner/existential)".into(),
747        ));
748    }
749    if p.kw("CROSS") {
750        return Err(SqlError::Unsupported("CROSS JOIN".into()));
751    }
752    let mut join = None;
753    let mut join_on = None;
754    if p.kw("INNER") || matches!(p.peek(), Some(Tok::Word(w)) if w.eq_ignore_ascii_case("JOIN")) {
755        p.expect_kw("JOIN")?;
756        let t = p.ident()?;
757        let alias = if p.kw("AS") {
758            Some(p.ident()?)
759        } else {
760            match p.peek() {
761                Some(Tok::Word(w)) if !w.eq_ignore_ascii_case("ON") => Some(p.ident()?),
762                _ => None,
763            }
764        };
765        p.expect_kw("ON")?;
766        let l = p.col_ref()?;
767        if !p.sym('=') {
768            return Err(SqlError::Unsupported("non-equi JOIN".into()));
769        }
770        let r = p.col_ref()?;
771        join = Some((t, alias));
772        join_on = Some((l, r));
773        if matches!(p.peek(), Some(Tok::Word(w))
774            if ["JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS"]
775                .contains(&w.to_ascii_uppercase().as_str()))
776        {
777            return Err(SqlError::Unsupported(
778                "more than one JOIN (chain resolutions with '~>' instead)".into(),
779            ));
780        }
781    }
782
783    let scope = Scope {
784        from: (from_table.clone(), from_alias),
785        join: join.clone(),
786        on_clause: false,
787    };
788
789    let where_cond = p.kw("WHERE").then(|| p.cond()).transpose()?;
790    let group_by = p
791        .kw("GROUP")
792        .then(|| {
793            p.expect_kw("BY")?;
794            p.col_ref()
795        })
796        .transpose()?;
797    let having = p.kw("HAVING").then(|| p.cond()).transpose()?;
798    let order_by = p
799        .kw("ORDER")
800        .then(|| -> Result<(ColRef, bool), SqlError> {
801            p.expect_kw("BY")?;
802            let c = p.col_ref()?;
803            let desc = p.kw("DESC");
804            if !desc {
805                p.kw("ASC");
806            }
807            Ok((c, desc))
808        })
809        .transpose()?;
810    let limit = p
811        .kw("LIMIT")
812        .then(|| match p.peek().cloned() {
813            Some(Tok::Num(n)) => {
814                p.pos += 1;
815                Ok(n)
816            }
817            _ => Err(SqlError::Syntax("LIMIT takes a number".into())),
818        })
819        .transpose()?;
820    if let Some(t) = p.peek() {
821        return Err(SqlError::Unsupported(format!(
822            "trailing SQL after the query ({t:?})"
823        )));
824    }
825    if group_by.is_none() && having.is_some() {
826        return Err(SqlError::Unsupported(
827            "HAVING without GROUP BY (a whole-table group)".into(),
828        ));
829    }
830
831    // ---- emit ----
832    let mut q = String::new();
833    if let Some((jt, _)) = &join {
834        let (l, r) = join_on.as_ref().expect("join has ON");
835        // The left/FROM table becomes the correlation context; the
836        // joined table the result context.
837        let (left_col, right_col) = if scope.is_left(l)? { (l, r) } else { (r, l) };
838        write!(
839            q,
840            "/{}/* <=> /{}/*[::{} = $$::{}",
841            quarb_key(&from_table)?,
842            quarb_key(jt)?,
843            quarb_key(&right_col.column)?,
844            quarb_key(&left_col.column)?
845        )
846        .unwrap();
847        if let Some(w) = &where_cond {
848            write!(q, " and {}", emit_cond(w, &scope.on(), &mut notes)?).unwrap();
849        }
850        q.push(']');
851        notes.push(
852            "JOIN: existential semantics — one result row per FROM-table row, \
853             the joined table bound to its first witness"
854                .to_string(),
855        );
856    } else {
857        write!(q, "/{}/*", quarb_key(&from_table)?).unwrap();
858        if let Some(w) = &where_cond {
859            write!(q, "[{}]", emit_cond(w, &scope, &mut notes)?).unwrap();
860        }
861    }
862
863    // GROUP BY: the aggregate rides the plain pipe.
864    if let Some(k) = &group_by {
865        if distinct {
866            return Err(SqlError::Unsupported("SELECT DISTINCT with GROUP BY".into()));
867        }
868        let aggs: Vec<&SelectItem> = items
869            .iter()
870            .filter(|i| matches!(i, SelectItem::Agg(..)))
871            .collect();
872        if aggs.len() != 1 {
873            return Err(SqlError::Unsupported(
874                "GROUP BY translates with exactly one aggregate in the select list".into(),
875            ));
876        }
877        let SelectItem::Agg(a, col, alias) = aggs[0] else {
878            unreachable!()
879        };
880        // Any plain select item must be the group key — anything
881        // else would silently vanish from the translation.
882        let mut key_alias = None;
883        for item in &items {
884            match item {
885                SelectItem::Col(c, ka) if c.column.eq_ignore_ascii_case(&k.column) => {
886                    key_alias = ka.clone();
887                }
888                SelectItem::Col(c, _) => {
889                    return Err(SqlError::Unsupported(format!(
890                        "the non-aggregate column '{}' is not the GROUP BY key",
891                        c.column
892                    )));
893                }
894                SelectItem::Star => {
895                    return Err(SqlError::Unsupported("SELECT * with GROUP BY".into()));
896                }
897                SelectItem::Agg(..) => {}
898            }
899        }
900        notes.push(
901            "GROUP BY: SQL keeps a NULL-key group; Quarb's group drops null keys".to_string(),
902        );
903        if let Some(c) = col {
904            let op = scope.operand(c)?;
905            if matches!(a, Agg::CountCol) {
906                notes.push(format!(
907                    "COUNT({}): Quarb count counts all; the [{op} != null] filter \
908                     restores SQL's NULL-skipping",
909                    c.column
910                ));
911                write!(q, "[{op} != null]").unwrap();
912            }
913            if !matches!(a, Agg::Count | Agg::CountCol) {
914                write!(q, " | {op}").unwrap();
915            }
916        }
917        match &key_alias {
918            Some(ka) => {
919                write!(q, " @| group({}, {})", quarb_str(ka), scope.operand(k)?).unwrap()
920            }
921            None => write!(q, " @| group({})", scope.operand(k)?).unwrap(),
922        }
923        let name = alias.clone().unwrap_or_else(|| agg_fn(a).to_string());
924        if !plain_register(&name) {
925            return Err(SqlError::Unsupported(format!(
926                "the aggregate alias {name:?} (not a plain register name)"
927            )));
928        }
929        write!(q, " | {} | .{name}", agg_fn(a)).unwrap();
930        if let Some(h) = &having {
931            // HAVING refers to the aggregate (an aggregate call,
932            // its alias, or its function name) or the group key.
933            let key_field = key_alias.as_deref().unwrap_or(&k.column);
934            let cond = emit_having(h, a, col.as_ref(), &name, &k.column, key_field)?;
935            write!(q, " | [{cond}]").unwrap();
936        }
937        write!(q, " | %.").unwrap();
938    } else if items.iter().any(|i| matches!(i, SelectItem::Agg(..))) {
939        // Bare aggregates over the whole table.
940        if items.len() != 1 {
941            return Err(SqlError::Unsupported(
942                "mixing aggregates and columns without GROUP BY".into(),
943            ));
944        }
945        if distinct {
946            return Err(SqlError::Unsupported(
947                "SELECT DISTINCT with an aggregate".into(),
948            ));
949        }
950        let SelectItem::Agg(a, col, _) = &items[0] else {
951            unreachable!()
952        };
953        if let Some(c) = col {
954            let op = scope.operand(c)?;
955            if matches!(a, Agg::CountCol) {
956                notes.push(format!(
957                    "COUNT({}): Quarb count counts all; the [{op} != null] filter \
958                     restores SQL's NULL-skipping",
959                    c.column
960                ));
961                write!(q, "[{op} != null]").unwrap();
962            }
963            if !matches!(a, Agg::Count | Agg::CountCol) {
964                write!(q, " | {op}").unwrap();
965            }
966        }
967        write!(q, " @| {}", agg_fn(a)).unwrap();
968    } else {
969        // Plain column selection: sort, then DISTINCT's dedup
970        // (stable, so the sorted order survives), then LIMIT —
971        // SQL applies DISTINCT before ORDER BY and LIMIT.
972        if let Some((c, desc)) = &order_by {
973            write!(q, " @| sort_by({})", scope.operand(c)?).unwrap();
974            if *desc {
975                q.push_str(" @| reverse");
976            }
977        }
978        if distinct {
979            if items.len() != 1 {
980                return Err(SqlError::Unsupported(
981                    "SELECT DISTINCT translates for a single column".into(),
982                ));
983            }
984            let SelectItem::Col(c, _) = &items[0] else {
985                return Err(SqlError::Unsupported("SELECT DISTINCT *".into()));
986            };
987            write!(q, " | {} @| unique", scope.operand(c)?).unwrap();
988            if let Some(n) = &limit {
989                write!(q, " @| [..{n}]").unwrap();
990            }
991            return Ok(Translation { query: q, notes });
992        }
993        if let Some(n) = &limit {
994            write!(q, " @| [..{n}]").unwrap();
995        }
996        if items.len() == 1 && matches!(items[0], SelectItem::Star) {
997            // row nodes as-is
998            notes.push("SELECT *: the result is the row nodes (their locators print)".to_string());
999        } else {
1000            let mut fields = Vec::new();
1001            for item in &items {
1002                match item {
1003                    SelectItem::Star => {
1004                        return Err(SqlError::Unsupported("mixing * with named columns".into()));
1005                    }
1006                    SelectItem::Col(c, alias) => {
1007                        let op = scope.operand(c)?;
1008                        match alias {
1009                            Some(a) => fields.push(format!("{}, {op}", quarb_str(a))),
1010                            // The witness side names its field with
1011                            // the SQL qualifier, so two-sided select
1012                            // lists keep distinct keys.
1013                            None if op.starts_with("$*") => {
1014                                let name = match &c.table {
1015                                    Some(t) => format!("{t}.{}", c.column),
1016                                    None => c.column.clone(),
1017                                };
1018                                fields.push(format!("{}, {op}", quarb_str(&name)));
1019                            }
1020                            None => fields.push(op),
1021                        }
1022                    }
1023                    SelectItem::Agg(..) => unreachable!("handled above"),
1024                }
1025            }
1026            write!(q, " | rec({})", fields.join(", ")).unwrap();
1027            notes.push("the result streams as records (JSONL), not a table".to_string());
1028        }
1029        return Ok(Translation { query: q, notes });
1030    }
1031
1032    // ORDER BY / LIMIT after grouped or aggregate forms; the sort
1033    // key names a field of the group record.
1034    if let Some((c, desc)) = &order_by {
1035        write!(q, " @| sort_by(::{})", quarb_key(&c.column)?).unwrap();
1036        if *desc {
1037            q.push_str(" @| reverse");
1038        }
1039    }
1040    if let Some(n) = &limit {
1041        write!(q, " @| [..{n}]").unwrap();
1042    }
1043    Ok(Translation { query: q, notes })
1044}
1045
1046/// A name usable as a bare Quarb register (`.name` / `$.name`).
1047fn plain_register(name: &str) -> bool {
1048    !name.is_empty()
1049        && name
1050            .chars()
1051            .next()
1052            .is_some_and(|c| c.is_alphabetic() || c == '_')
1053        && name.chars().all(|c| c.is_alphanumeric() || c == '_')
1054}
1055
1056/// A HAVING condition: a single comparison whose left side names
1057/// the (single) aggregate — an aggregate call matching the select
1058/// list's, its alias, or its function name (it is the topic's
1059/// pushed register, `$_`) — or the group key (`$.key`).
1060fn emit_having(
1061    c: &Cond,
1062    agg: &Agg,
1063    agg_col: Option<&ColRef>,
1064    agg_name: &str,
1065    key: &str,
1066    key_field: &str,
1067) -> Result<String, SqlError> {
1068    let rhs_text = |rhs: &Scalar| -> Result<String, SqlError> {
1069        match rhs {
1070            Scalar::Col(_) => Err(SqlError::Unsupported(
1071                "HAVING compares against a literal".into(),
1072            )),
1073            Scalar::Str(v) => Ok(quarb_str(v)),
1074            Scalar::Num(n) => Ok(n.clone()),
1075            Scalar::Null => Ok("null".to_string()),
1076        }
1077    };
1078    match c {
1079        Cond::AggCmp(a, col, op, rhs) => {
1080            let same_col = match (col, agg_col) {
1081                (None, None) => true,
1082                (Some(x), Some(y)) => x.column.eq_ignore_ascii_case(&y.column),
1083                _ => false,
1084            };
1085            if a != agg || !same_col {
1086                return Err(SqlError::Unsupported(
1087                    "HAVING refers to an aggregate not in the select list".into(),
1088                ));
1089            }
1090            Ok(format!("$_ {op} {}", rhs_text(rhs)?))
1091        }
1092        Cond::Cmp(col, op, rhs) => {
1093            let lhs = if col.column.eq_ignore_ascii_case(agg_name) {
1094                "$_".to_string()
1095            } else if col.column.eq_ignore_ascii_case(key) {
1096                if !plain_register(key_field) {
1097                    return Err(SqlError::Unsupported(format!(
1098                        "the group key {key_field:?} in HAVING (not a plain register name)"
1099                    )));
1100                }
1101                format!("$.{key_field}")
1102            } else {
1103                return Err(SqlError::Unsupported(format!(
1104                    "the HAVING column '{}' (name the aggregate or the group key)",
1105                    col.column
1106                )));
1107            };
1108            Ok(format!("{lhs} {op} {}", rhs_text(rhs)?))
1109        }
1110        _ => Err(SqlError::Unsupported(
1111            "HAVING translates for a single comparison".into(),
1112        )),
1113    }
1114}