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` → truthiness, `IN (…)` → an `or`-chain,
14//!   `LIKE` → `*=` for `%x%`, `=~` anchors for `x%` / `%x`.
15//! - Aggregates (`COUNT(*)`, `COUNT(col)`, `SUM`/`AVG`/`MIN`/`MAX`)
16//!   → `@|` reductions; `GROUP BY k` → `@| group(::k)` with the
17//!   aggregate riding the plain pipe and `HAVING` a filter after
18//!   it.
19//! - `JOIN t2 ON t2.b = t1.a` → correlation: `/t1/* <=>
20//!   /t2/*[::b = $*1::a]`, with two-sided select lists projected
21//!   through the witness (`$*1::col`).
22//! - `ORDER BY` → `@| sort_by(…)` (`DESC` via `@| reverse`, or a
23//!   numeric `-` key when mixed); `LIMIT n` → `@| [..n]`.
24//!
25//! Refused: subqueries, `UNION`, window functions, `CASE`,
26//! multi-join chains (one `JOIN` translates), aggregate arithmetic
27//! (`SUM(a) + 1`), and any non-`SELECT` statement.
28//!
29//! Known semantic divergences are reported as
30//! [`Translation::notes`] rather than errors:
31//!
32//! - Quarb's `count` counts all rows (SQL `COUNT(col)` skips NULLs;
33//!   the translation adds the dropna filter, and says so).
34//! - Quarb streams records (JSONL), not a result table.
35//! - `LIKE` translations are case-sensitive; MySQL's default
36//!   collation is not.
37
38mod export;
39pub use export::{
40    Partial, Pushdown, export, partial_pushdown, partial_pushdown_explained, pushdown,
41    pushdown_explained,
42};
43
44use std::fmt::Write as _;
45
46/// An error translating a SQL statement.
47#[derive(Debug, thiserror::Error)]
48pub enum SqlError {
49    #[error("SQL syntax error: {0}")]
50    Syntax(String),
51    #[error("unsupported SQL construct: {0}")]
52    Unsupported(String),
53}
54
55/// A successful translation: the Quarb query, plus notes on known
56/// semantic divergences that apply to it.
57#[derive(Debug)]
58pub struct Translation {
59    pub query: String,
60    pub notes: Vec<String>,
61}
62
63// ---------------------------------------------------------------
64// Lexer
65// ---------------------------------------------------------------
66
67#[derive(Debug, Clone, PartialEq)]
68enum Tok {
69    /// A bare identifier or keyword (uppercased for keywords).
70    Word(String),
71    /// A 'string literal'.
72    Str(String),
73    Num(String),
74    Sym(char),
75    /// `<=`, `>=`, `<>`, `!=`
76    Op2(String),
77}
78
79fn lex(input: &str) -> Result<Vec<Tok>, SqlError> {
80    let chars: Vec<char> = input.chars().collect();
81    let mut out = Vec::new();
82    let mut i = 0;
83    while i < chars.len() {
84        let c = chars[i];
85        if c.is_whitespace() {
86            i += 1;
87            continue;
88        }
89        if c == '\'' {
90            let mut s = String::new();
91            i += 1;
92            loop {
93                match chars.get(i) {
94                    Some('\'') if chars.get(i + 1) == Some(&'\'') => {
95                        s.push('\'');
96                        i += 2;
97                    }
98                    Some('\'') => {
99                        i += 1;
100                        break;
101                    }
102                    Some(&ch) => {
103                        s.push(ch);
104                        i += 1;
105                    }
106                    None => return Err(SqlError::Syntax("unterminated string".into())),
107                }
108            }
109            out.push(Tok::Str(s));
110            continue;
111        }
112        if c.is_ascii_digit() {
113            let mut s = String::new();
114            while let Some(&ch) = chars.get(i) {
115                if ch.is_ascii_digit() || ch == '.' {
116                    s.push(ch);
117                    i += 1;
118                } else {
119                    break;
120                }
121            }
122            out.push(Tok::Num(s));
123            continue;
124        }
125        if c.is_alphabetic() || c == '_' || c == '"' || c == '`' {
126            // Quoted identifiers keep their case; bare words are
127            // identifiers-or-keywords (keywords match uppercased).
128            if c == '"' || c == '`' {
129                let quote = c;
130                let mut s = String::new();
131                i += 1;
132                while let Some(&ch) = chars.get(i) {
133                    i += 1;
134                    if ch == quote {
135                        break;
136                    }
137                    s.push(ch);
138                }
139                out.push(Tok::Word(s));
140                continue;
141            }
142            let mut s = String::new();
143            while let Some(&ch) = chars.get(i) {
144                if ch.is_alphanumeric() || ch == '_' {
145                    s.push(ch);
146                    i += 1;
147                } else {
148                    break;
149                }
150            }
151            out.push(Tok::Word(s));
152            continue;
153        }
154        if (c == '<' && matches!(chars.get(i + 1), Some('=') | Some('>')))
155            || (c == '>' && chars.get(i + 1) == Some(&'='))
156            || (c == '!' && chars.get(i + 1) == Some(&'='))
157        {
158            out.push(Tok::Op2(format!("{c}{}", chars[i + 1])));
159            i += 2;
160            continue;
161        }
162        if "(),.*=<>;".contains(c) {
163            out.push(Tok::Sym(c));
164            i += 1;
165            continue;
166        }
167        return Err(SqlError::Syntax(format!("unexpected character '{c}'")));
168    }
169    Ok(out)
170}
171
172// ---------------------------------------------------------------
173// Parser / translator
174// ---------------------------------------------------------------
175
176/// A column reference: optionally table-qualified.
177#[derive(Debug, Clone)]
178struct ColRef {
179    table: Option<String>,
180    column: String,
181}
182
183#[derive(Debug, Clone)]
184enum Scalar {
185    Col(ColRef),
186    Str(String),
187    Num(String),
188    Null,
189}
190
191#[derive(Debug)]
192enum Cond {
193    Cmp(ColRef, String, Scalar),
194    Like(ColRef, String),
195    IsNull(ColRef, bool),
196    In(ColRef, Vec<Scalar>),
197    And(Box<Cond>, Box<Cond>),
198    Or(Box<Cond>, Box<Cond>),
199    Not(Box<Cond>),
200}
201
202#[derive(Debug, Clone, PartialEq)]
203enum Agg {
204    Count,
205    CountCol,
206    Sum,
207    Avg,
208    Min,
209    Max,
210}
211
212#[derive(Debug)]
213enum SelectItem {
214    Star,
215    Col(ColRef, Option<String>),
216    Agg(Agg, Option<ColRef>, Option<String>),
217}
218
219struct Parser {
220    toks: Vec<Tok>,
221    pos: usize,
222}
223
224impl Parser {
225    fn peek(&self) -> Option<&Tok> {
226        self.toks.get(self.pos)
227    }
228
229    fn kw(&mut self, word: &str) -> bool {
230        if matches!(self.peek(), Some(Tok::Word(w)) if w.eq_ignore_ascii_case(word)) {
231            self.pos += 1;
232            true
233        } else {
234            false
235        }
236    }
237
238    fn expect_kw(&mut self, word: &str) -> Result<(), SqlError> {
239        if self.kw(word) {
240            Ok(())
241        } else {
242            Err(SqlError::Syntax(format!("expected {word}")))
243        }
244    }
245
246    fn sym(&mut self, c: char) -> bool {
247        if self.peek() == Some(&Tok::Sym(c)) {
248            self.pos += 1;
249            true
250        } else {
251            false
252        }
253    }
254
255    fn ident(&mut self) -> Result<String, SqlError> {
256        match self.peek() {
257            Some(Tok::Word(w)) => {
258                let w = w.clone();
259                self.pos += 1;
260                Ok(w)
261            }
262            other => Err(SqlError::Syntax(format!(
263                "expected an identifier, found {other:?}"
264            ))),
265        }
266    }
267
268    fn col_ref(&mut self) -> Result<ColRef, SqlError> {
269        let first = self.ident()?;
270        if self.sym('.') {
271            let column = self.ident()?;
272            Ok(ColRef {
273                table: Some(first),
274                column,
275            })
276        } else {
277            Ok(ColRef {
278                table: None,
279                column: first,
280            })
281        }
282    }
283
284    fn scalar(&mut self) -> Result<Scalar, SqlError> {
285        match self.peek().cloned() {
286            Some(Tok::Str(s)) => {
287                self.pos += 1;
288                Ok(Scalar::Str(s))
289            }
290            Some(Tok::Num(n)) => {
291                self.pos += 1;
292                Ok(Scalar::Num(n))
293            }
294            Some(Tok::Word(w)) if w.eq_ignore_ascii_case("NULL") => {
295                self.pos += 1;
296                Ok(Scalar::Null)
297            }
298            Some(Tok::Word(_)) => Ok(Scalar::Col(self.col_ref()?)),
299            other => Err(SqlError::Syntax(format!(
300                "expected a value, found {other:?}"
301            ))),
302        }
303    }
304
305    // cond := and_cond (OR and_cond)*
306    fn cond(&mut self) -> Result<Cond, SqlError> {
307        let mut left = self.and_cond()?;
308        while self.kw("OR") {
309            let right = self.and_cond()?;
310            left = Cond::Or(Box::new(left), Box::new(right));
311        }
312        Ok(left)
313    }
314
315    fn and_cond(&mut self) -> Result<Cond, SqlError> {
316        let mut left = self.not_cond()?;
317        while self.kw("AND") {
318            let right = self.not_cond()?;
319            left = Cond::And(Box::new(left), Box::new(right));
320        }
321        Ok(left)
322    }
323
324    fn not_cond(&mut self) -> Result<Cond, SqlError> {
325        if self.kw("NOT") {
326            return Ok(Cond::Not(Box::new(self.not_cond()?)));
327        }
328        if self.sym('(') {
329            let inner = self.cond()?;
330            if !self.sym(')') {
331                return Err(SqlError::Syntax("expected ')'".into()));
332            }
333            return Ok(inner);
334        }
335        self.comparison()
336    }
337
338    fn comparison(&mut self) -> Result<Cond, SqlError> {
339        let col = self.col_ref()?;
340        if self.kw("IS") {
341            let not = self.kw("NOT");
342            self.expect_kw("NULL")?;
343            return Ok(Cond::IsNull(col, !not));
344        }
345        if self.kw("LIKE") {
346            match self.peek().cloned() {
347                Some(Tok::Str(p)) => {
348                    self.pos += 1;
349                    return Ok(Cond::Like(col, p));
350                }
351                _ => return Err(SqlError::Syntax("LIKE takes a string pattern".into())),
352            }
353        }
354        if self.kw("IN") {
355            if !self.sym('(') {
356                return Err(SqlError::Syntax("IN takes a parenthesized list".into()));
357            }
358            let mut items = Vec::new();
359            loop {
360                items.push(self.scalar()?);
361                if !self.sym(',') {
362                    break;
363                }
364            }
365            if !self.sym(')') {
366                return Err(SqlError::Syntax("expected ')' after IN list".into()));
367            }
368            return Ok(Cond::In(col, items));
369        }
370        let op = match self.peek().cloned() {
371            Some(Tok::Sym('=')) => {
372                self.pos += 1;
373                "=".to_string()
374            }
375            Some(Tok::Sym('<')) => {
376                self.pos += 1;
377                "<".to_string()
378            }
379            Some(Tok::Sym('>')) => {
380                self.pos += 1;
381                ">".to_string()
382            }
383            Some(Tok::Op2(o)) => {
384                self.pos += 1;
385                match o.as_str() {
386                    "<>" | "!=" => "!=".to_string(),
387                    other => other.to_string(),
388                }
389            }
390            other => {
391                return Err(SqlError::Syntax(format!(
392                    "expected an operator, found {other:?}"
393                )));
394            }
395        };
396        let rhs = self.scalar()?;
397        Ok(Cond::Cmp(col, op, rhs))
398    }
399}
400
401// ---------------------------------------------------------------
402// Emission
403// ---------------------------------------------------------------
404
405/// The tables in scope: (name, alias). The first is the FROM table
406/// (correlation context `$*1` when a JOIN is present); the second
407/// the JOIN table.
408struct Scope {
409    from: (String, Option<String>),
410    join: Option<(String, Option<String>)>,
411}
412
413impl Scope {
414    /// Whether `col` belongs to the FROM (left) table: qualified by
415    /// its name or alias, or unqualified with no join.
416    fn is_left(&self, col: &ColRef) -> Result<bool, SqlError> {
417        let Some(q) = &col.table else {
418            if self.join.is_some() {
419                return Err(SqlError::Unsupported(format!(
420                    "unqualified column '{}' in a JOIN (qualify it)",
421                    col.column
422                )));
423            }
424            return Ok(true);
425        };
426        let matches = |(name, alias): &(String, Option<String>)| {
427            q.eq_ignore_ascii_case(name)
428                || alias.as_ref().is_some_and(|a| q.eq_ignore_ascii_case(a))
429        };
430        if matches(&self.from) {
431            Ok(true)
432        } else if self.join.as_ref().is_some_and(matches) {
433            Ok(false)
434        } else {
435            Err(SqlError::Syntax(format!("unknown table qualifier '{q}'")))
436        }
437    }
438
439    /// The operand for `col`: `::col`, or `$*1::col` for the left
440    /// side under a join (whose result context is the right side).
441    fn operand(&self, col: &ColRef) -> Result<String, SqlError> {
442        Ok(if self.join.is_some() && self.is_left(col)? {
443            format!("$*1::{}", col.column)
444        } else {
445            format!("::{}", col.column)
446        })
447    }
448}
449
450fn scalar_text(s: &Scalar) -> String {
451    match s {
452        Scalar::Col(c) => format!("::{}", c.column),
453        Scalar::Str(v) => format!("\"{v}\""),
454        Scalar::Num(n) => n.clone(),
455        Scalar::Null => "null".to_string(),
456    }
457}
458
459fn emit_cond(c: &Cond, scope: &Scope, notes: &mut Vec<String>) -> Result<String, SqlError> {
460    Ok(match c {
461        Cond::Cmp(col, op, rhs) => {
462            let lhs = scope.operand(col)?;
463            let rhs = match rhs {
464                Scalar::Col(r) => scope.operand(r)?,
465                other => scalar_text(other),
466            };
467            format!("{lhs} {op} {rhs}")
468        }
469        Cond::Like(col, pat) => {
470            let lhs = scope.operand(col)?;
471            let inner = pat.trim_matches('%');
472            if inner.contains('%') || inner.contains('_') {
473                return Err(SqlError::Unsupported(format!(
474                    "LIKE pattern '{pat}' (only simple %x%, x%, %x forms translate)"
475                )));
476            }
477            notes.push(
478                "LIKE: translated to a case-insensitive regex (SQL's default \
479                 ASCII case folding)"
480                    .to_string(),
481            );
482            let esc = regex_escape(inner);
483            match (pat.starts_with('%'), pat.ends_with('%')) {
484                (true, true) => format!("{lhs} =~ /(?i){esc}/"),
485                (false, true) => format!("{lhs} =~ /(?i)^{esc}/"),
486                (true, false) => format!("{lhs} =~ /(?i){esc}$/"),
487                (false, false) => format!("{lhs} =~ /(?i)^{esc}$/"),
488            }
489        }
490        Cond::IsNull(col, is_null) => {
491            let lhs = scope.operand(col)?;
492            if *is_null { format!("not {lhs}") } else { lhs }
493        }
494        Cond::In(col, items) => {
495            let lhs = scope.operand(col)?;
496            let parts: Vec<String> = items
497                .iter()
498                .map(|s| format!("{lhs} = {}", scalar_text(s)))
499                .collect();
500            format!("({})", parts.join(" or "))
501        }
502        Cond::And(a, b) => format!(
503            "{} and {}",
504            emit_cond(a, scope, notes)?,
505            emit_cond(b, scope, notes)?
506        ),
507        Cond::Or(a, b) => format!(
508            "({} or {})",
509            emit_cond(a, scope, notes)?,
510            emit_cond(b, scope, notes)?
511        ),
512        Cond::Not(a) => format!("not ({})", emit_cond(a, scope, notes)?),
513    })
514}
515
516fn regex_escape(s: &str) -> String {
517    s.chars()
518        .flat_map(|c| {
519            if "\\.+*?()|[]{}^$/".contains(c) {
520                vec!['\\', c]
521            } else {
522                vec![c]
523            }
524        })
525        .collect()
526}
527
528fn agg_fn(a: &Agg) -> &'static str {
529    match a {
530        Agg::Count | Agg::CountCol => "count",
531        Agg::Sum => "sum",
532        Agg::Avg => "mean",
533        Agg::Min => "min",
534        Agg::Max => "max",
535    }
536}
537
538/// Translate one SQL `SELECT` statement to a Quarb query.
539pub fn translate(sql: &str) -> Result<Translation, SqlError> {
540    let toks = lex(sql.trim().trim_end_matches(';'))?;
541    let mut p = Parser { toks, pos: 0 };
542    let mut notes = Vec::new();
543
544    p.expect_kw("SELECT")
545        .map_err(|_| SqlError::Unsupported("only SELECT statements translate".into()))?;
546    let distinct = p.kw("DISTINCT");
547
548    // The select list.
549    let mut items = Vec::new();
550    loop {
551        if p.sym('*') {
552            items.push(SelectItem::Star);
553        } else if let Some(Tok::Word(w)) = p.peek().cloned() {
554            let agg = match w.to_ascii_uppercase().as_str() {
555                "COUNT" => Some(Agg::Count),
556                "SUM" => Some(Agg::Sum),
557                "AVG" => Some(Agg::Avg),
558                "MIN" => Some(Agg::Min),
559                "MAX" => Some(Agg::Max),
560                _ => None,
561            };
562            if let Some(mut a) = agg
563                && matches!(p.toks.get(p.pos + 1), Some(Tok::Sym('(')))
564            {
565                {
566                    p.pos += 2;
567                    let col = if p.sym('*') {
568                        None
569                    } else {
570                        let c = p.col_ref()?;
571                        if a == Agg::Count {
572                            a = Agg::CountCol;
573                        }
574                        Some(c)
575                    };
576                    if !p.sym(')') {
577                        return Err(SqlError::Syntax("expected ')' after aggregate".into()));
578                    }
579                    let alias = p.kw("AS").then(|| p.ident()).transpose()?;
580                    items.push(SelectItem::Agg(a, col, alias));
581                    if p.sym(',') {
582                        continue;
583                    }
584                    break;
585                }
586            }
587            let col = p.col_ref()?;
588            let alias = p.kw("AS").then(|| p.ident()).transpose()?;
589            items.push(SelectItem::Col(col, alias));
590        } else {
591            return Err(SqlError::Syntax("expected a select item".into()));
592        }
593        if !p.sym(',') {
594            break;
595        }
596    }
597
598    p.expect_kw("FROM")?;
599    let from_table = p.ident()?;
600    let from_alias = match p.peek() {
601        Some(Tok::Word(w))
602            if ![
603                "JOIN", "INNER", "WHERE", "GROUP", "ORDER", "LIMIT", "HAVING",
604            ]
605            .contains(&w.to_ascii_uppercase().as_str()) =>
606        {
607            Some(p.ident()?)
608        }
609        _ => None,
610    };
611
612    // One optional JOIN ... ON a = b.
613    let mut join = None;
614    let mut join_on = None;
615    if p.kw("INNER") || matches!(p.peek(), Some(Tok::Word(w)) if w.eq_ignore_ascii_case("JOIN")) {
616        p.expect_kw("JOIN")?;
617        let t = p.ident()?;
618        let alias = match p.peek() {
619            Some(Tok::Word(w)) if !w.eq_ignore_ascii_case("ON") => Some(p.ident()?),
620            _ => None,
621        };
622        p.expect_kw("ON")?;
623        let l = p.col_ref()?;
624        if !p.sym('=') {
625            return Err(SqlError::Unsupported("non-equi JOIN".into()));
626        }
627        let r = p.col_ref()?;
628        join = Some((t, alias));
629        join_on = Some((l, r));
630        if matches!(p.peek(), Some(Tok::Word(w)) if w.eq_ignore_ascii_case("JOIN"))
631            || matches!(p.peek(), Some(Tok::Word(w)) if w.eq_ignore_ascii_case("INNER"))
632            || matches!(p.peek(), Some(Tok::Word(w)) if w.eq_ignore_ascii_case("LEFT"))
633        {
634            return Err(SqlError::Unsupported(
635                "more than one JOIN (chain resolutions with '~>' instead)".into(),
636            ));
637        }
638    }
639
640    let scope = Scope {
641        from: (from_table.clone(), from_alias),
642        join: join.clone(),
643    };
644
645    let where_cond = p.kw("WHERE").then(|| p.cond()).transpose()?;
646    let group_by = p
647        .kw("GROUP")
648        .then(|| {
649            p.expect_kw("BY")?;
650            p.col_ref()
651        })
652        .transpose()?;
653    let having = p.kw("HAVING").then(|| p.cond()).transpose()?;
654    let order_by = p
655        .kw("ORDER")
656        .then(|| -> Result<(ColRef, bool), SqlError> {
657            p.expect_kw("BY")?;
658            let c = p.col_ref()?;
659            let desc = p.kw("DESC");
660            if !desc {
661                p.kw("ASC");
662            }
663            Ok((c, desc))
664        })
665        .transpose()?;
666    let limit = p
667        .kw("LIMIT")
668        .then(|| match p.peek().cloned() {
669            Some(Tok::Num(n)) => {
670                p.pos += 1;
671                Ok(n)
672            }
673            _ => Err(SqlError::Syntax("LIMIT takes a number".into())),
674        })
675        .transpose()?;
676    if let Some(t) = p.peek() {
677        return Err(SqlError::Unsupported(format!(
678            "trailing SQL after the query ({t:?})"
679        )));
680    }
681
682    // ---- emit ----
683    let mut q = String::new();
684    if let Some((jt, _)) = &join {
685        let (l, r) = join_on.as_ref().expect("join has ON");
686        // The left/FROM table becomes the correlation context; the
687        // joined table the result context.
688        let (left_col, right_col) = if scope.is_left(l)? { (l, r) } else { (r, l) };
689        write!(
690            q,
691            "/{from_table}/* <=> /{jt}/*[::{} = $*1::{}",
692            right_col.column, left_col.column
693        )
694        .unwrap();
695        if let Some(w) = &where_cond {
696            write!(q, " and {}", emit_cond(w, &scope, &mut notes)?).unwrap();
697        }
698        q.push(']');
699        notes.push(
700            "JOIN: existential semantics — one result row per joined-table row, \
701             bound to its first witness"
702                .to_string(),
703        );
704    } else {
705        write!(q, "/{from_table}/*").unwrap();
706        if let Some(w) = &where_cond {
707            write!(q, "[{}]", emit_cond(w, &scope, &mut notes)?).unwrap();
708        }
709    }
710
711    // GROUP BY: the aggregate rides the plain pipe.
712    if let Some(k) = &group_by {
713        let aggs: Vec<&SelectItem> = items
714            .iter()
715            .filter(|i| matches!(i, SelectItem::Agg(..)))
716            .collect();
717        if aggs.len() != 1 {
718            return Err(SqlError::Unsupported(
719                "GROUP BY translates with exactly one aggregate in the select list".into(),
720            ));
721        }
722        let SelectItem::Agg(a, col, alias) = aggs[0] else {
723            unreachable!()
724        };
725        if let Some(c) = col {
726            if matches!(a, Agg::CountCol) {
727                notes.push(format!(
728                    "COUNT({0}): Quarb count counts all; the dropna filter [::{0}] restores \
729                     SQL's NULL-skipping",
730                    c.column
731                ));
732                write!(q, "[::{}]", c.column).unwrap();
733            }
734            if !matches!(a, Agg::Count | Agg::CountCol) {
735                write!(q, " | ::{}", c.column).unwrap();
736            }
737        }
738        write!(q, " @| group(::{})", k.column).unwrap();
739        let name = alias.clone().unwrap_or_else(|| agg_fn(a).to_string());
740        write!(q, " | {} | .{name}", agg_fn(a)).unwrap();
741        if let Some(h) = &having {
742            // HAVING refers to the aggregate (by alias or function
743            // name) or the group key.
744            let cond = emit_having(h, &name)?;
745            write!(q, " | [{cond}]").unwrap();
746        }
747        write!(q, " | %.").unwrap();
748    } else if items.iter().any(|i| matches!(i, SelectItem::Agg(..))) {
749        // Bare aggregates over the whole table.
750        if items.len() != 1 {
751            return Err(SqlError::Unsupported(
752                "mixing aggregates and columns without GROUP BY".into(),
753            ));
754        }
755        let SelectItem::Agg(a, col, _) = &items[0] else {
756            unreachable!()
757        };
758        if let Some(c) = col {
759            if matches!(a, Agg::CountCol) {
760                notes.push(format!(
761                    "COUNT({0}): Quarb count counts all; the dropna filter [::{0}] restores \
762                     SQL's NULL-skipping",
763                    c.column
764                ));
765                write!(q, "[::{}]", c.column).unwrap();
766            }
767            if !matches!(a, Agg::Count | Agg::CountCol) {
768                write!(q, "::{}", c.column).unwrap();
769            }
770        }
771        write!(q, " @| {}", agg_fn(a)).unwrap();
772    } else {
773        // Plain column selection.
774        if let Some((c, desc)) = &order_by {
775            write!(q, " @| sort_by({})", scope.operand(c)?).unwrap();
776            if *desc {
777                q.push_str(" @| reverse");
778            }
779        }
780        if let Some(n) = &limit {
781            write!(q, " @| [..{n}]").unwrap();
782        }
783        if distinct {
784            if items.len() != 1 {
785                return Err(SqlError::Unsupported(
786                    "SELECT DISTINCT translates for a single column".into(),
787                ));
788            }
789            let SelectItem::Col(c, _) = &items[0] else {
790                return Err(SqlError::Unsupported("SELECT DISTINCT *".into()));
791            };
792            write!(q, " | {} @| unique", scope.operand(c)?).unwrap();
793        } else if items.len() == 1 && matches!(items[0], SelectItem::Star) {
794            // row nodes as-is
795            notes.push("SELECT *: the result is the row nodes (their locators print)".to_string());
796        } else {
797            let mut fields = Vec::new();
798            for item in &items {
799                match item {
800                    SelectItem::Star => {
801                        return Err(SqlError::Unsupported("mixing * with named columns".into()));
802                    }
803                    SelectItem::Col(c, alias) => {
804                        let op = scope.operand(c)?;
805                        match alias {
806                            Some(a) => fields.push(format!("\"{a}\", {op}")),
807                            // The witness side names its field with
808                            // the SQL qualifier, so two-sided select
809                            // lists keep distinct keys.
810                            None if op.starts_with("$*") => {
811                                let name = match &c.table {
812                                    Some(t) => format!("{t}.{}", c.column),
813                                    None => c.column.clone(),
814                                };
815                                fields.push(format!("\"{name}\", {op}"));
816                            }
817                            None => fields.push(op),
818                        }
819                    }
820                    SelectItem::Agg(..) => unreachable!("handled above"),
821                }
822            }
823            write!(q, " | rec({})", fields.join(", ")).unwrap();
824            notes.push("the result streams as records (JSONL), not a table".to_string());
825        }
826        return Ok(Translation { query: q, notes });
827    }
828
829    // ORDER BY / LIMIT after grouped or aggregate forms.
830    if let Some((c, desc)) = &order_by {
831        write!(q, " @| sort_by(::{})", c.column).unwrap();
832        if *desc {
833            q.push_str(" @| reverse");
834        }
835    }
836    if let Some(n) = &limit {
837        write!(q, " @| [..{n}]").unwrap();
838    }
839    Ok(Translation { query: q, notes })
840}
841
842/// A HAVING condition: comparisons against the (single) aggregate,
843/// referred to by its alias or function name — it is the topic's
844/// pushed register.
845fn emit_having(c: &Cond, agg_name: &str) -> Result<String, SqlError> {
846    match c {
847        Cond::Cmp(col, op, rhs) => {
848            let lhs = if col.column.eq_ignore_ascii_case(agg_name)
849                || ["count", "sum", "avg", "min", "max", "n"]
850                    .contains(&col.column.to_ascii_lowercase().as_str())
851            {
852                "$_".to_string()
853            } else {
854                format!("$.{}", col.column)
855            };
856            Ok(format!("{lhs} {op} {}", scalar_text(rhs)))
857        }
858        _ => Err(SqlError::Unsupported(
859            "HAVING translates for a single comparison".into(),
860        )),
861    }
862}