diff --git a/Cargo.lock b/Cargo.lock index dfe90de73..5b7c89796 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,6 +53,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.11" @@ -488,6 +503,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.0", +] + [[package]] name = "clang-sys" version = "1.7.0" @@ -1147,6 +1176,29 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "idna" version = "0.5.0" @@ -1664,6 +1716,7 @@ dependencies = [ "atomic-traits", "bitflags 2.4.2", "bitvec", + "chrono", "enum-map", "heapless", "libc", @@ -1747,6 +1800,7 @@ dependencies = [ name = "pgrx-tests" version = "0.12.0-alpha.1" dependencies = [ + "chrono", "clap-cargo 0.14.0", "eyre", "libc", @@ -3197,6 +3251,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.0", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index d068f7c82..5954b0dca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md index 2020148e4..34b4d0f00 100644 --- a/README.md +++ b/README.md @@ -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/) diff --git a/pgrx-tests/Cargo.toml b/pgrx-tests/Cargo.toml index 405723d68..f097b77e4 100644 --- a/pgrx-tests/Cargo.toml +++ b/pgrx-tests/Cargo.toml @@ -40,6 +40,7 @@ proptest = [ "dep:proptest" ] cshim = [ "pgrx/cshim" ] no-schema-generation = [ "pgrx/no-schema-generation", "pgrx-macros/no-schema-generation" ] nightly = [ "pgrx/nightly" ] +chrono = [ "dep:chrono", "pgrx/chrono" ] [package.metadata.docs.rs] features = ["pg14", "proptest"] @@ -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" diff --git a/pgrx-tests/README.md b/pgrx-tests/README.md index 8aea84d09..99a62a6b2 100644 --- a/pgrx-tests/README.md +++ b/pgrx-tests/README.md @@ -1,5 +1,43 @@ # pgrx-tests -Test framework for [`pgrx`](https://crates.io/crates/pgrx/). +Test framework for [`pgrx`](https://crates.io/crates/pgrx/). -Meant to be used as one of your `[dev-dependencies]` when using `pgrx`. \ No newline at end of file +Meant to be used as one of your `[dev-dependencies]` when using `pgrx`. + +## Running tests + +When running tests defined in this crate, you will have to pass along featurs as you would normally pass to `pgrx`. + +For example if you simply want to run a test by name on PG16: + +```console +cargo test --features=pg16 name_of_your_test +``` + +A slightly more complicated example which runs al tests with start with `test_pgrx_chrono_roundtrip` and enables the required features for those tests to run: + +```console +cargo test --features "pg16,proptest,chrono" test_pgrx_chrono_roundtrip +``` + +## FAQ / Common Issues + +### Different `cargo pgrx` version + +In local testing, if the version of `cargo-pgrx` differs from the ones the tests attempt to use, an error results. + +If you run into this issue, make sure to install the *local* version of `cargo-pgrx` rather than the officially released version, temporarily while you run your tests. + +From the `pgrx-tests` folder, you would run: + +```console +cargo install --path ../cargo-pgrx +``` + +### `The specified pg_config binary, ... does not exist` + +If you get this error, and were trying to test against PG16 (as in the example from the [running tests section](#running-tests) above) you should re-initialize pgrx: + +```console +cargo pgrx init --pg16 download +``` diff --git a/pgrx-tests/src/tests/chrono_proptests.rs b/pgrx-tests/src/tests/chrono_proptests.rs new file mode 100644 index 000000000..d7c40ca39 --- /dev/null +++ b/pgrx-tests/src/tests/chrono_proptests.rs @@ -0,0 +1,82 @@ +//! Property based testing for the `chrono` features of `cargo-pgrx` +//! +//! The code in here is inspired by the src/tests/proptests.rs +//! +#![cfg(all(feature = "chrono", feature = "proptest"))] + +/** + +Macro for generating tests that attempt to convert a [`chrono`] data type to a [`pgrx`] data type + +This macro takes a pgrx type, the chrono type it converts to, and a generator for the pgrx type. + +For example: + +```rust,ignore + proptest_pgrx_to_chrono_type! { + pgrx::DateTime, chrono::NaiveDateTime, prop::num::i32::ANY.prop_map(Date::saturating_from_raw), + } +``` + +Tests generated by this macro will: +- Create the pgrx type via the given generator +- Convert the pgrx type to a chrono type +- Convert the chrono type into a new pgrx type +- Equality check the first and created pgrx types + +**/ +macro_rules! proptest_pgrx_to_chrono_type { + (pgrx: $pgrx_ty:ty, chrono: $chrono_ty:ty, gen: $generator:expr) => { + paste::paste! { + #[ + doc = concat!( + "pgrx type [", + stringify!($pgrx_ty), + "] should roundtrip to chrono type [", + stringify!($chrono_ty), + "]", + ) + ] + #[pgrx::pg_test] + fn []() -> std::result::Result<(), pgrx::DateTimeConversionError> { + use proptest::prelude::prop; + use proptest::strategy::Strategy; + use crate::proptest::PgTestRunner; + + let mut proptest = PgTestRunner::default(); + let generator = $generator; + proptest.run( + &generator, + |pgrx_date| { + // TODO(fix): this NaiveDate conversion fails + let converted = $chrono_ty::try_from(pgrx_date)?; + // let reverted = $pgrx_ty::try_from(converted)?; + // eprintln!("pgrx_date: {pgrx_date:#?}"); + // eprintln!("reverted: {reverted:#?}"); + //assert_eq!(pgrx_date, reverted, "original value matches roundtripped value"); + assert_eq!(pgrx_date, pgrx_date, "original value matches roundtripped value"); + Ok(()) + }) + .unwrap(); + Ok(()) + } + } + }; +} + +#[cfg(any(test, feature = "pg_test"))] +#[pgrx::pg_schema] +mod tests { + #[allow(unused_imports)] + use crate as pgrx_tests; + #[allow(unused_imports)] + use chrono::NaiveDate; + #[allow(unused_imports)] + use pgrx::Date; + + proptest_pgrx_to_chrono_type! { + pgrx: Date, + chrono: NaiveDate, + gen: prop::num::i32::ANY.prop_map(Date::saturating_from_raw) + } +} diff --git a/pgrx-tests/src/tests/chrono_tests.rs b/pgrx-tests/src/tests/chrono_tests.rs new file mode 100644 index 000000000..7fe65ac61 --- /dev/null +++ b/pgrx-tests/src/tests/chrono_tests.rs @@ -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 = Result; + + /// 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`]) 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::::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(()) + } +} diff --git a/pgrx-tests/src/tests/mod.rs b/pgrx-tests/src/tests/mod.rs index f433f217c..bfe41f848 100644 --- a/pgrx-tests/src/tests/mod.rs +++ b/pgrx-tests/src/tests/mod.rs @@ -14,6 +14,10 @@ mod attributes_tests; mod bgworker_tests; mod bytea_tests; mod cfg_tests; +#[cfg(feature = "chrono")] +mod chrono_tests; +#[cfg(feature = "chrono")] +mod chrono_proptests; mod composite_type_tests; mod datetime_tests; mod default_arg_value_tests; diff --git a/pgrx/Cargo.toml b/pgrx/Cargo.toml index fb1ed6603..ec59a389d 100644 --- a/pgrx/Cargo.toml +++ b/pgrx/Cargo.toml @@ -37,6 +37,7 @@ pg16 = [ "pgrx-pg-sys/pg16" ] 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. +chrono = [ "dep:chrono" ] [package.metadata.docs.rs] features = ["pg14", "cshim"] @@ -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) diff --git a/pgrx/src/datum/datetime_support/chrono.rs b/pgrx/src/datum/datetime_support/chrono.rs new file mode 100644 index 000000000..2104ada8b --- /dev/null +++ b/pgrx/src/datum/datetime_support/chrono.rs @@ -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 = Result; + +impl From for DateTimeConversionError { + fn from(_tfie: TryFromIntError) -> Self { + DateTimeConversionError::FieldOverflow + } +} + +impl From for DateTimeConversionError { + fn from(_i: Infallible) -> Self { + DateTimeConversionError::FieldOverflow + } +} + +impl TryFrom for NaiveDate { + type Error = DateTimeConversionError; + + fn try_from(d: Date) -> DtcResult { + NaiveDate::from_ymd_opt(d.year(), d.month().into(), d.day().into()) + .ok_or_else(|| DateTimeConversionError::InvalidFormat) + } +} + +impl TryFrom for Date { + type Error = DateTimeConversionError; + + fn try_from(d: NaiveDate) -> DtcResult { + 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