diff --git a/Cargo.lock b/Cargo.lock index a480e267f..00adb0dd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,6 +508,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -978,6 +984,7 @@ dependencies = [ "indexmap", "insta", "is_terminal_polyfill", + "jiff", "lettre", "mime", "mime_guess", @@ -1360,6 +1367,37 @@ dependencies = [ "tokio", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2514,6 +2552,60 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -2896,7 +2988,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", "foreign-types", "libc", @@ -3182,6 +3274,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -3401,7 +3508,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -3531,7 +3638,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -3681,6 +3788,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31" dependencies = [ "chrono", + "jiff", + "time", ] [[package]] @@ -3689,6 +3798,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4eaa419cdb9157da1361186b1959983eb2ea0dcb9a3c69dc45c449ecb2af8fef" dependencies = [ + "jiff", "sea-query", "sqlx", ] @@ -3710,7 +3820,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "core-foundation", "core-foundation-sys", "libc", @@ -3997,6 +4107,7 @@ dependencies = [ "sha2 0.10.9", "smallvec", "thiserror 2.0.21", + "time", "tokio", "tokio-stream", "tracing", @@ -4047,7 +4158,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "bitflags", + "bitflags 2.13.2", "byteorder", "bytes", "chrono", @@ -4065,6 +4176,7 @@ dependencies = [ "sha2 0.11.0", "sqlx-core", "thiserror 2.0.21", + "time", "tracing", ] @@ -4076,7 +4188,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.13.2", "byteorder", "chrono", "crc", @@ -4100,6 +4212,7 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror 2.0.21", + "time", "tracing", "whoami", ] @@ -4125,6 +4238,7 @@ dependencies = [ "serde", "sqlx-core", "thiserror 2.0.21", + "time", "tracing", "url", ] @@ -4548,7 +4662,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytes", "futures-util", "http 1.5.0", diff --git a/Cargo.toml b/Cargo.toml index 67185f883..1cebb37b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,6 +114,7 @@ indexmap = "2" insta = { version = "1", features = ["filters"] } insta-cmd = "0.7" is_terminal_polyfill = "1.70" +jiff = { version = "0.2.37", default-features = false } lettre = { version = "0.11.23", default-features = false } libtest-mimic = "0.8" mime = "0.3" diff --git a/cot/Cargo.toml b/cot/Cargo.toml index c8a4a944f..0af345f2a 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -43,6 +43,7 @@ humantime.workspace = true idna = { workspace = true, optional = true } indexmap.workspace = true is_terminal_polyfill.workspace = true +jiff = { workspace = true, features = ["default", "serde"] } lettre = { workspace = true, features = ["builder", "sendmail-transport", "smtp-transport", "tokio1", "tokio1-rustls", "ring", "rustls-platform-verifier"], optional = true } mime.workspace = true mime_guess.workspace = true @@ -52,11 +53,11 @@ securer-string.workspace = true pin-project-lite.workspace = true redis = { workspace = true, features = ["aio", "tokio-comp"], optional = true } schemars = { workspace = true, optional = true, features = ["derive"] } -sea-query = { workspace = true, optional = true } -sea-query-sqlx = { workspace = true, features = ["with-chrono"], optional = true } +sea-query = { workspace = true, features = ["with-jiff"], optional = true } +sea-query-sqlx = { workspace = true, features = ["with-chrono", "with-jiff", "with-time", "unimplemented-jiff-sqlx"], optional = true } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true -sqlx = { workspace = true, features = ["runtime-tokio", "chrono"], optional = true } +sqlx = { workspace = true, features = ["runtime-tokio", "chrono", "time"], optional = true } subtle = { workspace = true, features = ["std"] } swagger-ui-redist = { workspace = true, optional = true } thiserror.workspace = true diff --git a/cot/src/common_types.rs b/cot/src/common_types.rs index 0f7980e67..91f4f565b 100644 --- a/cot/src/common_types.rs +++ b/cot/src/common_types.rs @@ -16,6 +16,7 @@ use cot::db::impl_postgres::PostgresValueRef; use cot::db::impl_sqlite::SqliteValueRef; use cot::form::FormFieldValidationError; use email_address::EmailAddress; +use jiff::civil::Weekday; use securer_string::SecureString; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -813,6 +814,262 @@ impl Display for Email { #[cfg(feature = "db")] impl TextField for Email {} +/// A set of weekdays. +/// +/// This is useful, for instance, for storing a weekly schedule in the +/// database. The set is stored as a single integer, where each bit represents +/// one weekday. +/// +/// # Examples +/// +/// ``` +/// use cot::common_types::WeekdaySet; +/// use jiff::civil::Weekday; +/// +/// let mut set = WeekdaySet::EMPTY; +/// set.insert(Weekday::Monday); +/// set.insert(Weekday::Friday); +/// +/// assert!(set.contains(Weekday::Monday)); +/// assert!(!set.contains(Weekday::Tuesday)); +/// assert_eq!( +/// set.iter().collect::>(), +/// [Weekday::Monday, Weekday::Friday] +/// ); +/// ``` +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct WeekdaySet(u8); + +impl WeekdaySet { + /// An empty set. + pub const EMPTY: Self = Self(0); + + /// A set containing all the weekdays. + pub const ALL: Self = Self(0b111_1111); + + /// Creates a set containing a single weekday. + /// + /// # Examples + /// + /// ``` + /// use cot::common_types::WeekdaySet; + /// use jiff::civil::Weekday; + /// + /// let set = WeekdaySet::single(Weekday::Sunday); + /// assert_eq!(set.len(), 1); + /// assert!(set.contains(Weekday::Sunday)); + /// ``` + #[must_use] + pub fn single(weekday: Weekday) -> Self { + Self(1 << weekday.to_monday_zero_offset()) + } + + /// Returns `true` if the set contains the given weekday. + /// + /// # Examples + /// + /// ``` + /// use cot::common_types::WeekdaySet; + /// use jiff::civil::Weekday; + /// + /// assert!(WeekdaySet::ALL.contains(Weekday::Monday)); + /// assert!(!WeekdaySet::EMPTY.contains(Weekday::Monday)); + /// ``` + #[must_use] + pub fn contains(self, weekday: Weekday) -> bool { + self.0 & Self::single(weekday).0 != 0 + } + + /// Adds a weekday to the set. + /// + /// Returns `true` if the weekday was not already in the set. + /// + /// # Examples + /// + /// ``` + /// use cot::common_types::WeekdaySet; + /// use jiff::civil::Weekday; + /// + /// let mut set = WeekdaySet::EMPTY; + /// assert!(set.insert(Weekday::Monday)); + /// assert!(!set.insert(Weekday::Monday)); + /// ``` + pub fn insert(&mut self, weekday: Weekday) -> bool { + let was_missing = !self.contains(weekday); + self.0 |= Self::single(weekday).0; + was_missing + } + + /// Removes a weekday from the set. + /// + /// Returns `true` if the weekday was in the set. + /// + /// # Examples + /// + /// ``` + /// use cot::common_types::WeekdaySet; + /// use jiff::civil::Weekday; + /// + /// let mut set = WeekdaySet::ALL; + /// assert!(set.remove(Weekday::Monday)); + /// assert!(!set.remove(Weekday::Monday)); + /// ``` + pub fn remove(&mut self, weekday: Weekday) -> bool { + let was_present = self.contains(weekday); + self.0 &= !Self::single(weekday).0; + was_present + } + + /// Returns `true` if the set contains no weekdays. + /// + /// # Examples + /// + /// ``` + /// use cot::common_types::WeekdaySet; + /// + /// assert!(WeekdaySet::EMPTY.is_empty()); + /// assert!(!WeekdaySet::ALL.is_empty()); + /// ``` + #[must_use] + pub const fn is_empty(self) -> bool { + self.0 == 0 + } + + /// Returns the number of weekdays in the set. + /// + /// # Examples + /// + /// ``` + /// use cot::common_types::WeekdaySet; + /// + /// assert_eq!(WeekdaySet::EMPTY.len(), 0); + /// assert_eq!(WeekdaySet::ALL.len(), 7); + /// ``` + #[must_use] + pub const fn len(self) -> u32 { + self.0.count_ones() + } + + /// Returns an iterator over the weekdays in the set, starting from Monday. + /// + /// # Examples + /// + /// ``` + /// use cot::common_types::WeekdaySet; + /// use jiff::civil::Weekday; + /// + /// let set: WeekdaySet = [Weekday::Sunday, Weekday::Monday].into_iter().collect(); + /// assert_eq!( + /// set.iter().collect::>(), + /// [Weekday::Monday, Weekday::Sunday] + /// ); + /// ``` + pub fn iter(self) -> impl Iterator { + Weekday::Monday + .cycle_forward() + .take(7) + .filter(move |&weekday| self.contains(weekday)) + } + + #[cfg(feature = "db")] + const fn from_bits(bits: u8) -> Self { + Self(bits & Self::ALL.0) + } +} + +impl FromIterator for WeekdaySet { + fn from_iter>(iter: T) -> Self { + let mut set = Self::EMPTY; + for weekday in iter { + set.insert(weekday); + } + set + } +} + +#[cfg(feature = "fake")] +impl fake::Dummy for WeekdaySet { + fn dummy_with_rng(_: &fake::Faker, rng: &mut R) -> Self { + Self(rng.next_u32().to_le_bytes()[0] & Self::ALL.0) + } +} + +/// Weekday sets are stored as a bit mask, where the least significant bit +/// represents Monday. +#[cfg(feature = "db")] +impl DatabaseField for WeekdaySet { + const TYPE: ColumnType = ColumnType::TinyUnsignedInteger; +} + +#[cfg(feature = "db")] +impl ToDbValue for WeekdaySet { + fn to_db_value(&self) -> DbValue { + self.0.to_db_value() + } +} + +#[cfg(feature = "db")] +impl ToDbValue for Option { + fn to_db_value(&self) -> DbValue { + self.map(|set| set.0).to_db_value() + } +} + +#[cfg(feature = "db")] +impl FromDbValue for WeekdaySet { + #[cfg(feature = "sqlite")] + fn from_sqlite(value: SqliteValueRef<'_>) -> cot::db::Result + where + Self: Sized, + { + ::from_sqlite(value).map(Self::from_bits) + } + + #[cfg(feature = "postgres")] + fn from_postgres(value: PostgresValueRef<'_>) -> cot::db::Result + where + Self: Sized, + { + ::from_postgres(value).map(Self::from_bits) + } + + #[cfg(feature = "mysql")] + fn from_mysql(value: MySqlValueRef<'_>) -> cot::db::Result + where + Self: Sized, + { + ::from_mysql(value).map(Self::from_bits) + } +} + +#[cfg(feature = "db")] +impl FromDbValue for Option { + #[cfg(feature = "sqlite")] + fn from_sqlite(value: SqliteValueRef<'_>) -> cot::db::Result + where + Self: Sized, + { + as FromDbValue>::from_sqlite(value).map(|bits| bits.map(WeekdaySet::from_bits)) + } + + #[cfg(feature = "postgres")] + fn from_postgres(value: PostgresValueRef<'_>) -> cot::db::Result + where + Self: Sized, + { + as FromDbValue>::from_postgres(value) + .map(|bits| bits.map(WeekdaySet::from_bits)) + } + + #[cfg(feature = "mysql")] + fn from_mysql(value: MySqlValueRef<'_>) -> cot::db::Result + where + Self: Sized, + { + as FromDbValue>::from_mysql(value).map(|bits| bits.map(WeekdaySet::from_bits)) + } +} + #[cfg(test)] mod tests { use std::convert::TryFrom; @@ -820,6 +1077,85 @@ mod tests { use super::*; use crate::Template; + #[test] + fn weekday_set_empty() { + assert!(WeekdaySet::EMPTY.is_empty()); + assert_eq!(WeekdaySet::EMPTY.len(), 0); + assert_eq!(WeekdaySet::EMPTY.iter().count(), 0); + assert_eq!(WeekdaySet::default(), WeekdaySet::EMPTY); + } + + #[test] + fn weekday_set_single() { + let monday = WeekdaySet::single(Weekday::Monday); + assert_eq!(monday.0, 0b000_0001); + assert!(monday.contains(Weekday::Monday)); + assert!(!monday.contains(Weekday::Tuesday)); + + let sunday = WeekdaySet::single(Weekday::Sunday); + assert_eq!(sunday.0, 0b100_0000); + } + + #[test] + fn weekday_set_insert_remove() { + let mut set = WeekdaySet::EMPTY; + + assert!(set.insert(Weekday::Monday)); + assert!(!set.insert(Weekday::Monday)); + assert!(set.insert(Weekday::Friday)); + assert_eq!(set.len(), 2); + + assert!(set.remove(Weekday::Monday)); + assert!(!set.remove(Weekday::Monday)); + assert_eq!(set, WeekdaySet::single(Weekday::Friday)); + } + + #[test] + fn weekday_set_iter() { + let set: WeekdaySet = [Weekday::Sunday, Weekday::Wednesday, Weekday::Monday] + .into_iter() + .collect(); + + assert_eq!( + set.iter().collect::>(), + [Weekday::Monday, Weekday::Wednesday, Weekday::Sunday] + ); + assert_eq!(WeekdaySet::ALL.iter().count(), 7); + } + + #[cfg(feature = "db")] + #[test] + fn weekday_set_from_bits() { + assert_eq!(WeekdaySet::from_bits(0b101_0101).0, 0b101_0101); + // the most significant bit is ignored + assert_eq!(WeekdaySet::from_bits(0b1111_1111), WeekdaySet::ALL); + } + + #[cfg(feature = "db")] + #[test] + fn weekday_set_to_db_value() { + let set: WeekdaySet = [Weekday::Monday, Weekday::Friday].into_iter().collect(); + + assert_eq!(set.to_db_value(), DbValue::TinyUnsigned(Some(0b001_0001))); + assert_eq!( + Some(set).to_db_value(), + DbValue::TinyUnsigned(Some(0b001_0001)) + ); + assert_eq!( + None::.to_db_value(), + DbValue::TinyUnsigned(None) + ); + } + + #[cfg(feature = "db")] + #[test] + fn weekday_set_column_type() { + assert_eq!( + ::TYPE, + ColumnType::TinyUnsignedInteger + ); + } + #[test] fn url_new() { let parse_url = Url::new("https://example.com/").unwrap(); diff --git a/cot/src/db/fields.rs b/cot/src/db/fields.rs index bbbde19ac..9762ea0af 100644 --- a/cot/src/db/fields.rs +++ b/cot/src/db/fields.rs @@ -15,6 +15,12 @@ use crate::db::{ mod chrono_fields; mod chrono_wrapper; +mod jiff_fields; + +#[cfg(feature = "sqlite")] +pub(crate) use jiff_fields::jiff_to_sqlite_text_value; +#[cfg(any(feature = "postgres", feature = "mysql"))] +pub(crate) use jiff_fields::jiff_to_time_value; macro_rules! impl_from_sqlite_default { () => { @@ -241,7 +247,7 @@ impl_db_field!(chrono::NaiveDateTime, DateTime); impl_db_field!( chrono::WeekdaySet, TinyUnsignedInteger, - with chrono_wrapper::WeekdaySet + with crate::common_types::WeekdaySet ); impl_db_field!(String, Text); impl_db_field!(Vec, Blob); diff --git a/cot/src/db/fields/chrono_wrapper.rs b/cot/src/db/fields/chrono_wrapper.rs index 6b78a75a2..d15b9f3f2 100644 --- a/cot/src/db/fields/chrono_wrapper.rs +++ b/cot/src/db/fields/chrono_wrapper.rs @@ -1,5 +1,6 @@ use chrono::Weekday; +use crate::common_types::WeekdaySet; #[cfg(feature = "mysql")] use crate::db::impl_mysql::MySqlValueRef; #[cfg(feature = "postgres")] @@ -8,156 +9,35 @@ use crate::db::impl_postgres::PostgresValueRef; use crate::db::impl_sqlite::SqliteValueRef; use crate::db::{DatabaseError, DbValue, FromDbValue, SqlxValueRef, ToDbValue}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct WeekdaySet(u8); - -impl WeekdaySet { - pub const EMPTY: WeekdaySet = WeekdaySet(0); - pub(crate) const fn single(weekday: Weekday) -> Self { - match weekday { - Weekday::Mon => Self(0b000_0001), - Weekday::Tue => Self(0b000_0010), - Weekday::Wed => Self(0b000_0100), - Weekday::Thu => Self(0b000_1000), - Weekday::Fri => Self(0b001_0000), - Weekday::Sat => Self(0b010_0000), - Weekday::Sun => Self(0b100_0000), - } - } - - pub(crate) const fn contains(self, day: Weekday) -> bool { - self.0 & Self::single(day).0 != 0 - } - - pub(crate) fn insert(&mut self, day: Weekday) -> bool { - if self.contains(day) { - return false; - } - self.0 |= Self::single(day).0; - true - } +fn weekday_from_chrono(weekday: Weekday) -> jiff::civil::Weekday { + #[expect( + clippy::cast_possible_truncation, + reason = "the value is always in 0..7" + )] + jiff::civil::Weekday::from_monday_zero_offset(weekday.num_days_from_monday() as i8) + .expect("chrono::Weekday is always a valid weekday") +} - pub(crate) fn weekdays(self) -> Vec { - let mut weekdays = Vec::new(); - for weekday in [ - Weekday::Mon, - Weekday::Tue, - Weekday::Wed, - Weekday::Thu, - Weekday::Fri, - Weekday::Sat, - Weekday::Sun, - ] { - if self.contains(weekday) { - weekdays.push(weekday); - } - } - weekdays - } +fn weekday_to_chrono(weekday: jiff::civil::Weekday) -> Weekday { + Weekday::try_from(weekday.to_monday_zero_offset().cast_unsigned()) + .expect("jiff::civil::Weekday is always a valid weekday") } impl From for WeekdaySet { fn from(set: chrono::WeekdaySet) -> Self { - let mut new_set = WeekdaySet::EMPTY; - - for weekday in set.iter(Weekday::Mon) { - new_set.insert(weekday); - } - - new_set + set.iter(Weekday::Mon).map(weekday_from_chrono).collect() } } impl From for chrono::WeekdaySet { fn from(set: WeekdaySet) -> Self { let mut new_set = chrono::WeekdaySet::EMPTY; - - for weekday in set.weekdays() { - new_set.insert(weekday); + for weekday in set.iter() { + new_set.insert(weekday_to_chrono(weekday)); } - new_set } } -impl From for u8 { - fn from(set: WeekdaySet) -> Self { - set.0 - } -} - -impl From for WeekdaySet { - fn from(value: u8) -> Self { - WeekdaySet(value) - } -} - -impl FromDbValue for WeekdaySet { - #[cfg(feature = "sqlite")] - fn from_sqlite(value: SqliteValueRef<'_>) -> cot::db::Result - where - Self: Sized, - { - value.get::().map(WeekdaySet::from) - } - - #[cfg(feature = "postgres")] - fn from_postgres(value: PostgresValueRef<'_>) -> cot::db::Result - where - Self: Sized, - { - #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - value.get::().map(|v| WeekdaySet::from(v as u8)) - } - - #[cfg(feature = "mysql")] - fn from_mysql(value: MySqlValueRef<'_>) -> cot::db::Result - where - Self: Sized, - { - value.get::().map(WeekdaySet::from) - } -} - -impl FromDbValue for Option { - #[cfg(feature = "sqlite")] - fn from_sqlite(value: SqliteValueRef<'_>) -> cot::db::Result - where - Self: Sized, - { - value.get::>().map(|v| v.map(WeekdaySet::from)) - } - - #[cfg(feature = "postgres")] - fn from_postgres(value: PostgresValueRef<'_>) -> cot::db::Result - where - Self: Sized, - { - #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - value - .get::>() - .map(|v| v.map(|v| WeekdaySet::from(v as u8))) - } - - #[cfg(feature = "mysql")] - fn from_mysql(value: MySqlValueRef<'_>) -> cot::db::Result - where - Self: Sized, - { - value.get::>().map(|v| v.map(WeekdaySet::from)) - } -} - -impl ToDbValue for WeekdaySet { - fn to_db_value(&self) -> DbValue { - self.0.to_db_value() - } -} - -impl ToDbValue for Option { - fn to_db_value(&self) -> DbValue { - self.map(|val| val.0).to_db_value() - } -} impl FromDbValue for Weekday { #[cfg(feature = "sqlite")] @@ -200,139 +80,44 @@ impl ToDbValue for Weekday { #[cfg(test)] mod tests { use super::*; - use crate::db::DbValue; - - #[test] - fn weekday_set_empty() { - let set = WeekdaySet::EMPTY; - assert_eq!(set.0, 0); - assert_eq!(set.weekdays(), []); - } #[test] - fn weekday_set_single() { - let monday = WeekdaySet::single(Weekday::Mon); - assert_eq!(monday.0, 0b000_0001); - assert!(monday.contains(Weekday::Mon)); - assert!(!monday.contains(Weekday::Tue)); - - let friday = WeekdaySet::single(Weekday::Fri); - assert_eq!(friday.0, 0b001_0000); - assert!(friday.contains(Weekday::Fri)); - assert!(!friday.contains(Weekday::Mon)); - } - - #[test] - fn weekday_set_insert() { - let mut set = WeekdaySet::EMPTY; - - assert!(set.insert(Weekday::Mon)); - assert!(set.contains(Weekday::Mon)); - assert!(!set.insert(Weekday::Mon)); - - assert!(set.insert(Weekday::Fri)); - assert!(set.contains(Weekday::Fri)); - assert!(set.contains(Weekday::Mon)); - } - - #[test] - fn weekday_set_weekdays() { - let mut set = WeekdaySet::EMPTY; - set.insert(Weekday::Mon); - set.insert(Weekday::Wed); - set.insert(Weekday::Fri); - - let weekdays = set.weekdays(); - assert_eq!(weekdays, vec![Weekday::Mon, Weekday::Wed, Weekday::Fri]); + fn weekday_conversion() { + for (chrono_weekday, jiff_weekday) in [ + (Weekday::Mon, jiff::civil::Weekday::Monday), + (Weekday::Wed, jiff::civil::Weekday::Wednesday), + (Weekday::Sun, jiff::civil::Weekday::Sunday), + ] { + assert_eq!(weekday_from_chrono(chrono_weekday), jiff_weekday); + assert_eq!(weekday_to_chrono(jiff_weekday), chrono_weekday); + } } #[test] fn weekday_set_chrono_conversion() { let mut chrono_set = chrono::WeekdaySet::EMPTY; chrono_set.insert(Weekday::Mon); - chrono_set.insert(Weekday::Wed); - - // Test conversion from chrono to our type - let our_set = WeekdaySet::from(chrono_set); - assert!(our_set.contains(Weekday::Mon)); - assert!(our_set.contains(Weekday::Wed)); - assert!(!our_set.contains(Weekday::Tue)); + chrono_set.insert(Weekday::Sun); - // Test conversion back to chrono - let back_to_chrono = chrono::WeekdaySet::from(our_set); - assert!(back_to_chrono.contains(Weekday::Mon)); - assert!(back_to_chrono.contains(Weekday::Wed)); - assert!(!back_to_chrono.contains(Weekday::Tue)); + let set = WeekdaySet::from(chrono_set); + assert_eq!( + set.iter().collect::>(), + [jiff::civil::Weekday::Monday, jiff::civil::Weekday::Sunday] + ); - assert_eq!(chrono_set, back_to_chrono); + assert_eq!(chrono::WeekdaySet::from(set), chrono_set); } #[test] - fn weekday_set_from_u8() { - let set = WeekdaySet::from(0b101_0101); - assert!(set.contains(Weekday::Mon)); - assert!(!set.contains(Weekday::Tue)); - assert!(set.contains(Weekday::Wed)); - assert!(!set.contains(Weekday::Thu)); - assert!(set.contains(Weekday::Fri)); - assert!(!set.contains(Weekday::Sat)); - assert!(set.contains(Weekday::Sun)); - } - - #[test] - fn weekday_set_to_u8() { - let mut set = WeekdaySet::EMPTY; + fn chrono_weekday_set_to_db_value() { + let mut set = chrono::WeekdaySet::EMPTY; set.insert(Weekday::Mon); - set.insert(Weekday::Wed); set.insert(Weekday::Fri); - set.insert(Weekday::Sun); - - let value: u8 = set.into(); - assert_eq!(value, 0b101_0101); - } - - #[test] - fn weekday_set_all_weekdays() { - let all_weekdays = 0b111_1111u8; - let set = WeekdaySet::from(all_weekdays); - - for weekday in [ - Weekday::Mon, - Weekday::Tue, - Weekday::Wed, - Weekday::Thu, - Weekday::Fri, - Weekday::Sat, - Weekday::Sun, - ] { - assert!(set.contains(weekday)); - } - - assert_eq!(set.weekdays().len(), 7); - } - - #[test] - fn weekday_set_to_db_value() { - let mut set = WeekdaySet::EMPTY; - set.insert(Weekday::Mon); - set.insert(Weekday::Fri); - - let db_value = set.to_db_value(); - assert_eq!(db_value, DbValue::TinyUnsigned(Some(0b001_0001))); - } - - #[test] - fn weekday_set_option_to_db_value() { - let mut set = WeekdaySet::EMPTY; - set.insert(Weekday::Tue); - set.insert(Weekday::Thu); - - let some_set = Some(set); - let db_value = some_set.to_db_value(); - assert_eq!(db_value, DbValue::TinyUnsigned(Some(0b000_1010))); - let none_set: Option = None; - let db_value = none_set.to_db_value(); - assert_eq!(db_value, DbValue::TinyUnsigned(None)); + assert_eq!(set.to_db_value(), DbValue::TinyUnsigned(Some(0b001_0001))); + assert_eq!( + None::.to_db_value(), + DbValue::TinyUnsigned(None) + ); } } diff --git a/cot/src/db/fields/jiff_fields.rs b/cot/src/db/fields/jiff_fields.rs new file mode 100644 index 000000000..55dfc45be --- /dev/null +++ b/cot/src/db/fields/jiff_fields.rs @@ -0,0 +1,348 @@ +//! `DatabaseField` implementations for [`jiff`] types. +//! +//! `sqlx` doesn't support `jiff` types natively yet, so the values are passed +//! to the database as `time` types (PostgreSQL and MySQL) or as text (SQLite). +//! The conversion happens in the `prepare_values` function of each database +//! backend, right before the values are bound to the query. + +use jiff::civil; + +use crate::db::fields::impl_to_db_value_default; +#[cfg(feature = "mysql")] +use crate::db::impl_mysql::MySqlValueRef; +#[cfg(feature = "postgres")] +use crate::db::impl_postgres::PostgresValueRef; +#[cfg(feature = "sqlite")] +use crate::db::impl_sqlite::SqliteValueRef; +use crate::db::{ + ColumnType, DatabaseError, DatabaseField, DbValue, FromDbValue, Result, SqlxValueRef, ToDbValue, +}; +use crate::utils::jiff::{ + OutOfRangeError, date_from_time, datetime_from_time, time_from_time, timestamp_from_time, +}; + +fn map_out_of_range(result: std::result::Result) -> Result { + result.map_err(DatabaseError::value_decode) +} + +#[expect(clippy::unnecessary_wraps, reason = "to have a unified interface")] +fn civil_time_from_time(time: time::Time) -> std::result::Result { + Ok(time_from_time(time)) +} + +macro_rules! impl_jiff_db_field { + ($ty:ty, $time_ty:ty, $column_type:ident, $from_time:path) => { + impl DatabaseField for $ty { + const TYPE: ColumnType = ColumnType::$column_type; + } + + impl FromDbValue for $ty { + #[cfg(feature = "sqlite")] + fn from_sqlite(value: SqliteValueRef<'_>) -> Result { + map_out_of_range($from_time(value.get::<$time_ty>()?)) + } + + #[cfg(feature = "postgres")] + fn from_postgres(value: PostgresValueRef<'_>) -> Result { + map_out_of_range($from_time(value.get::<$time_ty>()?)) + } + + #[cfg(feature = "mysql")] + fn from_mysql(value: MySqlValueRef<'_>) -> Result { + map_out_of_range($from_time(value.get::<$time_ty>()?)) + } + } + + impl FromDbValue for Option<$ty> { + #[cfg(feature = "sqlite")] + fn from_sqlite(value: SqliteValueRef<'_>) -> Result { + map_out_of_range(value.get::>()?.map($from_time).transpose()) + } + + #[cfg(feature = "postgres")] + fn from_postgres(value: PostgresValueRef<'_>) -> Result { + map_out_of_range(value.get::>()?.map($from_time).transpose()) + } + + #[cfg(feature = "mysql")] + fn from_mysql(value: MySqlValueRef<'_>) -> Result { + map_out_of_range(value.get::>()?.map($from_time).transpose()) + } + } + + impl_to_db_value_default!($ty); + }; +} + +impl_jiff_db_field!( + jiff::Timestamp, + time::OffsetDateTime, + DateTimeWithTimeZone, + timestamp_from_time +); +impl_jiff_db_field!(civil::Date, time::Date, Date, date_from_time); +impl_jiff_db_field!(civil::Time, time::Time, Time, civil_time_from_time); +impl_jiff_db_field!( + civil::DateTime, + time::PrimitiveDateTime, + DateTime, + datetime_from_time +); + +fn weekday_from_db(value: u8) -> Result { + i8::try_from(value) + .ok() + .and_then(|value| civil::Weekday::from_monday_zero_offset(value).ok()) + .ok_or_else(|| DatabaseError::ValueDecode(format!("invalid weekday value: {value}").into())) +} + +impl FromDbValue for civil::Weekday { + #[cfg(feature = "sqlite")] + fn from_sqlite(value: SqliteValueRef<'_>) -> Result { + value.get::().and_then(weekday_from_db) + } + + #[cfg(feature = "postgres")] + fn from_postgres(value: PostgresValueRef<'_>) -> Result { + #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + value.get::().and_then(|v| weekday_from_db(v as u8)) + } + + #[cfg(feature = "mysql")] + fn from_mysql(value: MySqlValueRef<'_>) -> Result { + value.get::().and_then(weekday_from_db) + } +} + +impl ToDbValue for civil::Weekday { + fn to_db_value(&self) -> DbValue { + u32::from(self.to_monday_zero_offset().cast_unsigned()).into() + } +} + +/// Converts `jiff` values into `time` values that `sqlx` can bind. +#[cfg(any(feature = "postgres", feature = "mysql"))] +pub(crate) fn jiff_to_time_value(value: &mut DbValue) { + use crate::utils::jiff::{date_to_time, datetime_to_time, time_to_time, timestamp_to_time}; + + let converted = match value { + DbValue::JiffDate(date) => DbValue::TimeDate(date.map(date_to_time)), + DbValue::JiffTime(time) => DbValue::TimeTime(time.map(time_to_time)), + DbValue::JiffDateTime(datetime) => { + DbValue::TimeDateTime(datetime.as_deref().copied().map(datetime_to_time)) + } + DbValue::JiffTimestamp(timestamp) => { + DbValue::TimeDateTimeWithTimeZone(timestamp.as_deref().copied().map(timestamp_to_time)) + } + _ => return, + }; + *value = converted; +} + +/// Converts `jiff` values into text in the format used for storing date/time +/// values in SQLite. +/// +/// The format is the same as the one used by `sqlx` for `chrono` types, which +/// is what previous versions of Cot used. Since SQLite compares the values +/// as text, keeping the format the same ensures that the newly written values +/// can be compared with the existing ones. +#[cfg(feature = "sqlite")] +pub(crate) fn jiff_to_sqlite_text_value(value: &mut DbValue) { + let text = match value { + DbValue::JiffDate(date) => date.map(sqlite_date), + DbValue::JiffTime(time) => time.map(sqlite_time), + DbValue::JiffDateTime(datetime) => datetime.as_deref().copied().map(sqlite_datetime), + DbValue::JiffTimestamp(timestamp) => timestamp.as_deref().copied().map(sqlite_timestamp), + _ => return, + }; + *value = DbValue::String(text); +} + +/// `%F` in `chrono`'s format syntax. +#[cfg(feature = "sqlite")] +fn sqlite_date(date: civil::Date) -> String { + let (year, month, day) = (date.year(), date.month(), date.day()); + if year < 0 { + // the sign counts towards the width + format!("{year:05}-{month:02}-{day:02}") + } else { + format!("{year:04}-{month:02}-{day:02}") + } +} + +/// `%T%.f` in `chrono`'s format syntax. +#[cfg(feature = "sqlite")] +fn sqlite_time(time: civil::Time) -> String { + let nanos = time.subsec_nanosecond(); + let fraction = if nanos == 0 { + String::new() + } else if nanos % 1_000_000 == 0 { + format!(".{:03}", nanos / 1_000_000) + } else if nanos % 1_000 == 0 { + format!(".{:06}", nanos / 1_000) + } else { + format!(".{nanos:09}") + }; + + format!( + "{:02}:{:02}:{:02}{fraction}", + time.hour(), + time.minute(), + time.second() + ) +} + +/// `%F %T%.f` in `chrono`'s format syntax. +#[cfg(feature = "sqlite")] +fn sqlite_datetime(datetime: civil::DateTime) -> String { + format!( + "{} {}", + sqlite_date(datetime.date()), + sqlite_time(datetime.time()) + ) +} + +/// RFC 3339 with the UTC offset written as `+00:00` (as opposed to `Z`). +#[cfg(feature = "sqlite")] +fn sqlite_timestamp(timestamp: jiff::Timestamp) -> String { + let datetime = jiff::tz::Offset::UTC.to_datetime(timestamp); + format!( + "{}T{}+00:00", + sqlite_date(datetime.date()), + sqlite_time(datetime.time()) + ) +} + +#[cfg(test)] +mod tests { + use jiff::civil::{Weekday, date}; + + use super::*; + + #[test] + fn column_types() { + assert_eq!( + ::TYPE, + ColumnType::DateTimeWithTimeZone + ); + assert_eq!(::TYPE, ColumnType::Date); + assert_eq!(::TYPE, ColumnType::Time); + assert_eq!( + ::TYPE, + ColumnType::DateTime + ); + const { + assert!(!::NULLABLE); + assert!( as DatabaseField>::NULLABLE); + } + } + + #[test] + fn to_db_value() { + let timestamp: jiff::Timestamp = "2023-01-02T12:34:56Z".parse().unwrap(); + assert_eq!( + timestamp.to_db_value(), + DbValue::JiffTimestamp(Some(Box::new(timestamp))) + ); + assert_eq!( + None::.to_db_value(), + DbValue::JiffTimestamp(None) + ); + assert_eq!( + date(2023, 1, 2).to_db_value(), + DbValue::JiffDate(Some(date(2023, 1, 2))) + ); + } + + #[test] + fn weekday_to_db_value() { + assert_eq!(Weekday::Monday.to_db_value(), DbValue::Unsigned(Some(0))); + assert_eq!(Weekday::Sunday.to_db_value(), DbValue::Unsigned(Some(6))); + } + + #[test] + fn weekday_from_db_value() { + assert_eq!(weekday_from_db(0).unwrap(), Weekday::Monday); + assert_eq!(weekday_from_db(6).unwrap(), Weekday::Sunday); + assert!(weekday_from_db(7).is_err()); + assert!(weekday_from_db(u8::MAX).is_err()); + } + + #[cfg(any(feature = "postgres", feature = "mysql"))] + #[test] + fn jiff_to_time_value_converts() { + let mut value = date(2023, 1, 2).at(12, 34, 56, 0).to_db_value(); + jiff_to_time_value(&mut value); + assert_eq!( + value, + DbValue::TimeDateTime(Some(time::PrimitiveDateTime::new( + time::Date::from_calendar_date(2023, time::Month::January, 2).unwrap(), + time::Time::from_hms(12, 34, 56).unwrap(), + ))) + ); + + let mut value = None::.to_db_value(); + jiff_to_time_value(&mut value); + assert_eq!(value, DbValue::TimeDateTimeWithTimeZone(None)); + } + + #[cfg(any(feature = "postgres", feature = "mysql"))] + #[test] + fn jiff_to_time_value_ignores_other_values() { + let mut value = DbValue::Int(Some(42)); + jiff_to_time_value(&mut value); + assert_eq!(value, DbValue::Int(Some(42))); + } + + #[cfg(feature = "sqlite")] + fn sqlite_text(value: &impl ToDbValue) -> DbValue { + let mut value = value.to_db_value(); + jiff_to_sqlite_text_value(&mut value); + value + } + + #[cfg(feature = "sqlite")] + #[test] + fn jiff_to_sqlite_text_value_converts() { + let string = |s: &str| DbValue::String(Some(s.to_owned())); + + assert_eq!(sqlite_text(&date(2023, 1, 2)), string("2023-01-02")); + assert_eq!(sqlite_text(&date(-1, 1, 2)), string("-0001-01-02")); + assert_eq!(sqlite_text(&date(12, 1, 2)), string("0012-01-02")); + assert_eq!(sqlite_text(&civil::time(1, 2, 3, 0)), string("01:02:03")); + assert_eq!( + sqlite_text(&civil::time(1, 2, 3, 100_000_000)), + string("01:02:03.100") + ); + assert_eq!( + sqlite_text(&civil::time(1, 2, 3, 120_000)), + string("01:02:03.000120") + ); + assert_eq!( + sqlite_text(&civil::time(1, 2, 3, 1)), + string("01:02:03.000000001") + ); + assert_eq!( + sqlite_text(&date(2023, 1, 2).at(12, 34, 56, 789_000_000)), + string("2023-01-02 12:34:56.789") + ); + assert_eq!( + sqlite_text( + &"2023-01-02T12:34:56.123456+01:00" + .parse::() + .unwrap() + ), + string("2023-01-02T11:34:56.123456+00:00") + ); + assert_eq!(sqlite_text(&None::), DbValue::String(None)); + } + + #[cfg(feature = "sqlite")] + #[test] + fn jiff_to_sqlite_text_value_is_idempotent() { + let mut value = date(2023, 1, 2).to_db_value(); + jiff_to_sqlite_text_value(&mut value); + jiff_to_sqlite_text_value(&mut value); + assert_eq!(value, DbValue::String(Some("2023-01-02".to_owned()))); + } +} diff --git a/cot/src/db/impl_mysql.rs b/cot/src/db/impl_mysql.rs index 548dfd428..0f919a6af 100644 --- a/cot/src/db/impl_mysql.rs +++ b/cot/src/db/impl_mysql.rs @@ -17,8 +17,10 @@ impl DatabaseMySql { Ok(()) } - fn prepare_values(_values: &mut sea_query_sqlx::SqlxValues) { - // No changes are needed for MySQL + fn prepare_values(values: &mut sea_query_sqlx::SqlxValues) { + for value in &mut values.0.0 { + crate::db::fields::jiff_to_time_value(value); + } } #[expect(clippy::unnecessary_wraps)] // to have a unified interface between database impls diff --git a/cot/src/db/impl_postgres.rs b/cot/src/db/impl_postgres.rs index bea2d813a..e2fba58ba 100644 --- a/cot/src/db/impl_postgres.rs +++ b/cot/src/db/impl_postgres.rs @@ -19,6 +19,7 @@ impl DatabasePostgres { fn prepare_values(values: &mut sea_query_sqlx::SqlxValues) { for value in &mut values.0.0 { Self::tinyint_to_smallint(value); + crate::db::fields::jiff_to_time_value(value); } } diff --git a/cot/src/db/impl_sqlite.rs b/cot/src/db/impl_sqlite.rs index 31b078e97..18ab192df 100644 --- a/cot/src/db/impl_sqlite.rs +++ b/cot/src/db/impl_sqlite.rs @@ -24,8 +24,10 @@ impl DatabaseSqlite { .await } - fn prepare_values(_values: &mut SqlxValues) { - // No changes are needed for SQLite + fn prepare_values(values: &mut SqlxValues) { + for value in &mut values.0.0 { + crate::db::fields::jiff_to_sqlite_text_value(value); + } } #[expect(clippy::unnecessary_wraps)] // to have a unified interface between database impls diff --git a/cot/src/utils.rs b/cot/src/utils.rs index 727cc65a7..d2d372962 100644 --- a/cot/src/utils.rs +++ b/cot/src/utils.rs @@ -4,3 +4,5 @@ pub(crate) mod chrono; pub mod cli; #[cfg(feature = "db")] pub(crate) mod graph; +#[cfg(feature = "db")] +pub(crate) mod jiff; diff --git a/cot/src/utils/jiff.rs b/cot/src/utils/jiff.rs new file mode 100644 index 000000000..7d4cb8c10 --- /dev/null +++ b/cot/src/utils/jiff.rs @@ -0,0 +1,188 @@ +//! Conversions between [`jiff`] and [`time`] types. +//! +//! Cot exposes `jiff` types in its public API, but some of its dependencies +//! (such as `sqlx` and `tower-sessions`) only work with `time` types. The +//! functions in this module bridge the gap. +//! +//! The conversions from `jiff` to `time` are infallible, because the range of +//! `jiff` types is a subset of the range of the corresponding `time` types. The +//! conversions in the other direction can fail for values at the edges of the +//! `time` range. + +use thiserror::Error; + +/// A `time` value can't be represented as a `jiff` value. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Error)] +#[error("date/time value is out of the supported range")] +pub(crate) struct OutOfRangeError; + +#[cfg(any(feature = "postgres", feature = "mysql", test))] +pub(crate) fn timestamp_to_time(timestamp: jiff::Timestamp) -> time::OffsetDateTime { + time::OffsetDateTime::from_unix_timestamp_nanos(timestamp.as_nanosecond()) + .expect("jiff::Timestamp range is a subset of time::OffsetDateTime range") +} + +pub(crate) fn timestamp_from_time( + datetime: time::OffsetDateTime, +) -> Result { + // Not using `jiff::Timestamp::from_nanosecond`, because as of jiff 0.2.37 + // it doesn't check whether the value is within the supported range. + jiff::Timestamp::new( + datetime.unix_timestamp(), + datetime.nanosecond().cast_signed(), + ) + .map_err(|_| OutOfRangeError) +} + +#[cfg(any(feature = "postgres", feature = "mysql", test))] +pub(crate) fn date_to_time(date: jiff::civil::Date) -> time::Date { + let month = time::Month::try_from(date.month().cast_unsigned()) + .expect("jiff::civil::Date month is always in range"); + time::Date::from_calendar_date(i32::from(date.year()), month, date.day().cast_unsigned()) + .expect("jiff::civil::Date range is a subset of time::Date range") +} + +pub(crate) fn date_from_time(date: time::Date) -> Result { + let year = i16::try_from(date.year()).map_err(|_| OutOfRangeError)?; + jiff::civil::Date::new( + year, + u8::from(date.month()).cast_signed(), + date.day().cast_signed(), + ) + .map_err(|_| OutOfRangeError) +} + +#[cfg(any(feature = "postgres", feature = "mysql", test))] +pub(crate) fn time_to_time(time: jiff::civil::Time) -> time::Time { + time::Time::from_hms_nano( + time.hour().cast_unsigned(), + time.minute().cast_unsigned(), + time.second().cast_unsigned(), + time.subsec_nanosecond().cast_unsigned(), + ) + .expect("jiff::civil::Time components are always in range") +} + +pub(crate) fn time_from_time(time: time::Time) -> jiff::civil::Time { + jiff::civil::Time::new( + time.hour().cast_signed(), + time.minute().cast_signed(), + time.second().cast_signed(), + time.nanosecond().cast_signed(), + ) + .expect("time::Time components are always in range") +} + +#[cfg(any(feature = "postgres", feature = "mysql", test))] +pub(crate) fn datetime_to_time(datetime: jiff::civil::DateTime) -> time::PrimitiveDateTime { + time::PrimitiveDateTime::new(date_to_time(datetime.date()), time_to_time(datetime.time())) +} + +pub(crate) fn datetime_from_time( + datetime: time::PrimitiveDateTime, +) -> Result { + Ok(date_from_time(datetime.date())?.to_datetime(time_from_time(datetime.time()))) +} + +#[cfg(test)] +mod tests { + use jiff::civil; + + use super::*; + + #[test] + fn timestamp_roundtrip() { + for timestamp in [ + jiff::Timestamp::UNIX_EPOCH, + jiff::Timestamp::MIN, + jiff::Timestamp::MAX, + "2023-01-02T12:34:56.123456789Z".parse().unwrap(), + "1900-01-02T12:34:56.000000001-05:00".parse().unwrap(), + ] { + let converted = timestamp_to_time(timestamp); + assert_eq!(converted.unix_timestamp_nanos(), timestamp.as_nanosecond()); + assert_eq!(timestamp_from_time(converted), Ok(timestamp)); + } + } + + #[test] + fn timestamp_from_time_keeps_instant_for_non_utc_offset() { + let datetime = time::OffsetDateTime::new_in_offset( + time::Date::from_calendar_date(2023, time::Month::January, 2).unwrap(), + time::Time::from_hms(12, 34, 56).unwrap(), + time::UtcOffset::from_hms(-2, -30, 0).unwrap(), + ); + + assert_eq!( + timestamp_from_time(datetime), + Ok("2023-01-02T15:04:56Z".parse().unwrap()) + ); + } + + #[test] + fn timestamp_from_time_out_of_range() { + let datetime = time::Date::MIN.midnight().assume_utc(); + + assert_eq!(timestamp_from_time(datetime), Err(OutOfRangeError)); + } + + #[test] + fn date_roundtrip() { + for date in [ + civil::Date::MIN, + civil::Date::MAX, + civil::date(2024, 2, 29), + civil::date(-1, 12, 31), + ] { + let converted = date_to_time(date); + assert_eq!( + ( + converted.year(), + u8::from(converted.month()), + converted.day() + ), + ( + i32::from(date.year()), + date.month().cast_unsigned(), + date.day().cast_unsigned() + ) + ); + assert_eq!(date_from_time(converted), Ok(date)); + } + } + + #[test] + fn time_roundtrip() { + for time in [ + civil::Time::MIN, + civil::Time::MAX, + civil::time(12, 34, 56, 123_456_789), + ] { + let converted = time_to_time(time); + assert_eq!( + converted.as_hms_nano(), + ( + time.hour().cast_unsigned(), + time.minute().cast_unsigned(), + time.second().cast_unsigned(), + time.subsec_nanosecond().cast_unsigned() + ) + ); + assert_eq!(time_from_time(converted), time); + } + } + + #[test] + fn datetime_roundtrip() { + for datetime in [ + civil::DateTime::MIN, + civil::DateTime::MAX, + civil::date(2023, 1, 2).at(12, 34, 56, 123_456_789), + ] { + let converted = datetime_to_time(datetime); + assert_eq!(converted.date(), date_to_time(datetime.date())); + assert_eq!(converted.time(), time_to_time(datetime.time())); + assert_eq!(datetime_from_time(converted), Ok(datetime)); + } + } +} diff --git a/cot/tests/db_testing/datetime_storage.rs b/cot/tests/db_testing/datetime_storage.rs new file mode 100644 index 000000000..19912da91 --- /dev/null +++ b/cot/tests/db_testing/datetime_storage.rs @@ -0,0 +1,406 @@ +//! Tests pinning the on-disk representation of date and time values. +//! +//! SQLite has no native date/time types, so the values are stored as text. +//! Since SQLite compares text values lexicographically, the exact format +//! matters for filtering and ordering: switching to a different format would +//! make the existing data incomparable with newly written data. + +use cot::db::migrations::{Field, Operation}; +use cot::db::query::expr::ExprSort; +use cot::db::{Auto, DatabaseField, Identifier, Model}; +use cot::test::TestDatabase; +use cot_macros::{model, query}; + +use crate::db_testing::run_migrations; + +const TABLE_NAME: &str = "cot__datetime_storage_model"; + +#[derive(Debug, Clone, PartialEq)] +#[model(table_name = "datetime_storage_model")] +struct ChronoModel { + #[model(primary_key)] + id: Auto, + date: chrono::NaiveDate, + time: chrono::NaiveTime, + datetime: chrono::NaiveDateTime, + datetime_fixed: chrono::DateTime, + datetime_utc: chrono::DateTime, +} + +/// The same table as [`ChronoModel`], but with all the columns read as text. +#[derive(Debug, Clone, PartialEq)] +#[model(table_name = "datetime_storage_model")] +struct TextModel { + #[model(primary_key)] + id: Auto, + date: String, + time: String, + datetime: String, + datetime_fixed: String, + datetime_utc: String, +} + +const CREATE_MODEL: Operation = Operation::create_model() + .table_name(Identifier::new(TABLE_NAME)) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("date"), + ::TYPE, + ), + Field::new( + Identifier::new("time"), + ::TYPE, + ), + Field::new( + Identifier::new("datetime"), + ::TYPE, + ), + Field::new( + Identifier::new("datetime_fixed"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("datetime_utc"), + as DatabaseField>::TYPE, + ), + ]) + .build(); + +fn chrono_model(nanos: u32, offset_secs: i32) -> ChronoModel { + let date = chrono::NaiveDate::from_ymd_opt(2026, 10, 4).unwrap(); + let time = chrono::NaiveTime::from_hms_nano_opt(12, 34, 56, nanos).unwrap(); + let datetime = date.and_time(time); + let offset = chrono::FixedOffset::east_opt(offset_secs).unwrap(); + + ChronoModel { + id: Auto::auto(), + date, + time, + datetime, + datetime_fixed: datetime.and_local_timezone(offset).unwrap(), + datetime_utc: datetime.and_utc(), + } +} + +fn text_model( + date: &str, + time: &str, + datetime: &str, + datetime_fixed: &str, + datetime_utc: &str, +) -> TextModel { + TextModel { + id: Auto::auto(), + date: date.to_owned(), + time: time.to_owned(), + datetime: datetime.to_owned(), + datetime_fixed: datetime_fixed.to_owned(), + datetime_utc: datetime_utc.to_owned(), + } +} + +fn expected_text_models() -> Vec { + vec![ + text_model( + "2026-10-04", + "12:34:56", + "2026-10-04 12:34:56", + "2026-10-04T12:34:56+00:00", + "2026-10-04T12:34:56+00:00", + ), + text_model( + "2026-10-04", + "12:34:56.789", + "2026-10-04 12:34:56.789", + "2026-10-04T12:34:56.789+01:00", + "2026-10-04T12:34:56.789+00:00", + ), + text_model( + "2026-10-04", + "12:34:56.123456", + "2026-10-04 12:34:56.123456", + "2026-10-04T12:34:56.123456-02:30", + "2026-10-04T12:34:56.123456+00:00", + ), + text_model( + "2026-10-04", + "12:34:56.123456789", + "2026-10-04 12:34:56.123456789", + "2026-10-04T12:34:56.123456789+05:45", + "2026-10-04T12:34:56.123456789+00:00", + ), + ] +} + +fn chrono_models() -> Vec { + vec![ + chrono_model(0, 0), + chrono_model(789_000_000, 3600), + chrono_model(123_456_000, -9000), + chrono_model(123_456_789, 20_700), + ] +} + +fn without_ids(models: &[T], clear_id: impl Fn(&mut T)) -> Vec { + models + .iter() + .cloned() + .map(|mut model| { + clear_id(&mut model); + model + }) + .collect() +} + +#[cot::test] +#[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" +)] +async fn sqlite_chrono_text_format() { + let db = &mut TestDatabase::new_sqlite().await.unwrap(); + run_migrations!(db, CREATE_MODEL); + + for mut model in chrono_models() { + model.save(&**db).await.unwrap(); + } + + let text_models = TextModel::objects() + .order_by([::Fields::id]) + .all(&**db) + .await + .unwrap(); + assert_eq!( + without_ids(&text_models, |model| model.id = Auto::auto()), + expected_text_models() + ); + + db.cleanup().await.unwrap(); +} + +#[cot::test] +#[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" +)] +async fn sqlite_chrono_reads_text_format() { + let db = &mut TestDatabase::new_sqlite().await.unwrap(); + run_migrations!(db, CREATE_MODEL); + + for mut model in expected_text_models() { + model.save(&**db).await.unwrap(); + } + + let chrono_models_from_db = ChronoModel::objects() + .order_by([::Fields::id]) + .all(&**db) + .await + .unwrap(); + let expected = chrono_models(); + assert_eq!( + without_ids(&chrono_models_from_db, |model| model.id = Auto::auto()), + expected + ); + for (actual, expected) in chrono_models_from_db.iter().zip(&expected) { + assert_eq!( + actual.datetime_fixed.offset(), + expected.datetime_fixed.offset() + ); + } + + db.cleanup().await.unwrap(); +} + +/// The same table as [`ChronoModel`], but with `jiff` types. +#[derive(Debug, Clone, PartialEq)] +#[model(table_name = "datetime_storage_model")] +struct JiffModel { + #[model(primary_key)] + id: Auto, + date: jiff::civil::Date, + time: jiff::civil::Time, + datetime: jiff::civil::DateTime, + datetime_fixed: jiff::Timestamp, + datetime_utc: jiff::Timestamp, +} + +impl From for JiffModel { + fn from(model: ChronoModel) -> Self { + let timestamp = |nanos: i64| jiff::Timestamp::from_nanosecond(i128::from(nanos)).unwrap(); + + Self { + id: model.id, + date: model.date.to_string().parse().unwrap(), + time: model.time.to_string().parse().unwrap(), + datetime: model.datetime.to_string().parse().unwrap(), + datetime_fixed: timestamp(model.datetime_fixed.timestamp_nanos_opt().unwrap()), + datetime_utc: timestamp(model.datetime_utc.timestamp_nanos_opt().unwrap()), + } + } +} + +#[cot::test] +#[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" +)] +async fn sqlite_jiff_text_format() { + let db = &mut TestDatabase::new_sqlite().await.unwrap(); + run_migrations!(db, CREATE_MODEL); + + for model in chrono_models() { + JiffModel::from(model).save(&**db).await.unwrap(); + } + + // `jiff::Timestamp` doesn't store the offset, so the values are always + // written in UTC; other than that, the format is the same as for `chrono`. + let mut expected = expected_text_models(); + expected[1].datetime_fixed = "2023-01-02T11:34:56.789+00:00".to_owned(); + expected[2].datetime_fixed = "2023-01-02T15:04:56.123456+00:00".to_owned(); + expected[3].datetime_fixed = "2023-01-02T06:49:56.123456789+00:00".to_owned(); + + let text_models = TextModel::objects().all(&**db).await.unwrap(); + assert_eq!( + without_ids(&text_models, |model| model.id = Auto::auto()), + expected + ); + + db.cleanup().await.unwrap(); +} + +#[cot::test] +#[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" +)] +async fn sqlite_jiff_reads_text_format() { + let db = &mut TestDatabase::new_sqlite().await.unwrap(); + run_migrations!(db, CREATE_MODEL); + + for mut model in expected_text_models() { + model.save(&**db).await.unwrap(); + } + + let jiff_models = JiffModel::objects().all(&**db).await.unwrap(); + assert_eq!( + without_ids(&jiff_models, |model| model.id = Auto::auto()), + chrono_models() + .into_iter() + .map(JiffModel::from) + .collect::>() + ); + + db.cleanup().await.unwrap(); +} + +/// The models that can be stored in all the databases without losing +/// precision (PostgreSQL and MySQL only support microsecond precision, and +/// MySQL doesn't store fractional seconds in `TIME` columns). +fn portable_chrono_models() -> Vec { + use chrono::Timelike; + + [ + chrono_model(0, 0), + chrono_model(789_000_000, 3600), + chrono_model(123_456_000, -9000), + ] + .into_iter() + .map(|mut model| { + model.time = model.time.with_nanosecond(0).unwrap(); + model + }) + .collect() +} + +#[cot_macros::dbtest] +async fn jiff_reads_values_written_by_chrono(db: &mut TestDatabase) { + run_migrations!(db, CREATE_MODEL); + + for mut model in portable_chrono_models() { + model.save(&**db).await.unwrap(); + } + + let mut jiff_models = JiffModel::objects().all(&**db).await.unwrap(); + jiff_models.sort_by_key(|model| model.id.unwrap()); + assert_eq!( + without_ids(&jiff_models, |model| model.id = Auto::auto()), + portable_chrono_models() + .into_iter() + .map(JiffModel::from) + .collect::>() + ); +} + +#[cot_macros::dbtest] +async fn chrono_reads_values_written_by_jiff(db: &mut TestDatabase) { + run_migrations!(db, CREATE_MODEL); + + for model in portable_chrono_models() { + JiffModel::from(model).save(&**db).await.unwrap(); + } + + let mut chrono_models = ChronoModel::objects().all(&**db).await.unwrap(); + chrono_models.sort_by_key(|model| model.id.unwrap()); + let chrono_models: Vec<_> = without_ids(&chrono_models, |model| model.id = Auto::auto()) + .into_iter() + .map(JiffModel::from) + .collect(); + assert_eq!( + chrono_models, + portable_chrono_models() + .into_iter() + .map(JiffModel::from) + .collect::>() + ); +} + +#[cot_macros::dbtest] +async fn jiff_filter_and_order(db: &mut TestDatabase) { + run_migrations!(db, CREATE_MODEL); + + // mix the values written by both libraries to make sure they can be + // compared + let models = portable_chrono_models(); + let mut first = models[0].clone(); + first.save(&**db).await.unwrap(); + JiffModel::from(models[1].clone()) + .save(&**db) + .await + .unwrap(); + let mut third = models[2].clone(); + third.save(&**db).await.unwrap(); + + let threshold: jiff::Timestamp = "2023-01-02T12:34:56.5Z".parse().unwrap(); + let later = query!(JiffModel, $datetime_utc > threshold) + .order_by([::Fields::datetime_utc.asc()]) + .all(&**db) + .await + .unwrap(); + assert_eq!( + later + .iter() + .map(|model| model.datetime_utc) + .collect::>(), + [JiffModel::from(models[1].clone()).datetime_utc,] + ); + + let datetime_threshold = jiff::civil::date(2023, 1, 2).at(12, 34, 56, 500_000_000); + let earlier = query!(JiffModel, $datetime < datetime_threshold) + .order_by([::Fields::datetime.asc()]) + .all(&**db) + .await + .unwrap(); + assert_eq!( + earlier + .iter() + .map(|model| model.datetime) + .collect::>(), + [ + jiff::civil::date(2023, 1, 2).at(12, 34, 56, 0), + jiff::civil::date(2023, 1, 2).at(12, 34, 56, 123_456_000), + ] + ); +} diff --git a/cot/tests/db_testing/fields.rs b/cot/tests/db_testing/fields.rs index 3a643f9b3..a83fcdcd4 100644 --- a/cot/tests/db_testing/fields.rs +++ b/cot/tests/db_testing/fields.rs @@ -1,6 +1,6 @@ use bytes::Bytes; use cot::auth::PasswordHash; -use cot::common_types::{Email, Password, Url}; +use cot::common_types::{Email, Password, Url, WeekdaySet}; use cot::db::migrations::{Field, Operation}; use cot::db::{Auto, Database, DatabaseField, Identifier, LimitedString, Model}; use cot::test::TestDatabase; @@ -63,6 +63,58 @@ impl Dummy for Url { } } +struct JiffDateFaker; + +impl Dummy for jiff::civil::Date { + fn dummy_with_rng(_: &JiffDateFaker, rng: &mut R) -> Self { + jiff::civil::date( + rng.random_range(1..=9999), + rng.random_range(1..=12), + rng.random_range(1..=28), + ) + } +} + +struct JiffTimeFaker; + +impl Dummy for jiff::civil::Time { + // MySQL doesn't store fractional seconds in `TIME` columns + fn dummy_with_rng(_: &JiffTimeFaker, rng: &mut R) -> Self { + jiff::civil::time( + rng.random_range(0..24), + rng.random_range(0..60), + rng.random_range(0..60), + 0, + ) + } +} + +struct JiffDateTimeFaker; + +impl Dummy for jiff::civil::DateTime { + fn dummy_with_rng(_: &JiffDateTimeFaker, rng: &mut R) -> Self { + let date: jiff::civil::Date = JiffDateFaker.fake_with_rng(rng); + // PostgreSQL and MySQL only support microsecond precision + date.at( + rng.random_range(0..24), + rng.random_range(0..60), + rng.random_range(0..60), + rng.random_range(0..1_000_000) * 1000, + ) + } +} + +struct JiffTimestampFaker; + +impl Dummy for jiff::Timestamp { + fn dummy_with_rng(_: &JiffTimestampFaker, rng: &mut R) -> Self { + // PostgreSQL and MySQL only support microsecond precision; MySQL's + // `TIMESTAMP` only supports dates between 1970 and 2038 + jiff::Timestamp::from_microsecond(rng.random_range(0..2_000_000_000_000_000)) + .expect("the value is in range") + } +} + #[derive(Debug, PartialEq, Dummy)] #[model] struct AllFieldsModel { @@ -89,6 +141,14 @@ struct AllFieldsModel { field_datetime: chrono::NaiveDateTime, #[dummy(faker = "fake::chrono::Precision::<6>")] field_datetime_timezone: chrono::DateTime, + #[dummy(faker = "JiffDateFaker")] + field_jiff_date: jiff::civil::Date, + #[dummy(faker = "JiffTimeFaker")] + field_jiff_time: jiff::civil::Time, + #[dummy(faker = "JiffDateTimeFaker")] + field_jiff_datetime: jiff::civil::DateTime, + #[dummy(faker = "JiffTimestampFaker")] + field_jiff_timestamp: jiff::Timestamp, field_string: String, field_blob: Vec, #[dummy(expr = "Bytes::from_static(b\"test bytes\")")] @@ -98,6 +158,7 @@ struct AllFieldsModel { field_option_limited_string: Option>, #[dummy(faker = "WeekdaySetFaker")] field_weekday_set: chrono::WeekdaySet, + field_jiff_weekday_set: WeekdaySet, #[dummy(faker = "EmailFaker")] field_email: Email, #[dummy(faker = "EmailFaker")] @@ -150,6 +211,10 @@ const CREATE_ALL_FIELDS_MODEL: Operation = Operation::create_model() all_fields_migration_field!(time, chrono::NaiveTime), all_fields_migration_field!(datetime, chrono::NaiveDateTime), all_fields_migration_field!(datetime_timezone, chrono::DateTime), + all_fields_migration_field!(jiff_date, jiff::civil::Date), + all_fields_migration_field!(jiff_time, jiff::civil::Time), + all_fields_migration_field!(jiff_datetime, jiff::civil::DateTime), + all_fields_migration_field!(jiff_timestamp, jiff::Timestamp), all_fields_migration_field!(string, String), all_fields_migration_field!(blob, Vec), all_fields_migration_field!(bytes, Bytes), @@ -157,6 +222,7 @@ const CREATE_ALL_FIELDS_MODEL: Operation = Operation::create_model() all_fields_migration_field!(limited_string, LimitedString<10>), all_fields_migration_field!(option_limited_string, Option>), all_fields_migration_field!(weekday_set, chrono::WeekdaySet), + all_fields_migration_field!(jiff_weekday_set, WeekdaySet), all_fields_migration_field!(email, Email), all_fields_migration_field!(option_email, Option), all_fields_migration_field!(url, Url), diff --git a/cot/tests/db_testing/mod.rs b/cot/tests/db_testing/mod.rs index bb312d5c3..7c2d19f0d 100644 --- a/cot/tests/db_testing/mod.rs +++ b/cot/tests/db_testing/mod.rs @@ -1,3 +1,4 @@ +mod datetime_storage; mod fields; mod migrations; mod query;