From a1d950ea711aa3aa9cacc1e825a5dcd2dd03b29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Sat, 3 Oct 2026 18:16:56 +0200 Subject: [PATCH 1/2] test(db): add tests for datetime storage --- cot/tests/db_testing/datetime_storage.rs | 214 +++++++++++++++++++++++ cot/tests/db_testing/mod.rs | 1 + 2 files changed, 215 insertions(+) create mode 100644 cot/tests/db_testing/datetime_storage.rs diff --git a/cot/tests/db_testing/datetime_storage.rs b/cot/tests/db_testing/datetime_storage.rs new file mode 100644 index 000000000..c9c54ad2b --- /dev/null +++ b/cot/tests/db_testing/datetime_storage.rs @@ -0,0 +1,214 @@ +//! 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::{Auto, DatabaseField, Identifier, Model}; +use cot::test::TestDatabase; +use cot_macros::model; + +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(); +} 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; From 2cfae01d9dd70ab5a15f25aa5a4611373e5d2b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Sat, 3 Oct 2026 18:35:22 +0200 Subject: [PATCH 2/2] feat(db): support jiff types as database fields Add `DatabaseField` implementations for `jiff::Timestamp` and `jiff::civil::{Date, Time, DateTime}`, and a new public `cot::common_types::WeekdaySet` built on `jiff::civil::Weekday`. SQLx doesn't support jiff natively yet (and sea-query-sqlx panics when binding jiff values for PostgreSQL and MySQL), so the values are represented as `DbValue::Jiff*` in Cot and converted right before binding: to `time` types for PostgreSQL and MySQL, and to text for SQLite. The SQLite text format is the same as the one used for chrono values, so the existing data stays comparable with the new values. `chrono::WeekdaySet` is now stored through the new `WeekdaySet` type; the on-disk representation is unchanged. --- Cargo.lock | 128 ++++++++- Cargo.toml | 1 + cot/Cargo.toml | 7 +- cot/src/common_types.rs | 336 ++++++++++++++++++++++ cot/src/db/fields.rs | 8 +- cot/src/db/fields/chrono_wrapper.rs | 291 +++---------------- cot/src/db/fields/jiff_fields.rs | 348 +++++++++++++++++++++++ cot/src/db/impl_mysql.rs | 6 +- cot/src/db/impl_postgres.rs | 1 + cot/src/db/impl_sqlite.rs | 6 +- cot/src/utils.rs | 2 + cot/src/utils/jiff.rs | 188 ++++++++++++ cot/tests/db_testing/datetime_storage.rs | 194 ++++++++++++- cot/tests/db_testing/fields.rs | 68 ++++- 14 files changed, 1314 insertions(+), 270 deletions(-) create mode 100644 cot/src/db/fields/jiff_fields.rs create mode 100644 cot/src/utils/jiff.rs 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 index c9c54ad2b..19912da91 100644 --- a/cot/tests/db_testing/datetime_storage.rs +++ b/cot/tests/db_testing/datetime_storage.rs @@ -6,9 +6,10 @@ //! 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; +use cot_macros::{model, query}; use crate::db_testing::run_migrations; @@ -212,3 +213,194 @@ async fn sqlite_chrono_reads_text_format() { 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),