Repository: sfackler/rust-postgres (language: rust)

Issue:
Support for jiff datetime types
This is the issue tracker for supporting https://github.com/BurntSushi/jiff datetime types.

PR incoming


Discussion / hints:

Fix patch:
diff --git a/postgres-types/CHANGELOG.md b/postgres-types/CHANGELOG.md
index 1e5cd31d8..b11e18d32 100644
--- a/postgres-types/CHANGELOG.md
+++ b/postgres-types/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 ## Unreleased
 
+### Added
+
+* Added support for `jiff` 0.1 via the `with-jiff-01` feature.
+
 ## v0.2.7 - 2024-07-21
 
 ### Added
diff --git a/postgres-types/Cargo.toml b/postgres-types/Cargo.toml
index e2d21b358..941f4fcc4 100644
--- a/postgres-types/Cargo.toml
+++ b/postgres-types/Cargo.toml
@@ -21,6 +21,7 @@ with-eui48-0_4 = ["eui48-04"]
 with-eui48-1 = ["eui48-1"]
 with-geo-types-0_6 = ["geo-types-06"]
 with-geo-types-0_7 = ["geo-types-0_7"]
+with-jiff-0_1 = ["jiff-01"]
 with-serde_json-1 = ["serde-1", "serde_json-1"]
 with-smol_str-01 = ["smol_str-01"]
 with-uuid-0_8 = ["uuid-08"]
@@ -46,6 +47,7 @@ eui48-04 = { version = "0.4", package = "eui48", optional = true }
 eui48-1 = { version = "1.0", package = "eui48", optional = true, default-features = false }
 geo-types-06 = { version = "0.6", package = "geo-types", optional = true }
 geo-types-0_7 = { version = "0.7", package = "geo-types", optional = true }
+jiff-01 = { version = "0.1", package = "jiff", optional = true }
 serde-1 = { version = "1.0", package = "serde", optional = true }
 serde_json-1 = { version = "1.0", package = "serde_json", optional = true }
 uuid-08 = { version = "0.8", package = "uuid", optional = true }
diff --git a/postgres-types/src/jiff_01.rs b/postgres-types/src/jiff_01.rs
new file mode 100644
index 000000000..d3215c0e6
--- /dev/null
+++ b/postgres-types/src/jiff_01.rs
@@ -0,0 +1,141 @@
+use bytes::BytesMut;
+use jiff_01::{
+    civil::{Date, DateTime, Time},
+    Span, SpanRound, Timestamp, Unit,
+};
+use postgres_protocol::types;
+use std::error::Error;
+
+use crate::{FromSql, IsNull, ToSql, Type};
+
+const fn base() -> DateTime {
+    DateTime::constant(2000, 1, 1, 0, 0, 0, 0)
+}
+
+/// The number of seconds from the Unix epoch to 2000-01-01 00:00:00 UTC.
+const PG_EPOCH: i64 = 946684800;
+
+fn base_ts() -> Timestamp {
+    Timestamp::new(PG_EPOCH, 0).unwrap()
+}
+
+fn round_us<'a>() -> SpanRound<'a> {
+    SpanRound::new().largest(Unit::Microsecond)
+}
+
+fn decode_err<E>(_e: E) -> Box<dyn Error + Sync + Send>
+where
+    E: Error,
+{
+    "value too large to decode".into()
+}
+
+fn transmit_err<E>(_e: E) -> Box<dyn Error + Sync + Send>
+where
+    E: Error,
+{
+    "value too large to transmit".into()
+}
+
+impl<'a> FromSql<'a> for DateTime {
+    fn from_sql(_: &Type, raw: &[u8]) -> Result<DateTime, Box<dyn Error + Sync + Send>> {
+        let v = types::timestamp_from_sql(raw)?;
+        Span::new()
+            .try_microseconds(v)
+            .and_then(|s| base().checked_add(s))
+            .map_err(decode_err)
+    }
+
+    accepts!(TIMESTAMP);
+}
+
+impl ToSql for DateTime {
+    fn to_sql(&self, _: &Type, w: &mut BytesMut) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
+        let v = self
+            .since(base())
+            .and_then(|s| s.round(round_us()))
+            .map_err(transmit_err)?
+            .get_microseconds();
+        types::timestamp_to_sql(v, w);
+        Ok(IsNull::No)
+    }
+
+    accepts!(TIMESTAMP);
+    to_sql_checked!();
+}
+
+impl<'a> FromSql<'a> for Timestamp {
+    fn from_sql(_: &Type, raw: &[u8]) -> Result<Timestamp, Box<dyn Error + Sync + Send>> {
+        let v = types::timestamp_from_sql(raw)?;
+        Span::new()
+            .try_microseconds(v)
+            .and_then(|s| base_ts().checked_add(s))
+            .map_err(decode_err)
+    }
+
+    accepts!(TIMESTAMPTZ);
+}
+
+impl ToSql for Timestamp {
+    fn to_sql(&self, _: &Type, w: &mut BytesMut) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
+        let v = self
+            .since(base_ts())
+            .and_then(|s| s.round(round_us()))
+            .map_err(transmit_err)?
+            .get_microseconds();
+        types::timestamp_to_sql(v, w);
+        Ok(IsNull::No)
+    }
+
+    accepts!(TIMESTAMPTZ);
+    to_sql_checked!();
+}
+
+impl<'a> FromSql<'a> for Date {
+    fn from_sql(_: &Type, raw: &[u8]) -> Result<Date, Box<dyn Error + Sync + Send>> {
+        let v = types::date_from_sql(raw)?;
+        Span::new()
+            .try_days(v)
+            .and_then(|s| base().date().checked_add(s))
+            .map_err(decode_err)
+    }
+    accepts!(DATE);
+}
+
+impl ToSql for Date {
+    fn to_sql(&self, _: &Type, w: &mut BytesMut) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
+        let v = self.since(base().date()).map_err(transmit_err)?.get_days();
+        types::date_to_sql(v, w);
+        Ok(IsNull::No)
+    }
+
+    accepts!(DATE);
+    to_sql_checked!();
+}
+
+impl<'a> FromSql<'a> for Time {
+    fn from_sql(_: &Type, raw: &[u8]) -> Result<Time, Box<dyn Error + Sync + Send>> {
+        let v = types::time_from_sql(raw)?;
+        Span::new()
+            .try_microseconds(v)
+            .and_then(|s| Time::midnight().checked_add(s))
+            .map_err(decode_err)
+    }
+
+    accepts!(TIME);
+}
+
+impl ToSql for Time {
+    fn to_sql(&self, _: &Type, w: &mut BytesMut) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
+        let v = self
+            .since(Time::midnight())
+            .and_then(|s| s.round(round_us()))
+            .map_err(transmit_err)?
+            .get_microseconds();
+        types::time_to_sql(v, w);
+        Ok(IsNull::No)
+    }
+
+    accepts!(TIME);
+    to_sql_checked!();
+}
diff --git a/postgres-types/src/lib.rs b/postgres-types/src/lib.rs
index 492039766..6ad2eff50 100644
--- a/postgres-types/src/lib.rs
+++ b/postgres-types/src/lib.rs
@@ -276,6 +276,8 @@ mod eui48_1;
 mod geo_types_06;
 #[cfg(feature = "with-geo-types-0_7")]
 mod geo_types_07;
+#[cfg(feature = "with-jiff-0_1")]
+mod jiff_01;
 #[cfg(feature = "with-serde_json-1")]
 mod serde_json_1;
 #[cfg(feature = "with-smol_str-01")]
@@ -491,6 +493,10 @@ impl WrongType {
 /// | `time::OffsetDateTime`          | TIMESTAMP WITH TIME ZONE            |
 /// | `time::Date`                    | DATE                                |
 /// | `time::Time`                    | TIME                                |
+/// | `jiff::civil::Date`             | DATE                                |
+/// | `jiff::civil::DateTime`         | TIMESTAMP                           |
+/// | `jiff::civil::Time`             | TIME                                |
+/// | `jiff::Timestamp`               | TIMESTAMP WITH TIME ZONE            |
 /// | `eui48::MacAddress`             | MACADDR                             |
 /// | `geo_types::Point<f64>`         | POINT                               |
 /// | `geo_types::Rect<f64>`          | BOX                                 |
diff --git a/postgres/CHANGELOG.md b/postgres/CHANGELOG.md
index 258cdb518..6feb629e4 100644
--- a/postgres/CHANGELOG.md
+++ b/postgres/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Change Log
 
+## Unreleased
+
+### Added
+
+* Added support for `jiff` 0.1 via the `with-jiff-01` feature.
+
 ## v0.19.8 - 2024-07-21
 
 ### Added
diff --git a/postgres/Cargo.toml b/postgres/Cargo.toml
index ff95c4f14..e0e580f7d 100644
--- a/postgres/Cargo.toml
+++ b/postgres/Cargo.toml
@@ -28,6 +28,7 @@ with-eui48-0_4 = ["tokio-postgres/with-eui48-0_4"]
 with-eui48-1 = ["tokio-postgres/with-eui48-1"]
 with-geo-types-0_6 = ["tokio-postgres/with-geo-types-0_6"]
 with-geo-types-0_7 = ["tokio-postgres/with-geo-types-0_7"]
+with-jiff-0_1 = ["tokio-postgres/with-jiff-0_1"]
 with-serde_json-1 = ["tokio-postgres/with-serde_json-1"]
 with-smol_str-01 = ["tokio-postgres/with-smol_str-01"]
 with-uuid-0_8 = ["tokio-postgres/with-uuid-0_8"]
diff --git a/tokio-postgres/CHANGELOG.md b/tokio-postgres/CHANGELOG.md
index e0be26296..bf17ec486 100644
--- a/tokio-postgres/CHANGELOG.md
+++ b/tokio-postgres/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 ## Unreleased
 
+### Added
+
+* Added support for `jiff` 0.1 via the `with-jiff-01` feature.
+
 ## v0.7.11 - 2024-07-21
 
 ### Fixed
diff --git a/tokio-postgres/Cargo.toml b/tokio-postgres/Cargo.toml
index c2f80dc7e..e1e84f7b1 100644
--- a/tokio-postgres/Cargo.toml
+++ b/tokio-postgres/Cargo.toml
@@ -34,6 +34,7 @@ with-eui48-0_4 = ["postgres-types/with-eui48-0_4"]
 with-eui48-1 = ["postgres-types/with-eui48-1"]
 with-geo-types-0_6 = ["postgres-types/with-geo-types-0_6"]
 with-geo-types-0_7 = ["postgres-types/with-geo-types-0_7"]
+with-jiff-0_1 = ["postgres-types/with-jiff-0_1"]
 with-serde_json-1 = ["postgres-types/with-serde_json-1"]
 with-smol_str-01 = ["postgres-types/with-smol_str-01"]
 with-uuid-0_8 = ["postgres-types/with-uuid-0_8"]
@@ -81,6 +82,7 @@ chrono-04 = { version = "0.4", package = "chrono", default-features = false }
 eui48-1 = { version = "1.0", package = "eui48", default-features = false }
 geo-types-06 = { version = "0.6", package = "geo-types" }
 geo-types-07 = { version = "0.7", package = "geo-types" }
+jiff-01 = { version = "0.1", package = "jiff" }
 serde-1 = { version = "1.0", package = "serde" }
 serde_json-1 = { version = "1.0", package = "serde_json" }
 smol_str-01 = { version = "0.1", package = "smol_str" }
diff --git a/tokio-postgres/src/lib.rs b/tokio-postgres/src/lib.rs
index a603158fb..ec843d511 100644
--- a/tokio-postgres/src/lib.rs
+++ b/tokio-postgres/src/lib.rs
@@ -111,6 +111,7 @@
 //! | `with-eui48-1` | Enable support for the 1.0 version of the `eui48` crate. | [eui48](https://crates.io/crates/eui48) 1.0 | no |
 //! | `with-geo-types-0_6` | Enable support for the 0.6 version of the `geo-types` crate. | [geo-types](https://crates.io/crates/geo-types/0.6.0) 0.6 | no |
 //! | `with-geo-types-0_7` | Enable support for the 0.7 version of the `geo-types` crate. | [geo-types](https://crates.io/crates/geo-types/0.7.0) 0.7 | no |
+//! | `with-jiff-0_1` | Enable support for the 0.1 version of the `jiff` crate. | [jiff](https://crates.io/crates/jiff/0.1.0) 0.1 | no |
 //! | `with-serde_json-1` | Enable support for the `serde_json` crate. | [serde_json](https://crates.io/crates/serde_json) 1.0 | no |
 //! | `with-uuid-0_8` | Enable support for the `uuid` crate. | [uuid](https://crates.io/crates/uuid) 0.8 | no |
 //! | `with-uuid-1` | Enable support for the `uuid` crate. | [uuid](https://crates.io/crates/uuid) 1.0 | no |


Test patch:
diff --git a/tokio-postgres/tests/test/types/jiff_01.rs b/tokio-postgres/tests/test/types/jiff_01.rs
new file mode 100644
index 000000000..7c9052676
--- /dev/null
+++ b/tokio-postgres/tests/test/types/jiff_01.rs
@@ -0,0 +1,175 @@
+use jiff_01::{
+    civil::{Date as JiffDate, DateTime, Time},
+    Timestamp as JiffTimestamp,
+};
+use std::fmt;
+use tokio_postgres::{
+    types::{Date, FromSqlOwned, Timestamp},
+    Client,
+};
+
+use crate::connect;
+use crate::types::test_type;
+
+#[tokio::test]
+async fn test_datetime_params() {
+    fn make_check(s: &str) -> (Option<DateTime>, &str) {
+        (Some(s.trim_matches('\'').parse().unwrap()), s)
+    }
+    test_type(
+        "TIMESTAMP",
+        &[
+            make_check("'1970-01-01 00:00:00.010000000'"),
+            make_check("'1965-09-25 11:19:33.100314000'"),
+            make_check("'2010-02-09 23:11:45.120200000'"),
+            (None, "NULL"),
+        ],
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn test_with_special_datetime_params() {
+    fn make_check(s: &str) -> (Timestamp<DateTime>, &str) {
+        (Timestamp::Value(s.trim_matches('\'').parse().unwrap()), s)
+    }
+    test_type(
+        "TIMESTAMP",
+        &[
+            make_check("'1970-01-01 00:00:00.010000000'"),
+            make_check("'1965-09-25 11:19:33.100314000'"),
+            make_check("'2010-02-09 23:11:45.120200000'"),
+            (Timestamp::PosInfinity, "'infinity'"),
+            (Timestamp::NegInfinity, "'-infinity'"),
+        ],
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn test_timestamp_params() {
+    fn make_check(s: &str) -> (Option<JiffTimestamp>, &str) {
+        (Some(s.trim_matches('\'').parse().unwrap()), s)
+    }
+    test_type(
+        "TIMESTAMP WITH TIME ZONE",
+        &[
+            make_check("'1970-01-01 00:00:00.010000000Z'"),
+            make_check("'1965-09-25 11:19:33.100314000Z'"),
+            make_check("'2010-02-09 23:11:45.120200000Z'"),
+            (None, "NULL"),
+        ],
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn test_with_special_timestamp_params() {
+    fn make_check(s: &str) -> (Timestamp<JiffTimestamp>, &str) {
+        (Timestamp::Value(s.trim_matches('\'').parse().unwrap()), s)
+    }
+    test_type(
+        "TIMESTAMP WITH TIME ZONE",
+        &[
+            make_check("'1970-01-01 00:00:00.010000000Z'"),
+            make_check("'1965-09-25 11:19:33.100314000Z'"),
+            make_check("'2010-02-09 23:11:45.120200000Z'"),
+            (Timestamp::PosInfinity, "'infinity'"),
+            (Timestamp::NegInfinity, "'-infinity'"),
+        ],
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn test_date_params() {
+    fn make_check(s: &str) -> (Option<JiffDate>, &str) {
+        (Some(s.trim_matches('\'').parse().unwrap()), s)
+    }
+    test_type(
+        "DATE",
+        &[
+            make_check("'1970-01-01'"),
+            make_check("'1965-09-25'"),
+            make_check("'2010-02-09'"),
+            (None, "NULL"),
+        ],
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn test_with_special_date_params() {
+    fn make_check(s: &str) -> (Date<JiffDate>, &str) {
+        (Date::Value(s.trim_matches('\'').parse().unwrap()), s)
+    }
+    test_type(
+        "DATE",
+        &[
+            make_check("'1970-01-01'"),
+            make_check("'1965-09-25'"),
+            make_check("'2010-02-09'"),
+            (Date::PosInfinity, "'infinity'"),
+            (Date::NegInfinity, "'-infinity'"),
+        ],
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn test_time_params() {
+    fn make_check(s: &str) -> (Option<Time>, &str) {
+        (Some(s.trim_matches('\'').parse().unwrap()), s)
+    }
+    test_type(
+        "TIME",
+        &[
+            make_check("'00:00:00.010000000'"),
+            make_check("'11:19:33.100314000'"),
+            make_check("'23:11:45.120200000'"),
+            (None, "NULL"),
+        ],
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn test_special_params_without_wrapper() {
+    async fn assert_overflows<T>(client: &mut Client, val: &str, sql_type: &str)
+    where
+        T: FromSqlOwned + fmt::Debug,
+    {
+        let err = client
+            .query_one(&*format!("SELECT {}::{}", val, sql_type), &[])
+            .await
+            .unwrap()
+            .try_get::<_, T>(0)
+            .unwrap_err();
+
+        assert_eq!(
+            err.to_string(),
+            "error deserializing column 0: value too large to decode"
+        );
+
+        let err = client
+            .query_one(&*format!("SELECT {}::{}", val, sql_type), &[])
+            .await
+            .unwrap()
+            .try_get::<_, T>(0)
+            .unwrap_err();
+
+        assert_eq!(
+            err.to_string(),
+            "error deserializing column 0: value too large to decode"
+        );
+    }
+
+    let mut client = connect("user=postgres").await;
+
+    assert_overflows::<DateTime>(&mut client, "'-infinity'", "timestamp").await;
+    assert_overflows::<DateTime>(&mut client, "'infinity'", "timestamp").await;
+    assert_overflows::<JiffTimestamp>(&mut client, "'-infinity'", "timestamptz").await;
+    assert_overflows::<JiffTimestamp>(&mut client, "'infinity'", "timestamptz").await;
+    assert_overflows::<JiffDate>(&mut client, "'-infinity'", "date").await;
+    assert_overflows::<JiffDate>(&mut client, "'infinity'", "date").await;
+}
diff --git a/tokio-postgres/tests/test/types/mod.rs b/tokio-postgres/tests/test/types/mod.rs
index 62d54372a..f962bf10a 100644
--- a/tokio-postgres/tests/test/types/mod.rs
+++ b/tokio-postgres/tests/test/types/mod.rs
@@ -23,6 +23,8 @@ mod eui48_1;
 mod geo_types_06;
 #[cfg(feature = "with-geo-types-0_7")]
 mod geo_types_07;
+#[cfg(feature = "with-jiff-0_1")]
+mod jiff_01;
 #[cfg(feature = "with-serde_json-1")]
 mod serde_json_1;
 #[cfg(feature = "with-smol_str-01")]