Skip to content

Commit 53aa492

Browse files
committed
fix: handle negative timezone offsets in timestamptz text-format decode
The text-format decoder for NaiveDateTime used s.contains('+') to detect timezone offsets in PostgreSQL's text output. This missed negative offsets (e.g. -05 for America/New_York), causing the parse to fail and silently return the Unix epoch (1970-01-01T00:00:00+00:00). This affects any query using the simple query protocol (no bind parameters) that returns a TIMESTAMPTZ column when the session timezone is west of UTC. Fix: try parsing with timezone offset first (%#z), then fall back to parsing without timezone. Added tests for positive, negative, and missing timezone offsets.
1 parent d6a68b1 commit 53aa492

1 file changed

Lines changed: 70 additions & 11 deletions

File tree

sqlx-core/src/postgres/types/chrono/datetime.rs

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,11 @@ impl<'r> Decode<'r, Postgres> for NaiveDateTime {
6868

6969
PgValueFormat::Text => {
7070
let s = value.as_str()?;
71-
NaiveDateTime::parse_from_str(
72-
s,
73-
if s.contains('+') {
74-
// Contains a time-zone specifier
75-
// This is given for timestamptz for some reason
76-
// Postgres already guarantees this to always be UTC
77-
"%Y-%m-%d %H:%M:%S%.f%#z"
78-
} else {
79-
"%Y-%m-%d %H:%M:%S%.f"
80-
},
81-
)?
71+
// Try with timezone offset first (handles both positive and
72+
// negative offsets like +05 or -05), then fall back to parsing
73+
// without timezone for plain timestamps.
74+
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f%#z")
75+
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f"))?
8276
}
8377
})
8478
}
@@ -114,3 +108,68 @@ impl<'r> Decode<'r, Postgres> for DateTime<FixedOffset> {
114108
Ok(Utc.fix().from_utc_datetime(&naive))
115109
}
116110
}
111+
112+
#[cfg(test)]
113+
mod tests {
114+
use super::*;
115+
use crate::postgres::PgValueFormat;
116+
117+
fn text_decode_naive(s: &str) -> Result<NaiveDateTime, BoxDynError> {
118+
let value = PgValueRef {
119+
value: Some(s.as_bytes()),
120+
row: None,
121+
type_info: PgTypeInfo::TIMESTAMPTZ,
122+
format: PgValueFormat::Text,
123+
};
124+
<NaiveDateTime as Decode<Postgres>>::decode(value)
125+
}
126+
127+
#[test]
128+
fn test_decode_timestamptz_negative_offset() {
129+
// PostgreSQL returns this text format when session timezone is America/New_York (UTC-5)
130+
let dt = text_decode_naive("2020-12-31 19:00:00-05").unwrap();
131+
assert_eq!(
132+
dt,
133+
NaiveDate::from_ymd_opt(2020, 12, 31)
134+
.unwrap()
135+
.and_hms_opt(19, 0, 0)
136+
.unwrap()
137+
);
138+
}
139+
140+
#[test]
141+
fn test_decode_timestamptz_positive_offset() {
142+
let dt = text_decode_naive("2021-01-01 05:00:00+05").unwrap();
143+
assert_eq!(
144+
dt,
145+
NaiveDate::from_ymd_opt(2021, 1, 1)
146+
.unwrap()
147+
.and_hms_opt(5, 0, 0)
148+
.unwrap()
149+
);
150+
}
151+
152+
#[test]
153+
fn test_decode_timestamp_no_offset() {
154+
let dt = text_decode_naive("2021-01-01 00:00:00").unwrap();
155+
assert_eq!(
156+
dt,
157+
NaiveDate::from_ymd_opt(2021, 1, 1)
158+
.unwrap()
159+
.and_hms_opt(0, 0, 0)
160+
.unwrap()
161+
);
162+
}
163+
164+
#[test]
165+
fn test_decode_timestamptz_with_fractional_seconds() {
166+
let dt = text_decode_naive("2021-01-01 00:00:00.123456-05").unwrap();
167+
assert_eq!(
168+
dt,
169+
NaiveDate::from_ymd_opt(2021, 1, 1)
170+
.unwrap()
171+
.and_hms_micro_opt(0, 0, 0, 123456)
172+
.unwrap()
173+
);
174+
}
175+
}

0 commit comments

Comments
 (0)