diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34088979..b2f513e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,6 +124,24 @@ jobs: # - name: Run integration tests # run: cargo test --test integration + # This job tests that examples outside the workspace build. + # + # These are outside the workspace because their dependency graphs are + # absolutely ludicrous. + examples: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + - name: Build sqlx-postgres example + run: cargo build --manifest-path ./examples/sqlx-postgres/Cargo.toml + - name: Run sqlx-sqlite example + run: cargo run --manifest-path ./examples/sqlx-sqlite/Cargo.toml + # Generic testing for most cross targets. Some get special treatment in # other jobs. cross: diff --git a/.vim/coc-settings.json b/.vim/coc-settings.json index 97042fb0..21b7e80b 100644 --- a/.vim/coc-settings.json +++ b/.vim/coc-settings.json @@ -1,6 +1,8 @@ { "rust-analyzer.linkedProjects": [ "bench/Cargo.toml", + "examples/sqlx-postgres/Cargo.toml", + "examples/sqlx-sqlite/Cargo.toml", "Cargo.toml" ] } diff --git a/Cargo.toml b/Cargo.toml index 6ba2e94c..894bea38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,10 +32,12 @@ include = [ members = [ "crates/jiff-cli", "crates/jiff-icu", + "crates/jiff-sqlx", "crates/jiff-tzdb", "crates/jiff-tzdb-platform", "examples/*", ] +exclude = ["examples/sqlx-postgres", "examples/sqlx-sqlite"] # Features are documented in the "Crate features" section of the crate docs: # https://docs.rs/jiff/*/#crate-features diff --git a/crates/jiff-icu/Cargo.toml b/crates/jiff-icu/Cargo.toml index a236f32d..26edf5b6 100644 --- a/crates/jiff-icu/Cargo.toml +++ b/crates/jiff-icu/Cargo.toml @@ -6,9 +6,9 @@ license = "Unlicense OR MIT" homepage = "https://github.com/BurntSushi/jiff/tree/master/crates/jiff-icu" repository = "https://github.com/BurntSushi/jiff" documentation = "https://docs.rs/jiff-icu" -description = "The entire Time Zone Database embedded into your binary." +description = "Conversion routines for Jiff and ICU4X." categories = ["date-and-time"] -keywords = ["date", "time", "temporal", "zone", "iana"] +keywords = ["date", "time", "temporal", "zone", "icu"] workspace = "../.." edition = "2021" rust-version = "1.70" diff --git a/crates/jiff-sqlx/COPYING b/crates/jiff-sqlx/COPYING new file mode 100644 index 00000000..bb9c20a0 --- /dev/null +++ b/crates/jiff-sqlx/COPYING @@ -0,0 +1,3 @@ +This project is dual-licensed under the Unlicense and MIT licenses. + +You may use this code under the terms of either license. diff --git a/crates/jiff-sqlx/Cargo.toml b/crates/jiff-sqlx/Cargo.toml new file mode 100644 index 00000000..23b995cd --- /dev/null +++ b/crates/jiff-sqlx/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "jiff-sqlx" +version = "0.0.1" #:version +authors = ["Andrew Gallant "] +license = "Unlicense OR MIT" +homepage = "https://github.com/BurntSushi/jiff/tree/master/crates/jiff-sqlx" +repository = "https://github.com/BurntSushi/jiff" +documentation = "https://docs.rs/jiff-sqlx" +description = "Integration for Jiff in sqlx." +categories = ["date-and-time"] +keywords = ["date", "time", "jiff", "sqlx", "zone"] +workspace = "../.." +edition = "2021" +rust-version = "1.70" +include = ["/*.rs", "/*.dat", "COPYING", "LICENSE-MIT", "UNLICENSE"] + +[lib] +name = "jiff_sqlx" +bench = false +path = "src/lib.rs" + +[features] +default = [] +postgres = ["dep:sqlx-postgres"] +sqlite = ["dep:sqlx-sqlite"] + +[dependencies] +jiff = { path = "../..", default-features = false } +sqlx-core = { version = "0.8.0", default-features = false } +sqlx-postgres = { version = "0.8.0", default-features = false, optional = true } +sqlx-sqlite = { version = "0.8.0", default-features = false, optional = true } + +[dev-dependencies] +jiff = { path = "../..", default-features = true } diff --git a/crates/jiff-sqlx/LICENSE-MIT b/crates/jiff-sqlx/LICENSE-MIT new file mode 100644 index 00000000..3b0a5dc0 --- /dev/null +++ b/crates/jiff-sqlx/LICENSE-MIT @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/crates/jiff-sqlx/README.md b/crates/jiff-sqlx/README.md new file mode 100644 index 00000000..20dd0fad --- /dev/null +++ b/crates/jiff-sqlx/README.md @@ -0,0 +1,7 @@ +jiff-icu +======== +WIP + +### Documentation + +https://docs.rs/jiff-icu diff --git a/crates/jiff-sqlx/UNLICENSE b/crates/jiff-sqlx/UNLICENSE new file mode 100644 index 00000000..68a49daa --- /dev/null +++ b/crates/jiff-sqlx/UNLICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/crates/jiff-sqlx/src/lib.rs b/crates/jiff-sqlx/src/lib.rs new file mode 100644 index 00000000..9145041d --- /dev/null +++ b/crates/jiff-sqlx/src/lib.rs @@ -0,0 +1,73 @@ +/*! +This crate provides integration points for [Jiff](jiff) and [SQLx][sqlx]. + +Examples can be found in the +[examples directory of the Jiff repository][examples]. + +# Organization + +This crates defines several types that wrap corresponding types in Jiff. Each +wrapper type provides implementations of traits found in SQLx. For most types, +these are the [`sqlx_core::types::Type`], [`sqlx_core::decode::Decode`] and +[`sqlx_core::encode::Encode`] traits. + +The intended workflow is to use these wrapper types within your wire types for +encoding and decoding data from databases such as PostgreSQL. The wrapper types +own the logic for encoding and decoding the data in database specific formats. + +In order to the minimize the annoyance of wrapper types, the following +conveniences are afforded: + +* A [`ToSqlx`] trait is provided. Several Jiff types implement this trait. The +trait provides easy conversion to the corresponding wrapper type in this crate. +* A concrete `to_jiff` method is provided on each wrapper type. For example, +[`Timestamp::to_jiff`]. This method is the reverse of `ToSqlx`. This converts +from the wrapper type to the corresponding Jiff type. +* There are `From` trait implementations from the wrapper type to the +corresponding Jiff type, and vice versa. + +# Database support + +At present, both PostgreSQL and SQLite are supported. + +Ideally, MySQL support would be present too, but it +[appears impossible until SQLx exposes some APIs][sqlx-mysql-bunk]. + +# Future + +This crate exists because there are generally only three ways to implement +the necessary traits in SQLx: + +1. Make Jiff depend on SQLx, and implement the corresponding traits where +Jiff's types are defined. +2. Make SQLx depend on Jiff, and implement the corresponding traits where the +traits are defined. +3. Make a crate like this one with types that wrap Jiff's types, and implements +the corresponding traits for the wrapper types. + +This was done because it seems inappropriate for a "lower level" crate like +Jiff to depend on SQLx. And while it might be appropriate for SQLx to optionally +depend on Jiff (like it does for [`chrono`] or [`time`]), at time of writing, +Jiff is still early in its life. It's totally reasonable to wait for it to +mature. Plus, the thought of three different datetime integrations is, +admittedly, tough to stomach. + +In the future, it may be prudent for this crate to be upstreamed into SQLx +itself. + +[sqlx]: https://docs.rs/sqlx/0.8 +[examples]: https://github.com/BurntSushi/jiff/tree/master/examples/uptime +[`chrono`]: https://docs.rs/chrono +[`time`]: https://docs.rs/time +[sqlx-mysql-bunk]: https://github.com/launchbadge/sqlx/issues/3487#issuecomment-2641843693 +*/ + +#![deny(missing_docs)] + +pub use self::wrappers::{Date, DateTime, Span, Time, Timestamp, ToSqlx}; + +#[cfg(feature = "postgres")] +mod postgres; +#[cfg(feature = "sqlite")] +mod sqlite; +mod wrappers; diff --git a/crates/jiff-sqlx/src/postgres.rs b/crates/jiff-sqlx/src/postgres.rs new file mode 100644 index 00000000..edcb179c --- /dev/null +++ b/crates/jiff-sqlx/src/postgres.rs @@ -0,0 +1,290 @@ +use jiff::{civil, tz}; +use sqlx_core::{ + decode::Decode, + encode::{Encode, IsNull}, + error::BoxDynError, + types::Type, +}; +use sqlx_postgres::{ + types::{Oid, PgInterval}, + PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueFormat, PgValueRef, + Postgres, +}; + +use crate::{Date, DateTime, Span, Time, Timestamp, ToSqlx}; + +/// Apprently the actual format of values on the wire is not +/// a documented guarantee of PostgreSQL.[1] Instead, I just `sqlx`'s +/// source code for `chrono` to figure out what the type of the source +/// data is. +/// +/// [1]: https://www.postgresql.org/docs/current/protocol-overview.html#PROTOCOL-FORMAT-CODES +static POSTGRES_EPOCH_DATE: civil::Date = civil::date(2000, 1, 1); +static POSTGRES_EPOCH_DATETIME: civil::DateTime = + civil::date(2000, 1, 1).at(0, 0, 0, 0); +static POSTGRES_EPOCH_TIMESTAMP: i64 = 946684800; +static MIDNIGHT: civil::Time = civil::Time::midnight(); +static UTC: tz::TimeZone = tz::TimeZone::UTC; + +// We currently don't support `Zoned` integration in this wrapper crate. +// See comments in `src/wrappers.rs`. +// +// Ref: https://github.com/launchbadge/sqlx/issues/3487#issuecomment-2636542379 +/* +impl Type for Zoned { + fn type_info() -> PgTypeInfo { + // https://github.com/launchbadge/sqlx/blob/65229f7ff91ecd38be7c10fb61ff3e05bedabe87/sqlx-postgres/src/type_info.rs#L473 + PgTypeInfo::with_oid(Oid(25)) + } +} + +impl PgHasArrayType for Zoned { + fn array_type_info() -> PgTypeInfo { + // https://github.com/launchbadge/sqlx/blob/65229f7ff91ecd38be7c10fb61ff3e05bedabe87/sqlx-postgres/src/type_info.rs#L503 + PgTypeInfo::with_oid(Oid(1009)) + } +} + +impl Encode<'_, Postgres> for Zoned { + fn encode_by_ref( + &self, + buf: &mut PgArgumentBuffer, + ) -> Result { + // There's no PostgreSQL data type for storing timestamps with time + // zones (despite the existence of a `TIMESTAMP WITH TIME ZONE` type, + // which is in fact just a timestamp), so we just use strings and + // RFC 9557 timestamps. + Encode::::encode(self.to_jiff().to_string(), buf) + } +} + +impl<'r> Decode<'r, Postgres> for Zoned { + fn decode(value: PgValueRef<'r>) -> Result { + Ok(value.as_str()?.parse::()?.to_sqlx()) + } +} +*/ + +impl Type for Timestamp { + fn type_info() -> PgTypeInfo { + // https://github.com/launchbadge/sqlx/blob/65229f7ff91ecd38be7c10fb61ff3e05bedabe87/sqlx-postgres/src/type_info.rs#L525 + PgTypeInfo::with_oid(Oid(1184)) + } +} + +impl PgHasArrayType for Timestamp { + fn array_type_info() -> PgTypeInfo { + // https://github.com/launchbadge/sqlx/blob/65229f7ff91ecd38be7c10fb61ff3e05bedabe87/sqlx-postgres/src/type_info.rs#L526 + PgTypeInfo::with_oid(Oid(1185)) + } +} + +impl Encode<'_, Postgres> for Timestamp { + fn encode_by_ref( + &self, + buf: &mut PgArgumentBuffer, + ) -> Result { + // I guess the encoding here, based on sqlx, is the same as civil time. + // But the assumption is that the civil time is in UTC. + let dt = UTC.to_datetime(self.to_jiff()).to_sqlx(); + Encode::::encode(dt, buf) + } +} + +impl<'r> Decode<'r, Postgres> for Timestamp { + fn decode(value: PgValueRef<'r>) -> Result { + match value.format() { + PgValueFormat::Binary => { + // The encoding is the number of *microseconds* since + // POSTGRES_EPOCH_DATETIME. + let micros: i64 = Decode::::decode(value)?; + let micros = jiff::SignedDuration::from_micros(micros); + // OK because the timestamp is known to be valid and in range. + let epoch = + jiff::Timestamp::from_second(POSTGRES_EPOCH_TIMESTAMP) + .unwrap(); + Ok(epoch.checked_add(micros)?.to_sqlx()) + } + PgValueFormat::Text => { + // The encoding is just ISO 8601 I think? Close to RFC 3339, + // but not quite I think. Either way, Jiff's default parser + // will handle it. + // + // This does swallow the offset (but respects it correctl so + // that the proper instant is parsed). If one needs the offset, + // we'll need to expose a new `TimestampWithOffset` wrapper + // type. Please file an issue. (But this seems fraught since + // it's only available in text mode I guess? WTF.) + Ok(value.as_str()?.parse::()?.to_sqlx()) + } + } + } +} + +impl Type for DateTime { + fn type_info() -> PgTypeInfo { + // https://github.com/launchbadge/sqlx/blob/65229f7ff91ecd38be7c10fb61ff3e05bedabe87/sqlx-postgres/src/type_info.rs#L521 + // Note that we use the oid for a type called "timestamp," even + // though this is clearly not a timestamp. It's a civil datetime. + // But that's PostgreSQL (or I guess just SQL) for you. + PgTypeInfo::with_oid(Oid(1114)) + } +} + +impl PgHasArrayType for DateTime { + fn array_type_info() -> PgTypeInfo { + // https://github.com/launchbadge/sqlx/blob/65229f7ff91ecd38be7c10fb61ff3e05bedabe87/sqlx-postgres/src/type_info.rs#L522 + PgTypeInfo::with_oid(Oid(1115)) + } +} + +impl Encode<'_, Postgres> for DateTime { + fn encode_by_ref( + &self, + buf: &mut PgArgumentBuffer, + ) -> Result { + // The encoding is the number of *microseconds* since + // POSTGRES_EPOCH_DATETIME. + let micros = + self.to_jiff().duration_since(POSTGRES_EPOCH_DATETIME).as_micros(); + // OK because the maximum duration between two Jiff civil datetimes + // is 631,107,417,599,999,999, which is less than i64::MAX. + let micros = i64::try_from(micros).unwrap(); + Encode::::encode(micros, buf) + } +} + +impl<'r> Decode<'r, Postgres> for DateTime { + fn decode(value: PgValueRef<'r>) -> Result { + match value.format() { + PgValueFormat::Binary => { + // The encoding is the number of *microseconds* since + // POSTGRES_EPOCH_DATETIME. + let micros: i64 = Decode::::decode(value)?; + let micros = jiff::SignedDuration::from_micros(micros); + Ok(POSTGRES_EPOCH_DATETIME.checked_add(micros)?.to_sqlx()) + } + PgValueFormat::Text => { + // The encoding is just ISO 8601 I think? + // The `chrono` implementation in `sqlx` does a dance with + // trying to parse offsets, but Jiff's `civil::DateTime` + // parser will handle that automatically. + Ok(value.as_str()?.parse::()?.to_sqlx()) + } + } + } +} + +impl Type for Date { + fn type_info() -> PgTypeInfo { + // https://github.com/launchbadge/sqlx/blob/65229f7ff91ecd38be7c10fb61ff3e05bedabe87/sqlx-postgres/src/type_info.rs#L519 + PgTypeInfo::with_oid(Oid(1082)) + } +} + +impl PgHasArrayType for Date { + fn array_type_info() -> PgTypeInfo { + // https://github.com/launchbadge/sqlx/blob/65229f7ff91ecd38be7c10fb61ff3e05bedabe87/sqlx-postgres/src/type_info.rs#L523 + PgTypeInfo::with_oid(Oid(1182)) + } +} + +impl Encode<'_, Postgres> for Date { + fn encode_by_ref( + &self, + buf: &mut PgArgumentBuffer, + ) -> Result { + // The encoding is the number of days since + // POSTGRES_EPOCH_DATE. + let days = (self.to_jiff() - POSTGRES_EPOCH_DATE).get_days(); + Encode::::encode(days, buf) + } +} + +impl<'r> Decode<'r, Postgres> for Date { + fn decode(value: PgValueRef<'r>) -> Result { + match value.format() { + PgValueFormat::Binary => { + // The encoding is the number of days since + // POSTGRES_EPOCH_DATE. + let days: i32 = Decode::::decode(value)?; + let span = jiff::Span::new().try_days(days)?; + Ok(POSTGRES_EPOCH_DATE.checked_add(span)?.to_sqlx()) + } + PgValueFormat::Text => { + // The encoding is just ISO 8601. + Ok(value.as_str()?.parse::()?.to_sqlx()) + } + } + } +} + +impl Type for Time { + fn type_info() -> PgTypeInfo { + // https://github.com/launchbadge/sqlx/blob/65229f7ff91ecd38be7c10fb61ff3e05bedabe87/sqlx-postgres/src/type_info.rs#L520 + PgTypeInfo::with_oid(Oid(1083)) + } +} + +impl PgHasArrayType for Time { + fn array_type_info() -> PgTypeInfo { + // https://github.com/launchbadge/sqlx/blob/65229f7ff91ecd38be7c10fb61ff3e05bedabe87/sqlx-postgres/src/type_info.rs#L524 + PgTypeInfo::with_oid(Oid(1183)) + } +} + +impl Encode<'_, Postgres> for Time { + fn encode_by_ref( + &self, + buf: &mut PgArgumentBuffer, + ) -> Result { + // The encoding is the number of *microseconds* since midnight. + let micros = self.to_jiff().duration_since(MIDNIGHT).as_micros(); + // OK since the max number of microseconds here is + // 86399999999, which always fits into an `i64`. + let micros = i64::try_from(micros).unwrap(); + Encode::::encode(micros, buf) + } +} + +impl<'r> Decode<'r, Postgres> for Time { + fn decode(value: PgValueRef<'r>) -> Result { + match value.format() { + PgValueFormat::Binary => { + // The encoding is the number of *microseconds* since midnight. + let micros: i64 = Decode::::decode(value)?; + let micros = jiff::SignedDuration::from_micros(micros); + Ok(MIDNIGHT.checked_add(micros)?.to_sqlx()) + } + PgValueFormat::Text => { + // The encoding is just ISO 8601. + Ok(value.as_str()?.parse::()?.to_sqlx()) + } + } + } +} + +impl Type for Span { + fn type_info() -> PgTypeInfo { + // https://github.com/launchbadge/sqlx/blob/65229f7ff91ecd38be7c10fb61ff3e05bedabe87/sqlx-postgres/src/type_info.rs#L527 + PgTypeInfo::with_oid(Oid(1186)) + } +} + +impl PgHasArrayType for Span { + fn array_type_info() -> PgTypeInfo { + // https://github.com/launchbadge/sqlx/blob/65229f7ff91ecd38be7c10fb61ff3e05bedabe87/sqlx-postgres/src/type_info.rs#L528 + PgTypeInfo::with_oid(Oid(1187)) + } +} + +impl<'r> Decode<'r, Postgres> for Span { + fn decode(value: PgValueRef<'r>) -> Result { + let interval: PgInterval = Decode::::decode(value)?; + let span = jiff::Span::new() + .try_months(interval.months)? + .try_days(interval.days)? + .try_microseconds(interval.microseconds)?; + Ok(span.to_sqlx()) + } +} diff --git a/crates/jiff-sqlx/src/sqlite.rs b/crates/jiff-sqlx/src/sqlite.rs new file mode 100644 index 00000000..dc498989 --- /dev/null +++ b/crates/jiff-sqlx/src/sqlite.rs @@ -0,0 +1,143 @@ +use jiff::fmt::temporal::DateTimeParser; +use sqlx_core::{ + decode::Decode, + encode::{Encode, IsNull}, + error::BoxDynError, + types::Type, +}; +use sqlx_sqlite::{ + Sqlite, SqliteArgumentValue, SqliteTypeInfo, SqliteValueRef, +}; + +use crate::{Date, DateTime, Time, Timestamp, ToSqlx}; + +static PARSER: DateTimeParser = DateTimeParser::new(); + +impl Type for Timestamp { + fn type_info() -> SqliteTypeInfo { + >::type_info() + } + + fn compatible(ty: &SqliteTypeInfo) -> bool { + >::compatible(ty) + || >::compatible(ty) + } +} + +impl Encode<'_, Sqlite> for Timestamp { + fn encode_by_ref( + &self, + buf: &mut Vec>, + ) -> Result { + Encode::::encode(self.to_jiff().to_string(), buf) + } +} + +impl<'r> Decode<'r, Sqlite> for Timestamp { + fn decode(value: SqliteValueRef<'r>) -> Result { + // We use a `&str` here because we might need to parse an `f64` from + // it. std doesn't support parsing `f64` from `&[u8]` AND it seems like + // we can pass `value` by reference or clone it, so we are limited + // to exactly one decode. WTF. + let text = <&str as Decode>::decode(value)?; + // If there's a `:` somewhere, then it must be a textual timestamp. + // Moreover, a textual timestamp requires that a `:` be present + // somewhere for Jiff to parse it. (SQLite might not strictly require + // this though...) + if text.contains(':') { + let date = PARSER.parse_timestamp(text)?; + return Ok(date.to_sqlx()); + } + let julian_days = text.parse::()?; + julian_days_to_timestamp(julian_days).map(jiff::Timestamp::to_sqlx) + } +} + +impl Type for DateTime { + fn type_info() -> SqliteTypeInfo { + >::type_info() + } +} + +impl Encode<'_, Sqlite> for DateTime { + fn encode_by_ref( + &self, + buf: &mut Vec>, + ) -> Result { + Encode::::encode(self.to_jiff().to_string(), buf) + } +} + +impl<'r> Decode<'r, Sqlite> for DateTime { + fn decode(value: SqliteValueRef<'r>) -> Result { + let text = <&[u8] as Decode>::decode(value)?; + let date = PARSER.parse_datetime(text)?; + Ok(date.to_sqlx()) + } +} + +impl Type for Date { + fn type_info() -> SqliteTypeInfo { + >::type_info() + } +} + +impl Encode<'_, Sqlite> for Date { + fn encode_by_ref( + &self, + buf: &mut Vec>, + ) -> Result { + Encode::::encode(self.to_jiff().to_string(), buf) + } +} + +impl<'r> Decode<'r, Sqlite> for Date { + fn decode(value: SqliteValueRef<'r>) -> Result { + let text = <&[u8] as Decode>::decode(value)?; + let date = PARSER.parse_date(text)?; + Ok(date.to_sqlx()) + } +} + +impl Type for Time { + fn type_info() -> SqliteTypeInfo { + >::type_info() + } +} + +impl Encode<'_, Sqlite> for Time { + fn encode_by_ref( + &self, + buf: &mut Vec>, + ) -> Result { + Encode::::encode(self.to_jiff().to_string(), buf) + } +} + +impl<'r> Decode<'r, Sqlite> for Time { + fn decode(value: SqliteValueRef<'r>) -> Result { + static PARSER: DateTimeParser = DateTimeParser::new(); + + let text = <&[u8] as Decode>::decode(value)?; + let date = PARSER.parse_time(text)?; + Ok(date.to_sqlx()) + } +} + +fn julian_days_to_timestamp( + days: f64, +) -> Result { + // The Unix epoch in terms of SQLite julian days: + // + // sqlite> select julianday('1970-01-01T00:00:00Z'); + // julianday('1970-01-01T00:00:00Z') + // --------------------------------- + // 2440587.5 + static UNIX_EPOCH_AS_JULIAN_DAYS: f64 = 2440587.5; + // SQLite assumes 24 hours in every day. + static SECONDS_PER_DAY: f64 = 86400.0; + + let timestamp = (days - UNIX_EPOCH_AS_JULIAN_DAYS) * SECONDS_PER_DAY; + let sdur = jiff::SignedDuration::try_from_secs_f64(timestamp)?; + Ok(jiff::Timestamp::from_duration(sdur)?) +} diff --git a/crates/jiff-sqlx/src/wrappers.rs b/crates/jiff-sqlx/src/wrappers.rs new file mode 100644 index 00000000..a8d9d455 --- /dev/null +++ b/crates/jiff-sqlx/src/wrappers.rs @@ -0,0 +1,267 @@ +/// A trait for convenient conversions from Jiff types to SQLx types. +/// +/// # Example +/// +/// This shows how to convert a [`jiff::Timestamp`] to a [`Timestamp`]: +/// +/// ``` +/// use jiff_sqlx::ToSqlx; +/// +/// let ts: jiff::Timestamp = "2025-02-20T17:00-05".parse()?; +/// let wrapper = ts.to_sqlx(); +/// assert_eq!(format!("{wrapper:?}"), "Timestamp(2025-02-20T22:00:00Z)"); +/// +/// # Ok::<(), Box>(()) +/// ``` +pub trait ToSqlx { + /// The wrapper type to convert to. + type Target; + + /// A conversion method that converts a Jiff type to a SQLx wrapper type. + fn to_sqlx(self) -> Self::Target; +} + +// We currently don't support `Zoned` integration in this wrapper crate. To +// briefly explain why, a `Zoned` is _both_ a timestamp and a time zone. And +// it isn't necessarily a dumb time zone like `-05:00`. It is intended to be a +// real time zone like `America/New_York` or `Australia/Tasmania`. +// +// However, PostgreSQL doesn't really have a primitive type that specifically +// supports "timestamp with time zone." Comically, it does have a `TIMESTAMP +// WITH TIME ZONE` type (from the SQL standard, as I understand it), but it's +// actually just a timestamp. It doesn't store any other data than a timestamp. +// The difference between `TIMESTAMP WITHOUT TIME ZONE` and `TIMESTAMP WITH +// TIME ZONE` is, principally, that fixed offsets are respected in the former +// but completely ignored in the latter. (PostgreSQL's documentation refers +// to fixed offsets as "time zones," which is rather antiquated IMO.) And, +// conventionally, `TIMESTAMP WITHOUT TIME ZONE` is civil (local) time. +// +// So what's the problem? Well, if we try to stuff a `Zoned` into a +// `TIMESTAMP WITH TIME ZONE`, then the *only* thing that actually +// gets stored is a timestamp. No time zone. No offset. Nothing. That +// means the time zone attached to `Zoned` gets dropped. So if you put +// `2025-02-15T17:00-05[America/New_york]` into the database, then you'll +// always get `2025-02-15T22:00+00[UTC]` out. That's a silent dropping of the +// time zone data. I personally think this would be extremely surprising, +// and it could lead to bugs if you assume the time zone is correctly +// round-tripped. (For example, DST safe arithmetic would no longer apply.) +// And indeed, this is a principle design goal of Jiff: `Zoned` values can be +// losslessly transmitted. +// +// An alternative here is to provide a `Zoned` wrapper, but store it in +// a `TEXT` field as an RFC 9557 timestamp. This would permit lossless +// round-tripping. The problem is that this may also violate user expectations +// because a datetime is not stored in a datetime field. Thus, you won't be +// able to use PostgreSQL native functionality to handle it as a date. +// +// So for now, I opted to take the conservative choice and just not provide a +// `Zoned` impl. This way, we can gather use cases and make a better informed +// decision of what to do. Plus, the maintainer of `sqlx` seemed unconvinced +// that a `Zoned` impl that drops time zone data was a problem, so out of +// deference there, I started with this more conservative strategy. +// +// Of course, if you want to store something in `TIMESTAMP WITHOUT TIME ZONE`, +// then you can just use `Timestamp`. +// +// Ref: https://github.com/launchbadge/sqlx/issues/3487#issuecomment-2636542379 +/* +#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] +pub struct Zoned(jiff::Zoned); + +impl Zoned { + pub fn to_jiff(&self) -> jiff::Zoned { + self.0.clone() + } +} + +impl<'a> ToSqlx for &'a jiff::Zoned { + type Target = Zoned; + + fn to_sqlx(self) -> Zoned { + Zoned(self.clone()) + } +} + +impl From for Zoned { + fn from(x: jiff::Zoned) -> Zoned { + Zoned(x) + } +} + +impl From for jiff::Zoned { + fn from(x: Zoned) -> jiff::Zoned { + x.0 + } +} + +impl core::ops::Deref for Zoned { + type Target = jiff::Zoned; + + fn deref(&self) -> &jiff::Zoned { + &self.0 + } +} +*/ + +/// A wrapper type for [`jiff::Timestamp`]. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] +pub struct Timestamp(jiff::Timestamp); + +impl Timestamp { + /// Converts this wrapper to a [`jiff::Timestamp`]. + pub fn to_jiff(self) -> jiff::Timestamp { + self.0 + } +} + +impl ToSqlx for jiff::Timestamp { + type Target = Timestamp; + + fn to_sqlx(self) -> Timestamp { + Timestamp(self) + } +} + +impl From for Timestamp { + fn from(x: jiff::Timestamp) -> Timestamp { + Timestamp(x) + } +} + +impl From for jiff::Timestamp { + fn from(x: Timestamp) -> jiff::Timestamp { + x.0 + } +} + +/// A wrapper type for [`jiff::civil::DateTime`]. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] +pub struct DateTime(jiff::civil::DateTime); + +impl DateTime { + /// Converts this wrapper to a [`jiff::civil::DateTime`]. + pub fn to_jiff(self) -> jiff::civil::DateTime { + self.0 + } +} + +impl ToSqlx for jiff::civil::DateTime { + type Target = DateTime; + + fn to_sqlx(self) -> DateTime { + DateTime(self) + } +} + +impl From for DateTime { + fn from(x: jiff::civil::DateTime) -> DateTime { + DateTime(x) + } +} + +impl From for jiff::civil::DateTime { + fn from(x: DateTime) -> jiff::civil::DateTime { + x.0 + } +} + +/// A wrapper type for [`jiff::civil::Date`]. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] +pub struct Date(jiff::civil::Date); + +impl Date { + /// Converts this wrapper to a [`jiff::civil::Date`]. + pub fn to_jiff(self) -> jiff::civil::Date { + self.0 + } +} + +impl ToSqlx for jiff::civil::Date { + type Target = Date; + + fn to_sqlx(self) -> Date { + Date(self) + } +} + +impl From for Date { + fn from(x: jiff::civil::Date) -> Date { + Date(x) + } +} + +impl From for jiff::civil::Date { + fn from(x: Date) -> jiff::civil::Date { + x.0 + } +} + +/// A wrapper type for [`jiff::civil::Time`]. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] +pub struct Time(jiff::civil::Time); + +impl Time { + /// Converts this wrapper to a [`jiff::civil::Time`]. + pub fn to_jiff(self) -> jiff::civil::Time { + self.0 + } +} + +impl ToSqlx for jiff::civil::Time { + type Target = Time; + + fn to_sqlx(self) -> Time { + Time(self) + } +} + +impl From for Time { + fn from(x: jiff::civil::Time) -> Time { + Time(x) + } +} + +impl From