#[derive(Debug, Clone, thiserror::Error)]
pub enum IntervalParseError {
#[error("empty interval expression")]
Empty,
#[error("invalid interval amount: '{0}'")]
InvalidAmount(String),
#[error("unknown interval unit: '{0}'")]
UnknownUnit(String),
#[error("cannot parse interval: '{0}'")]
Unparseable(String),
}
pub fn is_kv_storage_mode(upper: &str) -> bool {
if !upper.contains("STORAGE") {
return false;
}
if let Some(pos) = upper.find("STORAGE") {
let after = &upper[pos + 7..];
let trimmed =
after.trim_start_matches(|c: char| c.is_whitespace() || c == '=' || c == '\'');
return trimmed.starts_with("KV")
&& (trimmed.len() == 2
|| trimmed[2..].starts_with(|c: char| {
c.is_whitespace() || c == '\'' || c == ',' || c == ';'
}));
}
false
}
pub fn parse_interval_to_ms(s: &str) -> Result<u64, IntervalParseError> {
let trimmed = s.trim();
let inner = if trimmed.to_uppercase().starts_with("INTERVAL") {
trimmed[8..].trim()
} else {
trimmed
};
let unquoted = inner.trim_matches('\'').trim();
if unquoted.is_empty() {
return Err(IntervalParseError::Empty);
}
if let Some(ms) = try_parse_short_interval(unquoted) {
return Ok(ms);
}
let parts: Vec<&str> = unquoted.split_whitespace().collect();
if parts.len() >= 2 && parts.len().is_multiple_of(2) {
let mut total_ms: u64 = 0;
for chunk in parts.chunks(2) {
let amount: u64 = chunk[0]
.parse()
.map_err(|_| IntervalParseError::InvalidAmount(chunk[0].to_string()))?;
let unit = chunk[1].to_lowercase();
let multiplier = unit_to_ms_multiplier(&unit)
.ok_or_else(|| IntervalParseError::UnknownUnit(unit.clone()))?;
total_ms += amount * multiplier;
}
return Ok(total_ms);
}
if parts.len() == 1
&& let Ok(ms) = unquoted.parse::<u64>()
{
return Ok(ms);
}
Err(IntervalParseError::Unparseable(unquoted.to_string()))
}
pub fn try_parse_short_interval(s: &str) -> Option<u64> {
let s = s.trim();
if s.is_empty() {
return None;
}
let num_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
if num_end == 0 || num_end == s.len() {
return None;
}
let amount: u64 = s[..num_end].parse().ok()?;
let unit = &s[num_end..].to_lowercase();
let multiplier = unit_to_ms_multiplier(unit)?;
Some(amount * multiplier)
}
fn unit_to_ms_multiplier(unit: &str) -> Option<u64> {
match unit {
"ms" | "millisecond" | "milliseconds" => Some(1),
"s" | "sec" | "second" | "seconds" => Some(1_000),
"m" | "min" | "minute" | "minutes" => Some(60_000),
"h" | "hr" | "hour" | "hours" => Some(3_600_000),
"d" | "day" | "days" => Some(86_400_000),
"w" | "week" | "weeks" => Some(604_800_000),
"y" | "year" | "years" => Some(31_536_000_000),
_ => None,
}
}
pub fn find_with_option(upper: &str, option: &str) -> Option<usize> {
let with_pos = upper.find("WITH")?;
let after_with = &upper[with_pos..];
after_with.find(option).map(|p| with_pos + p)
}
pub fn find_with_option_end(s: &str) -> usize {
let mut in_quote = false;
for (i, c) in s.char_indices() {
match c {
'\'' => in_quote = !in_quote,
',' | ';' if !in_quote => return i,
_ => {}
}
}
s.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_kv_storage_mode() {
assert!(is_kv_storage_mode("WITH STORAGE = 'KV'"));
assert!(is_kv_storage_mode("WITH STORAGE KV"));
assert!(is_kv_storage_mode("WITH STORAGE='KV'"));
assert!(is_kv_storage_mode(
"WITH STORAGE = 'KV', TTL = INTERVAL '1H'"
));
assert!(!is_kv_storage_mode("WITH STORAGE = 'STRICT'"));
assert!(!is_kv_storage_mode("WITH STORAGE = 'COLUMNAR'"));
assert!(!is_kv_storage_mode("CREATE COLLECTION KV_STUFF"));
}
#[test]
fn interval_parsing_short_form() {
assert_eq!(parse_interval_to_ms("INTERVAL '15m'").unwrap(), 900_000);
assert_eq!(parse_interval_to_ms("INTERVAL '1h'").unwrap(), 3_600_000);
assert_eq!(parse_interval_to_ms("INTERVAL '30s'").unwrap(), 30_000);
assert_eq!(parse_interval_to_ms("INTERVAL '2d'").unwrap(), 172_800_000);
assert_eq!(parse_interval_to_ms("'500ms'").unwrap(), 500);
}
#[test]
fn interval_parsing_long_form() {
assert_eq!(
parse_interval_to_ms("INTERVAL '15 minutes'").unwrap(),
900_000
);
assert_eq!(
parse_interval_to_ms("INTERVAL '1 hour'").unwrap(),
3_600_000
);
assert_eq!(
parse_interval_to_ms("INTERVAL '30 seconds'").unwrap(),
30_000
);
assert_eq!(
parse_interval_to_ms("INTERVAL '2 days'").unwrap(),
172_800_000
);
}
#[test]
fn interval_parsing_bare_number() {
assert_eq!(parse_interval_to_ms("5000").unwrap(), 5000);
}
#[test]
fn interval_parsing_errors() {
assert!(parse_interval_to_ms("INTERVAL ''").is_err());
assert!(parse_interval_to_ms("INTERVAL 'abc'").is_err());
assert!(parse_interval_to_ms("INTERVAL '15 foobar'").is_err());
}
#[test]
fn with_option_end_respects_quotes() {
assert_eq!(find_with_option_end("'hello, world', next"), 14);
assert_eq!(find_with_option_end("simple, next"), 6);
assert_eq!(find_with_option_end("no_comma"), 8);
}
}