1mod 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#[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#[derive(Debug)]
65pub struct Translation {
66 pub query: String,
67 pub notes: Vec<String>,
68}
69
70#[derive(Debug, Clone, PartialEq)]
75enum Tok {
76 Word(String),
78 Str(String),
80 Num(String),
81 Sym(char),
82 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 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#[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 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
228fn 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 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 fn agg_call(&mut self, mut a: Agg) -> Result<(Agg, Option<ColRef>), SqlError> {
380 self.pos += 1; 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 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; 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
475struct Scope {
483 from: (String, Option<String>),
484 join: Option<(String, Option<String>)>,
485}
486
487impl Scope {
488 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 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
525fn 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
542fn 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
564fn 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 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
662pub 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 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; 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 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 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 let mut q = String::new();
808 if let Some((jt, _)) = &join {
809 let (l, r) = join_on.as_ref().expect("join has ON");
810 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 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 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 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 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 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 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 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 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
1021fn 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
1031fn 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}