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