Add filter_datetime module for FilterBar timestamp parsing
Standalone module (no engine/index/filter-grammar deps) that normalizes relative humantime (5h, -3m), absolute time-of-day (12:00, resolved to today in UTC), and full date/RFC3339 literals (2024-01-15, 2024-01-15T12:00:00Z) into a single Timestamp, given a passed-in `now` for testability. Adds humantime and chrono dependencies. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1fb564d06b
commit
0311eeb712
@@ -0,0 +1,324 @@
|
||||
//! Datetime parsing for the filter query language's timestamp comparisons.
|
||||
//!
|
||||
//! This module is intentionally standalone: it has no dependency on the engine/index
|
||||
//! types or the filter grammar module, so it can be developed, tested, and reused
|
||||
//! independently of the rest of the filter machinery.
|
||||
//!
|
||||
//! Three input forms are supported, all normalized to a single [`Timestamp`]:
|
||||
//!
|
||||
//! 1. Relative humantime, e.g. `5h`, `-3m` — resolved against a supplied "now" at
|
||||
//! parse/eval time (parsed with the `humantime` crate).
|
||||
//! 2. Absolute time-of-day, e.g. `12:00` — resolves to that time on the current day.
|
||||
//! **This module treats "current day" as the UTC calendar day of `now`, and resolves
|
||||
//! the time-of-day in UTC.** (We use `chrono` for date/time parsing since no other
|
||||
//! datetime crate was already a dependency.)
|
||||
//! 3. Full date/datetime literals, e.g. `2024-01-15` or `2024-01-15T12:00:00Z`
|
||||
//! (RFC3339-ish). A bare date is interpreted as midnight UTC on that date.
|
||||
|
||||
// Not yet wired into the filter grammar/engine (a separate integration ticket); allow
|
||||
// dead code so this standalone module builds warning-free until that lands.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
|
||||
|
||||
use crate::timestamp::Timestamp;
|
||||
|
||||
/// Error returned when a datetime filter literal can't be resolved to a [`Timestamp`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DateTimeParseError {
|
||||
/// The input matched the shape of a supported format (relative duration,
|
||||
/// time-of-day, or date/datetime) but its value was invalid or ambiguous,
|
||||
/// e.g. an out-of-range time like `25:99`.
|
||||
Invalid { input: String, reason: String },
|
||||
/// The input didn't match any supported format at all.
|
||||
Unparseable(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DateTimeParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DateTimeParseError::Invalid { input, reason } => {
|
||||
write!(f, "invalid datetime literal {input:?}: {reason}")
|
||||
}
|
||||
DateTimeParseError::Unparseable(input) => write!(
|
||||
f,
|
||||
"unparseable datetime literal {input:?}: expected a relative duration \
|
||||
(e.g. \"5h\", \"-3m\"), a time-of-day (e.g. \"12:00\"), or a date/datetime \
|
||||
(e.g. \"2024-01-15\" or \"2024-01-15T12:00:00Z\")"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DateTimeParseError {}
|
||||
|
||||
/// Parses a filter datetime literal into an absolute [`Timestamp`], resolving any
|
||||
/// relative or day-relative forms against `now`.
|
||||
///
|
||||
/// `now` is taken as a parameter (rather than this function calling `Timestamp::now()`
|
||||
/// internally) so callers can pass a fixed value in tests.
|
||||
pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(DateTimeParseError::Unparseable(input.to_string()));
|
||||
}
|
||||
|
||||
if let Some(result) = try_relative(trimmed, now) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if let Some(result) = try_full_datetime(trimmed) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if let Some(result) = try_time_of_day(trimmed, now) {
|
||||
return result;
|
||||
}
|
||||
|
||||
Err(DateTimeParseError::Unparseable(input.to_string()))
|
||||
}
|
||||
|
||||
/// Tries to interpret `input` as a signed relative humantime duration (`5h`, `-3m`).
|
||||
///
|
||||
/// Returns `None` if `input` doesn't look like a relative duration at all (so the
|
||||
/// caller can try the other formats); returns `Some(Err(..))` if it looks like one but
|
||||
/// the duration itself is malformed.
|
||||
fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
|
||||
let (sign, rest) = match input.strip_prefix('-') {
|
||||
Some(rest) => (-1i128, rest),
|
||||
None => match input.strip_prefix('+') {
|
||||
Some(rest) => (1i128, rest),
|
||||
None => (1i128, input),
|
||||
},
|
||||
};
|
||||
|
||||
// Only treat this as an attempted relative duration if it starts with a digit
|
||||
// followed by (eventually) an alphabetic unit -- this keeps dates like
|
||||
// "2024-01-15" (which start with digits but aren't durations) from being
|
||||
// mis-attempted here and reported as "invalid duration" instead of falling
|
||||
// through to the date parser.
|
||||
let looks_like_duration = rest
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_digit())
|
||||
&& rest.chars().any(|c| c.is_ascii_alphabetic())
|
||||
&& !rest.contains('-')
|
||||
&& !rest.contains(':');
|
||||
if !looks_like_duration {
|
||||
return None;
|
||||
}
|
||||
|
||||
match humantime::parse_duration(rest) {
|
||||
Ok(duration) => {
|
||||
let nanos = duration.as_nanos() as i128 * sign;
|
||||
let now_nanos = now.as_millis() as i128 * 1_000_000;
|
||||
let result_millis = ((now_nanos + nanos) / 1_000_000) as i64;
|
||||
Some(Ok(Timestamp::from_millis(result_millis)))
|
||||
}
|
||||
Err(err) => Some(Err(DateTimeParseError::Invalid {
|
||||
input: input.to_string(),
|
||||
reason: format!("not a valid relative duration: {err}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to interpret `input` as a full date or RFC3339-ish datetime literal.
|
||||
fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError>> {
|
||||
// Only attempt this path for inputs shaped like a date (contain a '-' separating
|
||||
// digits, e.g. "2024-01-15"); otherwise let other parsers have a shot.
|
||||
let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-';
|
||||
if !looks_like_date {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(input) {
|
||||
return Some(Ok(timestamp_from_datetime(dt.with_timezone(&Utc))));
|
||||
}
|
||||
|
||||
match NaiveDate::parse_from_str(input, "%Y-%m-%d") {
|
||||
Ok(date) => {
|
||||
let dt = date.and_hms_opt(0, 0, 0).expect("midnight is always valid");
|
||||
Some(Ok(timestamp_from_datetime(Utc.from_utc_datetime(&dt))))
|
||||
}
|
||||
Err(_) => Some(Err(DateTimeParseError::Invalid {
|
||||
input: input.to_string(),
|
||||
reason: "not a valid date or RFC3339 datetime (expected e.g. \"2024-01-15\" or \
|
||||
\"2024-01-15T12:00:00Z\")"
|
||||
.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to interpret `input` as a bare time-of-day (`12:00`, `00:00`, `23:59:59`),
|
||||
/// resolved against the UTC calendar day of `now`.
|
||||
fn try_time_of_day(
|
||||
input: &str,
|
||||
now: Timestamp,
|
||||
) -> Option<Result<Timestamp, DateTimeParseError>> {
|
||||
if !input.contains(':') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let time = NaiveTime::parse_from_str(input, "%H:%M:%S")
|
||||
.or_else(|_| NaiveTime::parse_from_str(input, "%H:%M"));
|
||||
|
||||
let time = match time {
|
||||
Ok(time) => time,
|
||||
Err(err) => {
|
||||
return Some(Err(DateTimeParseError::Invalid {
|
||||
input: input.to_string(),
|
||||
reason: format!("not a valid time-of-day (expected e.g. \"12:00\"): {err}"),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let now_millis = now.as_millis();
|
||||
let now_dt = Utc.timestamp_millis_opt(now_millis).single().unwrap_or_else(|| {
|
||||
// Fall back to the epoch date if `now` is somehow out of range; this should
|
||||
// not happen in practice since `Timestamp` always comes from a valid instant.
|
||||
Utc.timestamp_millis_opt(0).single().expect("epoch is valid")
|
||||
});
|
||||
let today = now_dt.date_naive();
|
||||
let dt = Utc.from_utc_datetime(&today.and_time(time));
|
||||
Some(Ok(timestamp_from_datetime(dt)))
|
||||
}
|
||||
|
||||
fn timestamp_from_datetime(dt: DateTime<Utc>) -> Timestamp {
|
||||
Timestamp::from_millis(dt.timestamp_millis())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fixed "now": 2024-06-15T12:34:56Z.
|
||||
fn fixed_now() -> Timestamp {
|
||||
let dt = Utc.with_ymd_and_hms(2024, 6, 15, 12, 34, 56).unwrap();
|
||||
Timestamp::from_millis(dt.timestamp_millis())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_positive_duration() {
|
||||
let now = fixed_now();
|
||||
let got = parse_datetime("5h", now).expect("should parse");
|
||||
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 17, 34, 56).unwrap();
|
||||
assert_eq!(got.as_millis(), expected.timestamp_millis());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_negative_duration() {
|
||||
let now = fixed_now();
|
||||
let got = parse_datetime("-3m", now).expect("should parse");
|
||||
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 12, 31, 56).unwrap();
|
||||
assert_eq!(got.as_millis(), expected.timestamp_millis());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_explicit_plus() {
|
||||
let now = fixed_now();
|
||||
let got = parse_datetime("+10s", now).expect("should parse");
|
||||
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 12, 35, 6).unwrap();
|
||||
assert_eq!(got.as_millis(), expected.timestamp_millis());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_of_day_resolves_to_today() {
|
||||
let now = fixed_now();
|
||||
let got = parse_datetime("12:00", now).expect("should parse");
|
||||
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
|
||||
assert_eq!(got.as_millis(), expected.timestamp_millis());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_of_day_midnight() {
|
||||
let now = fixed_now();
|
||||
let got = parse_datetime("00:00", now).expect("should parse");
|
||||
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 0, 0, 0).unwrap();
|
||||
assert_eq!(got.as_millis(), expected.timestamp_millis());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_of_day_end_of_day() {
|
||||
let now = fixed_now();
|
||||
let got = parse_datetime("23:59", now).expect("should parse");
|
||||
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 23, 59, 0).unwrap();
|
||||
assert_eq!(got.as_millis(), expected.timestamp_millis());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_of_day_with_seconds() {
|
||||
let now = fixed_now();
|
||||
let got = parse_datetime("23:59:59", now).expect("should parse");
|
||||
let expected = Utc.with_ymd_and_hms(2024, 6, 15, 23, 59, 59).unwrap();
|
||||
assert_eq!(got.as_millis(), expected.timestamp_millis());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_date_only() {
|
||||
let now = fixed_now();
|
||||
let got = parse_datetime("2024-01-15", now).expect("should parse");
|
||||
let expected = Utc.with_ymd_and_hms(2024, 1, 15, 0, 0, 0).unwrap();
|
||||
assert_eq!(got.as_millis(), expected.timestamp_millis());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_datetime_rfc3339() {
|
||||
let now = fixed_now();
|
||||
let got = parse_datetime("2024-01-15T12:00:00Z", now).expect("should parse");
|
||||
let expected = Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap();
|
||||
assert_eq!(got.as_millis(), expected.timestamp_millis());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_datetime_rfc3339_with_offset() {
|
||||
let now = fixed_now();
|
||||
let got = parse_datetime("2024-01-15T12:00:00+02:00", now).expect("should parse");
|
||||
let expected = Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap();
|
||||
assert_eq!(got.as_millis(), expected.timestamp_millis());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_time_of_day_is_an_error() {
|
||||
let now = fixed_now();
|
||||
let err = parse_datetime("25:99", now).expect_err("should not parse");
|
||||
match err {
|
||||
DateTimeParseError::Invalid { .. } => {}
|
||||
other => panic!("expected Invalid, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_date_is_an_error() {
|
||||
let now = fixed_now();
|
||||
let err = parse_datetime("2024-13-40", now).expect_err("should not parse");
|
||||
match err {
|
||||
DateTimeParseError::Invalid { .. } => {}
|
||||
other => panic!("expected Invalid, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totally_unparseable_input_is_an_error() {
|
||||
let now = fixed_now();
|
||||
let err = parse_datetime("not a date at all", now).expect_err("should not parse");
|
||||
match err {
|
||||
DateTimeParseError::Unparseable(_) => {}
|
||||
other => panic!("expected Unparseable, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_is_an_error() {
|
||||
let now = fixed_now();
|
||||
let err = parse_datetime("", now).expect_err("should not parse");
|
||||
assert!(matches!(err, DateTimeParseError::Unparseable(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_panic_on_garbage() {
|
||||
let now = fixed_now();
|
||||
for input in ["-", ":", "--", "::", "5", "h5", "T", "-T:", "🎉"] {
|
||||
let _ = parse_datetime(input, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod api;
|
||||
mod data_value;
|
||||
mod dto;
|
||||
mod engine;
|
||||
mod filter_datetime;
|
||||
mod index_key;
|
||||
mod ingest;
|
||||
mod metric_primitive;
|
||||
|
||||
Reference in New Issue
Block a user