use super::error::SqlError;
use super::temporal::TemporalClause;
use crate::core::temporal::time;
use crate::query::plan::TemporalContext;
#[derive(Debug, Clone)]
pub struct ExtractedTemporal {
pub cleaned_sql: String,
pub system_time: Option<TemporalClause>,
pub valid_time: Option<TemporalClause>,
}
impl ExtractedTemporal {
pub fn to_temporal_context(&self) -> Result<Option<TemporalContext>, SqlError> {
match (&self.system_time, &self.valid_time) {
(None, None) => Ok(None),
(Some(TemporalClause::SystemTimeAsOf(ts)), None) => {
Ok(Some(TemporalContext::as_of(time::now(), *ts)))
}
(Some(TemporalClause::SystemTimeBetween(range)), None) => {
Ok(Some(TemporalContext::transaction_time_between(*range)))
}
(None, Some(TemporalClause::ValidTimeAsOf(ts))) => {
Ok(Some(TemporalContext::as_of(*ts, time::now())))
}
(None, Some(TemporalClause::ValidTimeBetween(range))) => {
Ok(Some(TemporalContext::valid_time_between(*range)))
}
(
Some(TemporalClause::SystemTimeAsOf(tx_ts)),
Some(TemporalClause::ValidTimeAsOf(vt_ts)),
) => Ok(Some(TemporalContext::as_of(*vt_ts, *tx_ts))),
(
Some(TemporalClause::ValidTimeAsOf(vt_ts)),
Some(TemporalClause::SystemTimeAsOf(tx_ts)),
) => {
Ok(Some(TemporalContext::as_of(*vt_ts, *tx_ts)))
}
(
Some(TemporalClause::SystemTimeBetween(_)),
Some(TemporalClause::ValidTimeAsOf(_)),
)
| (
Some(TemporalClause::SystemTimeAsOf(_)),
Some(TemporalClause::ValidTimeBetween(_)),
)
| (
Some(TemporalClause::ValidTimeBetween(_)),
Some(TemporalClause::SystemTimeAsOf(_)),
)
| (
Some(TemporalClause::ValidTimeAsOf(_)),
Some(TemporalClause::SystemTimeBetween(_)),
)
| (
Some(TemporalClause::SystemTimeBetween(_)),
Some(TemporalClause::ValidTimeBetween(_)),
)
| (
Some(TemporalClause::ValidTimeBetween(_)),
Some(TemporalClause::SystemTimeBetween(_)),
) => Err(SqlError::UnsupportedFeature(
"Bi-temporal queries mixing AS OF and BETWEEN clauses are not yet supported"
.to_string(),
)),
(Some(TemporalClause::BiTemporal { .. }), _)
| (_, Some(TemporalClause::BiTemporal { .. })) => Err(SqlError::UnsupportedFeature(
"BiTemporal clause variant is not supported in this context".to_string(),
)),
_ => Err(SqlError::InvalidTemporalClause(
"Invalid temporal clause combination".to_string(),
)),
}
}
}
#[derive(Debug, PartialEq)]
enum Token {
For,
SystemTime,
ValidTime,
AsOf,
Between,
And,
Timestamp,
QuotedValue(String),
Other(String),
}
fn tokenize_temporal_keywords(sql: &str) -> Vec<(usize, Token)> {
let mut tokens = Vec::new();
let mut chars = sql.char_indices().peekable();
while let Some((byte_idx, ch)) = chars.next() {
if ch.is_whitespace() {
continue;
}
if ch == '\'' {
let mut value = String::new();
while let Some(&(_, current_ch)) = chars.peek() {
if current_ch == '\'' {
chars.next(); if let Some(&(_, next_ch)) = chars.peek() {
if next_ch == '\'' {
chars.next(); value.push('\'');
} else {
break;
}
} else {
break;
}
} else {
value.push(current_ch);
chars.next();
}
}
tokens.push((byte_idx, Token::QuotedValue(value)));
continue;
}
if ch.is_alphanumeric() || ch == '_' {
let word_start = byte_idx;
let mut word_end = byte_idx + ch.len_utf8();
while let Some(&(next_idx, next_ch)) = chars.peek() {
if next_ch.is_alphanumeric() || next_ch == '_' {
word_end = next_idx + next_ch.len_utf8();
chars.next();
} else {
break;
}
}
let word = &sql[word_start..word_end];
let token = if word.eq_ignore_ascii_case("FOR") {
Token::For
} else if word.eq_ignore_ascii_case("SYSTEM_TIME") {
Token::SystemTime
} else if word.eq_ignore_ascii_case("VALID_TIME") {
Token::ValidTime
} else if word.eq_ignore_ascii_case("AS") {
let mut lookahead = chars.clone();
while let Some(&(_, next_ch)) = lookahead.peek() {
if next_ch.is_whitespace() {
lookahead.next();
} else {
break;
}
}
let mut is_of = false;
if let Some(&(of_start, next_ch)) = lookahead.peek() {
let is_valid_char = next_ch.is_alphanumeric() || next_ch == '_';
if is_valid_char {
let mut of_end = of_start + next_ch.len_utf8();
lookahead.next();
while let Some(&(n_idx, n_ch)) = lookahead.peek() {
if n_ch.is_alphanumeric() || n_ch == '_' {
of_end = n_idx + n_ch.len_utf8();
lookahead.next();
} else {
break;
}
}
if sql[of_start..of_end].eq_ignore_ascii_case("OF") {
is_of = true;
}
}
}
if is_of {
chars = lookahead;
Token::AsOf
} else {
Token::Other(word.to_string())
}
} else if word.eq_ignore_ascii_case("BETWEEN") {
Token::Between
} else if word.eq_ignore_ascii_case("AND") {
Token::And
} else if word.eq_ignore_ascii_case("TIMESTAMP") {
Token::Timestamp
} else {
Token::Other(word.to_string())
};
tokens.push((byte_idx, token));
continue;
}
}
tokens
}
fn extract_temporal_clause_from_tokens(
tokens: &[(usize, Token)],
time_type_token: Token,
) -> Result<Option<(TemporalClause, usize, usize)>, SqlError> {
let mut i = 0;
while i < tokens.len() {
if tokens[i].1 == Token::For && i + 1 < tokens.len() && tokens[i + 1].1 == time_type_token {
let start_byte = tokens[i].0;
let time_name = match time_type_token {
Token::SystemTime => "SYSTEM_TIME",
Token::ValidTime => "VALID_TIME",
_ => unreachable!(),
};
if i + 2 < tokens.len() && tokens[i + 2].1 == Token::AsOf {
if i + 3 >= tokens.len() {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} AS OF requires TIMESTAMP keyword",
time_name
)));
}
if tokens[i + 3].1 != Token::Timestamp {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} AS OF requires TIMESTAMP keyword, found {:?}",
time_name,
tokens[i + 3].1
)));
}
if i + 4 >= tokens.len() {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} AS OF TIMESTAMP requires a quoted timestamp value",
time_name
)));
}
if let Token::QuotedValue(ref ts_str) = tokens[i + 4].1 {
let ts = TemporalClause::parse_timestamp(ts_str)?;
let end_byte = if i + 5 < tokens.len() {
tokens[i + 5].0
} else {
tokens[i + 4].0 + ts_str.len() + 2 };
let clause = match time_type_token {
Token::SystemTime => TemporalClause::SystemTimeAsOf(ts),
Token::ValidTime => TemporalClause::ValidTimeAsOf(ts),
_ => unreachable!(),
};
return Ok(Some((clause, start_byte, end_byte)));
} else {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} AS OF TIMESTAMP requires a quoted timestamp value, found {:?}",
time_name,
tokens[i + 4].1
)));
}
} else if i + 2 < tokens.len() && tokens[i + 2].1 == Token::Between {
if i + 3 >= tokens.len() {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} BETWEEN requires TIMESTAMP keyword",
time_name
)));
}
if tokens[i + 3].1 != Token::Timestamp {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} BETWEEN requires TIMESTAMP keyword, found {:?}",
time_name,
tokens[i + 3].1
)));
}
if i + 4 >= tokens.len() {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} BETWEEN TIMESTAMP requires a quoted timestamp value",
time_name
)));
}
if let Token::QuotedValue(ref start_str) = tokens[i + 4].1 {
if i + 5 >= tokens.len() {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} BETWEEN requires AND keyword",
time_name
)));
}
if tokens[i + 5].1 != Token::And {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} BETWEEN requires AND keyword, found {:?}",
time_name,
tokens[i + 5].1
)));
}
if i + 6 >= tokens.len() {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} BETWEEN ... AND requires TIMESTAMP keyword",
time_name
)));
}
if tokens[i + 6].1 != Token::Timestamp {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} BETWEEN ... AND requires TIMESTAMP keyword, found {:?}",
time_name,
tokens[i + 6].1
)));
}
if i + 7 >= tokens.len() {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} BETWEEN ... AND TIMESTAMP requires a quoted timestamp value",
time_name
)));
}
if let Token::QuotedValue(ref end_str) = tokens[i + 7].1 {
let start_ts = TemporalClause::parse_timestamp(start_str)?;
let end_ts = TemporalClause::parse_timestamp(end_str)?;
let end_byte = if i + 8 < tokens.len() {
tokens[i + 8].0
} else {
tokens[i + 7].0 + end_str.len() + 2 };
let clause = match time_type_token {
Token::SystemTime => {
TemporalClause::system_time_between(start_ts, end_ts)?
}
Token::ValidTime => {
TemporalClause::valid_time_between(start_ts, end_ts)?
}
_ => unreachable!(),
};
return Ok(Some((clause, start_byte, end_byte)));
} else {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} BETWEEN ... AND TIMESTAMP requires a quoted timestamp value, found {:?}",
time_name,
tokens[i + 7].1
)));
}
} else {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} BETWEEN TIMESTAMP requires a quoted timestamp value, found {:?}",
time_name,
tokens[i + 4].1
)));
}
} else if i + 2 < tokens.len() {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} must be followed by AS OF or BETWEEN, found {:?}",
time_name,
tokens[i + 2].1
)));
} else {
return Err(SqlError::InvalidTemporalClause(format!(
"FOR {} must be followed by AS OF or BETWEEN",
time_name
)));
}
}
i += 1;
}
Ok(None)
}
pub fn extract_temporal_clauses(sql: &str) -> Result<ExtractedTemporal, SqlError> {
let tokens = tokenize_temporal_keywords(sql);
let mut cleaned = sql.to_string();
let mut system_time: Option<TemporalClause> = None;
let mut valid_time: Option<TemporalClause> = None;
let mut removals: Vec<(usize, usize)> = Vec::new();
if let Some((clause, start, end)) =
extract_temporal_clause_from_tokens(&tokens, Token::SystemTime)?
{
system_time = Some(clause);
removals.push((start, end));
}
if let Some((clause, start, end)) =
extract_temporal_clause_from_tokens(&tokens, Token::ValidTime)?
{
valid_time = Some(clause);
removals.push((start, end));
}
removals.sort_by_key(|removal| std::cmp::Reverse(removal.0));
for (start, end) in removals {
cleaned.replace_range(start..end, "");
}
let mut optimized_cleaned = String::with_capacity(cleaned.len());
let mut first = true;
for word in cleaned.split_whitespace() {
if !first {
optimized_cleaned.push(' ');
}
optimized_cleaned.push_str(word);
first = false;
}
cleaned = optimized_cleaned;
Ok(ExtractedTemporal {
cleaned_sql: cleaned,
system_time,
valid_time,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::temporal::Timestamp;
#[test]
fn test_tokenize_keywords() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME AS OF TIMESTAMP '1000'";
let tokens = tokenize_temporal_keywords(sql);
assert!(tokens.iter().any(|(_, t)| t == &Token::For));
assert!(tokens.iter().any(|(_, t)| t == &Token::SystemTime));
assert!(tokens.iter().any(|(_, t)| t == &Token::AsOf));
assert!(tokens.iter().any(|(_, t)| t == &Token::Timestamp));
assert!(
tokens
.iter()
.any(|(_, t)| matches!(t, Token::QuotedValue(v) if v == "1000"))
);
}
#[test]
fn test_extract_system_time_as_of() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME AS OF TIMESTAMP '1000000' WHERE age > 21";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "SELECT * FROM nodes WHERE age > 21");
assert!(result.system_time.is_some());
assert!(matches!(
result.system_time,
Some(TemporalClause::SystemTimeAsOf(_))
));
}
#[test]
fn test_extract_system_time_between() {
let sql =
"SELECT * FROM nodes FOR SYSTEM_TIME BETWEEN TIMESTAMP '1000' AND TIMESTAMP '2000'";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "SELECT * FROM nodes");
assert!(result.system_time.is_some());
assert!(matches!(
result.system_time,
Some(TemporalClause::SystemTimeBetween(_))
));
}
#[test]
fn test_extract_valid_time_as_of() {
let sql = "SELECT * FROM nodes FOR VALID_TIME AS OF TIMESTAMP '1000000'";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "SELECT * FROM nodes");
assert!(result.valid_time.is_some());
assert!(matches!(
result.valid_time,
Some(TemporalClause::ValidTimeAsOf(_))
));
}
#[test]
fn test_extract_bitemporal() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME AS OF TIMESTAMP '2000' FOR VALID_TIME AS OF TIMESTAMP '1500'";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "SELECT * FROM nodes");
assert!(result.system_time.is_some());
assert!(result.valid_time.is_some());
}
#[test]
fn test_extract_with_extra_whitespace() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME AS OF TIMESTAMP '1000'";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "SELECT * FROM nodes");
assert!(result.system_time.is_some());
}
#[test]
fn test_to_temporal_context_system_time_only() {
let extracted = ExtractedTemporal {
cleaned_sql: "SELECT * FROM nodes".to_string(),
system_time: Some(TemporalClause::SystemTimeAsOf(Timestamp::from(1000))),
valid_time: None,
};
let ctx = extracted.to_temporal_context().unwrap();
assert!(ctx.is_some());
let ctx = ctx.unwrap();
assert!(ctx.as_of_tuple().is_some());
}
#[test]
fn test_to_temporal_context_bitemporal() {
let extracted = ExtractedTemporal {
cleaned_sql: "SELECT * FROM nodes".to_string(),
system_time: Some(TemporalClause::SystemTimeAsOf(Timestamp::from(2000))),
valid_time: Some(TemporalClause::ValidTimeAsOf(Timestamp::from(1500))),
};
let ctx = extracted.to_temporal_context().unwrap();
assert!(ctx.is_some());
let ctx = ctx.unwrap();
assert!(ctx.as_of_tuple().is_some());
let (vt, tt) = ctx.as_of_tuple().unwrap();
assert_eq!(vt.wallclock(), 1500);
assert_eq!(tt.wallclock(), 2000);
}
#[test]
fn test_unsupported_mixed_between_and_as_of() {
let extracted = ExtractedTemporal {
cleaned_sql: "SELECT * FROM nodes".to_string(),
system_time: Some(TemporalClause::SystemTimeBetween(
crate::core::temporal::TimeRange::new(Timestamp::from(1000), Timestamp::from(2000))
.unwrap(),
)),
valid_time: Some(TemporalClause::ValidTimeAsOf(Timestamp::from(1500))),
};
let result = extracted.to_temporal_context();
assert!(result.is_err());
assert!(matches!(result, Err(SqlError::UnsupportedFeature(_))));
}
#[test]
fn test_string_literal_with_temporal_keywords() {
let sql = "SELECT * FROM nodes WHERE description = 'FOR SYSTEM_TIME AS OF TIMESTAMP'";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(
result.cleaned_sql,
"SELECT * FROM nodes WHERE description = 'FOR SYSTEM_TIME AS OF TIMESTAMP'"
);
assert!(result.system_time.is_none());
assert!(result.valid_time.is_none());
}
#[test]
fn test_escaped_quotes_in_string_literals() {
let sql =
"SELECT * FROM nodes WHERE name = 'O''Brien' FOR SYSTEM_TIME AS OF TIMESTAMP '1000'";
let result = extract_temporal_clauses(sql).unwrap();
assert!(result.cleaned_sql.contains("O''Brien"));
assert!(result.system_time.is_some());
}
#[test]
fn test_multiple_spaces_and_newlines() {
let sql = "SELECT * FROM nodes\n FOR\n SYSTEM_TIME\n AS OF\n TIMESTAMP\n '1000'\n WHERE age > 21";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "SELECT * FROM nodes WHERE age > 21");
assert!(result.system_time.is_some());
}
#[test]
fn test_tabs_in_temporal_clause() {
let sql = "SELECT * FROM nodes FOR\tSYSTEM_TIME\tAS OF\tTIMESTAMP\t'1000'";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "SELECT * FROM nodes");
assert!(result.system_time.is_some());
}
#[test]
fn test_temporal_clause_at_end_of_query() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME AS OF TIMESTAMP '1000'";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "SELECT * FROM nodes");
assert!(result.system_time.is_some());
}
#[test]
fn test_both_between_clauses_unsupported() {
let extracted = ExtractedTemporal {
cleaned_sql: "SELECT * FROM nodes".to_string(),
system_time: Some(TemporalClause::SystemTimeBetween(
crate::core::temporal::TimeRange::new(Timestamp::from(1000), Timestamp::from(2000))
.unwrap(),
)),
valid_time: Some(TemporalClause::ValidTimeBetween(
crate::core::temporal::TimeRange::new(Timestamp::from(1500), Timestamp::from(2500))
.unwrap(),
)),
};
let result = extracted.to_temporal_context();
assert!(result.is_err());
assert!(matches!(result, Err(SqlError::UnsupportedFeature(_))));
}
#[test]
fn test_timestamp_with_leading_zeros() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME AS OF TIMESTAMP '0000001000'";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "SELECT * FROM nodes");
assert!(result.system_time.is_some());
if let Some(TemporalClause::SystemTimeAsOf(ts)) = result.system_time {
assert_eq!(ts.wallclock(), 1000);
} else {
panic!("Expected SystemTimeAsOf");
}
}
#[test]
fn test_empty_query_string() {
let result = extract_temporal_clauses("").unwrap();
assert_eq!(result.cleaned_sql, "");
assert!(result.system_time.is_none());
assert!(result.valid_time.is_none());
}
#[test]
fn test_only_temporal_clause() {
let sql = "FOR SYSTEM_TIME AS OF TIMESTAMP '1000'";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "");
assert!(result.system_time.is_some());
}
#[test]
fn test_case_insensitive_keywords() {
let sql = "SELECT * FROM nodes for system_time as of timestamp '1000'";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "SELECT * FROM nodes");
assert!(result.system_time.is_some());
}
#[test]
fn test_mixed_case_keywords() {
let sql = "SELECT * FROM nodes FoR SyStEm_TiMe As Of TiMeStAmP '1000'";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "SELECT * FROM nodes");
assert!(result.system_time.is_some());
}
#[test]
fn test_temporal_context_with_now() {
let extracted = ExtractedTemporal {
cleaned_sql: "SELECT * FROM nodes".to_string(),
system_time: Some(TemporalClause::SystemTimeAsOf(Timestamp::from(1000))),
valid_time: None,
};
let ctx = extracted.to_temporal_context().unwrap();
assert!(ctx.is_some());
let ctx = ctx.unwrap();
let (vt, tt) = ctx.as_of_tuple().unwrap();
assert_eq!(tt.wallclock(), 1000); assert!(vt.wallclock() > 0); }
#[test]
fn test_error_missing_timestamp_keyword() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME AS OF '1000'";
let result = extract_temporal_clauses(sql);
assert!(result.is_err());
assert!(matches!(result, Err(SqlError::InvalidTemporalClause(_))));
}
#[test]
fn test_error_missing_timestamp_value() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME AS OF TIMESTAMP";
let result = extract_temporal_clauses(sql);
assert!(result.is_err());
assert!(matches!(result, Err(SqlError::InvalidTemporalClause(_))));
if let Err(SqlError::InvalidTemporalClause(msg)) = result {
assert!(msg.contains("requires a quoted timestamp value"));
}
}
#[test]
fn test_error_between_missing_and() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME BETWEEN TIMESTAMP '1000'";
let result = extract_temporal_clauses(sql);
assert!(result.is_err());
assert!(matches!(result, Err(SqlError::InvalidTemporalClause(_))));
if let Err(SqlError::InvalidTemporalClause(msg)) = result {
assert!(msg.contains("requires AND keyword"));
}
}
#[test]
fn test_error_between_missing_second_timestamp() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME BETWEEN TIMESTAMP '1000' AND";
let result = extract_temporal_clauses(sql);
assert!(result.is_err());
assert!(matches!(result, Err(SqlError::InvalidTemporalClause(_))));
if let Err(SqlError::InvalidTemporalClause(msg)) = result {
assert!(msg.contains("requires TIMESTAMP keyword"));
}
}
#[test]
fn test_error_between_missing_second_value() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME BETWEEN TIMESTAMP '1000' AND TIMESTAMP";
let result = extract_temporal_clauses(sql);
assert!(result.is_err());
assert!(matches!(result, Err(SqlError::InvalidTemporalClause(_))));
if let Err(SqlError::InvalidTemporalClause(msg)) = result {
assert!(msg.contains("requires a quoted timestamp value"));
}
}
#[test]
fn test_error_for_system_time_without_as_of_or_between() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME WHERE age > 21";
let result = extract_temporal_clauses(sql);
assert!(result.is_err());
assert!(matches!(result, Err(SqlError::InvalidTemporalClause(_))));
if let Err(SqlError::InvalidTemporalClause(msg)) = result {
assert!(msg.contains("must be followed by AS OF or BETWEEN"));
}
}
#[test]
fn test_error_for_valid_time_incomplete() {
let sql = "SELECT * FROM nodes FOR VALID_TIME";
let result = extract_temporal_clauses(sql);
assert!(result.is_err());
assert!(matches!(result, Err(SqlError::InvalidTemporalClause(_))));
if let Err(SqlError::InvalidTemporalClause(msg)) = result {
assert!(msg.contains("must be followed by AS OF or BETWEEN"));
}
}
#[test]
fn test_whitespace_preserved_after_temporal_removal() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME AS OF TIMESTAMP '1000' WHERE age > 21";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "SELECT * FROM nodes WHERE age > 21");
assert!(!result.cleaned_sql.contains("nodesWHERE"));
}
#[test]
fn test_multiple_temporal_clauses_whitespace() {
let sql = "SELECT * FROM nodes FOR SYSTEM_TIME AS OF TIMESTAMP '2000' FOR VALID_TIME AS OF TIMESTAMP '1500' WHERE age > 21";
let result = extract_temporal_clauses(sql).unwrap();
assert_eq!(result.cleaned_sql, "SELECT * FROM nodes WHERE age > 21");
assert!(!result.cleaned_sql.contains("nodesWHERE"));
assert!(result.system_time.is_some());
assert!(result.valid_time.is_some());
}
}