Skip to content

Commit 3965615

Browse files
committed
data: Convert timezone offset to UTC for MySQL DATETIME coercion
MySQL converts timezone-aware string literals to the session timezone (assumed UTC) before storing as DATETIME. For example, CAST('2020-01-02 03:04:05+08:00' AS DATETIME) yields '2020-01-01 19:04:05'. ReadySet's text-to-DateTime coercion was preserving the raw offset instead, causing incorrect comparisons in cached queries. Fix the DfType::DateTime arm in TimestampTz::coerce_to() to convert to UTC via naive_utc() when a timezone offset is present, matching MySQL's behavior. Fixes: REA-6487 Release-Note-Core: Fix MySQL DATETIME lookups with timezone offsets in WHERE clause. Change-Id: I7a7b16c09f58f3656e5315a1d9aed1c2e88cfa00 Reviewed-on: https://gerrit.readyset.name/c/readyset/+/12281 Tested-by: Buildkite CI Reviewed-by: Marcelo Altmann <marcelo@readyset.io>
1 parent 968d819 commit 3965615

3 files changed

Lines changed: 86 additions & 5 deletions

File tree

logictests/mysql/datetime_lookup.test

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,48 @@ select * from dt_test where dt < '2025-01-01 00:00:00'
4747
2
4848
2024-01-15 23:59:59
4949

50+
-- REA-6487: Timezone offset in WHERE clause is converted to session tz (UTC).
51+
-- MySQL: CAST('2020-01-02 03:04:05+08:00' AS DATETIME) = '2020-01-01 19:04:05'
52+
53+
statement ok
54+
create table dt_tz(id int, dt datetime);
55+
56+
statement ok
57+
insert into dt_tz values (1, '2020-01-02 03:04:05');
58+
59+
-- No offset: exact match.
60+
query IZ
61+
select * from dt_tz where dt = '2020-01-02 03:04:05'
62+
----
63+
1
64+
2020-01-02 03:04:05
65+
66+
-- +00:00 offset: already UTC, matches.
67+
query IZ
68+
select * from dt_tz where dt = '2020-01-02 03:04:05+00:00'
69+
----
70+
1
71+
2020-01-02 03:04:05
72+
73+
-- Insert rows at the expected UTC-converted values so we can verify
74+
-- positive matches, not just absence.
75+
statement ok
76+
insert into dt_tz values (2, '2020-01-01 19:04:05'), (3, '2020-01-02 08:04:05');
77+
78+
-- +08:00 offset: converts to 2020-01-01 19:04:05 UTC, matches row 2.
79+
query IZ
80+
select * from dt_tz where dt = '2020-01-02 03:04:05+08:00'
81+
----
82+
2
83+
2020-01-01 19:04:05
84+
85+
-- -05:00 offset: converts to 2020-01-02 08:04:05 UTC, matches row 3.
86+
query IZ
87+
select * from dt_tz where dt = '2020-01-02 03:04:05-05:00'
88+
----
89+
3
90+
2020-01-02 08:04:05
91+
5092
-- Subsecond precision
5193
statement ok
5294
create table dt_micro(id int, dt datetime(6));

readyset-data/src/text.rs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -976,16 +976,45 @@ mod tests {
976976
}
977977

978978
#[test]
979-
fn text_to_datetime_preserves_timezone() {
980-
// DateTime (MySQL) routes through coerce_to which preserves the offset —
981-
// MySQL DATETIME has no timezone stripping semantics like PG TIMESTAMP.
979+
fn text_to_datetime_converts_tz_to_utc() {
980+
// REA-6487: MySQL converts timezone-aware literals to session timezone
981+
// (assumed UTC) when storing as DATETIME.
982982
let dt_type = DfType::DateTime {
983983
subsecond_digits: 0,
984984
};
985+
986+
// +08:00 offset: 03:04:05 in +08 = 19:04:05 UTC on the previous day.
985987
let val = DfValue::from("2020-01-02 03:04:05+08:00")
986988
.coerce_to(&dt_type, &DfType::DEFAULT_TEXT)
987989
.unwrap();
988-
assert_eq!(val.to_string(), "2020-01-02 03:04:05+08:00");
990+
assert_eq!(val.to_string(), "2020-01-01 19:04:05");
991+
992+
// -05:00 offset: 03:04:05 in -05 = 08:04:05 UTC.
993+
let val = DfValue::from("2020-01-02 03:04:05-05:00")
994+
.coerce_to(&dt_type, &DfType::DEFAULT_TEXT)
995+
.unwrap();
996+
assert_eq!(val.to_string(), "2020-01-02 08:04:05");
997+
998+
// +00:00 offset: already UTC, no change.
999+
let val = DfValue::from("2020-01-02 03:04:05+00:00")
1000+
.coerce_to(&dt_type, &DfType::DEFAULT_TEXT)
1001+
.unwrap();
1002+
assert_eq!(val.to_string(), "2020-01-02 03:04:05");
1003+
1004+
// No offset: treated as session timezone (UTC), no change.
1005+
let val_no_tz = DfValue::from("2020-01-02 03:04:05")
1006+
.coerce_to(&dt_type, &DfType::DEFAULT_TEXT)
1007+
.unwrap();
1008+
assert_eq!(val_no_tz.to_string(), "2020-01-02 03:04:05");
1009+
1010+
// Subsecond digits + timezone offset + date/year boundary crossing.
1011+
let dt_type_6 = DfType::DateTime {
1012+
subsecond_digits: 6,
1013+
};
1014+
let val = DfValue::from("2020-01-01 00:30:00.123456+02:00")
1015+
.coerce_to(&dt_type_6, &DfType::DEFAULT_TEXT)
1016+
.unwrap();
1017+
assert_eq!(val.to_string(), "2019-12-31 22:30:00.123456");
9891018
}
9901019

9911020
#[test]

readyset-data/src/timestamp.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ impl TimestampTz {
497497
Ok(DfValue::TimestampTz(ts))
498498
}
499499
DfType::TimestampTz { subsecond_digits } => {
500+
// NOTE: Assumes session timezone is UTC.
500501
// TODO: when converting into a timestamp with tz on postgres should apply
501502
// local tz, but what is local for noria?
502503
let mut ts_tz = *self;
@@ -505,7 +506,16 @@ impl TimestampTz {
505506
Ok(DfValue::TimestampTz(ts_tz))
506507
}
507508
DfType::DateTime { subsecond_digits } => {
508-
let mut ts = *self;
509+
// NOTE: Assumes session timezone is UTC.
510+
// MySQL converts timezone-aware literals to the session timezone
511+
// before storing as DATETIME.
512+
let mut ts: TimestampTz = if self.is_zero() {
513+
Self::zero()
514+
} else if self.has_timezone() {
515+
self.to_chrono().naive_utc().into()
516+
} else {
517+
*self
518+
};
509519
ts.set_subsecond_digits(subsecond_digits as u8);
510520
Ok(DfValue::TimestampTz(ts))
511521
}

0 commit comments

Comments
 (0)