1mod 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#[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#[derive(Debug)]
58pub struct Translation {
59 pub query: String,
60 pub notes: Vec<String>,
61}
62
63#[derive(Debug, Clone, PartialEq)]
68enum Tok {
69 Word(String),
71 Str(String),
73 Num(String),
74 Sym(char),
75 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 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#[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 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
401struct Scope {
409 from: (String, Option<String>),
410 join: Option<(String, Option<String>)>,
411}
412
413impl Scope {
414 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 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
538pub 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 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 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 let mut q = String::new();
684 if let Some((jt, _)) = &join {
685 let (l, r) = join_on.as_ref().expect("join has ON");
686 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 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 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 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 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 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 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 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
842fn 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}