ic_dbms_api/dbms/sanitize/
timezone.rs1use crate::prelude::{DateTime, IcDbmsResult, Sanitize, Value};
2
3pub 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
41pub 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}