nodedb_sql/parser/preprocess/
temporal.rs1use super::lex::{
40 find_ascii_case_insensitive, find_ascii_case_insensitive_from,
41 keyword_position_outside_literals,
42};
43use crate::temporal::{TemporalScope, ValidTime};
44use nodedb_types::SystemTimeScope;
45
46#[derive(Debug)]
48pub struct Extracted {
49 pub sql: String,
51 pub temporal: TemporalScope,
53}
54
55#[derive(Debug)]
57pub struct TemporalParseError(pub String);
58
59pub fn extract(sql: &str) -> Result<Option<Extracted>, TemporalParseError> {
63 let mut scope = TemporalScope::default();
64 let mut working = sql.to_string();
65 let mut any = false;
66
67 if let Some((rewritten, ms)) = strip_system_time_as_of(&working)? {
69 working = rewritten;
70 scope.system_time = SystemTimeScope::AsOf(ms);
71 any = true;
72 }
73 if let Some((rewritten, ms)) = strip_system_as_of_function(&working)? {
75 if scope.system_time.is_temporal() {
76 return Err(TemporalParseError(
77 "multiple FOR SYSTEM_TIME / __system_as_of__ clauses".into(),
78 ));
79 }
80 working = rewritten;
81 scope.system_time = SystemTimeScope::AsOf(ms);
82 any = true;
83 }
84 if let Some((rewritten, sys_scope)) = strip_as_of_system_time(&working)? {
86 if scope.system_time.is_temporal() {
87 return Err(TemporalParseError(
88 "multiple system-time AS OF clauses in one statement".into(),
89 ));
90 }
91 working = rewritten;
92 scope.system_time = sys_scope;
93 any = true;
94 if strip_as_of_system_time(&working)?.is_some() {
95 return Err(TemporalParseError(
96 "multiple system-time AS OF clauses in one statement".into(),
97 ));
98 }
99 }
100 if let Some((rewritten, vt)) = strip_valid_time(&working)? {
102 working = rewritten;
103 scope.valid_time = vt;
104 any = true;
105 }
106 if let Some((rewritten, ms)) = strip_as_of_valid_time(&working)? {
108 if !matches!(scope.valid_time, ValidTime::Any) {
109 return Err(TemporalParseError(
110 "multiple valid-time AS OF clauses in one statement".into(),
111 ));
112 }
113 working = rewritten;
114 scope.valid_time = ValidTime::At(ms);
115 any = true;
116 }
117
118 if any {
119 Ok(Some(Extracted {
120 sql: working,
121 temporal: scope,
122 }))
123 } else {
124 Ok(None)
125 }
126}
127
128fn strip_system_time_as_of(sql: &str) -> Result<Option<(String, i64)>, TemporalParseError> {
130 let Some(start) = keyword_position_outside_literals(sql, "FOR SYSTEM_TIME") else {
131 return Ok(None);
132 };
133 let after_kw = start + "FOR SYSTEM_TIME".len();
135 let tail_upper = sql[after_kw..].to_uppercase();
136 let Some(_as_of_rel) = tail_upper.trim_start().strip_prefix("AS OF") else {
137 return Err(TemporalParseError(
138 "FOR SYSTEM_TIME must be followed by AS OF <ms>".into(),
139 ));
140 };
141 let leading_ws = sql[after_kw..].len() - sql[after_kw..].trim_start().len();
142 let as_of_abs = after_kw + leading_ws + "AS OF".len();
143 let (ms, end_abs) = parse_trailing_i64(sql, as_of_abs)?;
144 let mut out = String::with_capacity(sql.len());
145 out.push_str(&sql[..start]);
146 out.push(' ');
147 out.push_str(&sql[end_abs..]);
148 Ok(Some((out, ms)))
149}
150
151fn strip_system_as_of_function(sql: &str) -> Result<Option<(String, i64)>, TemporalParseError> {
158 let Some(start) = find_ascii_case_insensitive(sql, "__SYSTEM_AS_OF__(") else {
159 return Ok(None);
160 };
161 let after_open = start + "__SYSTEM_AS_OF__(".len();
162 let Some(close_rel) = sql[after_open..].find(')') else {
163 return Err(TemporalParseError(
164 "__system_as_of__(...) missing closing paren".into(),
165 ));
166 };
167 let close_abs = after_open + close_rel;
168 let arg = sql[after_open..close_abs].trim();
169 let ms: i64 = arg
170 .parse()
171 .map_err(|_| TemporalParseError(format!("__system_as_of__ arg not i64: {arg}")))?;
172 let mut out = String::with_capacity(sql.len());
173 out.push_str(&sql[..start]);
174 out.push_str("TRUE");
175 out.push_str(&sql[close_abs + 1..]);
176 Ok(Some((out, ms)))
177}
178
179fn strip_valid_time(sql: &str) -> Result<Option<(String, ValidTime)>, TemporalParseError> {
182 let Some(start) = keyword_position_outside_literals(sql, "FOR VALID_TIME") else {
183 return Ok(None);
184 };
185 let after_kw = start + "FOR VALID_TIME".len();
186 let tail_raw = &sql[after_kw..];
187 let tail_upper_owned = tail_raw.to_uppercase();
188 let tail_upper = tail_upper_owned.trim_start();
189 let leading_ws = tail_raw.len() - tail_raw.trim_start().len();
190
191 if let Some(rest) = tail_upper.strip_prefix("CONTAINS") {
192 let arg_abs = after_kw + leading_ws + "CONTAINS".len();
193 let (ms, end_abs) = parse_trailing_i64(sql, arg_abs)?;
194 let _ = rest;
195 let mut out = String::with_capacity(sql.len());
196 out.push_str(&sql[..start]);
197 out.push(' ');
198 out.push_str(&sql[end_abs..]);
199 return Ok(Some((out, ValidTime::At(ms))));
200 }
201 if let Some(rest) = tail_upper.strip_prefix("FROM") {
202 let arg_abs = after_kw + leading_ws + "FROM".len();
203 let (lo, lo_end) = parse_trailing_i64(sql, arg_abs)?;
204 let after_lo_raw = &sql[lo_end..];
205 let after_lo_upper_owned = after_lo_raw.to_uppercase();
206 let after_lo_upper = after_lo_upper_owned.trim_start();
207 let leading_ws2 = after_lo_raw.len() - after_lo_raw.trim_start().len();
208 let Some(_after_to) = after_lo_upper.strip_prefix("TO") else {
209 return Err(TemporalParseError(
210 "FOR VALID_TIME FROM <ms> must be followed by TO <ms>".into(),
211 ));
212 };
213 let hi_arg = lo_end + leading_ws2 + "TO".len();
214 let (hi, hi_end) = parse_trailing_i64(sql, hi_arg)?;
215 let _ = rest;
216 let mut out = String::with_capacity(sql.len());
217 out.push_str(&sql[..start]);
218 out.push(' ');
219 out.push_str(&sql[hi_end..]);
220 if hi <= lo {
221 return Err(TemporalParseError(format!(
222 "FOR VALID_TIME FROM {lo} TO {hi}: hi must be > lo"
223 )));
224 }
225 return Ok(Some((out, ValidTime::Range(lo, hi))));
226 }
227 Err(TemporalParseError(
228 "FOR VALID_TIME must be followed by CONTAINS or FROM".into(),
229 ))
230}
231
232fn parse_temporal_expr(token: &str) -> Result<i64, TemporalParseError> {
245 let t = token.trim();
246
247 if let Ok(v) = t.parse::<i64>() {
249 return Ok(v);
250 }
251
252 if t.to_uppercase() == "NOW()" {
254 let ms = std::time::SystemTime::now()
255 .duration_since(std::time::UNIX_EPOCH)
256 .map(|d| d.as_millis() as i64)
257 .unwrap_or(0);
258 return Ok(ms);
259 }
260
261 if (t.starts_with('\'') && t.ends_with('\'')) || (t.starts_with('"') && t.ends_with('"')) {
263 let inner = &t[1..t.len() - 1];
264 if let Ok(dt) = inner.parse::<chrono::DateTime<chrono::Utc>>() {
266 return Ok(dt.timestamp_millis());
267 }
268 if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(inner, "%Y-%m-%dT%H:%M:%S") {
270 use chrono::TimeZone as _;
271 return Ok(chrono::Utc.from_utc_datetime(&ndt).timestamp_millis());
272 }
273 return Err(TemporalParseError(format!(
274 "AS OF temporal expression: cannot parse timestamp string '{inner}'; \
275 expected RFC-3339 / ISO-8601 (e.g. '2024-01-15T00:00:00Z')"
276 )));
277 }
278
279 Err(TemporalParseError(format!(
280 "AS OF temporal expression '{t}' is not supported; \
281 use an integer (ms since epoch), NOW(), or an ISO-8601 string literal \
282 (e.g. '2024-01-15T00:00:00Z')"
283 )))
284}
285
286fn strip_as_of_system_time(
298 sql: &str,
299) -> Result<Option<(String, SystemTimeScope)>, TemporalParseError> {
300 let keyword = "AS OF SYSTEM TIME";
301 let Some(start) = keyword_position_outside_literals(sql, keyword) else {
302 return Ok(None);
303 };
304 let after_kw = start + keyword.len();
305 let (scope, end_abs) = parse_as_of_expr(sql, after_kw)?;
306 let mut out = String::with_capacity(sql.len());
307 out.push_str(sql[..start].trim_end());
308 out.push(' ');
309 out.push_str(sql[end_abs..].trim_start());
310 Ok(Some((out.trim().to_string(), scope)))
311}
312
313fn strip_as_of_valid_time(sql: &str) -> Result<Option<(String, i64)>, TemporalParseError> {
317 let keyword = "AS OF VALID TIME";
318 let Some(start) = keyword_position_outside_literals(sql, keyword) else {
319 return Ok(None);
320 };
321 let after_kw = start + keyword.len();
322 let (scope, end_abs) = parse_as_of_expr(sql, after_kw)?;
323 let ms = match scope {
324 SystemTimeScope::AsOf(ms) => ms,
325 SystemTimeScope::AllVersions => {
326 return Err(TemporalParseError(
327 "AS OF VALID TIME NULL is not supported; NULL (all-versions) \
328 applies only to SYSTEM TIME"
329 .into(),
330 ));
331 }
332 SystemTimeScope::Current => {
333 return Err(TemporalParseError(
334 "AS OF VALID TIME requires an expression".into(),
335 ));
336 }
337 };
338 let mut out = String::with_capacity(sql.len());
339 out.push_str(sql[..start].trim_end());
340 out.push(' ');
341 out.push_str(sql[end_abs..].trim_start());
342 Ok(Some((out.trim().to_string(), ms)))
343}
344
345fn parse_as_of_expr(
352 sql: &str,
353 offset: usize,
354) -> Result<(SystemTimeScope, usize), TemporalParseError> {
355 let rest = &sql[offset..];
356 let trimmed = rest.trim_start();
357 let leading = rest.len() - trimmed.len();
358 let abs_start = offset + leading;
359
360 if trimmed.starts_with('\'') || trimmed.starts_with('"') {
363 let quote = trimmed
364 .chars()
365 .next()
366 .expect("invariant: trimmed.starts_with('\\'' | '\"') guarantees at least one char");
367 let inner_start = 1;
368 let close = trimmed[inner_start..].find(quote).ok_or_else(|| {
369 TemporalParseError(format!(
370 "AS OF temporal expression: unterminated string literal at offset {abs_start}"
371 ))
372 })?;
373 let end_rel = inner_start + close + 1; let token = &trimmed[..end_rel];
375 let ms = parse_temporal_expr(token)?;
376 return Ok((SystemTimeScope::AsOf(ms), abs_start + end_rel));
377 }
378
379 let stop_tokens = [
384 "AS OF",
385 "WHERE",
386 "LIMIT",
387 "ORDER",
388 "GROUP",
389 "HAVING",
390 "UNION",
391 "EXCEPT",
392 "INTERSECT",
393 "FETCH",
394 "OFFSET",
395 ";",
396 ];
397 let mut end_rel = trimmed.len();
398 for stop in &stop_tokens {
399 let mut search_from = 0;
401 while let Some(abs_pos) = find_ascii_case_insensitive_from(trimmed, stop, search_from) {
402 let at_boundary =
403 abs_pos == 0 || trimmed[..abs_pos].ends_with(|c: char| c.is_ascii_whitespace());
404 if at_boundary {
405 end_rel = end_rel.min(abs_pos);
406 break;
407 }
408 search_from = abs_pos + stop.len();
409 }
410 }
411 let token = trimmed[..end_rel].trim();
413 if token.is_empty() {
414 return Err(TemporalParseError(format!(
415 "AS OF clause at offset {abs_start} has no expression"
416 )));
417 }
418 if token.eq_ignore_ascii_case("NULL") {
420 let raw_end = abs_start + end_rel;
421 return Ok((SystemTimeScope::AllVersions, raw_end));
422 }
423 let ms = parse_temporal_expr(token)?;
424 let raw_end = abs_start + end_rel;
426 Ok((SystemTimeScope::AsOf(ms), raw_end))
427}
428
429fn parse_trailing_i64(sql: &str, offset: usize) -> Result<(i64, usize), TemporalParseError> {
432 let rest = &sql[offset..];
433 let trimmed = rest.trim_start();
434 let leading = rest.len() - trimmed.len();
435 let end_rel = trimmed
436 .char_indices()
437 .take_while(|(i, c)| c.is_ascii_digit() || (*i == 0 && *c == '-'))
438 .map(|(i, c)| i + c.len_utf8())
439 .last()
440 .ok_or_else(|| TemporalParseError(format!("expected integer at offset {offset}")))?;
441 let num_str = &trimmed[..end_rel];
442 if trimmed[end_rel..]
443 .chars()
444 .next()
445 .is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, ';' | ',' | ')'))
446 {
447 return Err(TemporalParseError(format!(
448 "invalid character after integer temporal expression at offset {}",
449 offset + leading + end_rel
450 )));
451 }
452 let v: i64 = num_str
453 .parse()
454 .map_err(|_| TemporalParseError(format!("not an i64: {num_str}")))?;
455 Ok((v, offset + leading + end_rel))
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn passthrough_no_temporal() {
464 assert!(extract("SELECT * FROM t WHERE x = 1").unwrap().is_none());
465 }
466
467 #[test]
468 fn unicode_in_invalid_as_of_expression_returns_error_without_panicking() {
469 let result = extract("SELECT * FROM t FOR SYSTEM_TIME AS OF 100ffff LIMIT 1");
470 assert!(result.is_err());
471 }
472
473 #[test]
474 fn system_time_as_of() {
475 let ex = extract("SELECT * FROM t FOR SYSTEM_TIME AS OF 100 WHERE x = 1")
476 .unwrap()
477 .unwrap();
478 assert_eq!(ex.temporal.system_time, SystemTimeScope::AsOf(100));
479 assert_eq!(ex.temporal.valid_time, ValidTime::Any);
480 assert!(!ex.sql.to_uppercase().contains("FOR SYSTEM_TIME"));
481 assert!(ex.sql.contains("WHERE x = 1"));
482 }
483
484 #[test]
485 fn valid_time_contains() {
486 let ex = extract("SELECT * FROM t FOR VALID_TIME CONTAINS 250")
487 .unwrap()
488 .unwrap();
489 assert_eq!(ex.temporal.valid_time, ValidTime::At(250));
490 assert!(!ex.sql.to_uppercase().contains("FOR VALID_TIME"));
491 }
492
493 #[test]
494 fn valid_time_range() {
495 let ex = extract("SELECT * FROM t FOR VALID_TIME FROM 100 TO 300 LIMIT 10")
496 .unwrap()
497 .unwrap();
498 assert_eq!(ex.temporal.valid_time, ValidTime::Range(100, 300));
499 assert!(ex.sql.contains("LIMIT 10"));
500 }
501
502 #[test]
503 fn combined_system_and_valid() {
504 let ex = extract(
505 "SELECT * FROM t FOR SYSTEM_TIME AS OF 500 FOR VALID_TIME CONTAINS 250 LIMIT 5",
506 )
507 .unwrap()
508 .unwrap();
509 assert_eq!(ex.temporal.system_time, SystemTimeScope::AsOf(500));
510 assert_eq!(ex.temporal.valid_time, ValidTime::At(250));
511 }
512
513 #[test]
514 fn function_form() {
515 let ex = extract("SELECT __system_as_of__(777), x FROM t")
516 .unwrap()
517 .unwrap();
518 assert_eq!(ex.temporal.system_time, SystemTimeScope::AsOf(777));
519 assert!(ex.sql.contains("TRUE"));
520 assert!(!ex.sql.contains("__system_as_of__"));
521 }
522
523 #[test]
524 fn invalid_range_rejects() {
525 assert!(extract("SELECT * FROM t FOR VALID_TIME FROM 500 TO 100").is_err());
526 }
527
528 #[test]
529 fn valid_time_missing_verb() {
530 assert!(extract("SELECT * FROM t FOR VALID_TIME 100").is_err());
531 }
532
533 #[test]
534 fn as_of_system_time_integer() {
535 let ex = extract("SELECT * FROM array_slice('g', '{}') AS OF SYSTEM TIME 1700000000000")
536 .unwrap()
537 .unwrap();
538 assert_eq!(
539 ex.temporal.system_time,
540 SystemTimeScope::AsOf(1_700_000_000_000)
541 );
542 assert!(!ex.sql.to_uppercase().contains("AS OF SYSTEM TIME"));
543 }
544
545 #[test]
546 fn as_of_valid_time_integer() {
547 let ex = extract("SELECT * FROM array_slice('g', '{}') AS OF VALID TIME 1700000000001")
548 .unwrap()
549 .unwrap();
550 assert_eq!(ex.temporal.valid_time, ValidTime::At(1_700_000_000_001));
551 assert!(!ex.sql.to_uppercase().contains("AS OF VALID TIME"));
552 }
553
554 #[test]
555 fn as_of_system_time_iso8601() {
556 let ex = extract(
557 "SELECT * FROM array_slice('g', '{}') AS OF SYSTEM TIME '2024-01-15T00:00:00Z'",
558 )
559 .unwrap()
560 .unwrap();
561 assert_eq!(
563 ex.temporal.system_time,
564 SystemTimeScope::AsOf(1_705_276_800_000)
565 );
566 assert!(!ex.sql.to_uppercase().contains("AS OF SYSTEM TIME"));
567 }
568
569 #[test]
570 fn as_of_both_clauses() {
571 let ex = extract(
572 "SELECT * FROM array_slice('g', '{}') AS OF SYSTEM TIME 500 AS OF VALID TIME 250",
573 )
574 .unwrap()
575 .unwrap();
576 assert_eq!(ex.temporal.system_time, SystemTimeScope::AsOf(500));
577 assert_eq!(ex.temporal.valid_time, ValidTime::At(250));
578 }
579
580 #[test]
581 fn as_of_system_time_now() {
582 let before = std::time::SystemTime::now()
583 .duration_since(std::time::UNIX_EPOCH)
584 .unwrap()
585 .as_millis() as i64;
586 let ex = extract("SELECT * FROM array_slice('g', '{}') AS OF SYSTEM TIME NOW()")
587 .unwrap()
588 .unwrap();
589 let after = std::time::SystemTime::now()
590 .duration_since(std::time::UNIX_EPOCH)
591 .unwrap()
592 .as_millis() as i64;
593 let ts = ex
594 .temporal
595 .system_time
596 .as_of_ms()
597 .expect("NOW() resolves to AsOf");
598 assert!(
599 ts >= before && ts <= after,
600 "NOW() ts {ts} not in [{before}, {after}]"
601 );
602 }
603
604 #[test]
605 fn as_of_system_time_unsupported_expr_rejected() {
606 let err = extract(
607 "SELECT * FROM array_slice('g', '{}') AS OF SYSTEM TIME NOW() - INTERVAL '1 day'",
608 );
609 assert!(err.is_err(), "INTERVAL expression should be rejected");
610 }
611
612 #[test]
613 fn as_of_duplicate_system_time_rejected() {
614 assert!(
615 extract("SELECT * FROM t AS OF SYSTEM TIME 100 AS OF SYSTEM TIME 200").is_err(),
616 "duplicate AS OF SYSTEM TIME should be rejected"
617 );
618 }
619
620 #[test]
621 fn system_time_in_string_literal_not_triggered() {
622 let result = extract("SELECT * FROM t WHERE name = 'FOR SYSTEM_TIME'").unwrap();
625 assert!(
626 result.is_none(),
627 "FOR SYSTEM_TIME inside string literal must not trigger, got: {result:?}"
628 );
629 }
630
631 #[test]
632 fn system_time_as_of_outside_literal_triggered() {
633 let ex = extract("SELECT * FROM t FOR SYSTEM_TIME AS OF 100")
634 .unwrap()
635 .unwrap();
636 assert_eq!(ex.temporal.system_time, SystemTimeScope::AsOf(100));
637 }
638
639 #[test]
640 fn as_of_system_time_null_is_all_versions() {
641 let ex = extract("SELECT * FROM t AS OF SYSTEM TIME NULL WHERE x = 1")
642 .unwrap()
643 .unwrap();
644 assert_eq!(ex.temporal.system_time, SystemTimeScope::AllVersions);
645 assert!(!ex.sql.to_uppercase().contains("AS OF SYSTEM TIME"));
646 assert!(ex.sql.contains("WHERE x = 1"));
647 }
648
649 #[test]
650 fn as_of_system_time_null_case_insensitive() {
651 let ex = extract("SELECT * FROM t AS OF SYSTEM TIME null")
652 .unwrap()
653 .unwrap();
654 assert_eq!(ex.temporal.system_time, SystemTimeScope::AllVersions);
655 }
656
657 #[test]
658 fn as_of_valid_time_null_rejected() {
659 assert!(
660 extract("SELECT * FROM t AS OF VALID TIME NULL").is_err(),
661 "AS OF VALID TIME NULL must be rejected"
662 );
663 }
664}