diff --git a/Cargo.toml b/Cargo.toml index e0b93d1..194dabd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ members = [ "jiff-cli", "jiff-tzdb", "jiff-tzdb-platform", + "jiff-sqlx", "examples/*", ] diff --git a/jiff-sqlx/Cargo.toml b/jiff-sqlx/Cargo.toml new file mode 100644 index 0000000..288887c --- /dev/null +++ b/jiff-sqlx/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "jiff-sqlx" +version = "0.1.0" +license = "Unlicense OR MIT" +homepage = "https://github.com/BurntSushi/jiff/tree/master/jiff-sqlx" +repository = "https://github.com/BurntSushi/jiff" +documentation = "https://docs.rs/jiff-sqlx" +description = "Integration to use jiff structs for datetime types in sqlx." +categories = ["date-and-time"] +keywords = ["date", "time", "temporal", "zone", "iana"] +workspace = ".." +edition = "2021" +rust-version = "1.70" + +[features] +default = [] +postgres = ["sqlx/postgres"] + +[dependencies] +jiff = { path = ".." } +sqlx = { version = "0.8", default-features = false } diff --git a/jiff-sqlx/src/lib.rs b/jiff-sqlx/src/lib.rs new file mode 100644 index 0000000..8a5e6f6 --- /dev/null +++ b/jiff-sqlx/src/lib.rs @@ -0,0 +1,5 @@ +#[cfg(feature = "postgres")] +mod postgres; + +mod wrap_types; +pub use wrap_types::*; diff --git a/jiff-sqlx/src/postgres/date.rs b/jiff-sqlx/src/postgres/date.rs new file mode 100644 index 0000000..c39cd53 --- /dev/null +++ b/jiff-sqlx/src/postgres/date.rs @@ -0,0 +1,63 @@ +use crate::{Date, ToDate}; +use sqlx::encode::IsNull; +use sqlx::error::BoxDynError; +use sqlx::postgres::types::Oid; +use sqlx::postgres::{ + PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueFormat, PgValueRef, +}; +use sqlx::{Decode, Encode, Postgres, Type}; + +impl Type for Date { + fn type_info() -> PgTypeInfo { + // 1082 => PgType::Date + PgTypeInfo::with_oid(Oid(1082)) + } +} + +impl PgHasArrayType for Date { + fn array_type_info() -> PgTypeInfo { + // 1182 => PgType::DateArray + PgTypeInfo::with_oid(Oid(1182)) + } +} + +impl Encode<'_, Postgres> for Date { + fn encode_by_ref( + &self, + buf: &mut PgArgumentBuffer, + ) -> Result { + let date = self.to_jiff(); + + // DATE is encoded as the days since epoch + let days = date.since(postgres_epoch_date())?.get_days(); + Encode::::encode(days, buf) + } + + fn size_hint(&self) -> usize { + 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 date = jiff::Span::new() + .try_days(days) + .and_then(|s| postgres_epoch_date().checked_add(s))?; + date.to_sqlx() + } + PgValueFormat::Text => { + let s = value.as_str()?; + let date = jiff::civil::Date::strptime("%Y-%m-%d", s)?; + date.to_sqlx() + } + }) + } +} + +const fn postgres_epoch_date() -> jiff::civil::Date { + jiff::civil::Date::constant(2000, 1, 1) +} diff --git a/jiff-sqlx/src/postgres/datetime.rs b/jiff-sqlx/src/postgres/datetime.rs new file mode 100644 index 0000000..d182154 --- /dev/null +++ b/jiff-sqlx/src/postgres/datetime.rs @@ -0,0 +1,68 @@ +use crate::{DateTime, ToDateTime}; +use jiff::SignedDuration; +use sqlx::encode::IsNull; +use sqlx::error::BoxDynError; +use sqlx::postgres::types::Oid; +use sqlx::postgres::{ + PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueFormat, PgValueRef, +}; +use sqlx::{Decode, Encode, Postgres, Type}; +use std::str::FromStr; + +impl Type for DateTime { + fn type_info() -> PgTypeInfo { + // 1114 => PgType::Timestamp + PgTypeInfo::with_oid(Oid(1114)) + } +} + +impl PgHasArrayType for DateTime { + fn array_type_info() -> PgTypeInfo { + // 1115 => PgType::TimestampArray + PgTypeInfo::with_oid(Oid(1115)) + } +} + +impl Encode<'_, Postgres> for DateTime { + fn encode_by_ref( + &self, + buf: &mut PgArgumentBuffer, + ) -> Result { + let datetime = self.to_jiff(); + + // TIMESTAMP is encoded as the microseconds since the epoch + let micros = + datetime.duration_since(postgres_epoch_datetime()).as_micros(); + let micros = i64::try_from(micros).map_err(|_| { + format!("DateTime {datetime} out of range for Postgres: {micros}") + })?; + Encode::::encode(micros, buf) + } + + fn size_hint(&self) -> usize { + size_of::() + } +} + +impl<'r> Decode<'r, Postgres> for DateTime { + fn decode(value: PgValueRef<'r>) -> Result { + Ok(match value.format() { + PgValueFormat::Binary => { + // TIMESTAMP is encoded as the microseconds since the epoch + let us = Decode::::decode(value)?; + let datetime = postgres_epoch_datetime() + .checked_add(SignedDuration::from_micros(us))?; + datetime.to_sqlx() + } + PgValueFormat::Text => { + let s = value.as_str()?; + let datetime = jiff::civil::DateTime::from_str(s)?; + datetime.to_sqlx() + } + }) + } +} + +const fn postgres_epoch_datetime() -> jiff::civil::DateTime { + jiff::civil::DateTime::constant(2000, 1, 1, 0, 0, 0, 0) +} diff --git a/jiff-sqlx/src/postgres/interval.rs b/jiff-sqlx/src/postgres/interval.rs new file mode 100644 index 0000000..51f89cc --- /dev/null +++ b/jiff-sqlx/src/postgres/interval.rs @@ -0,0 +1,133 @@ +use crate::SignedDuration; +use sqlx::encode::IsNull; +use sqlx::error::BoxDynError; +use sqlx::postgres::types::{Oid, PgInterval}; +use sqlx::postgres::{PgArgumentBuffer, PgHasArrayType, PgTypeInfo}; +use sqlx::{Database, Decode, Encode, Postgres, Type}; + +impl Type for SignedDuration { + fn type_info() -> PgTypeInfo { + // 1186 => PgType::Interval + PgTypeInfo::with_oid(Oid(1186)) + } +} + +impl PgHasArrayType for SignedDuration { + fn array_type_info() -> PgTypeInfo { + // 1187 => PgType::IntervalArray + PgTypeInfo::with_oid(Oid(1187)) + } +} + +impl TryFrom for PgInterval { + type Error = BoxDynError; + + /// Convert a `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: SignedDuration) -> Result { + let value = value.to_jiff(); + + 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()) + } + } +} + +impl Encode<'_, Postgres> for 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 * size_of::() + } +} + +impl<'r> Decode<'r, Postgres> for SignedDuration { + fn decode( + value: ::ValueRef<'r>, + ) -> Result { + let pg_interval = PgInterval::decode(value)?; + + if pg_interval.months != 0 { + return Err( + "Cannot convert months in `INTERVAL` to SignedDuration".into(), + ); + } + + if pg_interval.days != 0 { + return Err( + "Cannot convert days in `INTERVAL` to SignedDuration".into() + ); + } + + let micros = pg_interval.microseconds; + Ok(SignedDuration(jiff::SignedDuration::from_micros(micros))) + } +} + +#[cfg(test)] +mod tests { + use crate::ToSignedDuration; + use sqlx::postgres::types::PgInterval; + + #[test] + 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).to_sqlx() + ) + .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).to_sqlx() + ) + .unwrap(), + &interval + ); + + // Case when precision loss occurs + assert!(PgInterval::try_from( + jiff::SignedDuration::from_nanos(27_000_001).to_sqlx() + ) + .is_err()); + assert!(PgInterval::try_from( + jiff::SignedDuration::from_nanos(-27_000_001).to_sqlx() + ) + .is_err()); + + // Case when microseconds overflow occurs + assert!(PgInterval::try_from( + jiff::SignedDuration::from_secs(10_000_000_000_000).to_sqlx() + ) + .is_err()); + assert!(PgInterval::try_from( + jiff::SignedDuration::from_secs(-10_000_000_000_000).to_sqlx() + ) + .is_err()); + } +} diff --git a/jiff-sqlx/src/postgres/mod.rs b/jiff-sqlx/src/postgres/mod.rs new file mode 100644 index 0000000..d7750fc --- /dev/null +++ b/jiff-sqlx/src/postgres/mod.rs @@ -0,0 +1,5 @@ +mod date; +mod datetime; +mod interval; +mod time; +mod timestamp; diff --git a/jiff-sqlx/src/postgres/time.rs b/jiff-sqlx/src/postgres/time.rs new file mode 100644 index 0000000..ef2a349 --- /dev/null +++ b/jiff-sqlx/src/postgres/time.rs @@ -0,0 +1,63 @@ +use crate::{Time, ToTime}; +use jiff::SignedDuration; +use sqlx::encode::IsNull; +use sqlx::error::BoxDynError; +use sqlx::postgres::types::Oid; +use sqlx::postgres::{ + PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueFormat, PgValueRef, +}; +use sqlx::{Decode, Encode, Postgres, Type}; + +impl Type for Time { + fn type_info() -> PgTypeInfo { + // 1083 => PgType::Time + PgTypeInfo::with_oid(Oid(1083)) + } +} + +impl PgHasArrayType for Time { + fn array_type_info() -> PgTypeInfo { + // 1183 => PgType::TimeArray + PgTypeInfo::with_oid(Oid(1183)) + } +} + +impl Encode<'_, Postgres> for Time { + fn encode_by_ref( + &self, + buf: &mut PgArgumentBuffer, + ) -> Result { + let time = self.to_jiff(); + + // TIME is encoded as the microseconds since midnight + let micros = + time.duration_since(jiff::civil::Time::midnight()).as_micros(); + let micros = i64::try_from(micros).map_err(|_| { + format!("Time {time} out of range for Postgres: {micros}") + })?; + Encode::::encode(micros, buf) + } + + fn size_hint(&self) -> usize { + 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)?; + let time = jiff::civil::Time::midnight() + .checked_add(SignedDuration::from_micros(us))?; + time.to_sqlx() + } + PgValueFormat::Text => { + let s = value.as_str()?; + let time = jiff::civil::Time::strptime("%H:%M:%S%.f", s)?; + time.to_sqlx() + } + }) + } +} diff --git a/jiff-sqlx/src/postgres/timestamp.rs b/jiff-sqlx/src/postgres/timestamp.rs new file mode 100644 index 0000000..35baf27 --- /dev/null +++ b/jiff-sqlx/src/postgres/timestamp.rs @@ -0,0 +1,70 @@ +use crate::{Timestamp, ToTimestamp}; +use jiff::SignedDuration; +use sqlx::encode::IsNull; +use sqlx::error::BoxDynError; +use sqlx::postgres::types::Oid; +use sqlx::postgres::{ + PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueFormat, +}; +use sqlx::{Database, Decode, Encode, Postgres, Type}; +use std::str::FromStr; + +impl Type for Timestamp { + fn type_info() -> PgTypeInfo { + // 1184 => PgType::Timestamptz + PgTypeInfo::with_oid(Oid(1184)) + } +} + +impl PgHasArrayType for Timestamp { + fn array_type_info() -> PgTypeInfo { + // 1185 => PgType::TimestamptzArray + PgTypeInfo::with_oid(Oid(1185)) + } +} + +impl Encode<'_, Postgres> for Timestamp { + fn encode_by_ref( + &self, + buf: &mut PgArgumentBuffer, + ) -> Result { + let ts = self.to_jiff(); + + // TIMESTAMP is encoded as the microseconds since the epoch + let micros = ts.duration_since(postgres_epoch_timestamp()).as_micros(); + let micros = i64::try_from(micros).map_err(|_| { + format!("Timestamp {ts} out of range for Postgres: {micros}") + })?; + Encode::::encode(micros, buf) + } + + fn size_hint(&self) -> usize { + size_of::() + } +} + +impl<'r> Decode<'r, Postgres> for Timestamp { + fn decode( + value: ::ValueRef<'r>, + ) -> Result { + Ok(match value.format() { + PgValueFormat::Binary => { + // TIMESTAMP is encoded as the microseconds since the epoch + let us = Decode::::decode(value)?; + let ts = postgres_epoch_timestamp() + .checked_add(SignedDuration::from_micros(us))?; + ts.to_sqlx() + } + PgValueFormat::Text => { + let s = value.as_str()?; + let ts = jiff::Timestamp::from_str(s)?; + ts.to_sqlx() + } + }) + } +} + +fn postgres_epoch_timestamp() -> jiff::Timestamp { + jiff::Timestamp::from_str("2000-01-01T00:00:00Z") + .expect("2000-01-01T00:00:00Z is a valid timestamp") +} diff --git a/jiff-sqlx/src/wrap_types.rs b/jiff-sqlx/src/wrap_types.rs new file mode 100644 index 0000000..f218ade --- /dev/null +++ b/jiff-sqlx/src/wrap_types.rs @@ -0,0 +1,40 @@ +macro_rules! define_wrap_type { + ($wrapper:ident, $wrapper_trait:ident, $origin:ty) => { + pub trait $wrapper_trait { + fn to_sqlx(self) -> $wrapper; + } + + #[derive(Debug, Clone, Copy)] + pub struct $wrapper($origin); + + impl $wrapper { + pub fn to_jiff(self) -> $origin { + self.0 + } + } + + impl $wrapper_trait for $origin { + fn to_sqlx(self) -> $wrapper { + $wrapper(self) + } + } + + impl From<$wrapper> for $origin { + fn from(value: $wrapper) -> Self { + value.0 + } + } + + impl From<$origin> for $wrapper { + fn from(value: $origin) -> Self { + Self(value) + } + } + }; +} + +define_wrap_type!(Timestamp, ToTimestamp, jiff::Timestamp); +define_wrap_type!(SignedDuration, ToSignedDuration, jiff::SignedDuration); +define_wrap_type!(Date, ToDate, jiff::civil::Date); +define_wrap_type!(Time, ToTime, jiff::civil::Time); +define_wrap_type!(DateTime, ToDateTime, jiff::civil::DateTime);