Skip to main content

raphtory_core/utils/
time.rs

1use chrono::{DateTime, Datelike, Duration, Months, NaiveDate};
2use itertools::Itertools;
3use raphtory_api::core::{storage::timeindex::EventTime, utils::time::ParseTimeError};
4use regex::Regex;
5use std::ops::{Add, Mul, Sub};
6
7pub(crate) const SECOND_MS: i64 = 1000;
8pub(crate) const MINUTE_MS: i64 = 60 * SECOND_MS;
9pub(crate) const HOUR_MS: i64 = 60 * MINUTE_MS;
10pub(crate) const DAY_MS: i64 = 24 * HOUR_MS;
11pub(crate) const WEEK_MS: i64 = 7 * DAY_MS;
12
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub enum IntervalSize {
15    Discrete(u64),
16    /// `months` is u32 because chrono::Months works with u32.
17    Temporal {
18        millis: u64,
19        months: u32,
20    },
21}
22
23impl IntervalSize {
24    /// Creates a 0 sized temporal `IntervalSize`. Should not be used to create Windows.
25    pub fn empty_temporal() -> Self {
26        IntervalSize::Temporal {
27            millis: 0,
28            months: 0,
29        }
30    }
31
32    fn months(months: i64) -> Self {
33        Self::Temporal {
34            millis: 0,
35            months: months as u32,
36        }
37    }
38
39    fn add_temporal(&self, other: IntervalSize) -> IntervalSize {
40        match (self, other) {
41            (
42                Self::Temporal {
43                    millis: ml1,
44                    months: mt1,
45                },
46                Self::Temporal {
47                    millis: ml2,
48                    months: mt2,
49                },
50            ) => Self::Temporal {
51                millis: ml1 + ml2,
52                months: mt1 + mt2,
53            },
54            _ => panic!("this function is not supposed to be used with discrete intervals"),
55        }
56    }
57}
58
59impl From<Duration> for IntervalSize {
60    fn from(value: Duration) -> Self {
61        Self::Temporal {
62            millis: value.num_milliseconds() as u64,
63            months: 0,
64        }
65    }
66}
67
68/// Used to keep track of the smallest unit provided, so that we can line up windows
69/// (eg. at the start of the hour, week, year, ...)
70#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
71pub enum AlignmentUnit {
72    Unaligned, // note that there is no functional difference between millisecond and unaligned for the time being
73    Millisecond,
74    Second,
75    Minute,
76    Hour,
77    Day,
78    Week,
79    Month,
80    Year,
81}
82
83impl AlignmentUnit {
84    /// Floors a UTC timestamp in milliseconds since Unix epoch to the nearest alignment unit.
85    pub fn align_timestamp(&self, timestamp: i64) -> i64 {
86        match self {
87            AlignmentUnit::Unaligned => timestamp,
88            AlignmentUnit::Millisecond => timestamp,
89            AlignmentUnit::Second => Self::floor_ms(timestamp, SECOND_MS),
90            AlignmentUnit::Minute => Self::floor_ms(timestamp, MINUTE_MS),
91            AlignmentUnit::Hour => Self::floor_ms(timestamp, HOUR_MS),
92            AlignmentUnit::Day => Self::floor_ms(timestamp, DAY_MS),
93            AlignmentUnit::Week => {
94                let offset = DAY_MS * 4; // 0 is a Thursday
95                Self::floor_ms(timestamp - offset, WEEK_MS) + offset
96            }
97            // Month and Year are variable (28, 30, or 31 days / 365 or 366 days so we can't simply use division)
98            AlignmentUnit::Month => {
99                let naive = DateTime::from_timestamp_millis(timestamp)
100                    .unwrap_or_else(|| {
101                        panic!("{timestamp} cannot be interpreted as a milliseconds timestamp.")
102                    })
103                    .naive_utc();
104                let y = naive.year();
105                let m = naive.month();
106                NaiveDate::from_ymd_opt(y, m, 1)
107                    .unwrap()
108                    .and_hms_milli_opt(0, 0, 0, 0)
109                    .unwrap()
110                    .and_utc()
111                    .timestamp_millis()
112            }
113            AlignmentUnit::Year => {
114                let naive = DateTime::from_timestamp_millis(timestamp)
115                    .unwrap_or_else(|| {
116                        panic!("{timestamp} cannot be interpreted as a milliseconds timestamp.")
117                    })
118                    .naive_utc();
119                let y = naive.year();
120                NaiveDate::from_ymd_opt(y, 1, 1)
121                    .unwrap()
122                    .and_hms_milli_opt(0, 0, 0, 0)
123                    .unwrap()
124                    .and_utc()
125                    .timestamp_millis()
126            }
127        }
128    }
129
130    /// Floors `ts` to a multiple of `unit_ms` using a remainder that is always non-negative,
131    /// so the result is the boundary at or before `ts`, even for negative timestamps.
132    #[inline]
133    fn floor_ms(ts: i64, unit_ms: i64) -> i64 {
134        ts - ts.rem_euclid(unit_ms)
135    }
136}
137
138impl TryFrom<String> for AlignmentUnit {
139    type Error = ParseTimeError;
140
141    fn try_from(value: String) -> Result<Self, Self::Error> {
142        Self::try_from(value.as_str())
143    }
144}
145
146impl TryFrom<&str> for AlignmentUnit {
147    type Error = ParseTimeError;
148
149    fn try_from(value: &str) -> Result<Self, Self::Error> {
150        let unit = match value.to_lowercase().as_str() {
151            "year" | "years" => AlignmentUnit::Year,
152            "month" | "months" => AlignmentUnit::Month,
153            "week" | "weeks" => AlignmentUnit::Week,
154            "day" | "days" => AlignmentUnit::Day,
155            "hour" | "hours" => AlignmentUnit::Hour,
156            "minute" | "minutes" => AlignmentUnit::Minute,
157            "second" | "seconds" => AlignmentUnit::Second,
158            "millisecond" | "milliseconds" => AlignmentUnit::Millisecond,
159            "unaligned" => AlignmentUnit::Unaligned,
160            unit => return Err(ParseTimeError::InvalidAlignmentUnit(unit.to_string())),
161        };
162        Ok(unit)
163    }
164}
165
166#[derive(Clone, Copy, Debug, PartialEq)]
167pub struct Interval {
168    /// Used if the `IntervalSize` is Temporal, keeps track of the smallest unit passed to line up windows.
169    /// (eg. at the start of the hour, week, year, ...). If the IntervalSize is discrete, this is `None`.
170    pub alignment_unit: Option<AlignmentUnit>,
171    /// The interval.
172    pub size: IntervalSize,
173}
174
175impl Default for Interval {
176    fn default() -> Self {
177        Self {
178            alignment_unit: None,
179            size: IntervalSize::Discrete(1),
180        }
181    }
182}
183
184impl TryFrom<String> for Interval {
185    type Error = ParseTimeError;
186
187    fn try_from(value: String) -> Result<Self, Self::Error> {
188        Self::try_from(value.as_str())
189    }
190}
191
192impl TryFrom<&str> for Interval {
193    type Error = ParseTimeError;
194    fn try_from(value: &str) -> Result<Self, Self::Error> {
195        let trimmed = value.trim();
196        let no_and = trimmed.replace("and", "");
197        let cleaned = {
198            let re = Regex::new(r"[\s&,]+").unwrap();
199            re.replace_all(&no_and, " ")
200        };
201
202        let tokens = cleaned.split(' ').collect_vec();
203
204        if tokens.len() < 2 || tokens.len() % 2 != 0 {
205            return Err(ParseTimeError::InvalidPairs);
206        }
207
208        let (temporal_sum, smallest_unit): (IntervalSize, AlignmentUnit) =
209            tokens.chunks(2).try_fold(
210                (IntervalSize::empty_temporal(), AlignmentUnit::Year), // start with the largest alignment unit
211                |(sum, smallest), chunk| {
212                    let (interval, unit) = Self::parse_duration(chunk[0], chunk[1])?;
213                    Ok::<_, ParseTimeError>((sum.add_temporal(interval), smallest.min(unit)))
214                },
215            )?;
216
217        Ok(Self {
218            alignment_unit: Some(smallest_unit),
219            size: temporal_sum,
220        })
221    }
222}
223
224impl TryFrom<u64> for Interval {
225    type Error = ParseTimeError;
226    fn try_from(value: u64) -> Result<Self, Self::Error> {
227        Ok(Self {
228            alignment_unit: None,
229            size: IntervalSize::Discrete(value),
230        })
231    }
232}
233
234impl TryFrom<u32> for Interval {
235    type Error = ParseTimeError;
236    fn try_from(value: u32) -> Result<Self, Self::Error> {
237        Ok(Self {
238            alignment_unit: None,
239            size: IntervalSize::Discrete(value as u64),
240        })
241    }
242}
243
244impl TryFrom<i32> for Interval {
245    type Error = ParseTimeError;
246    fn try_from(value: i32) -> Result<Self, Self::Error> {
247        if value >= 0 {
248            Ok(Self {
249                alignment_unit: None,
250                size: IntervalSize::Discrete(value as u64),
251            })
252        } else {
253            Err(ParseTimeError::NegativeInt)
254        }
255    }
256}
257
258impl TryFrom<i64> for Interval {
259    type Error = ParseTimeError;
260
261    fn try_from(value: i64) -> Result<Self, Self::Error> {
262        if value >= 0 {
263            Ok(Self {
264                alignment_unit: None,
265                size: IntervalSize::Discrete(value as u64),
266            })
267        } else {
268            Err(ParseTimeError::NegativeInt)
269        }
270    }
271}
272
273pub trait TryIntoInterval {
274    fn try_into_interval(self) -> Result<Interval, ParseTimeError>;
275}
276
277impl<T> TryIntoInterval for T
278where
279    Interval: TryFrom<T>,
280    ParseTimeError: From<<Interval as TryFrom<T>>::Error>,
281{
282    fn try_into_interval(self) -> Result<Interval, ParseTimeError> {
283        Ok(self.try_into()?)
284    }
285}
286
287impl Interval {
288    /// Return an option because there might be no exact translation to millis for some intervals
289    pub fn to_millis(&self) -> Option<u64> {
290        match self.size {
291            IntervalSize::Discrete(millis) => Some(millis),
292            IntervalSize::Temporal { millis, months } => (months == 0).then_some(millis),
293        }
294    }
295
296    fn parse_duration(
297        number: &str,
298        unit: &str,
299    ) -> Result<(IntervalSize, AlignmentUnit), ParseTimeError> {
300        let number: i64 = number.parse::<u64>()? as i64;
301        let duration = match unit {
302            "year" | "years" => (IntervalSize::months(number * 12), AlignmentUnit::Year),
303            "month" | "months" => (IntervalSize::months(number), AlignmentUnit::Month),
304            "week" | "weeks" => (Duration::weeks(number).into(), AlignmentUnit::Week),
305            "day" | "days" => (Duration::days(number).into(), AlignmentUnit::Day),
306            "hour" | "hours" => (Duration::hours(number).into(), AlignmentUnit::Hour),
307            "minute" | "minutes" => (Duration::minutes(number).into(), AlignmentUnit::Minute),
308            "second" | "seconds" => (Duration::seconds(number).into(), AlignmentUnit::Second),
309            "millisecond" | "milliseconds" => (
310                Duration::milliseconds(number).into(),
311                AlignmentUnit::Millisecond,
312            ),
313            unit => return Err(ParseTimeError::InvalidUnit(unit.to_string())),
314        };
315        Ok(duration)
316    }
317
318    pub fn discrete(num: u64) -> Self {
319        Interval {
320            alignment_unit: None,
321            size: IntervalSize::Discrete(num),
322        }
323    }
324
325    pub fn milliseconds(ms: i64) -> Self {
326        Interval {
327            alignment_unit: Some(AlignmentUnit::Millisecond),
328            size: IntervalSize::from(Duration::milliseconds(ms)),
329        }
330    }
331
332    pub fn seconds(seconds: i64) -> Self {
333        Interval {
334            alignment_unit: Some(AlignmentUnit::Second),
335            size: IntervalSize::from(Duration::seconds(seconds)),
336        }
337    }
338
339    pub fn minutes(minutes: i64) -> Self {
340        Interval {
341            alignment_unit: Some(AlignmentUnit::Minute),
342            size: IntervalSize::from(Duration::minutes(minutes)),
343        }
344    }
345
346    pub fn hours(hours: i64) -> Self {
347        Interval {
348            alignment_unit: Some(AlignmentUnit::Hour),
349            size: IntervalSize::from(Duration::hours(hours)),
350        }
351    }
352
353    pub fn days(days: i64) -> Self {
354        Interval {
355            alignment_unit: Some(AlignmentUnit::Day),
356            size: IntervalSize::from(Duration::days(days)),
357        }
358    }
359
360    pub fn weeks(weeks: i64) -> Self {
361        Interval {
362            alignment_unit: Some(AlignmentUnit::Week),
363            size: IntervalSize::from(Duration::weeks(weeks)),
364        }
365    }
366
367    pub fn months(months: i64) -> Self {
368        Interval {
369            alignment_unit: Some(AlignmentUnit::Month),
370            size: IntervalSize::months(months),
371        }
372    }
373
374    pub fn years(years: i64) -> Self {
375        Interval {
376            alignment_unit: Some(AlignmentUnit::Year),
377            size: IntervalSize::months(12 * years),
378        }
379    }
380
381    pub fn and(&self, other: &Self) -> Result<Self, IntervalTypeError> {
382        match (self.size, other.size) {
383            (IntervalSize::Discrete(l), IntervalSize::Discrete(r)) => Ok(Interval {
384                alignment_unit: None,
385                size: IntervalSize::Discrete(l + r),
386            }),
387            (IntervalSize::Temporal { .. }, IntervalSize::Temporal { .. }) => Ok(Interval {
388                alignment_unit: self.alignment_unit.min(other.alignment_unit),
389                size: self.size.add_temporal(other.size),
390            }),
391            (_, _) => Err(IntervalTypeError()),
392        }
393    }
394}
395
396#[derive(thiserror::Error, Debug)]
397#[error("Discrete and temporal intervals cannot be combined")]
398pub struct IntervalTypeError();
399
400impl Sub<Interval> for i64 {
401    type Output = i64;
402    fn sub(self, rhs: Interval) -> Self::Output {
403        match rhs.size {
404            IntervalSize::Discrete(number)
405            | IntervalSize::Temporal {
406                millis: number,
407                months: 0,
408            } => self - (number as i64),
409            IntervalSize::Temporal { millis, months } => {
410                // first we subtract the number of milliseconds and then the number of months for
411                // consistency with the implementation of Add (we revert back the steps) so we
412                // guarantee that:  time + interval - interval = time
413                let datetime = DateTime::from_timestamp_millis(self - millis as i64)
414                    .unwrap_or_else(|| {
415                        panic!("{self} cannot be interpreted as a milliseconds timestamp")
416                    })
417                    .naive_utc();
418                (datetime - Months::new(months))
419                    .and_utc()
420                    .timestamp_millis()
421            }
422        }
423    }
424}
425
426impl Add<Interval> for i64 {
427    type Output = i64;
428    fn add(self, rhs: Interval) -> Self::Output {
429        match rhs.size {
430            IntervalSize::Discrete(number)
431            | IntervalSize::Temporal {
432                millis: number,
433                months: 0,
434            } => self + (number as i64),
435            IntervalSize::Temporal { millis, months } => {
436                // first we add the number of months and then the number of milliseconds for
437                // consistency with the implementation of Sub (we revert back the steps) so we
438                // guarantee that:  time + interval - interval = time
439                let datetime = DateTime::from_timestamp_millis(self)
440                    .unwrap_or_else(|| {
441                        panic!("{self} cannot be interpreted as a milliseconds timestamp")
442                    })
443                    .naive_utc();
444                (datetime + Months::new(months))
445                    .and_utc()
446                    .timestamp_millis()
447                    + millis as i64
448            }
449        }
450    }
451}
452
453// since all IntervalSize values (discrete number and temporal millis/months) are unsigned,
454// we can only multiply with unsigned numbers.
455impl Mul<Interval> for u32 {
456    type Output = Interval;
457
458    fn mul(self, rhs: Interval) -> Self::Output {
459        match rhs.size {
460            IntervalSize::Discrete(number) => Interval {
461                alignment_unit: rhs.alignment_unit, // alignment_unit should be None
462                size: IntervalSize::Discrete((self as u64) * number),
463            },
464            IntervalSize::Temporal { millis, months } => Interval {
465                alignment_unit: rhs.alignment_unit,
466                size: IntervalSize::Temporal {
467                    millis: (self as u64) * millis,
468                    months: self * months,
469                },
470            },
471        }
472    }
473}
474
475impl Add<Interval> for EventTime {
476    type Output = EventTime;
477    fn add(self, rhs: Interval) -> Self::Output {
478        match rhs.size {
479            IntervalSize::Discrete(number) => EventTime(self.0 + (number as i64), self.1),
480            IntervalSize::Temporal { millis, months } => {
481                // first we add the number of months and then the number of milliseconds for
482                // consistency with the implementation of Sub (we revert back the steps) so we
483                // guarantee that:  time + interval - interval = time
484                let datetime = DateTime::from_timestamp_millis(self.0)
485                    .unwrap_or_else(|| {
486                        panic!("{self} cannot be interpreted as a milliseconds timestamp")
487                    })
488                    .naive_utc();
489                let timestamp = (datetime + Months::new(months))
490                    .and_utc()
491                    .timestamp_millis()
492                    + millis as i64;
493                EventTime(timestamp, self.1)
494            }
495        }
496    }
497}
498
499#[cfg(test)]
500mod time_tests {
501    use crate::utils::time::{AlignmentUnit, Interval, WEEK_MS};
502    use chrono::{DateTime, Datelike, NaiveTime, Utc, Weekday};
503    use proptest::{arbitrary::any, prelude::Strategy, proptest};
504    use raphtory_api::core::{
505        storage::timeindex::AsTime,
506        utils::time::{ParseTimeError, TryIntoTime},
507    };
508
509    #[test]
510    fn alignment_week_proptest() {
511        proptest!(|(dt in (-8334601228800000i64..8210266876800000).prop_filter_map("not a valid date", DateTime::from_timestamp_millis))| {
512            let ts = dt.timestamp_millis();
513            let aligned = AlignmentUnit::Week.align_timestamp(ts);
514            let aligned_dt = aligned.dt().unwrap();
515            assert_eq!(aligned_dt, aligned_dt.with_time(NaiveTime::from_num_seconds_from_midnight_opt(0, 0).unwrap()).unwrap());
516            assert!(ts - aligned < WEEK_MS);
517            assert_eq!(aligned_dt.weekday(), Weekday::Mon);
518
519
520        })
521    }
522
523    #[test]
524    fn interval_parsing() {
525        let second: u64 = 1000;
526        let minute = 60 * second;
527        let hour = 60 * minute;
528        let day = 24 * hour;
529        let week = 7 * day;
530
531        let interval: Interval = "1 day".try_into().unwrap();
532        assert_eq!(interval.to_millis().unwrap(), day);
533
534        let interval: Interval = "1 week".try_into().unwrap();
535        assert_eq!(interval.to_millis().unwrap(), week);
536
537        let interval: Interval = "4 weeks and 1 day".try_into().unwrap();
538        assert_eq!(interval.to_millis().unwrap(), 4 * week + day);
539
540        let interval: Interval = "2 days & 1 millisecond".try_into().unwrap();
541        assert_eq!(interval.to_millis().unwrap(), 2 * day + 1);
542
543        let interval: Interval = "2 days, 1 hour, and 2 minutes".try_into().unwrap();
544        assert_eq!(interval.to_millis().unwrap(), 2 * day + hour + 2 * minute);
545
546        let interval: Interval = "1 weeks ,   1 minute".try_into().unwrap();
547        assert_eq!(interval.to_millis().unwrap(), week + minute);
548
549        let interval: Interval = "23 seconds  and 34 millisecond and 1 minute"
550            .try_into()
551            .unwrap();
552        assert_eq!(interval.to_millis().unwrap(), 23 * second + 34 + minute);
553    }
554
555    #[test]
556    fn interval_parsing_with_months_and_years() {
557        let dt = "2020-01-01 00:00:00".try_into_time().unwrap();
558
559        let two_months: Interval = "2 months".try_into().unwrap();
560        let dt_plus_2_months = "2020-03-01 00:00:00".try_into_time().unwrap();
561        assert_eq!(dt + two_months, dt_plus_2_months);
562
563        let two_years: Interval = "2 years".try_into().unwrap();
564        let dt_plus_2_years = "2022-01-01 00:00:00".try_into_time().unwrap();
565        assert_eq!(dt + two_years, dt_plus_2_years);
566
567        let mix_interval: Interval = "1 year 1 month and 1 second".try_into().unwrap();
568        let dt_mix = "2021-02-01 00:00:01".try_into_time().unwrap();
569        assert_eq!(dt + mix_interval, dt_mix);
570    }
571
572    #[test]
573    fn invalid_intervals() {
574        let result: Result<Interval, ParseTimeError> = "".try_into();
575        assert_eq!(result, Err(ParseTimeError::InvalidPairs));
576
577        let result: Result<Interval, ParseTimeError> = "1".try_into();
578        assert_eq!(result, Err(ParseTimeError::InvalidPairs));
579
580        let result: Result<Interval, ParseTimeError> = "1 day and 5".try_into();
581        assert_eq!(result, Err(ParseTimeError::InvalidPairs));
582
583        let result: Result<Interval, ParseTimeError> = "1 daay".try_into();
584        assert_eq!(result, Err(ParseTimeError::InvalidUnit("daay".to_string())));
585
586        let result: Result<Interval, ParseTimeError> = "day 1".try_into();
587
588        match result {
589            Err(ParseTimeError::ParseInt { .. }) => (),
590            _ => panic!(),
591        }
592    }
593}