Skip to main content

nodedb_sql/parser/preprocess/
temporal.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Extract and strip NodeDB-specific bitemporal clauses before sqlparser
4//! sees the statement.
5//!
6//! Supported forms (case-insensitive, whitespace-tolerant):
7//!
8//! ```sql
9//! -- Table scan style (existing):
10//! SELECT ... FROM t FOR SYSTEM_TIME AS OF 1700000000000 WHERE ...
11//! SELECT ... FROM t FOR VALID_TIME CONTAINS 1700000000000 WHERE ...
12//! SELECT ... FROM t FOR VALID_TIME FROM 1700000000000 TO 1700001000000 WHERE ...
13//!
14//! -- Array read style (CockroachDB-inspired, two orthogonal clauses):
15//! SELECT ... FROM array_slice(...) AS OF SYSTEM TIME 1700000000000
16//! SELECT ... FROM array_slice(...) AS OF VALID TIME 1700000000000
17//! SELECT ... FROM array_slice(...) AS OF SYSTEM TIME 1700000000000 AS OF VALID TIME 1700000001000
18//! ```
19//!
20//! A function-form escape hatch is also accepted, anywhere in the statement,
21//! for pgwire drivers that reject non-standard clauses:
22//!
23//! ```sql
24//! SELECT __system_as_of__(1700000000000) FROM t WHERE ...
25//! ```
26//!
27//! All recognised clauses are extracted once per statement. Repeated clauses
28//! of the same kind return `Err`; conflicting valid-time forms (CONTAINS +
29//! FROM/TO) return `Err`.
30//!
31//! Timestamps are **milliseconds since Unix epoch**. Accepted expression forms:
32//! - Integer literal (milliseconds since epoch)
33//! - `NOW()` — resolved to the current wall-clock millisecond
34//! - ISO-8601 / RFC-3339 timestamp string `'2024-01-15T00:00:00Z'`
35//!
36//! Any other expression form is rejected with a typed `TemporalParseError`
37//! naming the unsupported form.
38
39use 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/// Output of the temporal preprocess stage.
47#[derive(Debug)]
48pub struct Extracted {
49    /// SQL with every temporal clause stripped, safe to hand to sqlparser.
50    pub sql: String,
51    /// Extracted temporal qualifier. Default when no clause was present.
52    pub temporal: TemporalScope,
53}
54
55/// Error shape — surfaced as `SqlError::Parse` by the caller.
56#[derive(Debug)]
57pub struct TemporalParseError(pub String);
58
59/// Strip temporal clauses from `sql` and return the rewritten text plus the
60/// extracted `TemporalScope`. Returns `Ok(None)` when no temporal clause is
61/// present so the caller can short-circuit to the existing pipeline.
62pub 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    // FOR SYSTEM_TIME AS OF (table-scan style)
68    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    // __system_as_of__(<int>) function escape hatch
74    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    // AS OF SYSTEM TIME <expr> (array read style, CockroachDB-inspired)
85    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    // FOR VALID_TIME CONTAINS/FROM…TO (table-scan style)
101    if let Some((rewritten, vt)) = strip_valid_time(&working)? {
102        working = rewritten;
103        scope.valid_time = vt;
104        any = true;
105    }
106    // AS OF VALID TIME <expr> (array read style)
107    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
128/// Match `FOR SYSTEM_TIME AS OF <integer>` case-insensitively and strip it.
129fn 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    // Locate `AS OF` after the keyword.
134    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
151/// Match `__system_as_of__(<int>)` anywhere in the statement and strip the
152/// call by replacing it with the literal `TRUE` so the surrounding
153/// expression still parses (e.g. `SELECT __system_as_of__(100), x` →
154/// `SELECT TRUE, x`). Returning `TRUE` keeps the projection column count
155/// stable; the planner ignores the synthetic column because the temporal
156/// scope is carried out-of-band.
157fn 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
179/// Match `FOR VALID_TIME CONTAINS <int>` or
180/// `FOR VALID_TIME FROM <int> TO <int>` and strip whichever is present.
181fn 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
232/// Resolve a temporal expression token to milliseconds since Unix epoch.
233///
234/// Supported forms:
235/// - Integer literal — interpreted directly as milliseconds.
236/// - `NOW()` — resolved to the current wall-clock millisecond via
237///   `std::time::SystemTime`. This is evaluated once at parse time, not
238///   deferred, so the resolved value is stable for the duration of the query.
239/// - ISO-8601 / RFC-3339 string literal `'2024-01-15T00:00:00Z'` — parsed
240///   with [`chrono`]'s `DateTime::parse_from_rfc3339`.
241///
242/// Any other expression form is rejected with a descriptive error rather than
243/// silently defaulting, so callers always get exactly the version they asked for.
244fn parse_temporal_expr(token: &str) -> Result<i64, TemporalParseError> {
245    let t = token.trim();
246
247    // Integer literal
248    if let Ok(v) = t.parse::<i64>() {
249        return Ok(v);
250    }
251
252    // NOW() — case-insensitive
253    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    // ISO-8601 string literal: 'YYYY-MM-DDTHH:MM:SSZ' (with surrounding quotes)
262    if (t.starts_with('\'') && t.ends_with('\'')) || (t.starts_with('"') && t.ends_with('"')) {
263        let inner = &t[1..t.len() - 1];
264        // Try RFC-3339 first (most common).
265        if let Ok(dt) = inner.parse::<chrono::DateTime<chrono::Utc>>() {
266            return Ok(dt.timestamp_millis());
267        }
268        // Also try NaiveDateTime + assume UTC.
269        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
286/// Match `AS OF SYSTEM TIME <expr>` (case-insensitive) anywhere in `sql` and
287/// strip it. The expression may be an integer literal, `NOW()`, or an ISO-8601
288/// timestamp string. Returns `None` if the clause is absent.
289///
290/// This is the CockroachDB-inspired form used on array read queries. It is
291/// parsed by NodeDB's pre-processor (option b — string-based extraction before
292/// sqlparser-rs sees the statement) because sqlparser-rs does not natively
293/// support the `AS OF VALID TIME` variant that the array engine also needs.
294/// Using the same pre-processor approach for both keeps the two clauses
295/// consistent and avoids mixing native sqlparser AS-OF support with custom
296/// preprocessing in the same pipeline.
297fn 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
313/// Match `AS OF VALID TIME <expr>` (case-insensitive) anywhere in `sql` and
314/// strip it. Same expression forms as `strip_as_of_system_time`. Returns `None`
315/// if the clause is absent.
316fn 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
345/// Extract the temporal expression that follows an AS-OF keyword at `offset`
346/// in `sql`. Returns `(resolved_ms, end_offset_exclusive)`.
347///
348/// The expression is delimited by end-of-string or the next keyword-boundary
349/// token (a subsequent `AS OF`, or certain SQL keywords that cannot appear
350/// inside a temporal expression). Quoted string literals are consumed whole.
351fn 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 the next non-whitespace character opens a quoted string, consume
361    // through the closing quote.
362    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; // include closing quote
374        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    // Otherwise scan to the next whitespace-delimited boundary. We stop at:
380    // a) another `AS OF` sequence (another clause),
381    // b) certain SQL delimiter tokens: WHERE, LIMIT, ORDER, GROUP, HAVING,
382    //    UNION, EXCEPT, INTERSECT, FETCH, FOR, OFFSET, semicolon.
383    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        // Only match at a word boundary (preceded by whitespace or start).
400        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    // Trim trailing whitespace from the extracted token.
412    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    // Bare NULL (case-insensitive) → all-versions (audit-log) mode.
419    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    // Advance past the expression (including trailing whitespace that was trimmed).
425    let raw_end = abs_start + end_rel;
426    Ok((SystemTimeScope::AsOf(ms), raw_end))
427}
428
429/// Parse an optionally-signed integer starting at `offset` in `sql`, skipping
430/// leading whitespace. Returns `(value, end_offset_exclusive)`.
431fn 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        // 2024-01-15T00:00:00Z in ms
562        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        // `FOR SYSTEM_TIME` inside a string literal must NOT be detected as a
623        // temporal clause.
624        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}