#![allow(dead_code)]
use std::iter::Peekable;
use std::str::Chars;
use crate::expressions::column_names::{is_simple_char, parse_escaped_field_name};
use crate::{DeltaResult, Error};
type CharStream<'a> = Peekable<Chars<'a>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Token {
Ident(String),
Literal(String),
Dot,
Lt,
Le,
Gt,
Ge,
Eq,
Ne,
NullSafeEq,
Plus,
Minus,
Keyword(Keyword),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Keyword {
And,
Or,
Not,
Is,
}
pub(super) fn tokenize(sql: &str) -> DeltaResult<Vec<Token>> {
let mut chars = sql.chars().peekable();
let mut tokens = Vec::new();
while let Some(&c) = chars.peek() {
match c {
c if c.is_whitespace() => {
chars.next();
}
'.' => {
if matches!(peek_second(&chars), Some(d) if d.is_ascii_digit()) {
tokens.push(Token::Literal(take_number(&mut chars)));
} else {
chars.next();
tokens.push(Token::Dot);
}
}
'=' => {
chars.next();
chars.next_if_eq(&'=');
tokens.push(Token::Eq);
}
'<' => {
chars.next();
let tok = if chars.next_if_eq(&'=').is_some() {
if chars.next_if_eq(&'>').is_some() {
Token::NullSafeEq
} else {
Token::Le
}
} else if chars.next_if_eq(&'>').is_some() {
Token::Ne
} else {
Token::Lt
};
tokens.push(tok);
}
'>' => {
chars.next();
let tok = if chars.next_if_eq(&'=').is_some() {
Token::Ge
} else {
Token::Gt
};
tokens.push(tok);
}
'!' => {
chars.next();
let tok = if chars.next_if_eq(&'=').is_some() {
Token::Ne
} else if chars.next_if_eq(&'>').is_some() {
Token::Le
} else if chars.next_if_eq(&'<').is_some() {
Token::Ge
} else {
return Err(unexpected('!', sql));
};
tokens.push(tok);
}
'\'' => tokens.push(Token::Literal(take_quoted_string(&mut chars, sql)?)),
'`' => {
chars.next();
tokens.push(Token::Ident(parse_escaped_field_name(&mut chars)?));
}
'+' => {
chars.next();
tokens.push(Token::Plus);
}
'-' => {
chars.next();
tokens.push(Token::Minus);
}
c if c.is_ascii_digit() => tokens.push(Token::Literal(take_number(&mut chars))),
c if c.is_ascii_alphabetic() || c == '_' => {
tokens.push(classify_word(&mut chars, sql)?)
}
_ => return Err(unexpected(c, sql)),
}
}
Ok(tokens)
}
fn unexpected(c: char, sql: &str) -> Error {
Error::generic(format!("unexpected character '{c}' in {sql}"))
}
fn peek_second(chars: &CharStream<'_>) -> Option<char> {
let mut lookahead = chars.clone();
lookahead.next();
lookahead.peek().copied()
}
fn take_quoted_string(chars: &mut CharStream<'_>, sql: &str) -> DeltaResult<String> {
if chars.next_if_eq(&'\'').is_none() {
return Err(Error::generic(format!(
"string literal must start with a quote in {sql}"
)));
}
let mut out = String::from('\'');
loop {
match chars.next() {
Some('\'') => {
out.push('\'');
match chars.next_if_eq(&'\'') {
Some(q) => out.push(q),
None => return Ok(out),
}
}
Some(c) => out.push(c),
None => {
return Err(Error::generic(format!(
"unterminated string literal in {sql}"
)))
}
}
}
}
fn take_number(chars: &mut CharStream<'_>) -> String {
let mut out = String::new();
while let Some(c) = chars.next_if(|c| c.is_ascii_digit() || *c == '.') {
out.push(c);
}
if let Some(e) = chars.next_if(|c| *c == 'e' || *c == 'E') {
out.push(e);
if let Some(sign) = chars.next_if(|c| *c == '+' || *c == '-') {
out.push(sign);
}
while let Some(c) = chars.next_if(|c| c.is_ascii_digit()) {
out.push(c);
}
}
out
}
fn classify_word(chars: &mut CharStream<'_>, sql: &str) -> DeltaResult<Token> {
let mut word = String::new();
while let Some(c) = chars.next_if(|c| is_simple_char(*c)) {
word.push(c);
}
let token = match word.to_ascii_uppercase().as_str() {
"AND" => Token::Keyword(Keyword::And),
"OR" => Token::Keyword(Keyword::Or),
"NOT" => Token::Keyword(Keyword::Not),
"IS" => Token::Keyword(Keyword::Is),
"NULL" | "TRUE" | "FALSE" => Token::Literal(word),
"X" if chars.peek() == Some(&'\'') => {
let quoted = take_quoted_string(chars, sql)?;
Token::Literal(format!("{word}{quoted}"))
}
"DATE" | "TIMESTAMP" | "TIMESTAMP_LTZ" | "TIMESTAMP_NTZ" if quote_follows(chars) => {
while chars.next_if(|c| c.is_whitespace()).is_some() {}
let quoted = take_quoted_string(chars, sql)?;
Token::Literal(format!("{word} {quoted}"))
}
_ => Token::Ident(word),
};
Ok(token)
}
fn quote_follows(chars: &CharStream<'_>) -> bool {
let mut lookahead = chars.clone();
while matches!(lookahead.peek(), Some(c) if c.is_whitespace()) {
lookahead.next();
}
lookahead.peek() == Some(&'\'')
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::{tokenize, Keyword, Token};
fn ident(s: &str) -> Token {
Token::Ident(s.to_string())
}
fn lit(s: &str) -> Token {
Token::Literal(s.to_string())
}
#[rstest]
#[case("<", Token::Lt)]
#[case("<=", Token::Le)]
#[case(">", Token::Gt)]
#[case(">=", Token::Ge)]
#[case("=", Token::Eq)]
#[case("==", Token::Eq)]
#[case("!=", Token::Ne)]
#[case("<>", Token::Ne)]
#[case("<=>", Token::NullSafeEq)]
#[case("!>", Token::Le)]
#[case("!<", Token::Ge)]
#[case("+", Token::Plus)]
#[case("-", Token::Minus)]
fn tokenizes_each_operator(#[case] op: &str, #[case] expected: Token) {
assert_eq!(
tokenize(&format!("a {op} 1")).unwrap(),
[ident("a"), expected.clone(), lit("1")]
);
assert_eq!(
tokenize(&format!("a{op}1")).unwrap(),
[ident("a"), expected, lit("1")]
);
}
#[rstest]
#[case("-5", &[Token::Minus, lit("5")])]
#[case("- 5", &[Token::Minus, lit("5")])]
#[case("+5", &[Token::Plus, lit("5")])]
#[case("-.5", &[Token::Minus, lit(".5")])]
#[case("-2e+1", &[Token::Minus, lit("2e+1")])]
#[case("1+1", &[lit("1"), Token::Plus, lit("1")])]
fn tokenizes_signed_number_as_sign_then_literal(#[case] sql: &str, #[case] expected: &[Token]) {
assert_eq!(tokenize(sql).unwrap(), expected);
}
#[rstest]
#[case("42", lit("42"))]
#[case(".5", lit(".5"))]
#[case("1e3", lit("1e3"))]
#[case("2e+1", lit("2e+1"))] #[case("'foo'", lit("'foo'"))]
#[case("'O''Brien'", lit("'O''Brien'"))] #[case("NULL", lit("NULL"))]
#[case("TRUE", lit("TRUE"))]
#[case("false", lit("false"))]
#[case("DATE '1970-01-02'", lit("DATE '1970-01-02'"))]
#[case("DATE'1970-01-02'", lit("DATE '1970-01-02'"))] #[case("X'01ff'", lit("X'01ff'"))]
fn tokenizes_literal_as_single_raw_token(#[case] sql: &str, #[case] expected: Token) {
assert_eq!(tokenize(sql).unwrap(), [expected]);
}
#[rstest]
#[case("DATE '2024-01-01'")]
#[case("TIMESTAMP '2024-01-01T00:00:00Z'")]
#[case("TIMESTAMP_LTZ '2024-01-01T00:00:00Z'")]
#[case("TIMESTAMP_NTZ '2024-01-01 00:00:00'")]
fn every_typed_keyword_tokenizes_to_one_literal(#[case] sql: &str) {
assert!(matches!(
tokenize(sql).unwrap().as_slice(),
[Token::Literal(_)]
));
}
#[rstest]
#[case("AND", Token::Keyword(Keyword::And))]
#[case("or", Token::Keyword(Keyword::Or))]
#[case("Not", Token::Keyword(Keyword::Not))]
#[case("IS", Token::Keyword(Keyword::Is))]
#[case("`AND`", ident("AND"))]
fn tokenizes_keyword_vs_quoted_identifier(#[case] sql: &str, #[case] expected: Token) {
assert_eq!(tokenize(sql).unwrap(), [expected]);
}
#[test]
fn tokenizes_dotted_column_path() {
assert_eq!(
tokenize("a.b.c").unwrap(),
[ident("a"), Token::Dot, ident("b"), Token::Dot, ident("c")]
);
}
#[rstest]
#[case("")]
#[case(" ")]
#[case("\t\n ")]
fn tokenizes_empty_or_whitespace_only_input_to_no_tokens(#[case] sql: &str) {
assert_eq!(tokenize(sql).unwrap(), []);
}
#[rstest]
#[case("1e", lit("1e"))]
#[case("1.2.3", lit("1.2.3"))]
#[case("1E3", lit("1E3"))]
#[case("5e-3", lit("5e-3"))]
fn tokenizes_malformed_number_as_single_raw_token(#[case] sql: &str, #[case] expected: Token) {
assert_eq!(tokenize(sql).unwrap(), [expected]);
}
#[rstest]
#[case("`my col`", ident("my col"))]
#[case("`a.b`", ident("a.b"))]
#[case("`a``b`", ident("a`b"))]
fn tokenizes_backtick_quoted_field(#[case] sql: &str, #[case] expected: Token) {
assert_eq!(tokenize(sql).unwrap(), [expected]);
}
#[test]
fn tokenizes_backtick_field_in_comparison_and_path() {
assert_eq!(
tokenize("`my col` > 0").unwrap(),
[ident("my col"), Token::Gt, lit("0")]
);
assert_eq!(
tokenize("a.`b c`").unwrap(),
[ident("a"), Token::Dot, ident("b c")]
);
assert!(tokenize("`unterminated").is_err());
}
#[rstest]
#[case("amount", ident("amount"))]
#[case("date", ident("date"))] #[case("x", ident("x"))]
fn tokenizes_bareword_as_identifier(#[case] sql: &str, #[case] expected: Token) {
assert_eq!(tokenize(sql).unwrap(), [expected]);
}
#[rstest]
#[case("a ! b")] #[case("(a)")] #[case("'unterminated")]
fn rejects_ungrammatical_input(#[case] sql: &str) {
assert!(tokenize(sql).is_err(), "expected {sql:?} to be rejected");
}
}