Skip to main content

risingwave_expr_impl/scalar/
timestamptz.rs

1// Copyright 2023 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use chrono::{LocalResult, Utc};
16use chrono_tz::Tz;
17use num_traits::CheckedNeg;
18use risingwave_common::types::{
19    CheckedAdd, F64, Interval, IntoOrdered, Timestamp, Timestamptz, write_date_time_tz,
20};
21use risingwave_expr::{ExprError, Result, function};
22use thiserror_ext::AsReport;
23
24/// Just a wrapper to reuse the `map_err` logic.
25#[inline(always)]
26pub fn time_zone_err(inner_err: String) -> ExprError {
27    ExprError::InvalidParam {
28        name: "time_zone",
29        reason: inner_err.into(),
30    }
31}
32
33/// Returns the wall-clock timestamp at evaluation time.
34///
35/// `volatile` tells the function registry this result can change across evaluations with the same
36/// inputs, so stream planning treats it as an impure expression.
37#[function("clock_timestamp() -> timestamptz", volatile)]
38fn clock_timestamp() -> Timestamptz {
39    Utc::now().into()
40}
41
42#[function("sec_to_timestamptz(float8) -> timestamptz")]
43pub fn f64_sec_to_timestamptz(elem: F64) -> Result<Timestamptz> {
44    // TODO(#4515): handle +/- infinity
45    let micros = (elem.0 * 1e6)
46        .into_ordered()
47        .try_into()
48        .map_err(|_| ExprError::NumericOutOfRange)?;
49    Timestamptz::from_micros(micros).ok_or(ExprError::NumericOutOfRange)
50}
51
52#[function("at_time_zone(timestamptz, varchar) -> timestamp")]
53pub fn timestamptz_at_time_zone(input: Timestamptz, time_zone: &str) -> Result<Timestamp> {
54    let time_zone = Timestamptz::lookup_time_zone(time_zone).map_err(time_zone_err)?;
55    Ok(timestamptz_at_time_zone_internal(input, time_zone))
56}
57
58pub fn timestamptz_at_time_zone_internal(input: Timestamptz, time_zone: Tz) -> Timestamp {
59    let instant_local = input.to_datetime_in_zone(time_zone);
60    let naive = instant_local.naive_local();
61    Timestamp(naive)
62}
63
64#[function("at_time_zone(timestamp, varchar) -> timestamptz")]
65pub fn timestamp_at_time_zone(input: Timestamp, time_zone: &str) -> Result<Timestamptz> {
66    let time_zone = Timestamptz::lookup_time_zone(time_zone).map_err(time_zone_err)?;
67    timestamp_at_time_zone_internal(input, time_zone)
68}
69
70pub fn timestamp_at_time_zone_internal(input: Timestamp, time_zone: Tz) -> Result<Timestamptz> {
71    // https://www.postgresql.org/docs/current/datetime-invalid-input.html
72    let instant_local = match input.0.and_local_timezone(time_zone) {
73        LocalResult::Single(t) => t,
74        // invalid time during daylight forward, use UTC offset before the transition
75        // we minus 3 hours in naive time first, do the timezone conversion, and add 3 hours back in the UTC timeline.
76        // This assumes jump forwards are less than 3 hours and there is a single change within this 3-hour window.
77        // see <https://github.com/risingwavelabs/risingwave/pull/15670#discussion_r1524211006>
78        LocalResult::None => {
79            (input.0 - chrono::Duration::hours(3))
80                .and_local_timezone(time_zone)
81                .single()
82                .ok_or_else(|| ExprError::InvalidParam {
83                    name: "local timestamp",
84                    reason: format!(
85                        "fail to interpret local timestamp \"{}\" in time zone \"{}\"",
86                        input, time_zone
87                    )
88                    .into(),
89                })?
90                + chrono::Duration::hours(3)
91        }
92        // ambiguous time during daylight backward, use UTC offset after the transition
93        LocalResult::Ambiguous(_, latest) => latest,
94    };
95    let usec = instant_local.timestamp_micros();
96    Ok(Timestamptz::from_micros_uncheck(usec))
97}
98
99#[function("cast_with_time_zone(timestamptz, varchar) -> varchar")]
100pub fn timestamptz_to_string(
101    elem: Timestamptz,
102    time_zone: &str,
103    writer: &mut impl std::fmt::Write,
104) -> Result<()> {
105    let time_zone = Timestamptz::lookup_time_zone(time_zone).map_err(time_zone_err)?;
106    let instant_local = elem.to_datetime_in_zone(time_zone);
107    write_date_time_tz(instant_local, writer).map_err(|e| ExprError::Internal(e.into()))
108}
109
110// Tries to interpret the string with a timezone, and if failing, tries to interpret the string as a
111// timestamp and then adjusts it with the session timezone.
112#[function("cast_with_time_zone(varchar, varchar) -> timestamptz")]
113pub fn str_to_timestamptz(elem: &str, time_zone: &str) -> Result<Timestamptz> {
114    elem.parse().or_else(|_| {
115        timestamp_at_time_zone(
116            elem.parse::<Timestamp>()
117                .map_err(|err| ExprError::Parse(err.to_report_string().into()))?,
118            time_zone,
119        )
120    })
121}
122
123/// This operation is zone agnostic.
124#[function("subtract(timestamptz, timestamptz) -> interval")]
125pub fn timestamptz_timestamptz_sub(l: Timestamptz, r: Timestamptz) -> Result<Interval> {
126    let usecs = l
127        .timestamp_micros()
128        .checked_sub(r.timestamp_micros())
129        .ok_or(ExprError::NumericOverflow)?;
130    let interval = Interval::from_month_day_usec(0, 0, usecs);
131    // https://github.com/postgres/postgres/blob/REL_15_3/src/backend/utils/adt/timestamp.c#L2697
132    let interval = interval.justify_hour().ok_or(ExprError::NumericOverflow)?;
133    Ok(interval)
134}
135
136#[function("subtract_with_time_zone(timestamptz, interval, varchar) -> timestamptz")]
137pub fn timestamptz_interval_sub(
138    input: Timestamptz,
139    interval: Interval,
140    time_zone: &str,
141) -> Result<Timestamptz> {
142    timestamptz_interval_add(
143        input,
144        interval.checked_neg().ok_or(ExprError::NumericOverflow)?,
145        time_zone,
146    )
147}
148
149#[function("add_with_time_zone(timestamptz, interval, varchar) -> timestamptz")]
150pub fn timestamptz_interval_add(
151    input: Timestamptz,
152    interval: Interval,
153    time_zone: &str,
154) -> Result<Timestamptz> {
155    let time_zone = Timestamptz::lookup_time_zone(time_zone).map_err(time_zone_err)?;
156    timestamptz_interval_add_internal(input, interval, time_zone)
157}
158
159pub fn timestamptz_interval_add_internal(
160    input: Timestamptz,
161    interval: Interval,
162    time_zone: Tz,
163) -> Result<Timestamptz> {
164    use num_traits::Zero as _;
165
166    // A month may have 28-31 days, a day may have 23 or 25 hours during Daylight Saving switch.
167    // So their interpretation depends on the local time of a specific zone.
168    let qualitative = interval.truncate_day();
169    // Units smaller than `day` are zone agnostic.
170    let quantitative = interval - qualitative;
171
172    let mut t = input;
173    if !qualitative.is_zero() {
174        // Only convert into and from naive local when necessary because it is lossy.
175        // See `e2e_test/batch/functions/issue_12072.slt.part` for the difference.
176        let naive = timestamptz_at_time_zone_internal(t, time_zone);
177        let naive = naive
178            .checked_add(qualitative)
179            .ok_or(ExprError::NumericOverflow)?;
180        t = timestamp_at_time_zone_internal(naive, time_zone)?;
181    }
182    let t = timestamptz_interval_quantitative(t, quantitative, i64::checked_add)?;
183    Ok(t)
184}
185
186// Retained mostly for backward compatibility with old query plans. The signature is also useful for
187// binder type inference.
188#[function("add(timestamptz, interval) -> timestamptz")]
189pub fn timestamptz_interval_add_legacy(l: Timestamptz, r: Interval) -> Result<Timestamptz> {
190    timestamptz_interval_quantitative(l, r, i64::checked_add)
191}
192
193#[function("subtract(timestamptz, interval) -> timestamptz")]
194pub fn timestamptz_interval_sub_legacy(l: Timestamptz, r: Interval) -> Result<Timestamptz> {
195    timestamptz_interval_quantitative(l, r, i64::checked_sub)
196}
197
198#[function("add(interval, timestamptz) -> timestamptz")]
199pub fn interval_timestamptz_add_legacy(l: Interval, r: Timestamptz) -> Result<Timestamptz> {
200    timestamptz_interval_add_legacy(r, l)
201}
202
203#[inline(always)]
204fn timestamptz_interval_quantitative(
205    l: Timestamptz,
206    r: Interval,
207    f: fn(i64, i64) -> Option<i64>,
208) -> Result<Timestamptz> {
209    // Without session TimeZone, we cannot add month/day in local time. See #5826.
210    if r.months() != 0 || r.days() != 0 {
211        return Err(ExprError::UnsupportedFunction(
212            "timestamp with time zone +/- interval of days".into(),
213        ));
214    }
215    let delta_usecs = r.usecs();
216    let usecs = f(l.timestamp_micros(), delta_usecs).ok_or(ExprError::NumericOutOfRange)?;
217    Timestamptz::from_micros(usecs).ok_or(ExprError::NumericOutOfRange)
218}
219
220#[cfg(test)]
221mod tests {
222    use risingwave_common::util::iter_util::ZipEqFast;
223
224    use super::*;
225
226    #[test]
227    fn test_time_zone_conversion() {
228        let zones = ["US/Pacific", "ASIA/SINGAPORE", "europe/zurich"];
229        #[rustfmt::skip]
230        let test_cases = [
231            // winter
232            ["2022-01-01 00:00:00Z", "2021-12-31 16:00:00", "2022-01-01 08:00:00", "2022-01-01 01:00:00"],
233            // summer
234            ["2022-07-01 00:00:00Z", "2022-06-30 17:00:00", "2022-07-01 08:00:00", "2022-07-01 02:00:00"],
235            // before and after PST -> PDT, where [02:00, 03:00) are invalid
236            ["2022-03-13 09:59:00Z", "2022-03-13 01:59:00", "2022-03-13 17:59:00", "2022-03-13 10:59:00"],
237            ["2022-03-13 10:00:00Z", "2022-03-13 03:00:00", "2022-03-13 18:00:00", "2022-03-13 11:00:00"],
238            // before and after CET -> CEST, where [02:00. 03:00) are invalid
239            ["2022-03-27 00:59:00Z", "2022-03-26 17:59:00", "2022-03-27 08:59:00", "2022-03-27 01:59:00"],
240            ["2022-03-27 01:00:00Z", "2022-03-26 18:00:00", "2022-03-27 09:00:00", "2022-03-27 03:00:00"],
241            // before and after CEST -> CET, where [02:00, 03:00) are ambiguous
242            ["2022-10-29 23:59:00Z", "2022-10-29 16:59:00", "2022-10-30 07:59:00", "2022-10-30 01:59:00"],
243            ["2022-10-30 02:00:00Z", "2022-10-29 19:00:00", "2022-10-30 10:00:00", "2022-10-30 03:00:00"],
244            // before and after PDT -> PST, where [01:00, 02:00) are ambiguous
245            ["2022-11-06 07:59:00Z", "2022-11-06 00:59:00", "2022-11-06 15:59:00", "2022-11-06 08:59:00"],
246            ["2022-11-06 10:00:00Z", "2022-11-06 02:00:00", "2022-11-06 18:00:00", "2022-11-06 11:00:00"],
247        ];
248        for case in test_cases {
249            let usecs = str_to_timestamptz(case[0], "UTC").unwrap();
250            case.iter()
251                .skip(1)
252                .zip_eq_fast(zones)
253                .for_each(|(local, zone)| {
254                    let local = local.parse().unwrap();
255
256                    let actual = timestamptz_at_time_zone(usecs, zone).unwrap();
257                    assert_eq!(local, actual);
258
259                    let actual = timestamp_at_time_zone(local, zone).unwrap();
260                    assert_eq!(usecs, actual);
261                });
262        }
263    }
264
265    #[test]
266    #[rustfmt::skip]
267    fn test_time_zone_conversion_daylight_forward() {
268        // [02:00. 03:00) are invalid
269        test("2022-03-13 02:00:00", "US/Pacific", "2022-03-13 10:00:00+00:00");
270        test("2022-03-13 03:00:00", "US/Pacific", "2022-03-13 10:00:00+00:00");
271        // [02:00. 03:00) are invalid
272        test("2022-03-27 02:00:00", "europe/zurich", "2022-03-27 01:00:00+00:00");
273        test("2022-03-27 03:00:00", "europe/zurich", "2022-03-27 01:00:00+00:00");
274        // [02:00. 02:30) are invalid
275        test("2023-10-01 02:00:00", "Australia/Lord_Howe", "2023-09-30 15:30:00+00:00");
276        test("2023-10-01 02:30:00", "Australia/Lord_Howe", "2023-09-30 15:30:00+00:00");
277        // FIXME: the jump should be        1981-12-31 23:29:59 to 1982-01-01 00:00:00,
278        //        but the actual jump is    1981-12-31 15:59:59 to 1981-12-31 16:30:00
279        // an arbitrary one-off change in Singapore jumping from 1981-12-31 23:29:59 to 1982-01-01 00:00:00
280        // test("1981-12-31 23:30:00", "Asia/Singapore", "1981-12-31 16:00:00+00:00");
281        // test("1982-01-01 00:00:00", "Asia/Singapore", "1981-12-31 16:00:00+00:00");
282
283        #[track_caller]
284        fn test(local: &str, zone: &str, instant: &str) {
285            let actual = timestamp_at_time_zone(local.parse().unwrap(), zone).unwrap().to_string();
286            assert_eq!(actual, instant);
287        }
288    }
289
290    #[test]
291    fn test_time_zone_conversion_daylight_backward() {
292        #[rustfmt::skip]
293        let test_cases = [
294            ("2022-10-30 00:00:00Z", "2022-10-30 02:00:00", "europe/zurich", false),
295            ("2022-10-30 00:59:00Z", "2022-10-30 02:59:00", "europe/zurich", false),
296            ("2022-10-30 01:00:00Z", "2022-10-30 02:00:00", "europe/zurich", true),
297            ("2022-10-30 01:59:00Z", "2022-10-30 02:59:00", "europe/zurich", true),
298            ("2022-11-06 08:00:00Z", "2022-11-06 01:00:00", "US/Pacific", false),
299            ("2022-11-06 08:59:00Z", "2022-11-06 01:59:00", "US/Pacific", false),
300            ("2022-11-06 09:00:00Z", "2022-11-06 01:00:00", "US/Pacific", true),
301            ("2022-11-06 09:59:00Z", "2022-11-06 01:59:00", "US/Pacific", true),
302        ];
303        for (instant, local, zone, preferred) in test_cases {
304            let usecs = str_to_timestamptz(instant, "UTC").unwrap();
305            let local = local.parse().unwrap();
306
307            let actual = timestamptz_at_time_zone(usecs, zone).unwrap();
308            assert_eq!(local, actual);
309
310            if preferred {
311                let actual = timestamp_at_time_zone(local, zone).unwrap();
312                assert_eq!(usecs, actual)
313            }
314        }
315    }
316
317    #[test]
318    fn test_timestamptz_to_and_from_string() {
319        let str1 = "0001-11-15 15:35:40.999999+08:00";
320        let timestamptz1 = str_to_timestamptz(str1, "UTC").unwrap();
321        assert_eq!(timestamptz1.timestamp_micros(), -62108094259000001);
322
323        let mut writer = String::new();
324        timestamptz_to_string(timestamptz1, "UTC", &mut writer).unwrap();
325        assert_eq!(writer, "0001-11-15 07:35:40.999999+00:00");
326
327        let str2 = "1969-12-31 23:59:59.999999+00:00";
328        let timestamptz2 = str_to_timestamptz(str2, "UTC").unwrap();
329        assert_eq!(timestamptz2.timestamp_micros(), -1);
330
331        let mut writer = String::new();
332        timestamptz_to_string(timestamptz2, "UTC", &mut writer).unwrap();
333        assert_eq!(writer, str2);
334
335        // Parse a timestamptz from a str without timezone
336        let str3 = "2022-01-01 00:00:00+08:00";
337        let timestamptz3 = str_to_timestamptz(str3, "UTC").unwrap();
338
339        let timestamp_from_no_tz =
340            str_to_timestamptz("2022-01-01 00:00:00", "Asia/Singapore").unwrap();
341        assert_eq!(timestamptz3, timestamp_from_no_tz);
342    }
343}