diff --git a/Cargo.lock b/Cargo.lock index 4d0481fa4c..4d585a5d1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1923,6 +1923,31 @@ version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +[[package]] +name = "jiff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a45489186a6123c128fdf6016183fcfab7113e1820eb813127e036e287233fb" +dependencies = [ + "jiff-tzdb-platform", + "windows-sys 0.52.0", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91335e575850c5c4c673b9bd467b0e025f164ca59d0564f69d0c2ee0ffad4653" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9835f0060a626fe59f160437bc725491a6af23133ea906500027d1bd2f8f4329" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jobserver" version = "0.1.31" @@ -3412,6 +3437,7 @@ dependencies = [ "hex", "indexmap 2.2.5", "ipnetwork", + "jiff", "log", "mac_address", "memchr", @@ -3673,6 +3699,7 @@ dependencies = [ "home", "ipnetwork", "itoa", + "jiff", "log", "mac_address", "md-5", diff --git a/Cargo.toml b/Cargo.toml index cfbdeff210..f4bcad9262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,7 @@ bigdecimal = ["sqlx-core/bigdecimal", "sqlx-macros?/bigdecimal", "sqlx-mysql?/bi bit-vec = ["sqlx-core/bit-vec", "sqlx-macros?/bit-vec", "sqlx-postgres?/bit-vec"] chrono = ["sqlx-core/chrono", "sqlx-macros?/chrono", "sqlx-mysql?/chrono", "sqlx-postgres?/chrono", "sqlx-sqlite?/chrono"] ipnetwork = ["sqlx-core/ipnetwork", "sqlx-macros?/ipnetwork", "sqlx-postgres?/ipnetwork"] +jiff = ["sqlx-core/jiff", "sqlx-postgres?/jiff"] mac_address = ["sqlx-core/mac_address", "sqlx-macros?/mac_address", "sqlx-postgres?/mac_address"] rust_decimal = ["sqlx-core/rust_decimal", "sqlx-macros?/rust_decimal", "sqlx-mysql?/rust_decimal", "sqlx-postgres?/rust_decimal"] time = ["sqlx-core/time", "sqlx-macros?/time", "sqlx-mysql?/time", "sqlx-postgres?/time", "sqlx-sqlite?/time"] @@ -138,6 +139,7 @@ bigdecimal = "0.4.0" bit-vec = "0.6.3" chrono = { version = "0.4.34", default-features = false, features = ["std", "clock"] } ipnetwork = "0.20.0" +jiff = { version = "0.1.13" } mac_address = "1.1.5" rust_decimal = { version = "1.26.1", default-features = false, features = ["std"] } time = { version = "0.3.36", features = ["formatting", "parsing", "macros"] } diff --git a/sqlx-core/Cargo.toml b/sqlx-core/Cargo.toml index 60f9573aae..81095590de 100644 --- a/sqlx-core/Cargo.toml +++ b/sqlx-core/Cargo.toml @@ -47,6 +47,7 @@ bit-vec = { workspace = true, optional = true } bigdecimal = { workspace = true, optional = true } rust_decimal = { workspace = true, optional = true } time = { workspace = true, optional = true } +jiff = { workspace = true, optional = true } ipnetwork = { workspace = true, optional = true } mac_address = { workspace = true, optional = true } uuid = { workspace = true, optional = true } diff --git a/sqlx-core/src/types/mod.rs b/sqlx-core/src/types/mod.rs index 25837b1e77..a567f84498 100644 --- a/sqlx-core/src/types/mod.rs +++ b/sqlx-core/src/types/mod.rs @@ -46,6 +46,13 @@ pub mod chrono { }; } +#[cfg(feature = "jiff")] +#[cfg_attr(docsrs, doc(cfg(feature = "jiff")))] +pub mod jiff { + #[doc(no_inline)] + pub use jiff::{civil::Date, civil::DateTime, civil::Time, Timestamp}; +} + #[cfg(feature = "bit-vec")] #[cfg_attr(docsrs, doc(cfg(feature = "bit-vec")))] #[doc(no_inline)] diff --git a/sqlx-postgres/Cargo.toml b/sqlx-postgres/Cargo.toml index 55a94eceb1..012f13863d 100644 --- a/sqlx-postgres/Cargo.toml +++ b/sqlx-postgres/Cargo.toml @@ -20,6 +20,7 @@ bigdecimal = ["dep:bigdecimal", "dep:num-bigint", "sqlx-core/bigdecimal"] bit-vec = ["dep:bit-vec", "sqlx-core/bit-vec"] chrono = ["dep:chrono", "sqlx-core/chrono"] ipnetwork = ["dep:ipnetwork", "sqlx-core/ipnetwork"] +jiff = ["dep:jiff", "sqlx-core/jiff"] mac_address = ["dep:mac_address", "sqlx-core/mac_address"] rust_decimal = ["dep:rust_decimal", "rust_decimal/maths", "sqlx-core/rust_decimal"] time = ["dep:time", "sqlx-core/time"] @@ -35,7 +36,7 @@ futures-util = { version = "0.3.19", default-features = false, features = ["allo # Cryptographic Primitives crc = "3.0.0" hkdf = "0.12.0" -hmac = { version = "0.12.0", default-features = false, features = ["reset"]} +hmac = { version = "0.12.0", default-features = false, features = ["reset"] } md-5 = { version = "0.10.0", default-features = false } rand = { version = "0.8.4", default-features = false, features = ["std", "std_rng"] } sha2 = { version = "0.10.0", default-features = false } @@ -45,6 +46,7 @@ bigdecimal = { workspace = true, optional = true } bit-vec = { workspace = true, optional = true } chrono = { workspace = true, optional = true } ipnetwork = { workspace = true, optional = true } +jiff = { workspace = true, optional = true } mac_address = { workspace = true, optional = true } rust_decimal = { workspace = true, optional = true } time = { workspace = true, optional = true } diff --git a/sqlx-postgres/src/types/interval.rs b/sqlx-postgres/src/types/interval.rs index 52ab549915..9ef7a97c1a 100644 --- a/sqlx-postgres/src/types/interval.rs +++ b/sqlx-postgres/src/types/interval.rs @@ -112,6 +112,58 @@ impl TryFrom for PgInterval { } } +#[cfg(feature = "jiff")] +impl Type for jiff::SignedDuration { + fn type_info() -> PgTypeInfo { + PgTypeInfo::INTERVAL + } +} + +#[cfg(feature = "jiff")] +impl PgHasArrayType for jiff::SignedDuration { + fn array_type_info() -> PgTypeInfo { + PgTypeInfo::INTERVAL_ARRAY + } +} + +#[cfg(feature = "jiff")] +impl Encode<'_, Postgres> for jiff::SignedDuration { + fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result { + let pg_interval = PgInterval::try_from(*self)?; + pg_interval.encode_by_ref(buf) + } + + fn size_hint(&self) -> usize { + 2 * mem::size_of::() + } +} + +#[cfg(feature = "jiff")] +impl TryFrom for PgInterval { + type Error = BoxDynError; + + /// Convert a `jiff::SignedDuration` to a `PgInterval`. + /// + /// This returns an error if there is a loss of precision using nanoseconds or if there is a + /// microseconds overflow. + fn try_from(value: jiff::SignedDuration) -> Result { + if value.subsec_nanos() % 1000 != 0 { + return Err("PostgreSQL `INTERVAL` does not support nanoseconds precision".into()); + } + + let micros = value.as_micros(); + if micros >= i64::MIN as i128 && micros <= i64::MAX as i128 { + Ok(Self { + months: 0, + days: 0, + microseconds: micros as i64, + }) + } else { + Err("Overflow has occurred for PostgreSQL `INTERVAL`".into()) + } + } +} + #[cfg(feature = "chrono")] impl Type for chrono::Duration { fn type_info() -> PgTypeInfo { @@ -330,6 +382,41 @@ fn test_pginterval_std() { assert!(PgInterval::try_from(std::time::Duration::from_secs(20_000_000_000_000)).is_err()); } +#[test] +#[cfg(feature = "jiff")] +fn test_pginterval_jiff() { + // Case for positive duration + let interval = PgInterval { + days: 0, + months: 0, + microseconds: 27_000, + }; + assert_eq!( + &PgInterval::try_from(jiff::SignedDuration::from_micros(27_000)).unwrap(), + &interval + ); + + // Case for negative duration + let interval = PgInterval { + days: 0, + months: 0, + microseconds: -27_000, + }; + assert_eq!( + &PgInterval::try_from(jiff::SignedDuration::from_micros(-27_000)).unwrap(), + &interval + ); + + // Case when precision loss occurs + assert!(PgInterval::try_from(jiff::SignedDuration::from_nanos(27_000_001)).is_err()); + assert!(PgInterval::try_from(jiff::SignedDuration::from_nanos(-27_000_001)).is_err()); + + // Case when microseconds overflow occurs + assert!(PgInterval::try_from(jiff::SignedDuration::from_secs(10_000_000_000_000)).is_err()); + assert!(PgInterval::try_from(jiff::SignedDuration::from_secs(-10_000_000_000_000)).is_err()); +} + + #[test] #[cfg(feature = "chrono")] fn test_pginterval_chrono() { diff --git a/sqlx-postgres/src/types/jiff/date.rs b/sqlx-postgres/src/types/jiff/date.rs new file mode 100644 index 0000000000..4ce27e4fc9 --- /dev/null +++ b/sqlx-postgres/src/types/jiff/date.rs @@ -0,0 +1,52 @@ +use std::mem; + +use crate::decode::Decode; +use crate::encode::{Encode, IsNull}; +use crate::error::BoxDynError; +use crate::types::Type; +use crate::{PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueFormat, PgValueRef, Postgres}; +use jiff::civil::Date; + +impl Type for Date { + fn type_info() -> PgTypeInfo { + PgTypeInfo::DATE + } +} + +impl PgHasArrayType for Date { + fn array_type_info() -> PgTypeInfo { + PgTypeInfo::DATE_ARRAY + } +} + +impl Encode<'_, Postgres> for Date { + fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result { + // DATE is encoded as the days since epoch + let days = (*self - postgres_epoch_date()).get_days(); + Encode::::encode(days, buf) + } + + fn size_hint(&self) -> usize { + mem::size_of::() + } +} + +impl<'r> Decode<'r, Postgres> for Date { + fn decode(value: PgValueRef<'r>) -> Result { + Ok(match value.format() { + PgValueFormat::Binary => { + // DATE is encoded as the days since epoch + let days: i32 = Decode::::decode(value)?; + let days = jiff::Span::new() + .try_days(days) + .map_err(|err| format!("value {days} overflow Postgres DATE: {err:?}"))?; + postgres_epoch_date() + days + } + PgValueFormat::Text => Date::strptime("%Y-%m-%d", value.as_str()?)?, + }) + } +} + +const fn postgres_epoch_date() -> Date { + Date::constant(2000, 1, 1) +} diff --git a/sqlx-postgres/src/types/jiff/datetime.rs b/sqlx-postgres/src/types/jiff/datetime.rs new file mode 100644 index 0000000000..b4ba5dbd8e --- /dev/null +++ b/sqlx-postgres/src/types/jiff/datetime.rs @@ -0,0 +1,104 @@ +use crate::decode::Decode; +use crate::encode::{Encode, IsNull}; +use crate::error::BoxDynError; +use crate::types::Type; +use crate::{PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueFormat, PgValueRef, Postgres}; +use jiff::civil::DateTime; +use jiff::tz::{Offset, TimeZone}; +use jiff::{SignedDuration, Timestamp}; +use std::mem; +use std::str::FromStr; + +impl Type for DateTime { + fn type_info() -> PgTypeInfo { + PgTypeInfo::TIMESTAMP + } +} + +impl Type for Timestamp { + fn type_info() -> PgTypeInfo { + PgTypeInfo::TIMESTAMPTZ + } +} + +impl PgHasArrayType for DateTime { + fn array_type_info() -> PgTypeInfo { + PgTypeInfo::TIMESTAMP_ARRAY + } +} + +impl PgHasArrayType for Timestamp { + fn array_type_info() -> PgTypeInfo { + PgTypeInfo::TIMESTAMPTZ_ARRAY + } +} + +impl Encode<'_, Postgres> for DateTime { + fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result { + // TIMESTAMP is encoded as the microseconds since the epoch + let micros = (*self - postgres_epoch_datetime()).get_microseconds(); + Encode::::encode(micros, buf) + } + + fn size_hint(&self) -> usize { + mem::size_of::() + } +} + +#[derive(Debug, thiserror::Error)] +#[error("error parsing datetime {squashed:?}")] +struct ParseError { + squashed: Vec, +} + +impl<'r> Decode<'r, Postgres> for DateTime { + fn decode(value: PgValueRef<'r>) -> Result { + match value.format() { + PgValueFormat::Binary => { + // TIMESTAMP is encoded as the microseconds since the epoch + let us = Decode::::decode(value)?; + Ok(postgres_epoch_datetime() + SignedDuration::from_micros(us)) + } + PgValueFormat::Text => { + let input = value.as_str()?; + let mut squashed = vec![]; + match DateTime::strptime("%Y-%m-%d %H:%M:%S%.f", input) { + Ok(datetime) => return Ok(datetime), + Err(err) => squashed.push(err), + } + match DateTime::strptime("%Y-%m-%d %H:%M:%S%.f%#z", input) { + Ok(datetime) => return Ok(datetime), + Err(err) => squashed.push(err), + } + Err(Box::new(ParseError { squashed })) + } + } + } +} + +impl Encode<'_, Postgres> for Timestamp { + fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result { + let datetime = Offset::UTC.to_datetime(*self); + Encode::::encode(datetime, buf) + } + + fn size_hint(&self) -> usize { + mem::size_of::() + } +} + +impl<'r> Decode<'r, Postgres> for Timestamp { + fn decode(value: PgValueRef<'r>) -> Result { + Ok(match value.format() { + PgValueFormat::Binary => { + let naive = >::decode(value)?; + naive.to_zoned(TimeZone::UTC)?.timestamp() + } + PgValueFormat::Text => Timestamp::from_str(value.as_str()?)?, + }) + } +} + +const fn postgres_epoch_datetime() -> DateTime { + DateTime::constant(2000, 1, 1, 0, 0, 0, 0) +} diff --git a/sqlx-postgres/src/types/jiff/mod.rs b/sqlx-postgres/src/types/jiff/mod.rs new file mode 100644 index 0000000000..bd27c4d2d5 --- /dev/null +++ b/sqlx-postgres/src/types/jiff/mod.rs @@ -0,0 +1,3 @@ +mod date; +mod datetime; +mod time; diff --git a/sqlx-postgres/src/types/jiff/time.rs b/sqlx-postgres/src/types/jiff/time.rs new file mode 100644 index 0000000000..a076368d4c --- /dev/null +++ b/sqlx-postgres/src/types/jiff/time.rs @@ -0,0 +1,45 @@ +use std::mem; +use jiff::civil::Time; +use jiff::SignedDuration; +use sqlx_core::decode::Decode; +use sqlx_core::encode::{Encode, IsNull}; +use sqlx_core::error::BoxDynError; +use sqlx_core::types::Type; +use crate::{PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueFormat, PgValueRef, Postgres}; + +impl Type for Time { + fn type_info() -> PgTypeInfo { + PgTypeInfo::TIME + } +} + +impl PgHasArrayType for Time { + fn array_type_info() -> PgTypeInfo { + PgTypeInfo::TIME_ARRAY + } +} + +impl Encode<'_, Postgres> for Time { + fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result { + // TIME is encoded as the microseconds since midnight + let micros = (*self - Time::midnight()).get_microseconds(); + Encode::::encode(micros, buf) + } + + fn size_hint(&self) -> usize { + mem::size_of::() + } +} + +impl<'r> Decode<'r, Postgres> for Time { + fn decode(value: PgValueRef<'r>) -> Result { + Ok(match value.format() { + PgValueFormat::Binary => { + // TIME is encoded as the microseconds since midnight + let us: i64 = Decode::::decode(value)?; + Time::midnight() + SignedDuration::from_micros(us) + } + PgValueFormat::Text => Time::strptime("%H:%M:%S%.f", value.as_str()?)?, + }) + } +} diff --git a/sqlx-postgres/src/types/mod.rs b/sqlx-postgres/src/types/mod.rs index 846f1b731d..1e18e5e8fe 100644 --- a/sqlx-postgres/src/types/mod.rs +++ b/sqlx-postgres/src/types/mod.rs @@ -218,6 +218,9 @@ mod numeric; #[cfg(feature = "rust_decimal")] mod rust_decimal; +#[cfg(feature = "jiff")] +mod jiff; + #[cfg(feature = "chrono")] mod chrono; diff --git a/sqlx-postgres/src/types/range.rs b/sqlx-postgres/src/types/range.rs index 5e1346d86c..4e843dad0f 100644 --- a/sqlx-postgres/src/types/range.rs +++ b/sqlx-postgres/src/types/range.rs @@ -154,6 +154,39 @@ impl Type for PgRange { } } +#[cfg(feature = "jiff")] +impl Type for PgRange { + fn type_info() -> PgTypeInfo { + PgTypeInfo::DATE_RANGE + } + + fn compatible(ty: &PgTypeInfo) -> bool { + range_compatible::(ty) + } +} + +#[cfg(feature = "jiff")] +impl Type for PgRange { + fn type_info() -> PgTypeInfo { + PgTypeInfo::TS_RANGE + } + + fn compatible(ty: &PgTypeInfo) -> bool { + range_compatible::(ty) + } +} + +#[cfg(feature = "jiff")] +impl Type for PgRange { + fn type_info() -> PgTypeInfo { + PgTypeInfo::TSTZ_RANGE + } + + fn compatible(ty: &PgTypeInfo) -> bool { + range_compatible::(ty) + } +} + #[cfg(feature = "chrono")] impl Type for PgRange { fn type_info() -> PgTypeInfo { @@ -246,6 +279,27 @@ impl PgHasArrayType for PgRange { } } +#[cfg(feature = "jiff")] +impl PgHasArrayType for PgRange { + fn array_type_info() -> PgTypeInfo { + PgTypeInfo::DATE_RANGE_ARRAY + } +} + +#[cfg(feature = "jiff")] +impl PgHasArrayType for PgRange { + fn array_type_info() -> PgTypeInfo { + PgTypeInfo::TS_RANGE_ARRAY + } +} + +#[cfg(feature = "jiff")] +impl PgHasArrayType for PgRange { + fn array_type_info() -> PgTypeInfo { + PgTypeInfo::TSTZ_RANGE_ARRAY + } +} + #[cfg(feature = "chrono")] impl PgHasArrayType for PgRange { fn array_type_info() -> PgTypeInfo {