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