Skip to main content

arrow_array/
timezone.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: See the NOTICE file distributed with this work for additional information regarding copyright ownership
3// SPDX-FileContributor: Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements
4
5//! Timezone for timestamp arrays
6
7use arrow_schema::ArrowError;
8use chrono::{DateTime, FixedOffset, NaiveDateTime};
9pub use private::{Tz, TzOffset};
10
11/// Parses a fixed offset of the form "+09:00", "-09" or "+0930"
12fn parse_fixed_offset(tz: &str) -> Option<FixedOffset> {
13    let bytes = tz.as_bytes();
14
15    let mut values = match bytes.len() {
16        // [+-]XX:XX
17        6 if bytes[3] == b':' => [bytes[1], bytes[2], bytes[4], bytes[5]],
18        // [+-]XXXX
19        5 => [bytes[1], bytes[2], bytes[3], bytes[4]],
20        // [+-]XX
21        3 => [bytes[1], bytes[2], b'0', b'0'],
22        _ => return None,
23    };
24    values.iter_mut().for_each(|x| *x = x.wrapping_sub(b'0'));
25    if values.iter().any(|x| *x > 9) {
26        return None;
27    }
28    let secs =
29        (values[0] * 10 + values[1]) as i32 * 60 * 60 + (values[2] * 10 + values[3]) as i32 * 60;
30
31    match bytes[0] {
32        b'+' => FixedOffset::east_opt(secs),
33        b'-' => FixedOffset::west_opt(secs),
34        _ => None,
35    }
36}
37
38#[cfg(feature = "chrono-tz")]
39mod private {
40    use super::*;
41    use chrono::offset::TimeZone;
42    use chrono::{LocalResult, NaiveDate, NaiveDateTime, Offset};
43    use std::fmt::Display;
44    use std::str::FromStr;
45
46    /// An [`Offset`] for [`Tz`]
47    #[derive(Debug, Copy, Clone)]
48    pub struct TzOffset {
49        tz: Tz,
50        offset: FixedOffset,
51    }
52
53    impl std::fmt::Display for TzOffset {
54        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55            self.offset.fmt(f)
56        }
57    }
58
59    impl Offset for TzOffset {
60        fn fix(&self) -> FixedOffset {
61            self.offset
62        }
63    }
64
65    /// An Arrow [`TimeZone`]
66    #[derive(Debug, Copy, Clone)]
67    pub struct Tz(TzInner);
68
69    #[derive(Debug, Copy, Clone)]
70    enum TzInner {
71        Timezone(chrono_tz::Tz),
72        Offset(FixedOffset),
73    }
74
75    impl FromStr for Tz {
76        type Err = ArrowError;
77
78        fn from_str(tz: &str) -> Result<Self, Self::Err> {
79            match parse_fixed_offset(tz) {
80                Some(offset) => Ok(Self(TzInner::Offset(offset))),
81                None => Ok(Self(TzInner::Timezone(tz.parse().map_err(|e| {
82                    ArrowError::ParseError(format!("Invalid timezone \"{tz}\": {e}"))
83                })?))),
84            }
85        }
86    }
87
88    impl Display for Tz {
89        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90            match self.0 {
91                TzInner::Timezone(tz) => tz.fmt(f),
92                TzInner::Offset(offset) => offset.fmt(f),
93            }
94        }
95    }
96
97    macro_rules! tz {
98        ($s:ident, $tz:ident, $b:block) => {
99            match $s.0 {
100                TzInner::Timezone($tz) => $b,
101                TzInner::Offset($tz) => $b,
102            }
103        };
104    }
105
106    impl TimeZone for Tz {
107        type Offset = TzOffset;
108
109        fn from_offset(offset: &Self::Offset) -> Self {
110            offset.tz
111        }
112
113        fn offset_from_local_date(&self, local: &NaiveDate) -> LocalResult<Self::Offset> {
114            tz!(self, tz, {
115                tz.offset_from_local_date(local).map(|x| TzOffset {
116                    tz: *self,
117                    offset: x.fix(),
118                })
119            })
120        }
121
122        fn offset_from_local_datetime(&self, local: &NaiveDateTime) -> LocalResult<Self::Offset> {
123            tz!(self, tz, {
124                tz.offset_from_local_datetime(local).map(|x| TzOffset {
125                    tz: *self,
126                    offset: x.fix(),
127                })
128            })
129        }
130
131        fn offset_from_utc_date(&self, utc: &NaiveDate) -> Self::Offset {
132            tz!(self, tz, {
133                TzOffset {
134                    tz: *self,
135                    offset: tz.offset_from_utc_date(utc).fix(),
136                }
137            })
138        }
139
140        fn offset_from_utc_datetime(&self, utc: &NaiveDateTime) -> Self::Offset {
141            tz!(self, tz, {
142                TzOffset {
143                    tz: *self,
144                    offset: tz.offset_from_utc_datetime(utc).fix(),
145                }
146            })
147        }
148    }
149
150    #[inline]
151    pub(super) fn new_gap_info(local: &NaiveDateTime, tz: &Tz) -> Option<GapInfo> {
152        let TzInner::Timezone(ctz) = &tz.0 else {
153            return None;
154        };
155        chrono_tz::GapInfo::new(local, ctz).map(|gi| GapInfo {
156            begin: gi.begin.map(|(ndt, ctz)| {
157                (
158                    ndt,
159                    TzOffset {
160                        tz: *tz,
161                        offset: ctz.fix(),
162                    },
163                )
164            }),
165            end: gi.end.map(|dt| dt.with_timezone(tz)),
166        })
167    }
168
169    #[cfg(test)]
170    mod tests {
171        use super::*;
172        use chrono::{Timelike, Utc};
173
174        #[test]
175        fn test_with_timezone() {
176            let vals = [
177                Utc.timestamp_millis_opt(37800000).unwrap(),
178                Utc.timestamp_millis_opt(86339000).unwrap(),
179            ];
180
181            assert_eq!(10, vals[0].hour());
182            assert_eq!(23, vals[1].hour());
183
184            let tz: Tz = "America/Los_Angeles".parse().unwrap();
185
186            assert_eq!(2, vals[0].with_timezone(&tz).hour());
187            assert_eq!(15, vals[1].with_timezone(&tz).hour());
188        }
189
190        #[test]
191        fn test_using_chrono_tz_and_utc_naive_date_time() {
192            let sydney_tz = "Australia/Sydney".to_string();
193            let tz: Tz = sydney_tz.parse().unwrap();
194            let sydney_offset_without_dst = FixedOffset::east_opt(10 * 60 * 60).unwrap();
195            let sydney_offset_with_dst = FixedOffset::east_opt(11 * 60 * 60).unwrap();
196            // Daylight savings ends
197            // When local daylight time was about to reach
198            // Sunday, 4 April 2021, 3:00:00 am clocks were turned backward 1 hour to
199            // Sunday, 4 April 2021, 2:00:00 am local standard time instead.
200
201            // Daylight savings starts
202            // When local standard time was about to reach
203            // Sunday, 3 October 2021, 2:00:00 am clocks were turned forward 1 hour to
204            // Sunday, 3 October 2021, 3:00:00 am local daylight time instead.
205
206            // Sydney 2021-04-04T02:30:00+11:00 is 2021-04-03T15:30:00Z
207            let utc_just_before_sydney_dst_ends = NaiveDate::from_ymd_opt(2021, 4, 3)
208                .unwrap()
209                .and_hms_nano_opt(15, 30, 0, 0)
210                .unwrap();
211            assert_eq!(
212                tz.offset_from_utc_datetime(&utc_just_before_sydney_dst_ends)
213                    .fix(),
214                sydney_offset_with_dst
215            );
216            // Sydney 2021-04-04T02:30:00+10:00 is 2021-04-03T16:30:00Z
217            let utc_just_after_sydney_dst_ends = NaiveDate::from_ymd_opt(2021, 4, 3)
218                .unwrap()
219                .and_hms_nano_opt(16, 30, 0, 0)
220                .unwrap();
221            assert_eq!(
222                tz.offset_from_utc_datetime(&utc_just_after_sydney_dst_ends)
223                    .fix(),
224                sydney_offset_without_dst
225            );
226            // Sydney 2021-10-03T01:30:00+10:00 is 2021-10-02T15:30:00Z
227            let utc_just_before_sydney_dst_starts = NaiveDate::from_ymd_opt(2021, 10, 2)
228                .unwrap()
229                .and_hms_nano_opt(15, 30, 0, 0)
230                .unwrap();
231            assert_eq!(
232                tz.offset_from_utc_datetime(&utc_just_before_sydney_dst_starts)
233                    .fix(),
234                sydney_offset_without_dst
235            );
236            // Sydney 2021-04-04T03:30:00+11:00 is 2021-10-02T16:30:00Z
237            let utc_just_after_sydney_dst_starts = NaiveDate::from_ymd_opt(2022, 10, 2)
238                .unwrap()
239                .and_hms_nano_opt(16, 30, 0, 0)
240                .unwrap();
241            assert_eq!(
242                tz.offset_from_utc_datetime(&utc_just_after_sydney_dst_starts)
243                    .fix(),
244                sydney_offset_with_dst
245            );
246        }
247
248        fn naive(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> NaiveDateTime {
249            NaiveDate::from_ymd_opt(y, mo, d)
250                .unwrap()
251                .and_hms_opt(h, mi, 0)
252                .unwrap()
253        }
254
255        fn utc(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> chrono::DateTime<Utc> {
256            Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap()
257        }
258
259        #[test]
260        fn test_gap_info_not_in_gap() {
261            let tz: Tz = "America/New_York".parse().unwrap();
262            // Just before and at the end of the 2021 spring forward gap
263            assert!(GapInfo::new(&naive(2021, 3, 14, 1, 59), &tz).is_none());
264            assert!(GapInfo::new(&naive(2021, 3, 14, 3, 0), &tz).is_none());
265            // Ambiguous time during the 2021 fall back overlap is not a gap
266            assert!(GapInfo::new(&naive(2021, 11, 7, 1, 30), &tz).is_none());
267        }
268
269        #[test]
270        fn test_gap_info_america_new_york() {
271            // Clocks went forward from 2021-03-14T02:00:00-05:00 to 2021-03-14T03:00:00-04:00
272            let tz: Tz = "America/New_York".parse().unwrap();
273            for local in [
274                naive(2021, 3, 14, 2, 0),
275                naive(2021, 3, 14, 2, 30),
276                naive(2021, 3, 14, 2, 59),
277            ] {
278                assert!(tz.from_local_datetime(&local).single().is_none());
279
280                let gap = GapInfo::new(&local, &tz).unwrap();
281                let (begin, begin_offset) = gap.begin.unwrap();
282                assert_eq!(begin, naive(2021, 3, 14, 2, 0));
283                assert_eq!(
284                    begin_offset.fix(),
285                    FixedOffset::west_opt(5 * 60 * 60).unwrap()
286                );
287
288                let end = gap.end.unwrap();
289                assert_eq!(end.naive_local(), naive(2021, 3, 14, 3, 0));
290                assert_eq!(
291                    end.offset().fix(),
292                    FixedOffset::west_opt(4 * 60 * 60).unwrap()
293                );
294                assert_eq!(end, utc(2021, 3, 14, 7, 0));
295                assert_eq!(end.timezone().to_string(), "America/New_York");
296            }
297        }
298
299        #[test]
300        fn test_gap_info_europe_london() {
301            // Clocks went forward from 2021-03-28T01:00:00+00:00 to 2021-03-28T02:00:00+01:00
302            let tz: Tz = "Europe/London".parse().unwrap();
303            let gap = GapInfo::new(&naive(2021, 3, 28, 1, 30), &tz).unwrap();
304
305            let (begin, begin_offset) = gap.begin.unwrap();
306            assert_eq!(begin, naive(2021, 3, 28, 1, 0));
307            assert_eq!(begin_offset.fix(), FixedOffset::east_opt(0).unwrap());
308
309            let end = gap.end.unwrap();
310            assert_eq!(end.naive_local(), naive(2021, 3, 28, 2, 0));
311            assert_eq!(end.offset().fix(), FixedOffset::east_opt(60 * 60).unwrap());
312            assert_eq!(end, utc(2021, 3, 28, 1, 0));
313        }
314
315        #[test]
316        fn test_gap_info_australia_sydney() {
317            // Clocks went forward from 2021-10-03T02:00:00+10:00 to 2021-10-03T03:00:00+11:00
318            let tz: Tz = "Australia/Sydney".parse().unwrap();
319            let gap = GapInfo::new(&naive(2021, 10, 3, 2, 30), &tz).unwrap();
320
321            let (begin, begin_offset) = gap.begin.unwrap();
322            assert_eq!(begin, naive(2021, 10, 3, 2, 0));
323            assert_eq!(
324                begin_offset.fix(),
325                FixedOffset::east_opt(10 * 60 * 60).unwrap()
326            );
327
328            let end = gap.end.unwrap();
329            assert_eq!(end.naive_local(), naive(2021, 10, 3, 3, 0));
330            assert_eq!(
331                end.offset().fix(),
332                FixedOffset::east_opt(11 * 60 * 60).unwrap()
333            );
334            assert_eq!(end, utc(2021, 10, 2, 16, 0));
335        }
336
337        #[test]
338        fn test_timezone_display() {
339            let test_cases = ["UTC", "America/Los_Angeles", "-08:00", "+05:30"];
340            for &case in &test_cases {
341                let tz: Tz = case.parse().unwrap();
342                assert_eq!(tz.to_string(), case);
343            }
344        }
345    }
346}
347
348#[cfg(not(feature = "chrono-tz"))]
349mod private {
350    use super::*;
351    use chrono::offset::TimeZone;
352    use chrono::{LocalResult, NaiveDate, NaiveDateTime, Offset};
353    use std::str::FromStr;
354
355    /// An [`Offset`] for [`Tz`]
356    #[derive(Debug, Copy, Clone)]
357    pub struct TzOffset(FixedOffset);
358
359    impl std::fmt::Display for TzOffset {
360        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361            self.0.fmt(f)
362        }
363    }
364
365    impl Offset for TzOffset {
366        fn fix(&self) -> FixedOffset {
367            self.0
368        }
369    }
370
371    /// An Arrow [`TimeZone`]
372    #[derive(Debug, Copy, Clone)]
373    pub struct Tz(FixedOffset);
374
375    impl FromStr for Tz {
376        type Err = ArrowError;
377
378        fn from_str(tz: &str) -> Result<Self, Self::Err> {
379            let offset = parse_fixed_offset(tz).ok_or_else(|| {
380                ArrowError::ParseError(format!(
381                    "Invalid timezone \"{tz}\": only offset based timezones supported without chrono-tz feature"
382                ))
383            })?;
384            Ok(Self(offset))
385        }
386    }
387
388    impl TimeZone for Tz {
389        type Offset = TzOffset;
390
391        fn from_offset(offset: &Self::Offset) -> Self {
392            Self(offset.0)
393        }
394
395        fn offset_from_local_date(&self, local: &NaiveDate) -> LocalResult<Self::Offset> {
396            self.0.offset_from_local_date(local).map(TzOffset)
397        }
398
399        fn offset_from_local_datetime(&self, local: &NaiveDateTime) -> LocalResult<Self::Offset> {
400            self.0.offset_from_local_datetime(local).map(TzOffset)
401        }
402
403        fn offset_from_utc_date(&self, utc: &NaiveDate) -> Self::Offset {
404            TzOffset(self.0.offset_from_utc_date(utc).fix())
405        }
406
407        fn offset_from_utc_datetime(&self, utc: &NaiveDateTime) -> Self::Offset {
408            TzOffset(self.0.offset_from_utc_datetime(utc).fix())
409        }
410    }
411
412    #[inline]
413    pub(super) fn new_gap_info(_local: &NaiveDateTime, _tz: &Tz) -> Option<GapInfo> {
414        // Fixed offsets never contain gaps
415        None
416    }
417}
418
419/// Represents the information of a gap.
420///
421/// This returns useful information that can be used when converting a local [`NaiveDateTime`]
422/// to a timezone-aware [`DateTime`] with [`chrono::TimeZone::from_local_datetime`] and a gap
423/// ([`chrono::LocalResult::None`]) is found.
424///
425/// # Examples
426///
427/// In the `America/New_York` timezone the clocks went forward one hour at `2026-03-08T02:00:00`.
428/// The local time `2026-03-08T01:30:00` exists, but `2026-03-08T02:30:00` falls in the gap and
429/// does not.
430///
431/// ```
432/// # #[cfg(feature = "chrono-tz")]
433/// # {
434/// use arrow_array::timezone::{GapInfo, Tz};
435/// use chrono::{DateTime, FixedOffset, LocalResult, NaiveDateTime, Offset, TimeZone};
436///
437/// let tz: Tz = "America/New_York".parse().unwrap();
438/// let expect_offset: FixedOffset = "-0500".parse().unwrap();
439///
440/// // A local time before the gap has a single offset, and is not in a gap.
441/// let exists: NaiveDateTime = "2026-03-08T01:30:00".parse().unwrap();
442/// let offset = tz.offset_from_local_datetime(&exists).single().unwrap();
443/// assert_eq!(offset.fix(), expect_offset);
444/// assert!(GapInfo::new(&exists, &tz).is_none());
445///
446/// // A local time inside the gap has no offset...
447/// let not_exist: NaiveDateTime = "2026-03-08T02:30:00".parse().unwrap();
448/// let offset = tz.offset_from_local_datetime(&not_exist);
449/// assert!(matches!(offset, LocalResult::None));
450///
451/// // ...but `GapInfo` describes the gap it falls in.
452/// let GapInfo { begin, end } = GapInfo::new(&not_exist, &tz).unwrap();
453///
454/// // The gap begins at 02:00 local time. The offset is the one in effect before the gap.
455/// let (begin_datetime, begin_offset) = begin.unwrap();
456/// let expect_begin: NaiveDateTime = "2026-03-08T02:00:00".parse().unwrap();
457/// assert_eq!(begin_datetime, expect_begin);
458/// assert_eq!(begin_offset.fix(), expect_offset);
459///
460/// // The gap ends at 03:00 local time, with the offset in effect after the gap.
461/// let expect_end: DateTime<FixedOffset> = "2026-03-08T03:00:00-0400".parse().unwrap();
462/// assert_eq!(end.unwrap(), expect_end.with_timezone(&tz));
463/// # }
464/// ```
465#[derive(Debug, Clone, Copy)]
466pub struct GapInfo {
467    /// When available it contains information about the beginning of the gap.
468    ///
469    /// The time represents the first instant in which the gap starts.
470    /// This means that it is the first instant that when used with
471    /// [`chrono::TimeZone::from_local_datetime`] it will return
472    /// [`chrono::LocalResult::None`].
473    ///
474    /// The offset represents the offset of the first instant before the gap.
475    pub begin: Option<(NaiveDateTime, TzOffset)>,
476
477    /// When available it contains the first instant after the gap.
478    pub end: Option<DateTime<Tz>>,
479}
480
481impl GapInfo {
482    /// Return information about a gap.
483    ///
484    /// It returns `None` if `local` is not in a gap for the current timezone. Timezones
485    /// that represent fixed offsets contain no gaps and therefore always return `None`.
486    ///
487    /// If `local` is at the limits of the known timestamps the fields `begin` or `end` in
488    /// [`GapInfo`] will be `None`.
489    pub fn new(local: &NaiveDateTime, tz: &Tz) -> Option<Self> {
490        private::new_gap_info(local, tz)
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use chrono::{NaiveDate, Offset, TimeZone};
498
499    #[test]
500    fn test_with_offset() {
501        let t = NaiveDate::from_ymd_opt(2000, 1, 1).unwrap();
502
503        let tz: Tz = "-00:00".parse().unwrap();
504        assert_eq!(tz.offset_from_utc_date(&t).fix().local_minus_utc(), 0);
505        let tz: Tz = "+00:00".parse().unwrap();
506        assert_eq!(tz.offset_from_utc_date(&t).fix().local_minus_utc(), 0);
507
508        let tz: Tz = "-10:00".parse().unwrap();
509        assert_eq!(
510            tz.offset_from_utc_date(&t).fix().local_minus_utc(),
511            -10 * 60 * 60
512        );
513        let tz: Tz = "+09:00".parse().unwrap();
514        assert_eq!(
515            tz.offset_from_utc_date(&t).fix().local_minus_utc(),
516            9 * 60 * 60
517        );
518
519        let tz = "+09".parse::<Tz>().unwrap();
520        assert_eq!(
521            tz.offset_from_utc_date(&t).fix().local_minus_utc(),
522            9 * 60 * 60
523        );
524
525        let tz = "+0900".parse::<Tz>().unwrap();
526        assert_eq!(
527            tz.offset_from_utc_date(&t).fix().local_minus_utc(),
528            9 * 60 * 60
529        );
530
531        let err = "+9:00".parse::<Tz>().unwrap_err().to_string();
532        assert!(err.contains("Invalid timezone"), "{}", err);
533    }
534
535    #[test]
536    fn test_gap_info_fixed_offset() {
537        let times = [
538            NaiveDate::from_ymd_opt(2000, 1, 1)
539                .unwrap()
540                .and_hms_opt(0, 0, 0)
541                .unwrap(),
542            // Times that are in a DST gap for some named timezones
543            NaiveDate::from_ymd_opt(2021, 3, 14)
544                .unwrap()
545                .and_hms_opt(2, 30, 0)
546                .unwrap(),
547            NaiveDate::from_ymd_opt(2021, 3, 28)
548                .unwrap()
549                .and_hms_opt(1, 30, 0)
550                .unwrap(),
551        ];
552
553        for tz in ["+00:00", "-05:00", "+01:00", "+09:30"] {
554            let tz: Tz = tz.parse().unwrap();
555            for t in &times {
556                assert!(GapInfo::new(t, &tz).is_none(), "{tz:?} {t}");
557            }
558        }
559    }
560}