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