Skip to content

Commit

Permalink
feat(pgrx): add chrono feature for easy conversions
Browse files Browse the repository at this point in the history
This commit adds a `chrono` feature flag to `pgrx`, which enables
conversions between `pgrx` native date/time (with or without timezone)
and `chrono` types like `NaiveDateTime`.
  • Loading branch information
t3hmrman committed Jul 1, 2024
1 parent 43e68d2 commit ba845a4
Show file tree
Hide file tree
Showing 8 changed files with 283 additions and 1 deletion.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ thiserror = "1"
unescape = "0.1.0" # for escaped-character-handling
url = "2.4.1" # the non-existent std::web
walkdir = "2" # directory recursion
chrono = "0.4.35" # conversions to chrono data structures

[profile.dev]
# Only include line tables in debuginfo. This reduces the size of target/ (after
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@ ASCII nor UTF-8 (as Postgres will then accept but ignore non-ASCII bytes).
For best results, always use PGRX with UTF-8, and set database encodings
explicitly upon database creation.

To easily convert `pgrx` temporal types (`pgrx::TimestampWithTimezone`, etc)
to [`chrono`] compatible types, enable the `chrono` feature.

## Digging Deeper

- [cargo-pgrx sub-command](cargo-pgrx/)
Expand Down
2 changes: 2 additions & 0 deletions pgrx-tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pg_test = [ ]
proptest = [ "dep:proptest" ]
cshim = [ "pgrx/cshim" ]
no-schema-generation = [ "pgrx/no-schema-generation", "pgrx-macros/no-schema-generation" ]
chrono = [ "dep:chrono", "pgrx/chrono" ]
nightly = [ "pgrx/nightly" ]

[package.metadata.docs.rs]
Expand Down Expand Up @@ -67,6 +68,7 @@ postgres = "0.19.7"
proptest = { version = "1", optional = true }
sysinfo = "0.29.10"
rand = "0.8.5"
chrono = { workspace = true, optional = true }

[dependencies.pgrx] # Not unified in workspace due to default-features key
path = "../pgrx"
Expand Down
75 changes: 75 additions & 0 deletions pgrx-tests/src/tests/chrono_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//! Tests for the `chrono` features of `cargo-pgrx`
//!
#![cfg(feature = "chrono")]

#[cfg(any(test, feature = "pg_test"))]
#[pgrx::pg_schema]
mod tests {
#[allow(unused_imports)]
use crate as pgrx_tests;

use std::result::Result;

use chrono::{Datelike as _, Timelike as _, Utc};

use pgrx::pg_test;
use pgrx::DateTimeConversionError;

// Utility class for errors
type DtcResult<T> = Result<T, DateTimeConversionError>;

/// Ensure simple conversion ([`pgrx::Date`] -> [`chrono::NaiveDate`]) works
#[pg_test]
fn chrono_simple_date_conversion() -> DtcResult<()> {
let original = pgrx::Date::new(1970, 1, 1)?;
let d = chrono::NaiveDate::try_from(original)?;
assert_eq!(d.year(), original.year(), "year matches");
assert_eq!(d.month(), 1, "month matches");
assert_eq!(d.day(), 1, "day matches");
let backwards = pgrx::Date::try_from(d)?;
assert_eq!(backwards, original);
Ok(())
}

/// Ensure simple conversion ([`pgrx::Time`] -> [`chrono::NaiveTime`]) works
#[pg_test]
fn chrono_simple_time_conversion() -> DtcResult<()> {
let original = pgrx::Time::new(12, 1, 59.0000001)?;
let d = chrono::NaiveTime::try_from(original)?;
assert_eq!(d.hour(), 12, "hours match");
assert_eq!(d.minute(), 1, "minutes match");
assert_eq!(d.second(), 59, "seconds match");
assert_eq!(d.nanosecond(), 0, "nanoseconds are zero (pg only supports microseconds)");
let backwards = pgrx::Time::try_from(d)?;
assert_eq!(backwards, original);
Ok(())
}

/// Ensure simple conversion ([`pgrx::Timestamp`] -> [`chrono::NaiveDateTime`]) works
#[pg_test]
fn chrono_simple_timestamp_conversion() -> DtcResult<()> {
let original = pgrx::Timestamp::new(1970, 1, 1, 1, 1, 1.0)?;
let d = chrono::NaiveDateTime::try_from(original)?;
assert_eq!(d.hour(), 1, "hours match");
assert_eq!(d.minute(), 1, "minutes match");
assert_eq!(d.second(), 1, "seconds match");
assert_eq!(d.nanosecond(), 0, "nanoseconds are zero (pg only supports microseconds)");
let backwards = pgrx::Timestamp::try_from(d)?;
assert_eq!(backwards, original, "NaiveDateTime -> Timestamp return conversion failed");
Ok(())
}

/// Ensure simple conversion ([`pgrx::TimestampWithTimeZone`] -> [`chrono::DateTime<Utc>`]) works
#[pg_test]
fn chrono_simple_datetime_with_time_zone_conversion() -> DtcResult<()> {
let original = pgrx::TimestampWithTimeZone::with_timezone(1970, 1, 1, 1, 1, 1.0, "utc")?;
let d = chrono::DateTime::<Utc>::try_from(original)?;
assert_eq!(d.hour(), 1, "hours match");
assert_eq!(d.minute(), 1, "minutes match");
assert_eq!(d.second(), 1, "seconds match");
assert_eq!(d.nanosecond(), 0, "nanoseconds are zero (pg only supports microseconds)");
let backwards = pgrx::TimestampWithTimeZone::try_from(d)?;
assert_eq!(backwards, original);
Ok(())
}
}
2 changes: 2 additions & 0 deletions pgrx-tests/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ mod attributes_tests;
mod bgworker_tests;
mod bytea_tests;
mod cfg_tests;
#[cfg(feature = "chrono")]
mod chrono_tests;
mod composite_type_tests;
mod datetime_tests;
mod default_arg_value_tests;
Expand Down
4 changes: 3 additions & 1 deletion pgrx/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,16 @@ crate-type = [ "rlib" ]

[features]
default = [ "cshim" ]
chrono = [ "dep:chrono" ]
cshim = [ "pgrx-pg-sys/cshim" ]
pg12 = [ "pgrx-pg-sys/pg12" ]
pg13 = [ "pgrx-pg-sys/pg13" ]
pg14 = [ "pgrx-pg-sys/pg14" ]
pg15 = [ "pgrx-pg-sys/pg15" ]
pg16 = [ "pgrx-pg-sys/pg16" ]
nightly = [] # For features and functionality which require nightly Rust - for example, std::mem::allocator.
no-schema-generation = ["pgrx-macros/no-schema-generation", "pgrx-sql-entity-graph/no-schema-generation"]
unsafe-postgres = [] # when trying to compile against something that looks like Postgres but claims to be different
nightly = [] # For features and functionality which require nightly Rust - for example, std::mem::allocator.

[package.metadata.docs.rs]
features = ["pg14", "cshim"]
Expand All @@ -60,6 +61,7 @@ enum-map = "2.6.3"
atomic-traits = "0.3.0" # PgAtomic and shmem init
bitflags = "2.4.0" # BackgroundWorker
bitvec = "1.0" # processing array nullbitmaps
chrono = { workspace = true, optional = true } # Conversions to chrono date time types
heapless = "0.8" # shmem and PgLwLock
libc.workspace = true # FFI type compat
seahash = "4.1.0" # derive(PostgresHash)
Expand Down
194 changes: 194 additions & 0 deletions pgrx/src/datum/datetime_support/chrono.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//! This module contains implementations and functionality that enables [`pgrx`] types (ex. [`pgrx::datum::Date`])
//! to be converted to [`chrono`] data types (ex. [`chrono::Date`])
//!
//! Note that `chrono` has no reasonable analog for the `time with timezone` (i.e. [`pgrx::TimeWithTimeZone`]), so there are no added conversions for that type outside of the ones already implemented.
#![cfg(feature = "chrono")]

use core::convert::Infallible;
use core::num::TryFromIntError;
use std::convert::TryFrom;

use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Utc};

use crate::datum::datetime_support::DateTimeConversionError;
use crate::datum::{Date, Time, Timestamp, TimestampWithTimeZone};

/// Convenience type for [`Result`]s that fail with a [`DateTimeConversionError`]
type DtcResult<T> = Result<T, DateTimeConversionError>;

impl From<TryFromIntError> for DateTimeConversionError {
fn from(_tfie: TryFromIntError) -> Self {
DateTimeConversionError::FieldOverflow
}
}

impl From<Infallible> for DateTimeConversionError {
fn from(_i: Infallible) -> Self {
DateTimeConversionError::FieldOverflow
}
}

impl TryFrom<Date> for NaiveDate {
type Error = DateTimeConversionError;

fn try_from(d: Date) -> DtcResult<NaiveDate> {
NaiveDate::from_ymd_opt(d.year(), d.month().into(), d.day().into())
.ok_or_else(|| DateTimeConversionError::InvalidFormat)
}
}

impl TryFrom<NaiveDate> for Date {
type Error = DateTimeConversionError;

fn try_from(d: NaiveDate) -> DtcResult<Date> {
let month = u8::try_from(d.month())?;
let day = u8::try_from(d.day())?;
Date::new(d.year(), month, day)
}
}

/// Note: conversions from Postgres' `time` type [`pgrx::Time`] to [`chrono::NaiveTime`]
/// incur a loss of precision as Postgres only exposes microseconds.
impl TryFrom<Time> for NaiveTime {
type Error = DateTimeConversionError;

fn try_from(t: Time) -> DtcResult<NaiveTime> {
let (hour, minute, second, microseconds) = t.to_hms_micro();
let seconds_micro: u32 = Into::<u32>::into(second)
.checked_mul(1_000_000)
.ok_or(DateTimeConversionError::FieldOverflow)?;
NaiveTime::from_hms_micro_opt(
hour.into(),
minute.into(),
second.into(),
// Since pgrx counts the fractional seconds (between 1_000_000 and 2_000_000),
// the microseconds value will be 1_000_000 * seconds + fractional.
//
// - at 12:01:01 => hour=12, minute=1, second=2, microseconds=2_000_000
// - at 12:01:59 => hour=12, minute=1, second=59, microseconds=59_000_000
// - at 12:01:59 => hour=12, minute=1, second=59, microseconds=59_000_000
//
// Since chrono *does* support leap seconds (representing them as 59 seconds & >1_000_000 microseconds)
// we can strip the microseconds in that case to zero and pretend they're not there,
// since Postgres does not support leap seconds
if second == 59 && microseconds > 1_000_000 { 0 } else { microseconds - seconds_micro },
)
.ok_or(DateTimeConversionError::FieldOverflow)
}
}

impl TryFrom<NaiveTime> for Time {
type Error = DateTimeConversionError;

fn try_from(t: NaiveTime) -> DtcResult<Time> {
let hour = u8::try_from(t.hour())?;
let minute = u8::try_from(t.minute())?;
Time::new(hour, minute, convert_chrono_seconds_to_pgrx(t.second(), t.nanosecond())?)
}
}

/// Normally as seconds are represented by `f64` in pgrx, we must convert
fn convert_chrono_seconds_to_pgrx(seconds: u32, nanos: u32) -> DtcResult<f64> {
let second_whole =
f64::try_from(seconds).map_err(|_| DateTimeConversionError::FieldOverflow)?;
let second_nanos = f64::try_from(nanos).map_err(|_| DateTimeConversionError::FieldOverflow)?;
Ok(second_whole + (second_nanos / 1_000_000_000.0))
}

/// Utility function for easy `f64` to `u32` conversion
fn f64_to_u32(n: f64) -> DtcResult<u32> {
let truncated = n.trunc();
if truncated.is_nan()
|| truncated.is_infinite()
|| truncated < 0.0
|| truncated > u32::MAX.into()
{
return Err(DateTimeConversionError::FieldOverflow);
}

Ok(truncated as u32)
}

/// Seconds are represented by `f64` in pgrx, with a maximum of microsecond precision
fn convert_pgrx_seconds_to_chrono(orig: f64) -> DtcResult<(u32, u32, u32)> {
let seconds = f64_to_u32(orig)?;
let microseconds = f64_to_u32((orig * 1_000_000.0) % 1_000_000.0)?;
let nanoseconds = f64_to_u32((orig * 1_000_000_000.0) % 1_000_000_000.0)?;
Ok((seconds, microseconds, nanoseconds))
}

///////////////
// Timestamp //
///////////////

/// Since [`pgrx::Timestamp`]s are tied to the Postgres instance's timezone,
/// to figure out *which* timezone it's actually in, we convert to a [`pgrx::TimestampWithTimeZone`].
///
/// Once the offset is known, we can create and return a [`chrono::NaiveDateTime`]
/// with the appropriate offset
impl TryFrom<Timestamp> for NaiveDateTime {
type Error = DateTimeConversionError;

fn try_from(t: Timestamp) -> DtcResult<Self> {
let twtz: TimestampWithTimeZone = t.into();
let (seconds, _micros, _nanos) = convert_pgrx_seconds_to_chrono(twtz.second())?;
NaiveDate::from_ymd_opt(twtz.year(), twtz.month().into(), twtz.day().into())
.ok_or(DateTimeConversionError::FieldOverflow)?
.and_hms_opt(twtz.hour().into(), twtz.minute().into(), seconds)
.ok_or(DateTimeConversionError::FieldOverflow)
}
}

impl TryFrom<NaiveDateTime> for Timestamp {
type Error = DateTimeConversionError;

fn try_from(ndt: NaiveDateTime) -> DtcResult<Self> {
let utc = ndt.and_utc();
let seconds = convert_chrono_seconds_to_pgrx(utc.second(), utc.nanosecond())?;
let twtz = TimestampWithTimeZone::with_timezone(
utc.year(),
utc.month().try_into()?,
utc.day().try_into()?,
utc.hour().try_into()?,
utc.minute().try_into()?,
seconds,
"utc",
)?;
Ok(twtz.to_utc())
}
}

///////////////////////////
// TimestampWithTimeZone //
///////////////////////////

impl TryFrom<TimestampWithTimeZone> for DateTime<Utc> {
type Error = DateTimeConversionError;

fn try_from(twtz: TimestampWithTimeZone) -> DtcResult<Self> {
let twtz = twtz.to_utc();
let (seconds, _micros, _nanos) = convert_pgrx_seconds_to_chrono(twtz.second())?;
let datetime = NaiveDate::from_ymd_opt(twtz.year(), twtz.month().into(), twtz.day().into())
.ok_or(DateTimeConversionError::FieldOverflow)?
.and_hms_opt(twtz.hour().into(), twtz.minute().into(), seconds)
.ok_or(DateTimeConversionError::FieldOverflow)?;
Ok(Self::from_naive_utc_and_offset(datetime, chrono::offset::Utc))
}
}

impl TryFrom<DateTime<Utc>> for TimestampWithTimeZone {
type Error = DateTimeConversionError;

fn try_from(ndt: DateTime<Utc>) -> DtcResult<Self> {
let seconds = convert_chrono_seconds_to_pgrx(ndt.second(), ndt.nanosecond())?;
Ok(Self::with_timezone(
ndt.year(),
ndt.month().try_into()?,
ndt.day().try_into()?,
ndt.hour().try_into()?,
ndt.minute().try_into()?,
seconds,
"utc",
)?)
}
}
3 changes: 3 additions & 0 deletions pgrx/src/datum/datetime_support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ use std::marker::PhantomData;
mod ctor;
mod ops;

#[cfg(feature = "chrono")]
mod chrono;

pub use ctor::*;

/// Tags to identify which "part" of a date or time-type value to extract or truncate to
Expand Down

0 comments on commit ba845a4

Please sign in to comment.