Skip to content

Commit 24f4d3e

Browse files
committed
postgres: Handle incoming ISO-8601 timestamps
Postgres' standard base representation of timestamps is "<date> <time>", with a space as separator: "2025-10-13 16:09:06"; PG takes that base format as input and returns as output. Additionally, there is a Note on the same page that states: ISO 8601 specifies the use of uppercase letter T to separate the date and time. PostgreSQL accepts that format on input, but on output it uses a space rather than T, as shown above. This is for readability and for consistency with RFC 3339 as well as some other database systems. Some drivers (node-js, psychopg2) will, by default, send the ISO-8601 flavored string representation. Readyset is currently not accepting the T separator variant in either the simple query protocol (string -> `DfValue`), nor in the text transfer format of the extended protocol path, a/k/a bound parameters to prepared statements (`psql-srv::codec::decoder`). This CL fixes both paths by performing the existing space-separator parsing first, then trying the `T`-separator parse on error. [0] www.postgresql.org/docs/current/datatype-datetime.html Fixes: REA-6104 Release-Note-Core: Accept IS0-8601 string timestamp separator in postgres (both simple and extended query protocols). Change-Id: I096bb650540784f897210f19e00f8f8f0cbfbbac Reviewed-on: https://gerrit.readyset.name/c/readyset/+/10678 Tested-by: Buildkite CI Reviewed-by: Johnathan Davis <jcd@readyset.io>
1 parent f45e248 commit 24f4d3e

File tree

2 files changed

+54
-4
lines changed

2 files changed

+54
-4
lines changed

psql-srv/src/codec/decoder.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,18 @@ const STARTUP_MESSAGE_APPLICATION_NAME_PARAMETER: &str = "application_name";
5050
const HEADER_LENGTH: usize = 5;
5151
const LENGTH_NULL_SENTINEL: i32 = -1;
5252
const NUL_BYTE: u8 = b'\0';
53+
54+
/// The standard postgres representation of a timestamp, as per [0].
55+
///
56+
/// [0] www.postgresql.org/docs/current/datatype-datetime.html
5357
const TIMESTAMP_FORMAT: &str = "%Y-%m-%d %H:%M:%S%.f";
5458

59+
/// Postgres also supports the standard ISO-8601 format, the the `T` separator
60+
/// between the date and the time. See note in pg docs: [0].
61+
///
62+
/// https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-DATETIME-INPUT
63+
const ISO_TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.f";
64+
5565
impl Decoder for Codec {
5666
type Item = FrontendMessage;
5767
type Error = Error;
@@ -455,7 +465,9 @@ fn get_text_value(src: &mut Bytes, t: &Type) -> Result<PsqlValue, Error> {
455465
// TODO: Does not correctly handle all valid timestamp representations. For example,
456466
// 8601/SQL timestamp format is assumed; infinity/-infinity are not supported.
457467
let (datetime, timezone_tag) =
458-
NaiveDateTime::parse_and_remainder(text_str, TIMESTAMP_FORMAT)?;
468+
NaiveDateTime::parse_and_remainder(text_str, TIMESTAMP_FORMAT).or_else(|_| {
469+
NaiveDateTime::parse_and_remainder(text_str, ISO_TIMESTAMP_FORMAT)
470+
})?;
459471
if timezone_tag.is_empty() {
460472
return Ok(PsqlValue::Timestamp(datetime));
461473
}
@@ -1482,6 +1494,19 @@ mod tests {
14821494
);
14831495
}
14841496

1497+
#[test]
1498+
fn test_decode_text_timestamp_separator() {
1499+
let mut buf = BytesMut::new();
1500+
buf.put_i32(22); // size
1501+
buf.extend_from_slice(b"2020-01-02T03:04:05.66"); // value
1502+
assert_eq!(
1503+
get_text_value(&mut buf.freeze(), &Type::TIMESTAMP).unwrap(),
1504+
PsqlValue::Timestamp(
1505+
NaiveDateTime::parse_from_str("2020-01-02 03:04:05.66", TIMESTAMP_FORMAT).unwrap()
1506+
)
1507+
);
1508+
}
1509+
14851510
#[test]
14861511
fn test_decode_text_timestamp() {
14871512
let mut buf = BytesMut::new();
@@ -1494,7 +1519,6 @@ mod tests {
14941519
)
14951520
);
14961521
}
1497-
14981522
#[test]
14991523
fn test_decode_text_timestamp_with_offset() {
15001524
let expected =

readyset-data/src/timestamp.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ use crate::{DfType, DfValue};
1515
/// The format for timestamps when parsed as text
1616
pub const TIMESTAMP_PARSE_FORMAT: &str = "%Y-%m-%d %H:%M:%S%.f";
1717

18+
/// The ISO-8601 format for timestamps with T separator
19+
pub const ISO_TIMESTAMP_PARSE_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.f";
20+
1821
/// The format for timestamps when presented as text
1922
pub const TIMESTAMP_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
2023

@@ -388,6 +391,7 @@ impl TimestampTz {
388391
Ok(
389392
if let Ok((naive_date_time, offset_tag)) =
390393
NaiveDateTime::parse_and_remainder(ts, TIMESTAMP_PARSE_FORMAT)
394+
.or_else(|_| NaiveDateTime::parse_and_remainder(ts, ISO_TIMESTAMP_PARSE_FORMAT))
391395
{
392396
if let Some(offset) = parse_timestamp_tag(offset_tag) {
393397
offset?
@@ -398,8 +402,6 @@ impl TimestampTz {
398402
} else {
399403
naive_date_time.into()
400404
}
401-
} else if let Ok(dt) = NaiveDateTime::parse_from_str(ts, TIMESTAMP_PARSE_FORMAT) {
402-
dt.into()
403405
} else if let Ok(dt) = NaiveDate::parse_from_str(ts, DATE_FORMAT) {
404406
// Make TimestampTz object with time portion 00:00:00
405407
dt.and_hms_opt(0, 0, 0)
@@ -838,6 +840,18 @@ mod tests {
838840
.unwrap()
839841
);
840842

843+
// Test ISO-8601 format with T separator
844+
assert_eq!(
845+
TimestampTz::from_str("2012-02-09T12:12:12")
846+
.unwrap()
847+
.to_chrono()
848+
.naive_local(),
849+
chrono::NaiveDate::from_ymd_opt(2012, 2, 9)
850+
.unwrap()
851+
.and_hms_opt(12, 12, 12)
852+
.unwrap()
853+
);
854+
841855
assert_eq!(
842856
TimestampTz::from_str("2004-10-19 10:23:54+02")
843857
.unwrap()
@@ -849,6 +863,18 @@ mod tests {
849863
.unwrap()
850864
);
851865

866+
// Test ISO-8601 format with T separator and timezone
867+
assert_eq!(
868+
TimestampTz::from_str("2004-10-19T10:23:54+02")
869+
.unwrap()
870+
.to_chrono(),
871+
chrono::FixedOffset::east_opt(2 * 60 * 60)
872+
.unwrap()
873+
.with_ymd_and_hms(2004, 10, 19, 10, 23, 54)
874+
.single()
875+
.unwrap()
876+
);
877+
852878
assert_eq!(
853879
TimestampTz::from_str("2004-10-19 10:23:54.1234+02")
854880
.unwrap()

0 commit comments

Comments
 (0)