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