Skip to main content

arrow_cast/
local_time.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//! Resolving wall clock ("local") readings to instants.
6//!
7//! Reading a [`NaiveDateTime`] as a wall clock time in an IANA timezone does not
8//! always identify a unique instant, because of daylight savings transitions.
9//! Everything that has to make that choice -- the cast kernel and the string
10//! parser -- goes through [`resolve_local_offset`] so that the two cannot drift
11//! apart.
12
13use chrono::{DateTime, LocalResult, NaiveDateTime, Offset, TimeDelta, TimeZone};
14
15/// Returns the offset to use when interpreting `local` as a wall clock reading
16/// in `tz`, or `None` if it cannot be resolved.
17///
18/// In an IANA timezone a wall clock reading does not always identify a unique
19/// instant, and this function picks one following the same rules as PostgreSQL
20/// and DuckDB:
21///
22/// * **Ambiguous** -- when the clocks go back ("fall back") the same reading
23///   occurs twice. The *later* instant is chosen, i.e. the offset in effect
24///   after the transition. For example `2024-11-03T01:30:00` in
25///   `America/New_York` is read as `-05:00` (EST), not `-04:00` (EDT).
26/// * **Nonexistent** -- when the clocks go forward ("spring forward") the
27///   reading never occurs. It is shifted forward by the length of the gap,
28///   which is the same as reading it with the offset in effect *before* the
29///   transition. For example `2024-03-10T02:30:00` in `America/New_York` is
30///   read as `-05:00` (EST) and therefore denotes `2024-03-10T03:30:00-04:00`.
31///
32/// Timezones with a fixed offset are never ambiguous and have no gaps.
33///
34/// The gap rule probes the offset 24 hours earlier. This is correct for the
35/// IANA timezone database, but this function accepts any [`TimeZone`]. For a
36/// timezone whose transitions do not obey the assumptions given in the body
37/// (no gap longer than 24 hours, no two transitions closer than 24 hours), the
38/// result can be `None` or an incorrect offset.
39///
40/// `None` is therefore not expected in practice: with the current timezone
41/// database every ambiguous or nonexistent reading resolves as described
42/// above, and the `None` path is only a safeguard. Callers then apply their
43/// usual error or null handling.
44///
45/// See <https://github.com/apache/arrow-rs/issues/11037> for the PostgreSQL and
46/// ICU (DuckDB) sources these rules are taken from.
47pub(crate) fn resolve_local_offset<T: TimeZone>(
48    tz: &T,
49    local: &NaiveDateTime,
50) -> Option<T::Offset> {
51    match tz.offset_from_local_datetime(local) {
52        LocalResult::Single(offset) => Some(offset),
53        LocalResult::Ambiguous(_earlier, later) => Some(later),
54        LocalResult::None => {
55            // The reading falls in a gap. Recover the offset in effect before
56            // the transition by probing 24 hours earlier.
57            //
58            // Two separate properties of the timezone database make this sound:
59            //
60            // 1. No local gap is longer than 24 hours, so the probe lands
61            //    outside this gap and is itself resolvable. Seven zones have a
62            //    gap of exactly 24 hours -- the dateline changes, such as
63            //    `Pacific/Apia` in 2011 and `Pacific/Kiritimati` in 1994. At the
64            //    last second of one of those the probe lands one second before
65            //    the gap starts, so the true margin here is one second, not a
66            //    comfortable one.
67            // 2. No two transitions are closer together than 24 hours, so the
68            //    offset the probe finds is the one in effect immediately before
69            //    this transition, and not some older offset. The smallest
70            //    observed interval is 167 hours (`America/Boa_Vista`, 2000).
71            //
72            // Property 1 is what makes the probe resolvable; property 2 is what
73            // makes the answer correct. If the probe is still unresolvable, give
74            // up and let the caller apply the usual error / null handling.
75            tz.offset_from_local_datetime(&(*local - TimeDelta::hours(24)))
76                .earliest()
77        }
78    }
79}
80
81/// Reads `local` as a wall clock time in `tz`, resolving daylight savings
82/// ambiguities and gaps as described on [`resolve_local_offset`].
83///
84/// This is [`TimeZone::from_local_datetime`] with the two unresolvable
85/// [`LocalResult`] cases decided rather than rejected.
86pub(crate) fn resolve_local_datetime<T: TimeZone>(
87    tz: &T,
88    local: &NaiveDateTime,
89) -> Option<DateTime<T>> {
90    let offset = resolve_local_offset(tz, local)?;
91    let utc = local.checked_sub_offset(offset.fix())?;
92    Some(DateTime::from_naive_utc_and_offset(utc, offset))
93}