Skip to main content

mz_pgtz/
timezone.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::cmp::Ordering;
11use std::fmt;
12
13use chrono::FixedOffset;
14use chrono_tz::Tz;
15use itertools::Itertools;
16use serde::{Deserialize, Serialize};
17use uncased::UncasedStr;
18
19use crate::abbrev::TIMEZONE_ABBREVS;
20
21/// The SQL definition of the contents of the `mz_timezone_names` view.
22pub const MZ_CATALOG_TIMEZONE_NAMES_SQL: &str =
23    include_str!(concat!(env!("OUT_DIR"), "/timezone.gen.sql"));
24
25/// Parsed timezone.
26#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub enum Timezone {
28    #[serde(with = "fixed_offset_serde")]
29    FixedOffset(FixedOffset),
30    Tz(Tz),
31}
32
33impl Timezone {
34    pub fn parse(tz: &str, spec: TimezoneSpec) -> Result<Self, String> {
35        build_timezone_offset_second(&tokenize_timezone(tz)?, tz, spec)
36    }
37}
38
39// We need to implement Serialize and Deserialize traits to include Timezone in the UnaryFunc enum.
40// FixedOffset doesn't implement these, even with the "serde" feature enabled.
41mod fixed_offset_serde {
42    use serde::de::Error;
43    use serde::{Deserializer, Serializer};
44
45    use super::*;
46
47    pub fn deserialize<'de, D: Deserializer<'de>>(
48        deserializer: D,
49    ) -> Result<FixedOffset, D::Error> {
50        let offset = i32::deserialize(deserializer)?;
51        FixedOffset::east_opt(offset).ok_or_else(|| {
52            Error::custom(format!("Invalid timezone offset: |{}| >= 86_400", offset))
53        })
54    }
55
56    pub fn serialize<S: Serializer>(
57        offset: &FixedOffset,
58        serializer: S,
59    ) -> Result<S::Ok, S::Error> {
60        serializer.serialize_i32(offset.local_minus_utc())
61    }
62}
63
64impl PartialOrd for Timezone {
65    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
66        Some(self.cmp(other))
67    }
68}
69
70// We need to implement Ord and PartialOrd to include Timezone in the UnaryFunc enum. Neither FixedOffset nor Tz
71// implement these so we do a simple ordinal comparison (FixedOffset variant < Tz variant), and break ties using
72// i32/str comparisons respectively.
73impl Ord for Timezone {
74    fn cmp(&self, other: &Self) -> Ordering {
75        use Timezone::*;
76        match (self, other) {
77            (FixedOffset(a), FixedOffset(b)) => a.local_minus_utc().cmp(&b.local_minus_utc()),
78            (Tz(a), Tz(b)) => a.name().cmp(b.name()),
79            (FixedOffset(_), Tz(_)) => Ordering::Less,
80            (Tz(_), FixedOffset(_)) => Ordering::Greater,
81        }
82    }
83}
84
85impl Default for Timezone {
86    fn default() -> Self {
87        Self::FixedOffset(FixedOffset::east_opt(0).unwrap())
88    }
89}
90
91impl fmt::Display for Timezone {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        match self {
94            Timezone::FixedOffset(offset) => offset.fmt(f),
95            Timezone::Tz(tz) => tz.fmt(f),
96        }
97    }
98}
99
100/// TimeStrToken represents valid tokens in time-like strings,
101/// i.e those used in INTERVAL, TIMESTAMP/TZ, DATE, and TIME.
102#[derive(Debug, Clone, PartialEq, Eq)]
103enum TimeStrToken {
104    Dash,
105    Colon,
106    Plus,
107    Zulu,
108    Num(u64, usize),
109    TzName(String),
110    Delim,
111}
112
113impl std::fmt::Display for TimeStrToken {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        use TimeStrToken::*;
116        match self {
117            Dash => write!(f, "-"),
118            Colon => write!(f, ":"),
119            Plus => write!(f, "+"),
120            Zulu => write!(f, "Z"),
121            Num(i, digits) => write!(f, "{:01$}", i, digits - 1),
122            TzName(n) => write!(f, "{}", n),
123            Delim => write!(f, " "),
124        }
125    }
126}
127
128fn tokenize_timezone(value: &str) -> Result<Vec<TimeStrToken>, String> {
129    let mut toks: Vec<TimeStrToken> = vec![];
130    let mut num_buf = String::with_capacity(4);
131    // If the timezone string has a colon, we need to parse all numbers naively.
132    // Otherwise we need to parse long sequences of digits as [..hhhhmm]
133    let split_nums: bool = !value.contains(':');
134
135    let value = value.trim_matches(|c: char| {
136        (c.is_ascii_whitespace() || c.is_ascii_punctuation()) && (c != '+' && c != '-')
137    });
138
139    // Takes a string and tries to parse it as a number token and insert it into
140    // the token list
141    fn parse_num(
142        toks: &mut Vec<TimeStrToken>,
143        n: &str,
144        split_nums: bool,
145        idx: usize,
146    ) -> Result<(), String> {
147        if n.is_empty() {
148            return Ok(());
149        }
150
151        let (first, second) = if n.len() > 2 && split_nums {
152            let (first, second) = n.split_at(n.len() - 2);
153            (first, Some(second))
154        } else {
155            (n, None)
156        };
157
158        toks.push(TimeStrToken::Num(
159            first.parse().map_err(|e| {
160                format!(
161                    "Unable to tokenize value {} as a number at index {}: {}",
162                    first, idx, e
163                )
164            })?,
165            first.len(),
166        ));
167
168        if let Some(second) = second {
169            toks.push(TimeStrToken::Num(
170                second.parse().map_err(|e| {
171                    format!(
172                        "Unable to tokenize value {} as a number at index {}: {}",
173                        second, idx, e
174                    )
175                })?,
176                second.len(),
177            ));
178        }
179
180        Ok(())
181    }
182
183    // Toggles whether or not we should skip whitespace. This would be nicer to
184    // do inline but ownership makes that annoying.
185    let mut space_skip_mode = false;
186    for (i, chr) in value.char_indices() {
187        // Stay in space skip mode iff already in it and element is space.
188        if space_skip_mode && chr.is_ascii_whitespace() {
189            continue;
190        } else {
191            space_skip_mode = false;
192        }
193
194        match chr {
195            ':' => {
196                parse_num(&mut toks, &num_buf, split_nums, i)?;
197                num_buf.clear();
198                toks.push(TimeStrToken::Colon);
199            }
200            '-' => {
201                parse_num(&mut toks, &num_buf, split_nums, i)?;
202                num_buf.clear();
203                toks.push(TimeStrToken::Dash);
204                space_skip_mode = true;
205            }
206            '+' => {
207                parse_num(&mut toks, &num_buf, split_nums, i)?;
208                num_buf.clear();
209                toks.push(TimeStrToken::Plus);
210                space_skip_mode = true;
211            }
212            chr if (chr == 'z' || chr == 'Z') && (i == value.len() - 1) => {
213                parse_num(&mut toks, &num_buf, split_nums, i)?;
214                num_buf.clear();
215                toks.push(TimeStrToken::Zulu);
216            }
217            chr if chr.is_digit(10) => num_buf.push(chr),
218            chr if chr.is_ascii_alphabetic() => {
219                parse_num(&mut toks, &num_buf, split_nums, i)?;
220                let substring = &value[i..];
221                toks.push(TimeStrToken::TzName(substring.to_string()));
222                return Ok(toks);
223            }
224            // PG allows arbitrary punctuation marks, which represent delim
225            chr if chr.is_ascii_whitespace() || chr.is_ascii_punctuation() => {
226                parse_num(&mut toks, &num_buf, split_nums, i)?;
227                num_buf.clear();
228                toks.push(TimeStrToken::Delim);
229            }
230            chr => {
231                return Err(format!(
232                    "Error tokenizing timezone string ('{}'): invalid character {:?} at offset {}",
233                    value, chr, i
234                ));
235            }
236        }
237    }
238    parse_num(&mut toks, &num_buf, split_nums, 0)?;
239    Ok(toks)
240}
241
242#[derive(Debug, Clone, Copy)]
243pub enum TimezoneSpec {
244    /// Offsets should be treated as an ISO 8601 time zone specification.
245    Iso,
246    /// Offsets should be treated as a POSIX-style time zone specification.
247    Posix,
248}
249
250fn build_timezone_offset_second(
251    tokens: &[TimeStrToken],
252    value: &str,
253    spec: TimezoneSpec,
254) -> Result<Timezone, String> {
255    use TimeStrToken::*;
256    static ALL_FORMATS: [&[TimeStrToken]; 12] = [
257        &[Plus, Num(0, 1), Colon, Num(0, 1), Colon, Num(0, 1)],
258        &[Dash, Num(0, 1), Colon, Num(0, 1), Colon, Num(0, 1)],
259        &[Plus, Num(0, 1), Colon, Num(0, 1)],
260        &[Dash, Num(0, 1), Colon, Num(0, 1)],
261        &[Plus, Num(0, 1), Num(0, 1), Num(0, 1)],
262        &[Dash, Num(0, 1), Num(0, 1), Num(0, 1)],
263        &[Plus, Num(0, 1), Num(0, 1)],
264        &[Dash, Num(0, 1), Num(0, 1)],
265        &[Plus, Num(0, 1)],
266        &[Dash, Num(0, 1)],
267        &[TzName(String::new())],
268        &[Zulu],
269    ];
270
271    let mut is_positive = true;
272    let mut hour_offset: Option<i32> = None;
273    let mut minute_offset: Option<i32> = None;
274    let mut second_offset: Option<i32> = None;
275
276    for format in ALL_FORMATS {
277        let actual = tokens.iter();
278
279        if actual.len() != format.len() {
280            continue;
281        }
282
283        for (i, (atok, etok)) in actual.zip_eq(format).enumerate() {
284            match (atok, etok) {
285                (Colon, Colon) | (Plus, Plus) => { /* Matching punctuation */ }
286                (Dash, Dash) => {
287                    is_positive = false;
288                }
289                (Num(val, _), Num(_, _)) => {
290                    let val = *val;
291                    match (hour_offset, minute_offset, second_offset) {
292                        (None, None, None) => {
293                            // Postgres allows timezones in the range -15:59:59..15:59:59
294                            if val <= 15 {
295                                hour_offset = Some(i32::try_from(val).expect(
296                                    "number between 0 and 15 should fit in signed 32-bit integer",
297                                ));
298                            } else {
299                                return Err(format!(
300                                    "Invalid timezone string ({}): timezone hour invalid {}",
301                                    value, val
302                                ));
303                            }
304                        }
305                        (Some(_), None, None) => {
306                            if val < 60 {
307                                minute_offset = Some(i32::try_from(val).expect(
308                                    "number between 0 and 59 should fit in signed 32-bit integer",
309                                ));
310                            } else {
311                                return Err(format!(
312                                    "Invalid timezone string ({}): timezone minute invalid {}",
313                                    value, val
314                                ));
315                            }
316                        }
317                        (Some(_), Some(_), None) => {
318                            if val < 60 {
319                                second_offset = Some(i32::try_from(val).expect(
320                                    "number between 0 and 59 should fit in signed 32-bit integer",
321                                ));
322                            } else {
323                                return Err(format!(
324                                    "Invalid timezone string ({}): timezone second invalid {}",
325                                    value, val
326                                ));
327                            }
328                        }
329                        // We've already seen an hour a minute and a second so we should
330                        // never see another number
331                        (Some(_), Some(_), Some(_)) => {
332                            return Err(format!(
333                                "Invalid timezone string ({}): invalid value {} at token index {}",
334                                value, val, i
335                            ));
336                        }
337                        _ => unreachable!("parsed a minute before an hour!"),
338                    }
339                }
340                (Zulu, Zulu) => return Ok(Default::default()),
341                (TzName(val), TzName(_)) => {
342                    if let Some(abbrev) = TIMEZONE_ABBREVS.get(UncasedStr::new(val)) {
343                        return Ok(abbrev.timezone());
344                    }
345
346                    return match Tz::from_str_insensitive(val) {
347                        Ok(tz) => Ok(Timezone::Tz(tz)),
348                        // Preserves the error text this message has always
349                        // had, independent of the timezone library's own
350                        // error messages.
351                        Err(_) => Err(format!(
352                            "Invalid timezone string ({value}): '{val}' is not a valid timezone. \
353                            Failed to parse {val} at token index {i}"
354                        )),
355                    };
356                }
357                (_, _) => {
358                    // Theres a mismatch between this format and the actual
359                    // token stream Stop trying to parse in this format and go
360                    // to the next one
361                    is_positive = true;
362                    hour_offset = None;
363                    minute_offset = None;
364                    second_offset = None;
365                    break;
366                }
367            }
368        }
369
370        // Return the first valid parsed result
371        if let Some(hour_offset) = hour_offset {
372            let mut tz_offset_second = hour_offset * 60 * 60;
373
374            if let Some(minute_offset) = minute_offset {
375                tz_offset_second += minute_offset * 60;
376            }
377
378            if let Some(second_offset) = second_offset {
379                tz_offset_second += second_offset;
380            }
381
382            let offset = match (is_positive, spec) {
383                (true, TimezoneSpec::Iso) | (false, TimezoneSpec::Posix) => {
384                    FixedOffset::east_opt(tz_offset_second).unwrap()
385                }
386                (false, TimezoneSpec::Iso) | (true, TimezoneSpec::Posix) => {
387                    FixedOffset::west_opt(tz_offset_second).unwrap()
388                }
389            };
390
391            return Ok(Timezone::FixedOffset(offset));
392        }
393    }
394
395    Err(format!("Cannot parse timezone offset {}", value))
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[mz_ore::test]
403    fn test_parse_timezone_offset_second() {
404        use Timezone::{FixedOffset as F, Tz as T};
405        let test_cases = [
406            ("+0:00", F(FixedOffset::east_opt(0).unwrap())),
407            ("-0:00", F(FixedOffset::east_opt(0).unwrap())),
408            ("+0:000000", F(FixedOffset::east_opt(0).unwrap())),
409            ("+000000:00", F(FixedOffset::east_opt(0).unwrap())),
410            ("+000000:000000", F(FixedOffset::east_opt(0).unwrap())),
411            ("+0", F(FixedOffset::east_opt(0).unwrap())),
412            ("+00", F(FixedOffset::east_opt(0).unwrap())),
413            ("+000", F(FixedOffset::east_opt(0).unwrap())),
414            ("+0000", F(FixedOffset::east_opt(0).unwrap())),
415            ("+00000000", F(FixedOffset::east_opt(0).unwrap())),
416            ("+0000001:000000", F(FixedOffset::east_opt(3600).unwrap())),
417            ("+0000000:000001", F(FixedOffset::east_opt(60).unwrap())),
418            ("+0000001:000001", F(FixedOffset::east_opt(3660).unwrap())),
419            (
420                "+0000001:000001:000001",
421                F(FixedOffset::east_opt(3661).unwrap()),
422            ),
423            ("+4:00", F(FixedOffset::east_opt(14400).unwrap())),
424            ("-4:00", F(FixedOffset::west_opt(14400).unwrap())),
425            ("+2:30", F(FixedOffset::east_opt(9000).unwrap())),
426            ("-5:15", F(FixedOffset::west_opt(18900).unwrap())),
427            ("+0:20", F(FixedOffset::east_opt(1200).unwrap())),
428            ("-0:20", F(FixedOffset::west_opt(1200).unwrap())),
429            ("+0:0:20", F(FixedOffset::east_opt(20).unwrap())),
430            ("+5", F(FixedOffset::east_opt(18000).unwrap())),
431            ("-5", F(FixedOffset::west_opt(18000).unwrap())),
432            ("+05", F(FixedOffset::east_opt(18000).unwrap())),
433            ("-05", F(FixedOffset::west_opt(18000).unwrap())),
434            ("+500", F(FixedOffset::east_opt(18000).unwrap())),
435            ("-500", F(FixedOffset::west_opt(18000).unwrap())),
436            ("+530", F(FixedOffset::east_opt(19800).unwrap())),
437            ("-530", F(FixedOffset::west_opt(19800).unwrap())),
438            ("+050", F(FixedOffset::east_opt(3000).unwrap())),
439            ("-050", F(FixedOffset::west_opt(3000).unwrap())),
440            ("+15", F(FixedOffset::east_opt(54000).unwrap())),
441            ("-15", F(FixedOffset::west_opt(54000).unwrap())),
442            ("+1515", F(FixedOffset::east_opt(54900).unwrap())),
443            ("+15:15:15", F(FixedOffset::east_opt(54915).unwrap())),
444            ("+015", F(FixedOffset::east_opt(900).unwrap())),
445            ("-015", F(FixedOffset::west_opt(900).unwrap())),
446            ("+0015", F(FixedOffset::east_opt(900).unwrap())),
447            ("-0015", F(FixedOffset::west_opt(900).unwrap())),
448            ("+00015", F(FixedOffset::east_opt(900).unwrap())),
449            ("-00015", F(FixedOffset::west_opt(900).unwrap())),
450            ("+005", F(FixedOffset::east_opt(300).unwrap())),
451            ("-005", F(FixedOffset::west_opt(300).unwrap())),
452            ("+0000005", F(FixedOffset::east_opt(300).unwrap())),
453            ("+00000100", F(FixedOffset::east_opt(3600).unwrap())),
454            ("Z", F(FixedOffset::east_opt(0).unwrap())),
455            ("z", F(FixedOffset::east_opt(0).unwrap())),
456            ("UTC", F(FixedOffset::east_opt(0).unwrap())),
457            ("Pacific/Auckland", T(Tz::Pacific__Auckland)),
458            ("America/New_York", T(Tz::America__New_York)),
459            ("America/Los_Angeles", T(Tz::America__Los_Angeles)),
460            ("utc", F(FixedOffset::east_opt(0).unwrap())),
461            ("pAcIfIc/AUcKlAnD", T(Tz::Pacific__Auckland)),
462            ("AMERICA/NEW_YORK", T(Tz::America__New_York)),
463            ("america/los_angeles", T(Tz::America__Los_Angeles)),
464            // Formatting test cases
465            ("+5:", F(FixedOffset::east_opt(18000).unwrap())),
466            ("-5:15:", F(FixedOffset::west_opt(18900).unwrap())),
467            ("-   5:15:", F(FixedOffset::west_opt(18900).unwrap())),
468            (
469                " ! ? ! - 5:15 ? ! ? ",
470                F(FixedOffset::west_opt(18900).unwrap()),
471            ),
472            (" UTC", F(FixedOffset::east_opt(0).unwrap())),
473            (" UTC ", F(FixedOffset::east_opt(0).unwrap())),
474            (" ? UTC ! ", F(FixedOffset::east_opt(0).unwrap())),
475        ];
476
477        for (timezone, expected) in test_cases.iter() {
478            match Timezone::parse(timezone, TimezoneSpec::Iso) {
479                Ok(tz) => assert_eq!(&tz, expected),
480                Err(e) => panic!(
481                    "Test failed when expected to pass test case: {} error: {}",
482                    timezone, e
483                ),
484            }
485        }
486
487        let failure_test_cases = [
488            "+25:00", "+120:00", "+0:61", "+0:500", " 12:30", "+-12:30", "+2525", "+2561",
489            "+255900", "+25", "+5::30", "++5:00", "--5:00", "a", "zzz", "ZZZ", "ZZ Top", " +",
490            " -", " ", "1", "12", "1234", "+16", "-17", "-14:60", "1:30:60",
491        ];
492
493        for test in failure_test_cases.iter() {
494            match Timezone::parse(test, TimezoneSpec::Iso) {
495                Ok(t) => panic!(
496                    "Test passed when expected to fail test case: {} parsed tz offset (seconds): {}",
497                    test, t
498                ),
499                Err(e) => println!("{}", e),
500            }
501        }
502    }
503}