ic_dbms_api/dbms/sanitize/
timezone.rs

1use crate::prelude::{DateTime, IcDbmsResult, Sanitize, Value};
2
3/// Sanitizer that ensures that all [`crate::prelude::DateTime`] values are within a specific timezone.
4///
5/// If you want to ensure that all datetime values are in UTC timezone, you can use directly the [`UtcSanitizer`],
6/// which actually is just a wrapper for this sanitizer with "UTC" as timezone.
7///
8/// The value provided is `i16` representing the timezone offset in minutes from UTC.
9///
10/// # Example
11///
12/// ```rust
13/// use ic_dbms_api::prelude::{CollapseWhitespaceSanitizer, Value, Sanitize as _};
14///
15/// let value = Value::Text("  Hello,       World!  ".into());
16/// let sanitizer = CollapseWhitespaceSanitizer;
17/// let sanitized_value = sanitizer.sanitize(value).unwrap();
18/// assert_eq!(sanitized_value, Value::Text("Hello, World!".into()));
19/// ```
20pub struct TimezoneSanitizer(pub i16);
21
22impl Sanitize for TimezoneSanitizer {
23    fn sanitize(&self, value: Value) -> IcDbmsResult<Value> {
24        match value {
25            Value::DateTime(dt) => {
26                let delta_minutes = self.0 - dt.timezone_offset_minutes;
27                let delta_us = delta_minutes as i64 * 60 * 1_000_000;
28
29                let ts = datetime_to_us(&dt) + delta_us;
30                let mut new_dt = us_to_datetime(ts);
31
32                new_dt.timezone_offset_minutes = self.0;
33
34                Ok(Value::DateTime(new_dt))
35            }
36            other => Ok(other),
37        }
38    }
39}
40
41/// Sanitizer that ensures that all [`crate::prelude::DateTime`] values are within the UTC timezone.
42///
43/// # Example
44///
45/// ```rust
46/// use ic_dbms_api::prelude::{CollapseWhitespaceSanitizer, Value, Sanitize as _};
47///
48/// let value = Value::Text("  Hello,       World!  ".into());
49/// let sanitizer = CollapseWhitespaceSanitizer;
50/// let sanitized_value = sanitizer.sanitize(value).unwrap();
51/// assert_eq!(sanitized_value, Value::Text("Hello, World!".into()));
52/// ```
53pub struct UtcSanitizer;
54
55impl Sanitize for UtcSanitizer {
56    fn sanitize(&self, value: Value) -> IcDbmsResult<Value> {
57        TimezoneSanitizer(0).sanitize(value)
58    }
59}
60
61fn us_to_datetime(mut ts: i64) -> DateTime {
62    let microsecond = (ts.rem_euclid(1_000_000)) as u32;
63    ts = ts.div_euclid(1_000_000);
64
65    let second = (ts.rem_euclid(60)) as u8;
66    ts = ts.div_euclid(60);
67
68    let minute = (ts.rem_euclid(60)) as u8;
69    ts = ts.div_euclid(60);
70
71    let hour = (ts.rem_euclid(24)) as u8;
72    let mut days = ts.div_euclid(24);
73
74    let mut year = 1970;
75    loop {
76        let yd = if is_leap(year) { 366 } else { 365 };
77        if days >= yd {
78            days -= yd;
79            year += 1;
80        } else {
81            break;
82        }
83    }
84
85    let mut month = 1;
86    loop {
87        let dim = days_in_month(year, month);
88        if days >= dim as i64 {
89            days -= dim as i64;
90            month += 1;
91        } else {
92            break;
93        }
94    }
95
96    let day = (days + 1) as u8;
97
98    DateTime {
99        year: year as u16,
100        month: month as u8,
101        day,
102        hour,
103        minute,
104        second,
105        microsecond,
106        timezone_offset_minutes: 0,
107    }
108}
109
110fn datetime_to_us(dt: &DateTime) -> i64 {
111    let mut days = 0i64;
112
113    for y in 1970..dt.year as i32 {
114        days += if is_leap(y) { 366 } else { 365 };
115    }
116
117    for m in 1..dt.month as i32 {
118        days += days_in_month(dt.year as i32, m) as i64;
119    }
120
121    days += (dt.day as i64) - 1;
122
123    let seconds = days * 86_400 + dt.hour as i64 * 3_600 + dt.minute as i64 * 60 + dt.second as i64;
124
125    seconds * 1_000_000 + dt.microsecond as i64
126}
127
128fn is_leap(year: i32) -> bool {
129    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
130}
131
132fn days_in_month(year: i32, month: i32) -> i32 {
133    match month {
134        1 => 31,
135        2 => {
136            if is_leap(year) {
137                29
138            } else {
139                28
140            }
141        }
142        3 => 31,
143        4 => 30,
144        5 => 31,
145        6 => 30,
146        7 => 31,
147        8 => 31,
148        9 => 30,
149        10 => 31,
150        11 => 30,
151        12 => 31,
152        _ => unreachable!(),
153    }
154}
155
156#[cfg(test)]
157mod tests {
158
159    use super::*;
160
161    #[test]
162    fn test_should_noop_timezone_if_same_offset() {
163        let sanitizer = TimezoneSanitizer(120);
164
165        let original = dt(2024, 3, 10, 12, 30, 0, 0, 120);
166        let value = Value::DateTime(original);
167
168        let out = sanitizer.sanitize(value).unwrap();
169
170        assert_eq!(out, Value::DateTime(original));
171    }
172
173    #[test]
174    fn test_should_shift_one_hour_forward() {
175        let sanitizer = TimezoneSanitizer(120);
176
177        let input = dt(2024, 3, 10, 12, 0, 0, 0, 60);
178        let expected = dt(2024, 3, 10, 13, 0, 0, 0, 120);
179
180        let out = sanitizer.sanitize(Value::DateTime(input)).unwrap();
181
182        assert_eq!(out, Value::DateTime(expected));
183    }
184
185    #[test]
186    fn test_should_shift_one_hour_backward_with_day_underflow() {
187        let sanitizer = UtcSanitizer;
188
189        let input = dt(2024, 3, 10, 0, 30, 0, 0, 60);
190        let expected = dt(2024, 3, 9, 23, 30, 0, 0, 0);
191
192        let out = sanitizer.sanitize(Value::DateTime(input)).unwrap();
193
194        assert_eq!(out, Value::DateTime(expected));
195    }
196
197    #[test]
198    fn test_should_underflow_across_month_boundary() {
199        let sanitizer = TimezoneSanitizer(0);
200
201        let input = dt(2024, 4, 1, 0, 15, 0, 0, 60);
202        let expected = dt(2024, 3, 31, 23, 15, 0, 0, 0);
203
204        let out = sanitizer.sanitize(Value::DateTime(input)).unwrap();
205
206        assert_eq!(out, Value::DateTime(expected));
207    }
208
209    #[test]
210    fn test_should_underflow_year_boundary() {
211        let sanitizer = TimezoneSanitizer(0);
212
213        let input = dt(2024, 1, 1, 0, 0, 0, 0, 60);
214        let expected = dt(2023, 12, 31, 23, 0, 0, 0, 0);
215
216        let out = sanitizer.sanitize(Value::DateTime(input)).unwrap();
217
218        assert_eq!(out, Value::DateTime(expected));
219    }
220
221    #[test]
222    fn test_should_shift_leap_day() {
223        let sanitizer = TimezoneSanitizer(0);
224
225        let input = dt(2024, 2, 29, 0, 30, 0, 0, 60);
226        let expected = dt(2024, 2, 28, 23, 30, 0, 0, 0);
227
228        let out = sanitizer.sanitize(Value::DateTime(input)).unwrap();
229
230        assert_eq!(out, Value::DateTime(expected));
231    }
232
233    #[test]
234    fn test_should_preserve_microseconds() {
235        let sanitizer = TimezoneSanitizer(60);
236
237        let input = dt(2024, 5, 20, 10, 0, 0, 999_999, 0);
238        let expected = dt(2024, 5, 20, 11, 0, 0, 999_999, 60);
239
240        let out = sanitizer.sanitize(Value::DateTime(input)).unwrap();
241
242        assert_eq!(out, Value::DateTime(expected));
243    }
244
245    #[test]
246    fn test_timezone_sanitizer_noop_on_non_datetime() {
247        let sanitizer = TimezoneSanitizer(60);
248
249        let value = Value::Int32(42.into());
250        let out = sanitizer.sanitize(value.clone()).unwrap();
251
252        assert_eq!(out, value);
253    }
254
255    #[test]
256    fn test_should_roundtrip_conversion() {
257        let dt0 = dt(2024, 6, 15, 18, 45, 12, 123_456, 0);
258
259        let to_plus2 = TimezoneSanitizer(120);
260        let to_utc = UtcSanitizer;
261
262        let v1 = to_plus2.sanitize(Value::DateTime(dt0)).unwrap();
263
264        let v2 = to_utc.sanitize(v1).unwrap();
265
266        assert_eq!(v2, Value::DateTime(dt0));
267    }
268
269    #[allow(clippy::too_many_arguments)]
270    fn dt(y: u16, mo: u8, d: u8, h: u8, mi: u8, s: u8, us: u32, tz: i16) -> DateTime {
271        DateTime {
272            year: y,
273            month: mo,
274            day: d,
275            hour: h,
276            minute: mi,
277            second: s,
278            microsecond: us,
279            timezone_offset_minutes: tz,
280        }
281    }
282}