From 8084a7e140053be2247a67a7a2148331d443cfa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Tue, 21 Apr 2026 20:21:30 +0200 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20quality=20review=20=E2=80=94=20ad?= =?UTF-8?q?d=20missing=20business-value=20methods=20and=20fix=20two=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - PhoneNumber: calling_code() fallback "+0" replaced with proper error; construction now fails for unknown country codes instead of silently producing an invalid E.164 number - Url::host(): strip port from host when URL contains an explicit port number (e.g. example.com:8080 → example.com); IPv6 bracket form preserved New methods: - TimeRange: contains(DateTime), overlaps(TimeRange) - BusinessHours: is_open_at(NaiveTime) - BoundingBox: contains(Coordinate) - BirthDate: is_minor() - UnixTimestamp: as_datetime() → DateTime - Locale: language(), region() - Money: add(), sub(), neg() (fulfils ROADMAP "immutable arithmetic helpers") - IpV4Address: is_loopback(), is_private() - Port: is_well_known(), is_registered(), is_ephemeral() - Percentage: as_fraction() - HexColor: to_rgb() - CardExpiryDate: months_until() Docs: all new methods documented in docs/*.md --- docs/finance.md | 12 +++++++ docs/geo.md | 1 + docs/net.md | 5 +++ docs/primitives.md | 3 ++ docs/temporal.md | 5 +++ src/contact/phone_number.rs | 16 ++++----- src/finance/card_expiry_date.rs | 10 ++++++ src/finance/money.rs | 62 +++++++++++++++++++++++++++++++++ src/finance/percentage.rs | 7 ++++ src/geo/bounding_box.rs | 41 ++++++++++++++++++++++ src/net/ip_v4_address.rs | 26 ++++++++++++++ src/net/port.rs | 26 ++++++++++++++ src/net/url.rs | 20 +++++++++-- src/primitives/hex_color.rs | 5 +++ src/primitives/locale.rs | 33 ++++++++++++++++++ src/temporal/birth_date.rs | 5 +++ src/temporal/business_hours.rs | 31 +++++++++++++++++ src/temporal/time_range.rs | 53 ++++++++++++++++++++++++++++ src/temporal/unix_timestamp.rs | 21 +++++++++++ 19 files changed, 371 insertions(+), 11 deletions(-) diff --git a/docs/finance.md b/docs/finance.md index 8c9f37c..6313290 100644 --- a/docs/finance.md +++ b/docs/finance.md @@ -73,6 +73,16 @@ assert_eq!(money.currency().value(), "EUR"); | `currency()` | `&CurrencyCode` | `CurrencyCode("EUR")` | | `into_inner()` | `MoneyInput` | — | +### Arithmetic helpers + +All operations are immutable — they return a new `Money` or `Result`. + +| Method | Returns | Notes | +|---|---|---| +| `add(&Money)` | `Result` | Fails if currencies differ | +| `sub(&Money)` | `Result` | Fails if currencies differ; result may be negative | +| `neg()` | `Money` | Negates the amount; always succeeds | + --- ## Iban @@ -196,6 +206,7 @@ assert!(Percentage::new(f64::NAN).is_err()); | Method | Returns | Example | |---|---|---| | `value()` | `&f64` | `42.5` | +| `as_fraction()` | `f64` | `0.425` (divides by 100) | | `into_inner()` | `f64` | `42.5` | --- @@ -294,6 +305,7 @@ assert!(CardExpiryDate::new("01/20".into()).is_err()); // past | `value()` | `&String` | `"12/28"` | | `month()` | `u8` | `12` | | `year()` | `u16` | `2028` | +| `months_until()` | `u32` | full months remaining until expiry | | `into_inner()` | `String` | — | ### Errors diff --git a/docs/geo.md b/docs/geo.md index 415ff20..212ccd2 100644 --- a/docs/geo.md +++ b/docs/geo.md @@ -154,6 +154,7 @@ pub struct BoundingBoxInput { | `value()` | `&str` | `"SW: 48.0, 14.0 / NE: 51.0, 18.0"` | | `sw()` | `&Coordinate` | south-west corner | | `ne()` | `&Coordinate` | north-east corner | +| `contains(&Coordinate)` | `bool` | inclusive on all four edges | | `into_inner()` | `BoundingBoxInput` | original input | ### Errors diff --git a/docs/net.md b/docs/net.md index 4785a0f..e6479a2 100644 --- a/docs/net.md +++ b/docs/net.md @@ -99,6 +99,8 @@ let ip: IpV4Address = "10.0.0.1".try_into()?; | Method | Returns | Example | |---|---|---| | `value()` | `&String` | `"192.168.1.1"` | +| `is_loopback()` | `bool` | `true` for `127.0.0.0/8` | +| `is_private()` | `bool` | `true` for `10/8`, `172.16/12`, `192.168/16` | | `into_inner()` | `String` | `"192.168.1.1"` | ### Errors @@ -186,6 +188,9 @@ assert!(Port::new(0).is_err()); | Method | Returns | Example | |---|---|---| | `value()` | `&u16` | `8080` | +| `is_well_known()` | `bool` | `true` for ports 1–1023 | +| `is_registered()` | `bool` | `true` for ports 1024–49151 | +| `is_ephemeral()` | `bool` | `true` for ports 49152–65535 | | `into_inner()` | `u16` | `8080` | ### Errors diff --git a/docs/primitives.md b/docs/primitives.md index d8c560c..a1e6f7a 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -270,6 +270,7 @@ let c: HexColor = "#1A2B3C".try_into()?; | `r()` | `u8` | `255` | | `g()` | `u8` | `0` | | `b()` | `u8` | `0` | +| `to_rgb()` | `(u8, u8, u8)` | `(255, 0, 0)` | | `into_inner()` | `String` | `"#FF0000"` | ### Errors @@ -306,6 +307,8 @@ assert_eq!(fr.value(), "fr"); | Method | Returns | Example | |---|---|---| | `value()` | `&String` | `"en-US"` | +| `language()` | `&str` | `"en"` (language subtag) | +| `region()` | `Option<&str>` | `Some("US")` / `None` for language-only tags | | `into_inner()` | `String` | `"en-US"` | ### Errors diff --git a/docs/temporal.md b/docs/temporal.md index 2a3240a..f78b565 100644 --- a/docs/temporal.md +++ b/docs/temporal.md @@ -31,6 +31,7 @@ assert!(UnixTimestamp::new(-1).is_err()); | Method | Returns | Example | |---|---|---| | `value()` | `&i64` | `1700000000` | +| `as_datetime()` | `DateTime` | converts to a chrono UTC datetime | | `into_inner()` | `i64` | `1700000000` | --- @@ -58,6 +59,7 @@ assert!(dob.age_years() > 0); |---|---|---| | `value()` | `&NaiveDate` | `1990-06-15` | | `age_years()` | `u32` | `35` | +| `is_minor()` | `bool` | `true` if age < 18 | | `into_inner()` | `NaiveDate` | — | ### Errors @@ -129,6 +131,8 @@ assert_eq!(range.duration().num_hours(), 2); | `start()` | `&DateTime` | — | | `end()` | `&DateTime` | — | | `duration()` | `Duration` | `2h` | +| `contains(&DateTime)` | `bool` | `[start, end)` — inclusive start, exclusive end | +| `overlaps(&TimeRange)` | `bool` | `true` if the two ranges share any instant | | `into_inner()` | `TimeRangeInput` | — | ### Errors @@ -170,6 +174,7 @@ assert_eq!(hours.duration().num_hours(), 8); | `open()` | `&NaiveTime` | `09:00` | | `close()` | `&NaiveTime` | `17:00` | | `duration()` | `Duration` | `8h` | +| `is_open_at(NaiveTime)` | `bool` | `[open, close)` — open inclusive, close exclusive | | `into_inner()` | `BusinessHoursInput` | — | ### Errors diff --git a/src/contact/phone_number.rs b/src/contact/phone_number.rs index 89f54d4..0374584 100644 --- a/src/contact/phone_number.rs +++ b/src/contact/phone_number.rs @@ -76,7 +76,9 @@ impl ValueObject for PhoneNumber { return Err(ValidationError::invalid("PhoneNumber", &number)); } - let prefix = calling_code(value.country_code.value()); + let prefix = calling_code(value.country_code.value()).ok_or_else(|| { + ValidationError::invalid("PhoneNumber", value.country_code.value()) + })?; let e164 = format!("{}{}", prefix, number); Ok(Self { @@ -100,7 +102,7 @@ impl ValueObject for PhoneNumber { impl PhoneNumber { /// Returns the ITU calling code prefix, e.g. `"+420"`. pub fn calling_code(&self) -> &str { - calling_code(self.input.country_code.value()) + calling_code(self.input.country_code.value()).unwrap_or("+0") } /// Returns the local number digits without the calling code, e.g. `"123456789"`. @@ -121,10 +123,8 @@ impl std::fmt::Display for PhoneNumber { } } -/// Maps an ISO 3166-1 alpha-2 country code to its ITU calling code prefix. -/// Returns `"+0"` for unknown codes — callers should ensure valid CountryCode input. -fn calling_code(country: &str) -> &'static str { - match country { +fn calling_code(country: &str) -> Option<&'static str> { + Some(match country { "AF" => "+93", "AL" => "+355", "DZ" => "+213", @@ -375,8 +375,8 @@ fn calling_code(country: &str) -> &'static str { "WF" => "+681", "EH" => "+212", "KY" => "+1345", - _ => "+0", - } + _ => return None, + }) } #[cfg(test)] diff --git a/src/finance/card_expiry_date.rs b/src/finance/card_expiry_date.rs index 6fc9dbb..c185718 100644 --- a/src/finance/card_expiry_date.rs +++ b/src/finance/card_expiry_date.rs @@ -105,6 +105,16 @@ impl CardExpiryDate { let yy: u16 = self.0[3..].parse().unwrap(); 2000 + yy } + + /// Returns the number of full months from the current month until expiry. + pub fn months_until(&self) -> u32 { + let now = Local::now(); + let current_year = now.year() as u16; + let current_month = now.month() as u8; + let expiry_months = self.year() * 12 + self.month() as u16; + let current_months = current_year * 12 + current_month as u16; + expiry_months.saturating_sub(current_months) as u32 + } } impl TryFrom<&str> for CardExpiryDate { diff --git a/src/finance/money.rs b/src/finance/money.rs index e79aa55..1976bbf 100644 --- a/src/finance/money.rs +++ b/src/finance/money.rs @@ -82,6 +82,39 @@ impl Money { pub fn currency(&self) -> &CurrencyCode { &self.currency } + + /// Returns the sum of `self` and `other`. Fails if currencies differ. + pub fn add(&self, other: &Money) -> Result { + if self.currency != other.currency { + return Err(ValidationError::invalid( + "Money", + &format!("cannot add {} and {}", self.currency, other.currency), + )); + } + let sum = self.amount + other.amount; + let canonical = format!("{:.2} {}", sum, self.currency); + Ok(Money { amount: sum, currency: self.currency.clone(), canonical }) + } + + /// Returns the difference `self - other`. Fails if currencies differ. + pub fn sub(&self, other: &Money) -> Result { + if self.currency != other.currency { + return Err(ValidationError::invalid( + "Money", + &format!("cannot subtract {} and {}", self.currency, other.currency), + )); + } + let diff = self.amount - other.amount; + let canonical = format!("{:.2} {}", diff, self.currency); + Ok(Money { amount: diff, currency: self.currency.clone(), canonical }) + } + + /// Returns the negation of this amount (e.g. a debt). + pub fn neg(&self) -> Money { + let negated = -self.amount; + let canonical = format!("{:.2} {}", negated, self.currency); + Money { amount: negated, currency: self.currency.clone(), canonical } + } } impl std::fmt::Display for Money { @@ -173,6 +206,35 @@ mod tests { assert_eq!(m.to_string(), m.value().to_owned()); } + #[test] + fn add_same_currency() { + let a = Money::new(MoneyInput { amount: "10.00".parse().unwrap(), currency: eur() }).unwrap(); + let b = Money::new(MoneyInput { amount: "5.50".parse().unwrap(), currency: eur() }).unwrap(); + let result = a.add(&b).unwrap(); + assert_eq!(result.value(), "15.50 EUR"); + } + + #[test] + fn add_different_currencies_fails() { + let a = Money::new(MoneyInput { amount: "10.00".parse().unwrap(), currency: eur() }).unwrap(); + let b = Money::new(MoneyInput { amount: "5.00".parse().unwrap(), currency: usd() }).unwrap(); + assert!(a.add(&b).is_err()); + } + + #[test] + fn sub_same_currency() { + let a = Money::new(MoneyInput { amount: "10.00".parse().unwrap(), currency: eur() }).unwrap(); + let b = Money::new(MoneyInput { amount: "3.00".parse().unwrap(), currency: eur() }).unwrap(); + let result = a.sub(&b).unwrap(); + assert_eq!(result.value(), "7.00 EUR"); + } + + #[test] + fn neg_returns_negated_amount() { + let m = Money::new(MoneyInput { amount: "10.00".parse().unwrap(), currency: eur() }).unwrap(); + assert_eq!(m.neg().value(), "-10.00 EUR"); + } + #[test] fn into_inner_roundtrip() { let input = MoneyInput { diff --git a/src/finance/percentage.rs b/src/finance/percentage.rs index cda8302..c0de012 100644 --- a/src/finance/percentage.rs +++ b/src/finance/percentage.rs @@ -56,6 +56,13 @@ impl ValueObject for Percentage { } } +impl Percentage { + /// Returns the value as a fraction in `0.0..=1.0` (e.g. `42.5` → `0.425`). + pub fn as_fraction(&self) -> f64 { + self.0 / 100.0 + } +} + impl std::fmt::Display for Percentage { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}%", self.0) diff --git a/src/geo/bounding_box.rs b/src/geo/bounding_box.rs index 424601a..af5f797 100644 --- a/src/geo/bounding_box.rs +++ b/src/geo/bounding_box.rs @@ -93,6 +93,16 @@ impl BoundingBox { pub fn ne(&self) -> &Coordinate { &self.ne } + + /// Returns `true` if `coord` lies within this bounding box (inclusive on all edges). + pub fn contains(&self, coord: &Coordinate) -> bool { + let lat = coord.lat().value(); + let lng = coord.lng().value(); + lat >= self.sw.lat().value() + && lat <= self.ne.lat().value() + && lng >= self.sw.lng().value() + && lng <= self.ne.lng().value() + } } impl std::fmt::Display for BoundingBox { @@ -169,6 +179,37 @@ mod tests { assert_eq!(*bbox.ne().lng().value(), 18.0); } + #[test] + fn contains_inside() { + let bbox = BoundingBox::new(BoundingBoxInput { + sw: coord(48.0, 14.0), + ne: coord(51.0, 18.0), + }) + .unwrap(); + assert!(bbox.contains(&coord(50.0, 16.0))); + } + + #[test] + fn contains_on_edge() { + let bbox = BoundingBox::new(BoundingBoxInput { + sw: coord(48.0, 14.0), + ne: coord(51.0, 18.0), + }) + .unwrap(); + assert!(bbox.contains(&coord(48.0, 14.0))); + assert!(bbox.contains(&coord(51.0, 18.0))); + } + + #[test] + fn contains_outside() { + let bbox = BoundingBox::new(BoundingBoxInput { + sw: coord(48.0, 14.0), + ne: coord(51.0, 18.0), + }) + .unwrap(); + assert!(!bbox.contains(&coord(52.0, 16.0))); + } + #[test] fn display_matches_value() { let bbox = BoundingBox::new(BoundingBoxInput { diff --git a/src/net/ip_v4_address.rs b/src/net/ip_v4_address.rs index 5abe9b5..a3e2056 100644 --- a/src/net/ip_v4_address.rs +++ b/src/net/ip_v4_address.rs @@ -63,6 +63,18 @@ impl ValueObject for IpV4Address { } } +impl IpV4Address { + /// Returns `true` for loopback addresses (`127.0.0.0/8`). + pub fn is_loopback(&self) -> bool { + self.0.parse::().map(|ip| ip.is_loopback()).unwrap_or(false) + } + + /// Returns `true` for private addresses (10/8, 172.16/12, 192.168/16). + pub fn is_private(&self) -> bool { + self.0.parse::().map(|ip| ip.is_private()).unwrap_or(false) + } +} + impl TryFrom<&str> for IpV4Address { type Error = ValidationError; @@ -127,6 +139,20 @@ mod tests { assert!(IpV4Address::new("::1".into()).is_err()); } + #[test] + fn is_loopback() { + assert!(IpV4Address::new("127.0.0.1".into()).unwrap().is_loopback()); + assert!(!IpV4Address::new("192.168.1.1".into()).unwrap().is_loopback()); + } + + #[test] + fn is_private() { + assert!(IpV4Address::new("10.0.0.1".into()).unwrap().is_private()); + assert!(IpV4Address::new("172.16.0.1".into()).unwrap().is_private()); + assert!(IpV4Address::new("192.168.1.1".into()).unwrap().is_private()); + assert!(!IpV4Address::new("8.8.8.8".into()).unwrap().is_private()); + } + #[test] fn try_from_str() { let ip: IpV4Address = "10.0.0.1".try_into().unwrap(); diff --git a/src/net/port.rs b/src/net/port.rs index bc40186..f661384 100644 --- a/src/net/port.rs +++ b/src/net/port.rs @@ -48,6 +48,23 @@ impl ValueObject for Port { } } +impl Port { + /// Returns `true` for well-known ports (1–1023). + pub fn is_well_known(&self) -> bool { + self.0 <= 1023 + } + + /// Returns `true` for registered ports (1024–49151). + pub fn is_registered(&self) -> bool { + (1024..=49151).contains(&self.0) + } + + /// Returns `true` for ephemeral / dynamic ports (49152–65535). + pub fn is_ephemeral(&self) -> bool { + self.0 >= 49152 + } +} + impl std::fmt::Display for Port { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) @@ -86,6 +103,15 @@ mod tests { assert!(Port::new(0).is_err()); } + #[test] + fn port_categories() { + assert!(Port::new(80).unwrap().is_well_known()); + assert!(Port::new(8080).unwrap().is_registered()); + assert!(Port::new(60000).unwrap().is_ephemeral()); + assert!(!Port::new(8080).unwrap().is_well_known()); + assert!(!Port::new(80).unwrap().is_ephemeral()); + } + #[test] fn display() { let port = Port::new(443).unwrap(); diff --git a/src/net/url.rs b/src/net/url.rs index 514c9bd..38205e9 100644 --- a/src/net/url.rs +++ b/src/net/url.rs @@ -74,16 +74,24 @@ impl Url { self.0.split("://").next().unwrap_or("") } - /// Returns the host, e.g. `"example.com"`. + /// Returns the host without port, e.g. `"example.com"`. pub fn host(&self) -> &str { let after_scheme = self.0.split("://").nth(1).unwrap_or(""); - after_scheme + let host_and_port = after_scheme .split('/') .next() .unwrap_or("") .split('?') .next() - .unwrap_or("") + .unwrap_or(""); + if host_and_port.starts_with('[') { + // IPv6 literal: "[::1]:8080" → "[::1]" + if let Some(i) = host_and_port.find(']') { + return &host_and_port[..=i]; + } + return host_and_port; + } + host_and_port.split(':').next().unwrap_or(host_and_port) } } @@ -154,6 +162,12 @@ mod tests { assert!(Url::new("https://".into()).is_err()); } + #[test] + fn host_strips_port() { + let url = Url::new("https://example.com:8080/path".into()).unwrap(); + assert_eq!(url.host(), "example.com"); + } + #[test] fn try_from_str() { let url: Url = "https://example.com".try_into().unwrap(); diff --git a/src/primitives/hex_color.rs b/src/primitives/hex_color.rs index 669eed7..9402300 100644 --- a/src/primitives/hex_color.rs +++ b/src/primitives/hex_color.rs @@ -94,6 +94,11 @@ impl HexColor { pub fn b(&self) -> u8 { Self::channel(&self.0, 5) } + + /// Returns the RGB channels as a tuple `(r, g, b)`. + pub fn to_rgb(&self) -> (u8, u8, u8) { + (self.r(), self.g(), self.b()) + } } impl TryFrom<&str> for HexColor { diff --git a/src/primitives/locale.rs b/src/primitives/locale.rs index 7789746..ebcf6c2 100644 --- a/src/primitives/locale.rs +++ b/src/primitives/locale.rs @@ -78,6 +78,20 @@ impl ValueObject for Locale { } } +impl Locale { + /// Returns the language subtag, e.g. `"en"` from `"en-US"`. + pub fn language(&self) -> &str { + self.0.split('-').next().unwrap_or(&self.0) + } + + /// Returns the region subtag if present, e.g. `Some("US")` from `"en-US"`. + pub fn region(&self) -> Option<&str> { + let mut parts = self.0.splitn(2, '-'); + parts.next(); + parts.next() + } +} + impl TryFrom<&str> for Locale { type Error = ValidationError; @@ -152,6 +166,25 @@ mod tests { assert!(Locale::new(String::new()).is_err()); } + #[test] + fn language_subtag() { + let l = Locale::new("en-US".into()).unwrap(); + assert_eq!(l.language(), "en"); + } + + #[test] + fn language_only_locale() { + let l = Locale::new("fr".into()).unwrap(); + assert_eq!(l.language(), "fr"); + assert_eq!(l.region(), None); + } + + #[test] + fn region_subtag() { + let l = Locale::new("cs-CZ".into()).unwrap(); + assert_eq!(l.region(), Some("CZ")); + } + #[test] fn try_from_str() { let l: Locale = "cs-CZ".try_into().unwrap(); diff --git a/src/temporal/birth_date.rs b/src/temporal/birth_date.rs index 8893cc3..5252951 100644 --- a/src/temporal/birth_date.rs +++ b/src/temporal/birth_date.rs @@ -74,6 +74,11 @@ impl BirthDate { (years - 1) as u32 } } + + /// Returns `true` if the person is under 18 years old as of today. + pub fn is_minor(&self) -> bool { + self.age_years() < 18 + } } impl std::fmt::Display for BirthDate { diff --git a/src/temporal/business_hours.rs b/src/temporal/business_hours.rs index e2d3451..11e2082 100644 --- a/src/temporal/business_hours.rs +++ b/src/temporal/business_hours.rs @@ -116,6 +116,11 @@ impl BusinessHours { pub fn duration(&self) -> Duration { self.close - self.open } + + /// Returns `true` if `time` falls within `[open, close)`. + pub fn is_open_at(&self, time: NaiveTime) -> bool { + time >= self.open && time < self.close + } } impl std::fmt::Display for BusinessHours { @@ -231,6 +236,32 @@ mod tests { } } + #[test] + fn is_open_at_during_hours() { + let h = BusinessHours::new(valid_input()).unwrap(); + let noon = NaiveTime::from_hms_opt(12, 0, 0).unwrap(); + assert!(h.is_open_at(noon)); + } + + #[test] + fn is_open_at_open_time_inclusive() { + let h = BusinessHours::new(valid_input()).unwrap(); + assert!(h.is_open_at(open())); + } + + #[test] + fn is_open_at_close_time_exclusive() { + let h = BusinessHours::new(valid_input()).unwrap(); + assert!(!h.is_open_at(close())); + } + + #[test] + fn is_open_at_before_open() { + let h = BusinessHours::new(valid_input()).unwrap(); + let early = NaiveTime::from_hms_opt(8, 0, 0).unwrap(); + assert!(!h.is_open_at(early)); + } + #[test] fn display_matches_value() { let h = BusinessHours::new(valid_input()).unwrap(); diff --git a/src/temporal/time_range.rs b/src/temporal/time_range.rs index 7eaaba9..140eddd 100644 --- a/src/temporal/time_range.rs +++ b/src/temporal/time_range.rs @@ -92,6 +92,16 @@ impl TimeRange { pub fn duration(&self) -> Duration { self.end - self.start } + + /// Returns `true` if `dt` falls within `[start, end)`. + pub fn contains(&self, dt: &DateTime) -> bool { + dt >= &self.start && dt < &self.end + } + + /// Returns `true` if this range overlaps with `other` (shares at least one instant). + pub fn overlaps(&self, other: &TimeRange) -> bool { + self.start < other.end && other.start < self.end + } } impl std::fmt::Display for TimeRange { @@ -186,6 +196,49 @@ mod tests { assert_eq!(r.to_string(), r.value().to_owned()); } + #[test] + fn contains_inside() { + let r = TimeRange::new(TimeRangeInput { start: start(), end: end() }).unwrap(); + let mid = Utc.with_ymd_and_hms(2025, 1, 1, 11, 0, 0).unwrap(); + assert!(r.contains(&mid)); + } + + #[test] + fn contains_at_start_inclusive() { + let r = TimeRange::new(TimeRangeInput { start: start(), end: end() }).unwrap(); + assert!(r.contains(&start())); + } + + #[test] + fn contains_at_end_exclusive() { + let r = TimeRange::new(TimeRangeInput { start: start(), end: end() }).unwrap(); + assert!(!r.contains(&end())); + } + + #[test] + fn contains_outside() { + let r = TimeRange::new(TimeRangeInput { start: start(), end: end() }).unwrap(); + let before = Utc.with_ymd_and_hms(2025, 1, 1, 9, 0, 0).unwrap(); + assert!(!r.contains(&before)); + } + + #[test] + fn overlaps_true() { + let r1 = TimeRange::new(TimeRangeInput { start: start(), end: end() }).unwrap(); + let overlap_start = Utc.with_ymd_and_hms(2025, 1, 1, 11, 0, 0).unwrap(); + let overlap_end = Utc.with_ymd_and_hms(2025, 1, 1, 13, 0, 0).unwrap(); + let r2 = TimeRange::new(TimeRangeInput { start: overlap_start, end: overlap_end }).unwrap(); + assert!(r1.overlaps(&r2)); + } + + #[test] + fn overlaps_adjacent_no_overlap() { + let r1 = TimeRange::new(TimeRangeInput { start: start(), end: end() }).unwrap(); + let after_end = Utc.with_ymd_and_hms(2025, 1, 1, 13, 0, 0).unwrap(); + let r2 = TimeRange::new(TimeRangeInput { start: end(), end: after_end }).unwrap(); + assert!(!r1.overlaps(&r2)); + } + #[test] fn into_inner_roundtrip() { let input = TimeRangeInput { diff --git a/src/temporal/unix_timestamp.rs b/src/temporal/unix_timestamp.rs index 939c96d..e55269a 100644 --- a/src/temporal/unix_timestamp.rs +++ b/src/temporal/unix_timestamp.rs @@ -1,3 +1,5 @@ +use chrono::{DateTime, TimeZone, Utc}; + use crate::errors::ValidationError; use crate::traits::ValueObject; @@ -51,6 +53,13 @@ impl ValueObject for UnixTimestamp { } } +impl UnixTimestamp { + /// Converts to a `DateTime`. + pub fn as_datetime(&self) -> DateTime { + Utc.timestamp_opt(self.0, 0).single().expect("valid timestamp") + } +} + impl std::fmt::Display for UnixTimestamp { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) @@ -84,6 +93,18 @@ mod tests { assert_eq!(ts.into_inner(), 42); } + #[test] + fn as_datetime_epoch() { + let ts = UnixTimestamp::new(0).unwrap(); + assert_eq!(ts.as_datetime().timestamp(), 0); + } + + #[test] + fn as_datetime_nonzero() { + let ts = UnixTimestamp::new(1_700_000_000).unwrap(); + assert_eq!(ts.as_datetime().timestamp(), 1_700_000_000); + } + #[test] fn display() { let ts = UnixTimestamp::new(1_000).unwrap(); From c0f98dde4774930c0d976d64d51c1ae239f1174b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Tue, 21 Apr 2026 20:24:12 +0200 Subject: [PATCH 02/15] fix: complete module exports and prelude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contact/mod.rs: export PhoneNumberInput and PhoneNumberOutput (PhoneNumberInput is a struct required to construct PhoneNumber — its absence was a real API gap) geo/mod.rs: export LatitudeInput/Output, LongitudeInput/Output, TimeZoneInput/Output, CountryRegionInput/Output (all were defined in source files but not re-exported from the module) net/mod.rs: export Input/Output type aliases for all 10 net types (Url, Domain, IpV4Address, IpV6Address, IpAddress, Port, MacAddress, MimeType, HttpStatusCode, ApiKey) lib.rs: expand prelude to cover all 8 domain modules (finance, geo, temporal, net, measurement, contact composites) so that `use arvo::prelude::*` actually brings all types into scope; fix feature flag list in crate-level docstring --- src/contact/mod.rs | 2 +- src/geo/mod.rs | 8 ++++---- src/lib.rs | 42 +++++++++++++++++++++++++++++++++++++++--- src/net/mod.rs | 20 ++++++++++---------- 4 files changed, 54 insertions(+), 18 deletions(-) diff --git a/src/contact/mod.rs b/src/contact/mod.rs index 441abc7..8d41519 100644 --- a/src/contact/mod.rs +++ b/src/contact/mod.rs @@ -7,7 +7,7 @@ mod website; pub use country_code::CountryCode; pub use email_address::EmailAddress; -pub use phone_number::PhoneNumber; +pub use phone_number::{PhoneNumber, PhoneNumberInput, PhoneNumberOutput}; pub use postal_address::PostalAddress; pub use postal_address::PostalAddressInput; pub use website::Website; diff --git a/src/geo/mod.rs b/src/geo/mod.rs index d109125..6ac3a39 100644 --- a/src/geo/mod.rs +++ b/src/geo/mod.rs @@ -7,7 +7,7 @@ mod time_zone; pub use bounding_box::{BoundingBox, BoundingBoxInput}; pub use coordinate::{Coordinate, CoordinateInput}; -pub use country_region::CountryRegion; -pub use latitude::Latitude; -pub use longitude::Longitude; -pub use time_zone::TimeZone; +pub use country_region::{CountryRegion, CountryRegionInput, CountryRegionOutput}; +pub use latitude::{Latitude, LatitudeInput, LatitudeOutput}; +pub use longitude::{Longitude, LongitudeInput, LongitudeOutput}; +pub use time_zone::{TimeZone, TimeZoneInput, TimeZoneOutput}; diff --git a/src/lib.rs b/src/lib.rs index aa599a3..9cd885a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,8 +15,9 @@ //! arvo = { version = "0.9", features = ["contact", "finance"] } //! ``` //! -//! Available features: `contact`, `serde`, `full`. -//! See [ROADMAP.md](https://github.com/codegress-com/arvo/blob/main/ROADMAP.md) for planned modules. +//! Available features: `contact`, `finance`, `geo`, `identifiers`, `measurement`, `net`, +//! `primitives`, `temporal`, `serde`, `full`. +//! See [ROADMAP.md](https://github.com/codegress-com/arvo/blob/main/ROADMAP.md) for the full type list. //! //! ## Quick start //! @@ -74,14 +75,49 @@ pub mod prelude { pub use crate::traits::ValueObject; #[cfg(feature = "contact")] - pub use crate::contact::{CountryCode, EmailAddress}; + pub use crate::contact::{ + CountryCode, EmailAddress, PhoneNumber, PhoneNumberInput, PostalAddress, PostalAddressInput, + Website, WebsiteInput, + }; + + #[cfg(feature = "finance")] + pub use crate::finance::{ + Bic, CardExpiryDate, CreditCardNumber, CurrencyCode, ExchangeRate, ExchangeRateInput, Iban, + Money, MoneyInput, Percentage, VatNumber, + }; + + #[cfg(feature = "geo")] + pub use crate::geo::{ + BoundingBox, BoundingBoxInput, Coordinate, CoordinateInput, CountryRegion, Latitude, + Longitude, TimeZone, + }; #[cfg(feature = "identifiers")] pub use crate::identifiers::{Ean8, Ean13, Isbn10, Isbn13, Issn, Slug, Vin}; + #[cfg(feature = "measurement")] + pub use crate::measurement::{ + Area, AreaInput, AreaUnit, Energy, EnergyInput, EnergyUnit, Frequency, FrequencyInput, + FrequencyUnit, Length, LengthInput, LengthUnit, Power, PowerInput, PowerUnit, Pressure, + PressureInput, PressureUnit, Speed, SpeedInput, SpeedUnit, Temperature, TemperatureInput, + TemperatureUnit, Volume, VolumeInput, VolumeUnit, Weight, WeightInput, WeightUnit, + }; + + #[cfg(feature = "net")] + pub use crate::net::{ + ApiKey, Domain, HttpStatusCode, IpAddress, IpV4Address, IpV6Address, MacAddress, MimeType, + Port, Url, + }; + #[cfg(feature = "primitives")] pub use crate::primitives::{ Base64String, BoundedString, HexColor, Locale, NonEmptyString, NonNegativeDecimal, NonNegativeInt, PositiveDecimal, PositiveInt, Probability, }; + + #[cfg(feature = "temporal")] + pub use crate::temporal::{ + BirthDate, BusinessHours, BusinessHoursInput, ExpiryDate, TimeRange, TimeRangeInput, + UnixTimestamp, + }; } diff --git a/src/net/mod.rs b/src/net/mod.rs index ee7dc8e..8726b0e 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -9,13 +9,13 @@ mod mime_type; mod port; mod url; -pub use api_key::ApiKey; -pub use domain::Domain; -pub use http_status_code::HttpStatusCode; -pub use ip_address::IpAddress; -pub use ip_v4_address::IpV4Address; -pub use ip_v6_address::IpV6Address; -pub use mac_address::MacAddress; -pub use mime_type::MimeType; -pub use port::Port; -pub use url::Url; +pub use api_key::{ApiKey, ApiKeyInput, ApiKeyOutput}; +pub use domain::{Domain, DomainInput, DomainOutput}; +pub use http_status_code::{HttpStatusCode, HttpStatusCodeInput, HttpStatusCodeOutput}; +pub use ip_address::{IpAddress, IpAddressInput, IpAddressOutput}; +pub use ip_v4_address::{IpV4Address, IpV4AddressInput, IpV4AddressOutput}; +pub use ip_v6_address::{IpV6Address, IpV6AddressInput, IpV6AddressOutput}; +pub use mac_address::{MacAddress, MacAddressInput, MacAddressOutput}; +pub use mime_type::{MimeType, MimeTypeInput, MimeTypeOutput}; +pub use port::{Port, PortInput, PortOutput}; +pub use url::{Url, UrlInput, UrlOutput}; From 63716c3e91fed88c17a083acace2e28efe236891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Tue, 21 Apr 2026 20:26:05 +0200 Subject: [PATCH 03/15] fix: export missing Input/Output type aliases from contact module CountryCodeInput/Output, EmailAddressInput/Output, PostalAddressOutput, and WebsiteOutput were defined in their source files but not re-exported from contact/mod.rs, making them inaccessible to crate consumers. --- src/contact/mod.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/contact/mod.rs b/src/contact/mod.rs index 8d41519..12636e3 100644 --- a/src/contact/mod.rs +++ b/src/contact/mod.rs @@ -5,10 +5,8 @@ mod phone_number; mod postal_address; mod website; -pub use country_code::CountryCode; -pub use email_address::EmailAddress; +pub use country_code::{CountryCode, CountryCodeInput, CountryCodeOutput}; +pub use email_address::{EmailAddress, EmailAddressInput, EmailAddressOutput}; pub use phone_number::{PhoneNumber, PhoneNumberInput, PhoneNumberOutput}; -pub use postal_address::PostalAddress; -pub use postal_address::PostalAddressInput; -pub use website::Website; -pub use website::WebsiteInput; +pub use postal_address::{PostalAddress, PostalAddressInput, PostalAddressOutput}; +pub use website::{Website, WebsiteInput, WebsiteOutput}; From 866e48ea3282b57a2ec4717954de39fd2a93a06f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Tue, 21 Apr 2026 20:27:19 +0200 Subject: [PATCH 04/15] fix: sync prelude with all module exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Input/Output type alias and Input struct now re-exported via prelude so that `use arvo::prelude::*` brings the complete public API into scope — consistent across all 8 feature modules. --- src/lib.rs | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 9cd885a..b32c42c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -76,24 +76,33 @@ pub mod prelude { #[cfg(feature = "contact")] pub use crate::contact::{ - CountryCode, EmailAddress, PhoneNumber, PhoneNumberInput, PostalAddress, PostalAddressInput, - Website, WebsiteInput, + CountryCode, CountryCodeInput, CountryCodeOutput, EmailAddress, EmailAddressInput, + EmailAddressOutput, PhoneNumber, PhoneNumberInput, PhoneNumberOutput, PostalAddress, + PostalAddressInput, PostalAddressOutput, Website, WebsiteInput, WebsiteOutput, }; #[cfg(feature = "finance")] pub use crate::finance::{ - Bic, CardExpiryDate, CreditCardNumber, CurrencyCode, ExchangeRate, ExchangeRateInput, Iban, - Money, MoneyInput, Percentage, VatNumber, + Bic, BicInput, BicOutput, CardExpiryDate, CardExpiryDateInput, CardExpiryDateOutput, + CreditCardNumber, CreditCardNumberInput, CreditCardNumberOutput, CurrencyCode, + CurrencyCodeInput, CurrencyCodeOutput, ExchangeRate, ExchangeRateInput, ExchangeRateOutput, + Iban, IbanInput, IbanOutput, Money, MoneyInput, MoneyOutput, Percentage, PercentageInput, + PercentageOutput, VatNumber, VatNumberInput, VatNumberOutput, }; #[cfg(feature = "geo")] pub use crate::geo::{ - BoundingBox, BoundingBoxInput, Coordinate, CoordinateInput, CountryRegion, Latitude, - Longitude, TimeZone, + BoundingBox, BoundingBoxInput, Coordinate, CoordinateInput, CountryRegion, + CountryRegionInput, CountryRegionOutput, Latitude, LatitudeInput, LatitudeOutput, + Longitude, LongitudeInput, LongitudeOutput, TimeZone, TimeZoneInput, TimeZoneOutput, }; #[cfg(feature = "identifiers")] - pub use crate::identifiers::{Ean8, Ean13, Isbn10, Isbn13, Issn, Slug, Vin}; + pub use crate::identifiers::{ + Ean8, Ean8Input, Ean8Output, Ean13, Ean13Input, Ean13Output, Isbn10, Isbn10Input, + Isbn10Output, Isbn13, Isbn13Input, Isbn13Output, Issn, IssnInput, IssnOutput, Slug, + SlugInput, SlugOutput, Vin, VinInput, VinOutput, + }; #[cfg(feature = "measurement")] pub use crate::measurement::{ @@ -105,19 +114,27 @@ pub mod prelude { #[cfg(feature = "net")] pub use crate::net::{ - ApiKey, Domain, HttpStatusCode, IpAddress, IpV4Address, IpV6Address, MacAddress, MimeType, - Port, Url, + ApiKey, ApiKeyInput, ApiKeyOutput, Domain, DomainInput, DomainOutput, HttpStatusCode, + HttpStatusCodeInput, HttpStatusCodeOutput, IpAddress, IpAddressInput, IpAddressOutput, + IpV4Address, IpV4AddressInput, IpV4AddressOutput, IpV6Address, IpV6AddressInput, + IpV6AddressOutput, MacAddress, MacAddressInput, MacAddressOutput, MimeType, MimeTypeInput, + MimeTypeOutput, Port, PortInput, PortOutput, Url, UrlInput, UrlOutput, }; #[cfg(feature = "primitives")] pub use crate::primitives::{ - Base64String, BoundedString, HexColor, Locale, NonEmptyString, NonNegativeDecimal, - NonNegativeInt, PositiveDecimal, PositiveInt, Probability, + Base64String, Base64StringInput, Base64StringOutput, BoundedString, HexColor, HexColorInput, + HexColorOutput, Locale, LocaleInput, LocaleOutput, NonEmptyString, NonEmptyStringInput, + NonEmptyStringOutput, NonNegativeDecimal, NonNegativeDecimalInput, NonNegativeDecimalOutput, + NonNegativeInt, NonNegativeIntInput, NonNegativeIntOutput, PositiveDecimal, + PositiveDecimalInput, PositiveDecimalOutput, PositiveInt, PositiveIntInput, + PositiveIntOutput, Probability, ProbabilityInput, ProbabilityOutput, }; #[cfg(feature = "temporal")] pub use crate::temporal::{ - BirthDate, BusinessHours, BusinessHoursInput, ExpiryDate, TimeRange, TimeRangeInput, - UnixTimestamp, + BirthDate, BirthDateInput, BirthDateOutput, BusinessHours, BusinessHoursInput, + BusinessHoursOutput, ExpiryDate, ExpiryDateInput, ExpiryDateOutput, TimeRange, + TimeRangeInput, TimeRangeOutput, UnixTimestamp, UnixTimestampInput, UnixTimestampOutput, }; } From 4e457e68c0a2c7352919a4fcd62abda6f54f63ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Tue, 21 Apr 2026 20:40:42 +0200 Subject: [PATCH 05/15] feat: implement TryFrom for all value objects Every VO now implements TryFrom for its Input type, enabling ergonomic construction with the ? operator and consistent with the existing TryFrom<&str> pattern already present in some types. Implemented conversions by Input type: - TryFrom<&str>: CreditCardNumber, ApiKey - TryFrom: Latitude, Longitude, Percentage, Probability - TryFrom: PositiveInt, NonNegativeInt, UnixTimestamp - TryFrom: Port, HttpStatusCode - TryFrom: PositiveDecimal, NonNegativeDecimal - TryFrom: BirthDate, ExpiryDate - TryFrom<*Input struct>: PhoneNumber, PostalAddress, Money, ExchangeRate, Coordinate, BoundingBox, BusinessHours, TimeRange, Length, Weight, Temperature, Volume, Area, Energy, Frequency, Power, Pressure, Speed BoundedString already had TryFrom<&str> (with const generics). --- src/contact/phone_number.rs | 8 ++++++++ src/contact/postal_address.rs | 8 ++++++++ src/finance/credit_card_number.rs | 8 ++++++++ src/finance/exchange_rate.rs | 8 ++++++++ src/finance/money.rs | 8 ++++++++ src/finance/percentage.rs | 8 ++++++++ src/geo/bounding_box.rs | 8 ++++++++ src/geo/coordinate.rs | 8 ++++++++ src/geo/latitude.rs | 8 ++++++++ src/geo/longitude.rs | 8 ++++++++ src/measurement/area.rs | 8 ++++++++ src/measurement/energy.rs | 8 ++++++++ src/measurement/frequency.rs | 8 ++++++++ src/measurement/length.rs | 8 ++++++++ src/measurement/power.rs | 8 ++++++++ src/measurement/pressure.rs | 8 ++++++++ src/measurement/speed.rs | 8 ++++++++ src/measurement/temperature.rs | 8 ++++++++ src/measurement/volume.rs | 8 ++++++++ src/measurement/weight.rs | 8 ++++++++ src/net/api_key.rs | 8 ++++++++ src/net/http_status_code.rs | 8 ++++++++ src/net/port.rs | 8 ++++++++ src/primitives/non_negative_decimal.rs | 8 ++++++++ src/primitives/non_negative_int.rs | 8 ++++++++ src/primitives/positive_decimal.rs | 8 ++++++++ src/primitives/positive_int.rs | 8 ++++++++ src/primitives/probability.rs | 8 ++++++++ src/temporal/birth_date.rs | 8 ++++++++ src/temporal/business_hours.rs | 8 ++++++++ src/temporal/expiry_date.rs | 8 ++++++++ src/temporal/time_range.rs | 8 ++++++++ src/temporal/unix_timestamp.rs | 8 ++++++++ 33 files changed, 264 insertions(+) diff --git a/src/contact/phone_number.rs b/src/contact/phone_number.rs index 0374584..e034172 100644 --- a/src/contact/phone_number.rs +++ b/src/contact/phone_number.rs @@ -117,6 +117,14 @@ impl PhoneNumber { } /// Displays the phone number in canonical E.164 format. +impl TryFrom for PhoneNumber { + type Error = ValidationError; + + fn try_from(value: PhoneNumberInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for PhoneNumber { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.e164) diff --git a/src/contact/postal_address.rs b/src/contact/postal_address.rs index 21ca2a5..e6836ae 100644 --- a/src/contact/postal_address.rs +++ b/src/contact/postal_address.rs @@ -130,6 +130,14 @@ impl PostalAddress { } /// Displays the address in a human-readable multi-line format. +impl TryFrom for PostalAddress { + type Error = ValidationError; + + fn try_from(value: PostalAddressInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for PostalAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.formatted) diff --git a/src/finance/credit_card_number.rs b/src/finance/credit_card_number.rs index af51b9c..2ac5906 100644 --- a/src/finance/credit_card_number.rs +++ b/src/finance/credit_card_number.rs @@ -118,6 +118,14 @@ fn luhn_valid(digits: &str) -> bool { sum % 10 == 0 } +impl TryFrom<&str> for CreditCardNumber { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/finance/exchange_rate.rs b/src/finance/exchange_rate.rs index d1d2167..537606e 100644 --- a/src/finance/exchange_rate.rs +++ b/src/finance/exchange_rate.rs @@ -110,6 +110,14 @@ impl ExchangeRate { } } +impl TryFrom for ExchangeRate { + type Error = ValidationError; + + fn try_from(value: ExchangeRateInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for ExchangeRate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/finance/money.rs b/src/finance/money.rs index 1976bbf..21b96c2 100644 --- a/src/finance/money.rs +++ b/src/finance/money.rs @@ -117,6 +117,14 @@ impl Money { } } +impl TryFrom for Money { + type Error = ValidationError; + + fn try_from(value: MoneyInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Money { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/finance/percentage.rs b/src/finance/percentage.rs index c0de012..833e1c7 100644 --- a/src/finance/percentage.rs +++ b/src/finance/percentage.rs @@ -63,6 +63,14 @@ impl Percentage { } } +impl TryFrom for Percentage { + type Error = ValidationError; + + fn try_from(value: f64) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Percentage { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}%", self.0) diff --git a/src/geo/bounding_box.rs b/src/geo/bounding_box.rs index af5f797..cb868eb 100644 --- a/src/geo/bounding_box.rs +++ b/src/geo/bounding_box.rs @@ -105,6 +105,14 @@ impl BoundingBox { } } +impl TryFrom for BoundingBox { + type Error = ValidationError; + + fn try_from(value: BoundingBoxInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for BoundingBox { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/geo/coordinate.rs b/src/geo/coordinate.rs index 0cf8999..96f3df8 100644 --- a/src/geo/coordinate.rs +++ b/src/geo/coordinate.rs @@ -76,6 +76,14 @@ impl Coordinate { } } +impl TryFrom for Coordinate { + type Error = ValidationError; + + fn try_from(value: CoordinateInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Coordinate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/geo/latitude.rs b/src/geo/latitude.rs index fcf3a67..3f158a8 100644 --- a/src/geo/latitude.rs +++ b/src/geo/latitude.rs @@ -52,6 +52,14 @@ impl ValueObject for Latitude { } } +impl TryFrom for Latitude { + type Error = ValidationError; + + fn try_from(value: f64) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Latitude { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{:.6}", self.0) diff --git a/src/geo/longitude.rs b/src/geo/longitude.rs index 148b7c0..7e67af1 100644 --- a/src/geo/longitude.rs +++ b/src/geo/longitude.rs @@ -52,6 +52,14 @@ impl ValueObject for Longitude { } } +impl TryFrom for Longitude { + type Error = ValidationError; + + fn try_from(value: f64) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Longitude { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{:.6}", self.0) diff --git a/src/measurement/area.rs b/src/measurement/area.rs index 03edf4d..fcbea0f 100644 --- a/src/measurement/area.rs +++ b/src/measurement/area.rs @@ -94,6 +94,14 @@ impl Area { } } +impl TryFrom for Area { + type Error = ValidationError; + + fn try_from(value: AreaInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Area { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/measurement/energy.rs b/src/measurement/energy.rs index 79141ea..409a533 100644 --- a/src/measurement/energy.rs +++ b/src/measurement/energy.rs @@ -92,6 +92,14 @@ impl Energy { } } +impl TryFrom for Energy { + type Error = ValidationError; + + fn try_from(value: EnergyInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Energy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/measurement/frequency.rs b/src/measurement/frequency.rs index a791d19..c3fa789 100644 --- a/src/measurement/frequency.rs +++ b/src/measurement/frequency.rs @@ -91,6 +91,14 @@ impl Frequency { } } +impl TryFrom for Frequency { + type Error = ValidationError; + + fn try_from(value: FrequencyInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Frequency { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/measurement/length.rs b/src/measurement/length.rs index b230bf1..03de43c 100644 --- a/src/measurement/length.rs +++ b/src/measurement/length.rs @@ -94,6 +94,14 @@ impl Length { } } +impl TryFrom for Length { + type Error = ValidationError; + + fn try_from(value: LengthInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Length { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/measurement/power.rs b/src/measurement/power.rs index aea0305..34693ff 100644 --- a/src/measurement/power.rs +++ b/src/measurement/power.rs @@ -88,6 +88,14 @@ impl Power { } } +impl TryFrom for Power { + type Error = ValidationError; + + fn try_from(value: PowerInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Power { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/measurement/pressure.rs b/src/measurement/pressure.rs index d883061..8b1c5d4 100644 --- a/src/measurement/pressure.rs +++ b/src/measurement/pressure.rs @@ -95,6 +95,14 @@ impl Pressure { } } +impl TryFrom for Pressure { + type Error = ValidationError; + + fn try_from(value: PressureInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Pressure { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/measurement/speed.rs b/src/measurement/speed.rs index 1d38f33..25613dd 100644 --- a/src/measurement/speed.rs +++ b/src/measurement/speed.rs @@ -88,6 +88,14 @@ impl Speed { } } +impl TryFrom for Speed { + type Error = ValidationError; + + fn try_from(value: SpeedInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Speed { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/measurement/temperature.rs b/src/measurement/temperature.rs index 078e2b1..ba7a7af 100644 --- a/src/measurement/temperature.rs +++ b/src/measurement/temperature.rs @@ -107,6 +107,14 @@ impl Temperature { } } +impl TryFrom for Temperature { + type Error = ValidationError; + + fn try_from(value: TemperatureInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Temperature { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/measurement/volume.rs b/src/measurement/volume.rs index f5948c5..e8ebc90 100644 --- a/src/measurement/volume.rs +++ b/src/measurement/volume.rs @@ -90,6 +90,14 @@ impl Volume { } } +impl TryFrom for Volume { + type Error = ValidationError; + + fn try_from(value: VolumeInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Volume { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/measurement/weight.rs b/src/measurement/weight.rs index 66bc956..498a8bf 100644 --- a/src/measurement/weight.rs +++ b/src/measurement/weight.rs @@ -93,6 +93,14 @@ impl Weight { } } +impl TryFrom for Weight { + type Error = ValidationError; + + fn try_from(value: WeightInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Weight { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/net/api_key.rs b/src/net/api_key.rs index 0002a83..634c0db 100644 --- a/src/net/api_key.rs +++ b/src/net/api_key.rs @@ -77,6 +77,14 @@ impl std::fmt::Display for ApiKey { } } +impl TryFrom<&str> for ApiKey { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/net/http_status_code.rs b/src/net/http_status_code.rs index 80a8e08..50e3149 100644 --- a/src/net/http_status_code.rs +++ b/src/net/http_status_code.rs @@ -77,6 +77,14 @@ impl HttpStatusCode { } } +impl TryFrom for HttpStatusCode { + type Error = ValidationError; + + fn try_from(value: u16) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for HttpStatusCode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/net/port.rs b/src/net/port.rs index f661384..dad5764 100644 --- a/src/net/port.rs +++ b/src/net/port.rs @@ -65,6 +65,14 @@ impl Port { } } +impl TryFrom for Port { + type Error = ValidationError; + + fn try_from(value: u16) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Port { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/primitives/non_negative_decimal.rs b/src/primitives/non_negative_decimal.rs index 1d76560..3af36f3 100644 --- a/src/primitives/non_negative_decimal.rs +++ b/src/primitives/non_negative_decimal.rs @@ -55,6 +55,14 @@ impl ValueObject for NonNegativeDecimal { } } +impl TryFrom for NonNegativeDecimal { + type Error = ValidationError; + + fn try_from(value: Decimal) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for NonNegativeDecimal { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/primitives/non_negative_int.rs b/src/primitives/non_negative_int.rs index 926f8b4..b003a74 100644 --- a/src/primitives/non_negative_int.rs +++ b/src/primitives/non_negative_int.rs @@ -53,6 +53,14 @@ impl ValueObject for NonNegativeInt { } } +impl TryFrom for NonNegativeInt { + type Error = ValidationError; + + fn try_from(value: i64) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for NonNegativeInt { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/primitives/positive_decimal.rs b/src/primitives/positive_decimal.rs index 63e8c0b..2276daa 100644 --- a/src/primitives/positive_decimal.rs +++ b/src/primitives/positive_decimal.rs @@ -55,6 +55,14 @@ impl ValueObject for PositiveDecimal { } } +impl TryFrom for PositiveDecimal { + type Error = ValidationError; + + fn try_from(value: Decimal) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for PositiveDecimal { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/primitives/positive_int.rs b/src/primitives/positive_int.rs index b589415..65439ed 100644 --- a/src/primitives/positive_int.rs +++ b/src/primitives/positive_int.rs @@ -54,6 +54,14 @@ impl ValueObject for PositiveInt { } } +impl TryFrom for PositiveInt { + type Error = ValidationError; + + fn try_from(value: i64) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for PositiveInt { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/primitives/probability.rs b/src/primitives/probability.rs index f4b5ad0..f098181 100644 --- a/src/primitives/probability.rs +++ b/src/primitives/probability.rs @@ -54,6 +54,14 @@ impl ValueObject for Probability { } } +impl TryFrom for Probability { + type Error = ValidationError; + + fn try_from(value: f64) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for Probability { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/temporal/birth_date.rs b/src/temporal/birth_date.rs index 5252951..b990c78 100644 --- a/src/temporal/birth_date.rs +++ b/src/temporal/birth_date.rs @@ -81,6 +81,14 @@ impl BirthDate { } } +impl TryFrom for BirthDate { + type Error = ValidationError; + + fn try_from(value: NaiveDate) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for BirthDate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/temporal/business_hours.rs b/src/temporal/business_hours.rs index 11e2082..067a71e 100644 --- a/src/temporal/business_hours.rs +++ b/src/temporal/business_hours.rs @@ -123,6 +123,14 @@ impl BusinessHours { } } +impl TryFrom for BusinessHours { + type Error = ValidationError; + + fn try_from(value: BusinessHoursInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for BusinessHours { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/temporal/expiry_date.rs b/src/temporal/expiry_date.rs index 764fa60..1884d2e 100644 --- a/src/temporal/expiry_date.rs +++ b/src/temporal/expiry_date.rs @@ -60,6 +60,14 @@ impl ExpiryDate { } } +impl TryFrom for ExpiryDate { + type Error = ValidationError; + + fn try_from(value: NaiveDate) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for ExpiryDate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/temporal/time_range.rs b/src/temporal/time_range.rs index 140eddd..899d2eb 100644 --- a/src/temporal/time_range.rs +++ b/src/temporal/time_range.rs @@ -104,6 +104,14 @@ impl TimeRange { } } +impl TryFrom for TimeRange { + type Error = ValidationError; + + fn try_from(value: TimeRangeInput) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for TimeRange { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/temporal/unix_timestamp.rs b/src/temporal/unix_timestamp.rs index e55269a..0d0ecce 100644 --- a/src/temporal/unix_timestamp.rs +++ b/src/temporal/unix_timestamp.rs @@ -60,6 +60,14 @@ impl UnixTimestamp { } } +impl TryFrom for UnixTimestamp { + type Error = ValidationError; + + fn try_from(value: i64) -> Result { + Self::new(value) + } +} + impl std::fmt::Display for UnixTimestamp { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) From 851f8735eeff7048f28cde1bb3254c1e71dde74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Tue, 21 Apr 2026 20:50:55 +0200 Subject: [PATCH 06/15] feat: implement TryFrom<&str> for all Value Objects Replace redundant TryFrom wrappers (which just delegated to new()) with TryFrom<&str> implementations that parse the canonical string representation into the input type before calling Self::new(). Phone and postal address intentionally have no TryFrom due to parsing ambiguity. --- src/contact/phone_number.rs | 8 -------- src/contact/postal_address.rs | 8 -------- src/finance/exchange_rate.rs | 12 +++++++++--- src/finance/money.rs | 10 +++++++--- src/finance/percentage.rs | 7 ++++--- src/geo/bounding_box.rs | 12 +++++++++--- src/geo/coordinate.rs | 10 +++++++--- src/geo/latitude.rs | 7 ++++--- src/geo/longitude.rs | 7 ++++--- src/measurement/area.rs | 19 ++++++++++++++++--- src/measurement/energy.rs | 18 +++++++++++++++--- src/measurement/frequency.rs | 16 +++++++++++++--- src/measurement/length.rs | 18 +++++++++++++++--- src/measurement/power.rs | 16 +++++++++++++--- src/measurement/pressure.rs | 18 +++++++++++++++--- src/measurement/speed.rs | 16 +++++++++++++--- src/measurement/temperature.rs | 15 ++++++++++++--- src/measurement/volume.rs | 17 ++++++++++++++--- src/measurement/weight.rs | 18 +++++++++++++++--- src/net/http_status_code.rs | 7 ++++--- src/net/port.rs | 7 ++++--- src/primitives/non_negative_decimal.rs | 7 ++++--- src/primitives/non_negative_int.rs | 7 ++++--- src/primitives/positive_decimal.rs | 7 ++++--- src/primitives/positive_int.rs | 7 ++++--- src/primitives/probability.rs | 7 ++++--- src/temporal/birth_date.rs | 7 ++++--- src/temporal/business_hours.rs | 21 ++++++++++++++++++--- src/temporal/expiry_date.rs | 7 ++++--- src/temporal/time_range.rs | 10 +++++++--- src/temporal/unix_timestamp.rs | 7 ++++--- 31 files changed, 250 insertions(+), 103 deletions(-) diff --git a/src/contact/phone_number.rs b/src/contact/phone_number.rs index e034172..0374584 100644 --- a/src/contact/phone_number.rs +++ b/src/contact/phone_number.rs @@ -117,14 +117,6 @@ impl PhoneNumber { } /// Displays the phone number in canonical E.164 format. -impl TryFrom for PhoneNumber { - type Error = ValidationError; - - fn try_from(value: PhoneNumberInput) -> Result { - Self::new(value) - } -} - impl std::fmt::Display for PhoneNumber { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.e164) diff --git a/src/contact/postal_address.rs b/src/contact/postal_address.rs index e6836ae..21ca2a5 100644 --- a/src/contact/postal_address.rs +++ b/src/contact/postal_address.rs @@ -130,14 +130,6 @@ impl PostalAddress { } /// Displays the address in a human-readable multi-line format. -impl TryFrom for PostalAddress { - type Error = ValidationError; - - fn try_from(value: PostalAddressInput) -> Result { - Self::new(value) - } -} - impl std::fmt::Display for PostalAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.formatted) diff --git a/src/finance/exchange_rate.rs b/src/finance/exchange_rate.rs index 537606e..56bb956 100644 --- a/src/finance/exchange_rate.rs +++ b/src/finance/exchange_rate.rs @@ -110,11 +110,17 @@ impl ExchangeRate { } } -impl TryFrom for ExchangeRate { +impl TryFrom<&str> for ExchangeRate { type Error = ValidationError; - fn try_from(value: ExchangeRateInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("ExchangeRate", value); + let (pair_str, rate_str) = value.trim().split_once(' ').ok_or_else(err)?; + let (from_str, to_str) = pair_str.split_once('/').ok_or_else(err)?; + let from = CurrencyCode::new(from_str.to_owned()).map_err(|_| err())?; + let to = CurrencyCode::new(to_str.to_owned()).map_err(|_| err())?; + let rate: rust_decimal::Decimal = rate_str.trim().parse().map_err(|_| err())?; + Self::new(ExchangeRateInput { from, to, rate }) } } diff --git a/src/finance/money.rs b/src/finance/money.rs index 21b96c2..c8b6e6c 100644 --- a/src/finance/money.rs +++ b/src/finance/money.rs @@ -117,11 +117,15 @@ impl Money { } } -impl TryFrom for Money { +impl TryFrom<&str> for Money { type Error = ValidationError; - fn try_from(value: MoneyInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Money", value); + let (amount_str, currency_str) = value.trim().rsplit_once(' ').ok_or_else(err)?; + let amount: rust_decimal::Decimal = amount_str.trim().parse().map_err(|_| err())?; + let currency = CurrencyCode::new(currency_str.trim().to_owned()).map_err(|_| err())?; + Self::new(MoneyInput { amount, currency }) } } diff --git a/src/finance/percentage.rs b/src/finance/percentage.rs index 833e1c7..1a2fb4d 100644 --- a/src/finance/percentage.rs +++ b/src/finance/percentage.rs @@ -63,11 +63,12 @@ impl Percentage { } } -impl TryFrom for Percentage { +impl TryFrom<&str> for Percentage { type Error = ValidationError; - fn try_from(value: f64) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("Percentage", value))?; + Self::new(parsed) } } diff --git a/src/geo/bounding_box.rs b/src/geo/bounding_box.rs index cb868eb..2c05226 100644 --- a/src/geo/bounding_box.rs +++ b/src/geo/bounding_box.rs @@ -105,11 +105,17 @@ impl BoundingBox { } } -impl TryFrom for BoundingBox { +impl TryFrom<&str> for BoundingBox { type Error = ValidationError; - fn try_from(value: BoundingBoxInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("BoundingBox", value); + let (sw_part, ne_part) = value.trim().split_once(" / ").ok_or_else(err)?; + let sw_str = sw_part.strip_prefix("SW: ").ok_or_else(err)?; + let ne_str = ne_part.strip_prefix("NE: ").ok_or_else(err)?; + let sw = Coordinate::try_from(sw_str).map_err(|_| err())?; + let ne = Coordinate::try_from(ne_str).map_err(|_| err())?; + Self::new(BoundingBoxInput { sw, ne }) } } diff --git a/src/geo/coordinate.rs b/src/geo/coordinate.rs index 96f3df8..465ac3d 100644 --- a/src/geo/coordinate.rs +++ b/src/geo/coordinate.rs @@ -76,11 +76,15 @@ impl Coordinate { } } -impl TryFrom for Coordinate { +impl TryFrom<&str> for Coordinate { type Error = ValidationError; - fn try_from(value: CoordinateInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Coordinate", value); + let (lat_str, lng_str) = value.trim().split_once(", ").ok_or_else(err)?; + let lat = Latitude::new(lat_str.trim().parse::().map_err(|_| err())?).map_err(|_| err())?; + let lng = Longitude::new(lng_str.trim().parse::().map_err(|_| err())?).map_err(|_| err())?; + Self::new(CoordinateInput { lat, lng }) } } diff --git a/src/geo/latitude.rs b/src/geo/latitude.rs index 3f158a8..85c5642 100644 --- a/src/geo/latitude.rs +++ b/src/geo/latitude.rs @@ -52,11 +52,12 @@ impl ValueObject for Latitude { } } -impl TryFrom for Latitude { +impl TryFrom<&str> for Latitude { type Error = ValidationError; - fn try_from(value: f64) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("Latitude", value))?; + Self::new(parsed) } } diff --git a/src/geo/longitude.rs b/src/geo/longitude.rs index 7e67af1..f8cf35d 100644 --- a/src/geo/longitude.rs +++ b/src/geo/longitude.rs @@ -52,11 +52,12 @@ impl ValueObject for Longitude { } } -impl TryFrom for Longitude { +impl TryFrom<&str> for Longitude { type Error = ValidationError; - fn try_from(value: f64) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("Longitude", value))?; + Self::new(parsed) } } diff --git a/src/measurement/area.rs b/src/measurement/area.rs index fcbea0f..12256bd 100644 --- a/src/measurement/area.rs +++ b/src/measurement/area.rs @@ -94,11 +94,24 @@ impl Area { } } -impl TryFrom for Area { +impl TryFrom<&str> for Area { type Error = ValidationError; - fn try_from(value: AreaInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Area", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "mm²" => AreaUnit::Mm2, + "cm²" => AreaUnit::Cm2, + "m²" => AreaUnit::M2, + "km²" => AreaUnit::Km2, + "in²" => AreaUnit::In2, + "ft²" => AreaUnit::Ft2, + "ha" => AreaUnit::Ha, + _ => return Err(err()), + }; + Self::new(AreaInput { value: val, unit }) } } diff --git a/src/measurement/energy.rs b/src/measurement/energy.rs index 409a533..30a58c7 100644 --- a/src/measurement/energy.rs +++ b/src/measurement/energy.rs @@ -92,11 +92,23 @@ impl Energy { } } -impl TryFrom for Energy { +impl TryFrom<&str> for Energy { type Error = ValidationError; - fn try_from(value: EnergyInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Energy", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "J" => EnergyUnit::J, + "kJ" => EnergyUnit::KJ, + "MJ" => EnergyUnit::MJ, + "kWh" => EnergyUnit::KWh, + "cal" => EnergyUnit::Cal, + "kcal" => EnergyUnit::Kcal, + _ => return Err(err()), + }; + Self::new(EnergyInput { value: val, unit }) } } diff --git a/src/measurement/frequency.rs b/src/measurement/frequency.rs index c3fa789..c9baaf5 100644 --- a/src/measurement/frequency.rs +++ b/src/measurement/frequency.rs @@ -91,11 +91,21 @@ impl Frequency { } } -impl TryFrom for Frequency { +impl TryFrom<&str> for Frequency { type Error = ValidationError; - fn try_from(value: FrequencyInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Frequency", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "Hz" => FrequencyUnit::Hz, + "kHz" => FrequencyUnit::KHz, + "MHz" => FrequencyUnit::MHz, + "GHz" => FrequencyUnit::GHz, + _ => return Err(err()), + }; + Self::new(FrequencyInput { value: val, unit }) } } diff --git a/src/measurement/length.rs b/src/measurement/length.rs index 03de43c..f4dd936 100644 --- a/src/measurement/length.rs +++ b/src/measurement/length.rs @@ -94,11 +94,23 @@ impl Length { } } -impl TryFrom for Length { +impl TryFrom<&str> for Length { type Error = ValidationError; - fn try_from(value: LengthInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Length", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "mm" => LengthUnit::Mm, + "cm" => LengthUnit::Cm, + "m" => LengthUnit::M, + "km" => LengthUnit::Km, + "in" => LengthUnit::In, + "ft" => LengthUnit::Ft, + _ => return Err(err()), + }; + Self::new(LengthInput { value: val, unit }) } } diff --git a/src/measurement/power.rs b/src/measurement/power.rs index 34693ff..5adea99 100644 --- a/src/measurement/power.rs +++ b/src/measurement/power.rs @@ -88,11 +88,21 @@ impl Power { } } -impl TryFrom for Power { +impl TryFrom<&str> for Power { type Error = ValidationError; - fn try_from(value: PowerInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Power", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "W" => PowerUnit::W, + "kW" => PowerUnit::KW, + "MW" => PowerUnit::MW, + "hp" => PowerUnit::Hp, + _ => return Err(err()), + }; + Self::new(PowerInput { value: val, unit }) } } diff --git a/src/measurement/pressure.rs b/src/measurement/pressure.rs index 8b1c5d4..5cee80f 100644 --- a/src/measurement/pressure.rs +++ b/src/measurement/pressure.rs @@ -95,11 +95,23 @@ impl Pressure { } } -impl TryFrom for Pressure { +impl TryFrom<&str> for Pressure { type Error = ValidationError; - fn try_from(value: PressureInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Pressure", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "Pa" => PressureUnit::Pa, + "kPa" => PressureUnit::KPa, + "MPa" => PressureUnit::MPa, + "bar" => PressureUnit::Bar, + "psi" => PressureUnit::Psi, + "atm" => PressureUnit::Atm, + _ => return Err(err()), + }; + Self::new(PressureInput { value: val, unit }) } } diff --git a/src/measurement/speed.rs b/src/measurement/speed.rs index 25613dd..933554d 100644 --- a/src/measurement/speed.rs +++ b/src/measurement/speed.rs @@ -88,11 +88,21 @@ impl Speed { } } -impl TryFrom for Speed { +impl TryFrom<&str> for Speed { type Error = ValidationError; - fn try_from(value: SpeedInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Speed", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "m/s" => SpeedUnit::Ms, + "km/h" => SpeedUnit::Kmh, + "mph" => SpeedUnit::Mph, + "kn" => SpeedUnit::Kn, + _ => return Err(err()), + }; + Self::new(SpeedInput { value: val, unit }) } } diff --git a/src/measurement/temperature.rs b/src/measurement/temperature.rs index ba7a7af..a48552b 100644 --- a/src/measurement/temperature.rs +++ b/src/measurement/temperature.rs @@ -107,11 +107,20 @@ impl Temperature { } } -impl TryFrom for Temperature { +impl TryFrom<&str> for Temperature { type Error = ValidationError; - fn try_from(value: TemperatureInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Temperature", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "°C" => TemperatureUnit::Celsius, + "°F" => TemperatureUnit::Fahrenheit, + "K" => TemperatureUnit::Kelvin, + _ => return Err(err()), + }; + Self::new(TemperatureInput { value: val, unit }) } } diff --git a/src/measurement/volume.rs b/src/measurement/volume.rs index e8ebc90..0774cf7 100644 --- a/src/measurement/volume.rs +++ b/src/measurement/volume.rs @@ -90,11 +90,22 @@ impl Volume { } } -impl TryFrom for Volume { +impl TryFrom<&str> for Volume { type Error = ValidationError; - fn try_from(value: VolumeInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Volume", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "ml" => VolumeUnit::Ml, + "l" => VolumeUnit::L, + "m³" => VolumeUnit::M3, + "fl oz" => VolumeUnit::FlOz, + "gal" => VolumeUnit::Gal, + _ => return Err(err()), + }; + Self::new(VolumeInput { value: val, unit }) } } diff --git a/src/measurement/weight.rs b/src/measurement/weight.rs index 498a8bf..51050c8 100644 --- a/src/measurement/weight.rs +++ b/src/measurement/weight.rs @@ -93,11 +93,23 @@ impl Weight { } } -impl TryFrom for Weight { +impl TryFrom<&str> for Weight { type Error = ValidationError; - fn try_from(value: WeightInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Weight", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "mg" => WeightUnit::Mg, + "g" => WeightUnit::G, + "kg" => WeightUnit::Kg, + "t" => WeightUnit::T, + "oz" => WeightUnit::Oz, + "lb" => WeightUnit::Lb, + _ => return Err(err()), + }; + Self::new(WeightInput { value: val, unit }) } } diff --git a/src/net/http_status_code.rs b/src/net/http_status_code.rs index 50e3149..9f6cda1 100644 --- a/src/net/http_status_code.rs +++ b/src/net/http_status_code.rs @@ -77,11 +77,12 @@ impl HttpStatusCode { } } -impl TryFrom for HttpStatusCode { +impl TryFrom<&str> for HttpStatusCode { type Error = ValidationError; - fn try_from(value: u16) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("HttpStatusCode", value))?; + Self::new(parsed) } } diff --git a/src/net/port.rs b/src/net/port.rs index dad5764..0aba13f 100644 --- a/src/net/port.rs +++ b/src/net/port.rs @@ -65,11 +65,12 @@ impl Port { } } -impl TryFrom for Port { +impl TryFrom<&str> for Port { type Error = ValidationError; - fn try_from(value: u16) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("Port", value))?; + Self::new(parsed) } } diff --git a/src/primitives/non_negative_decimal.rs b/src/primitives/non_negative_decimal.rs index 3af36f3..bdb94d4 100644 --- a/src/primitives/non_negative_decimal.rs +++ b/src/primitives/non_negative_decimal.rs @@ -55,11 +55,12 @@ impl ValueObject for NonNegativeDecimal { } } -impl TryFrom for NonNegativeDecimal { +impl TryFrom<&str> for NonNegativeDecimal { type Error = ValidationError; - fn try_from(value: Decimal) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("NonNegativeDecimal", value))?; + Self::new(parsed) } } diff --git a/src/primitives/non_negative_int.rs b/src/primitives/non_negative_int.rs index b003a74..8947864 100644 --- a/src/primitives/non_negative_int.rs +++ b/src/primitives/non_negative_int.rs @@ -53,11 +53,12 @@ impl ValueObject for NonNegativeInt { } } -impl TryFrom for NonNegativeInt { +impl TryFrom<&str> for NonNegativeInt { type Error = ValidationError; - fn try_from(value: i64) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("NonNegativeInt", value))?; + Self::new(parsed) } } diff --git a/src/primitives/positive_decimal.rs b/src/primitives/positive_decimal.rs index 2276daa..91a8c25 100644 --- a/src/primitives/positive_decimal.rs +++ b/src/primitives/positive_decimal.rs @@ -55,11 +55,12 @@ impl ValueObject for PositiveDecimal { } } -impl TryFrom for PositiveDecimal { +impl TryFrom<&str> for PositiveDecimal { type Error = ValidationError; - fn try_from(value: Decimal) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("PositiveDecimal", value))?; + Self::new(parsed) } } diff --git a/src/primitives/positive_int.rs b/src/primitives/positive_int.rs index 65439ed..9c9c8a4 100644 --- a/src/primitives/positive_int.rs +++ b/src/primitives/positive_int.rs @@ -54,11 +54,12 @@ impl ValueObject for PositiveInt { } } -impl TryFrom for PositiveInt { +impl TryFrom<&str> for PositiveInt { type Error = ValidationError; - fn try_from(value: i64) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("PositiveInt", value))?; + Self::new(parsed) } } diff --git a/src/primitives/probability.rs b/src/primitives/probability.rs index f098181..9de500f 100644 --- a/src/primitives/probability.rs +++ b/src/primitives/probability.rs @@ -54,11 +54,12 @@ impl ValueObject for Probability { } } -impl TryFrom for Probability { +impl TryFrom<&str> for Probability { type Error = ValidationError; - fn try_from(value: f64) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("Probability", value))?; + Self::new(parsed) } } diff --git a/src/temporal/birth_date.rs b/src/temporal/birth_date.rs index b990c78..90886e8 100644 --- a/src/temporal/birth_date.rs +++ b/src/temporal/birth_date.rs @@ -81,11 +81,12 @@ impl BirthDate { } } -impl TryFrom for BirthDate { +impl TryFrom<&str> for BirthDate { type Error = ValidationError; - fn try_from(value: NaiveDate) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let parsed = chrono::NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| ValidationError::invalid("BirthDate", value))?; + Self::new(parsed) } } diff --git a/src/temporal/business_hours.rs b/src/temporal/business_hours.rs index 067a71e..f538f1f 100644 --- a/src/temporal/business_hours.rs +++ b/src/temporal/business_hours.rs @@ -123,11 +123,26 @@ impl BusinessHours { } } -impl TryFrom for BusinessHours { +impl TryFrom<&str> for BusinessHours { type Error = ValidationError; - fn try_from(value: BusinessHoursInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("BusinessHours", value); + let (day_str, times_str) = value.trim().split_once(' ').ok_or_else(err)?; + let weekday = match day_str { + "Mon" => chrono::Weekday::Mon, + "Tue" => chrono::Weekday::Tue, + "Wed" => chrono::Weekday::Wed, + "Thu" => chrono::Weekday::Thu, + "Fri" => chrono::Weekday::Fri, + "Sat" => chrono::Weekday::Sat, + "Sun" => chrono::Weekday::Sun, + _ => return Err(err()), + }; + let (open_str, close_str) = times_str.split_once('\u{2013}').ok_or_else(err)?; + let open = chrono::NaiveTime::parse_from_str(open_str.trim(), "%H:%M").map_err(|_| err())?; + let close = chrono::NaiveTime::parse_from_str(close_str.trim(), "%H:%M").map_err(|_| err())?; + Self::new(BusinessHoursInput { weekday, open, close }) } } diff --git a/src/temporal/expiry_date.rs b/src/temporal/expiry_date.rs index 1884d2e..e905dcb 100644 --- a/src/temporal/expiry_date.rs +++ b/src/temporal/expiry_date.rs @@ -60,11 +60,12 @@ impl ExpiryDate { } } -impl TryFrom for ExpiryDate { +impl TryFrom<&str> for ExpiryDate { type Error = ValidationError; - fn try_from(value: NaiveDate) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let parsed = chrono::NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| ValidationError::invalid("ExpiryDate", value))?; + Self::new(parsed) } } diff --git a/src/temporal/time_range.rs b/src/temporal/time_range.rs index 899d2eb..9e8e7d2 100644 --- a/src/temporal/time_range.rs +++ b/src/temporal/time_range.rs @@ -104,11 +104,15 @@ impl TimeRange { } } -impl TryFrom for TimeRange { +impl TryFrom<&str> for TimeRange { type Error = ValidationError; - fn try_from(value: TimeRangeInput) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("TimeRange", value); + let (start_str, end_str) = value.trim().split_once(" / ").ok_or_else(err)?; + let start: chrono::DateTime = start_str.trim().parse().map_err(|_| err())?; + let end: chrono::DateTime = end_str.trim().parse().map_err(|_| err())?; + Self::new(TimeRangeInput { start, end }) } } diff --git a/src/temporal/unix_timestamp.rs b/src/temporal/unix_timestamp.rs index 0d0ecce..78df9ba 100644 --- a/src/temporal/unix_timestamp.rs +++ b/src/temporal/unix_timestamp.rs @@ -60,11 +60,12 @@ impl UnixTimestamp { } } -impl TryFrom for UnixTimestamp { +impl TryFrom<&str> for UnixTimestamp { type Error = ValidationError; - fn try_from(value: i64) -> Result { - Self::new(value) + fn try_from(value: &str) -> Result { + let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("UnixTimestamp", value))?; + Self::new(parsed) } } From 3ff6e9d6147372af729a7bccea7f04b0fda28cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Wed, 22 Apr 2026 07:11:29 +0200 Subject: [PATCH 07/15] test: add TryFrom<&str> tests for all Value Objects Cover happy path, invalid format, and invalid value for all 29 types that received TryFrom<&str> implementations. --- src/finance/exchange_rate.rs | 16 ++++++++++++++++ src/finance/money.rs | 16 ++++++++++++++++ src/finance/percentage.rs | 16 ++++++++++++++++ src/geo/bounding_box.rs | 17 +++++++++++++++++ src/geo/coordinate.rs | 16 ++++++++++++++++ src/geo/latitude.rs | 16 ++++++++++++++++ src/geo/longitude.rs | 16 ++++++++++++++++ src/measurement/area.rs | 16 ++++++++++++++++ src/measurement/energy.rs | 16 ++++++++++++++++ src/measurement/frequency.rs | 16 ++++++++++++++++ src/measurement/length.rs | 16 ++++++++++++++++ src/measurement/power.rs | 16 ++++++++++++++++ src/measurement/pressure.rs | 16 ++++++++++++++++ src/measurement/speed.rs | 16 ++++++++++++++++ src/measurement/temperature.rs | 16 ++++++++++++++++ src/measurement/volume.rs | 16 ++++++++++++++++ src/measurement/weight.rs | 16 ++++++++++++++++ src/net/http_status_code.rs | 16 ++++++++++++++++ src/net/port.rs | 16 ++++++++++++++++ src/primitives/non_negative_decimal.rs | 16 ++++++++++++++++ src/primitives/non_negative_int.rs | 16 ++++++++++++++++ src/primitives/positive_decimal.rs | 16 ++++++++++++++++ src/primitives/positive_int.rs | 16 ++++++++++++++++ src/primitives/probability.rs | 16 ++++++++++++++++ src/temporal/birth_date.rs | 16 ++++++++++++++++ src/temporal/business_hours.rs | 16 ++++++++++++++++ src/temporal/expiry_date.rs | 16 ++++++++++++++++ src/temporal/time_range.rs | 16 ++++++++++++++++ src/temporal/unix_timestamp.rs | 16 ++++++++++++++++ 29 files changed, 465 insertions(+) diff --git a/src/finance/exchange_rate.rs b/src/finance/exchange_rate.rs index 56bb956..007b5dc 100644 --- a/src/finance/exchange_rate.rs +++ b/src/finance/exchange_rate.rs @@ -234,4 +234,20 @@ mod tests { .unwrap(); assert_eq!(r.to_string(), r.value().to_owned()); } + + #[test] + fn try_from_parses_valid() { + let r = ExchangeRate::try_from("EUR/USD 1.0850").unwrap(); + assert_eq!(r.value(), "EUR/USD 1.0850"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(ExchangeRate::try_from("EURUSD1.0850").is_err()); + } + + #[test] + fn try_from_rejects_missing_slash() { + assert!(ExchangeRate::try_from("EURUSD 1.0850").is_err()); + } } diff --git a/src/finance/money.rs b/src/finance/money.rs index c8b6e6c..24dc15b 100644 --- a/src/finance/money.rs +++ b/src/finance/money.rs @@ -256,4 +256,20 @@ mod tests { let m = Money::new(input.clone()).unwrap(); assert_eq!(m.into_inner(), input); } + + #[test] + fn try_from_parses_valid() { + let m = Money::try_from("10.50 EUR").unwrap(); + assert_eq!(m.value(), "10.50 EUR"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Money::try_from("10.50EUR").is_err()); + } + + #[test] + fn try_from_rejects_invalid_currency() { + assert!(Money::try_from("10.50 INVALID").is_err()); + } } diff --git a/src/finance/percentage.rs b/src/finance/percentage.rs index 1a2fb4d..3acac6c 100644 --- a/src/finance/percentage.rs +++ b/src/finance/percentage.rs @@ -132,4 +132,20 @@ mod tests { let p = Percentage::new(33.3).unwrap(); assert_eq!(p.into_inner(), 33.3); } + + #[test] + fn try_from_parses_valid() { + let p = Percentage::try_from("42.5").unwrap(); + assert_eq!(*p.value(), 42.5); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(Percentage::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_out_of_range() { + assert!(Percentage::try_from("101.0").is_err()); + } } diff --git a/src/geo/bounding_box.rs b/src/geo/bounding_box.rs index 2c05226..6e0aca2 100644 --- a/src/geo/bounding_box.rs +++ b/src/geo/bounding_box.rs @@ -233,4 +233,21 @@ mod tests { .unwrap(); assert_eq!(bbox.to_string(), bbox.value()); } + + #[test] + fn try_from_parses_valid() { + let bbox = BoundingBox::try_from("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000").unwrap(); + assert!(bbox.value().starts_with("SW:")); + assert!(bbox.value().contains("NE:")); + } + + #[test] + fn try_from_rejects_missing_prefix() { + assert!(BoundingBox::try_from("48.0, 14.0 / 51.0, 18.0").is_err()); + } + + #[test] + fn try_from_rejects_sw_north_of_ne() { + assert!(BoundingBox::try_from("SW: 52.000000, 14.000000 / NE: 51.000000, 18.000000").is_err()); + } } diff --git a/src/geo/coordinate.rs b/src/geo/coordinate.rs index 465ac3d..fc09c37 100644 --- a/src/geo/coordinate.rs +++ b/src/geo/coordinate.rs @@ -132,4 +132,20 @@ mod tests { assert_eq!(*inner.lat.value(), 48.858844); assert_eq!(*inner.lng.value(), 2.294351); } + + #[test] + fn try_from_parses_valid() { + let c = Coordinate::try_from("48.858844, 2.294351").unwrap(); + assert_eq!(c.value(), "48.858844, 2.294351"); + } + + #[test] + fn try_from_rejects_no_comma_separator() { + assert!(Coordinate::try_from("48.858844 2.294351").is_err()); + } + + #[test] + fn try_from_rejects_invalid_lat() { + assert!(Coordinate::try_from("91.0, 0.0").is_err()); + } } diff --git a/src/geo/latitude.rs b/src/geo/latitude.rs index 85c5642..98a913d 100644 --- a/src/geo/latitude.rs +++ b/src/geo/latitude.rs @@ -111,4 +111,20 @@ mod tests { let lat = Latitude::new(51.5074).unwrap(); assert_eq!(lat.into_inner(), 51.5074); } + + #[test] + fn try_from_parses_valid() { + let lat = Latitude::try_from("48.8588").unwrap(); + assert_eq!(*lat.value(), 48.8588); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(Latitude::try_from("not_a_number").is_err()); + } + + #[test] + fn try_from_rejects_out_of_range() { + assert!(Latitude::try_from("91.0").is_err()); + } } diff --git a/src/geo/longitude.rs b/src/geo/longitude.rs index f8cf35d..db2d48c 100644 --- a/src/geo/longitude.rs +++ b/src/geo/longitude.rs @@ -111,4 +111,20 @@ mod tests { let lng = Longitude::new(-0.1278).unwrap(); assert_eq!(lng.into_inner(), -0.1278); } + + #[test] + fn try_from_parses_valid() { + let lng = Longitude::try_from("2.294351").unwrap(); + assert_eq!(*lng.value(), 2.294351); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(Longitude::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_out_of_range() { + assert!(Longitude::try_from("181.0").is_err()); + } } diff --git a/src/measurement/area.rs b/src/measurement/area.rs index 12256bd..3704d4b 100644 --- a/src/measurement/area.rs +++ b/src/measurement/area.rs @@ -167,4 +167,20 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let a = Area::try_from("1.5 m²").unwrap(); + assert_eq!(a.value(), "1.5 m²"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Area::try_from("1.5").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Area::try_from("1.5 acres").is_err()); + } } diff --git a/src/measurement/energy.rs b/src/measurement/energy.rs index 30a58c7..4d5041d 100644 --- a/src/measurement/energy.rs +++ b/src/measurement/energy.rs @@ -164,4 +164,20 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let e = Energy::try_from("1.5 kJ").unwrap(); + assert_eq!(e.value(), "1.5 kJ"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Energy::try_from("1.5").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Energy::try_from("1.5 BTU").is_err()); + } } diff --git a/src/measurement/frequency.rs b/src/measurement/frequency.rs index c9baaf5..d04fad5 100644 --- a/src/measurement/frequency.rs +++ b/src/measurement/frequency.rs @@ -161,4 +161,20 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let f = Frequency::try_from("2.4 GHz").unwrap(); + assert_eq!(f.value(), "2.4 GHz"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Frequency::try_from("2.4").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Frequency::try_from("2.4 THz").is_err()); + } } diff --git a/src/measurement/length.rs b/src/measurement/length.rs index f4dd936..0a80214 100644 --- a/src/measurement/length.rs +++ b/src/measurement/length.rs @@ -181,4 +181,20 @@ mod tests { assert!(Length::new(LengthInput { value: 1.0, unit }).is_ok()); } } + + #[test] + fn try_from_parses_valid() { + let l = Length::try_from("1.5 km").unwrap(); + assert_eq!(l.value(), "1.5 km"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Length::try_from("1.5").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Length::try_from("1.5 parsec").is_err()); + } } diff --git a/src/measurement/power.rs b/src/measurement/power.rs index 5adea99..3c41670 100644 --- a/src/measurement/power.rs +++ b/src/measurement/power.rs @@ -158,4 +158,20 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let p = Power::try_from("3.7 kW").unwrap(); + assert_eq!(p.value(), "3.7 kW"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Power::try_from("3.7").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Power::try_from("3.7 CVs").is_err()); + } } diff --git a/src/measurement/pressure.rs b/src/measurement/pressure.rs index 5cee80f..5f56a94 100644 --- a/src/measurement/pressure.rs +++ b/src/measurement/pressure.rs @@ -167,4 +167,20 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let p = Pressure::try_from("101.325 kPa").unwrap(); + assert_eq!(p.value(), "101.325 kPa"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Pressure::try_from("101").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Pressure::try_from("1.0 hg").is_err()); + } } diff --git a/src/measurement/speed.rs b/src/measurement/speed.rs index 933554d..f4eae66 100644 --- a/src/measurement/speed.rs +++ b/src/measurement/speed.rs @@ -158,4 +158,20 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let s = Speed::try_from("120 km/h").unwrap(); + assert_eq!(s.value(), "120 km/h"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Speed::try_from("120").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Speed::try_from("120 warp").is_err()); + } } diff --git a/src/measurement/temperature.rs b/src/measurement/temperature.rs index a48552b..885bbb5 100644 --- a/src/measurement/temperature.rs +++ b/src/measurement/temperature.rs @@ -209,4 +209,20 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let t = Temperature::try_from("100 °C").unwrap(); + assert_eq!(t.value(), "100 °C"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Temperature::try_from("100").is_err()); + } + + #[test] + fn try_from_rejects_below_absolute_zero() { + assert!(Temperature::try_from("-500 K").is_err()); + } } diff --git a/src/measurement/volume.rs b/src/measurement/volume.rs index 0774cf7..094d200 100644 --- a/src/measurement/volume.rs +++ b/src/measurement/volume.rs @@ -161,4 +161,20 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let v = Volume::try_from("1.5 l").unwrap(); + assert_eq!(v.value(), "1.5 l"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Volume::try_from("1.5").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Volume::try_from("1.5 cups").is_err()); + } } diff --git a/src/measurement/weight.rs b/src/measurement/weight.rs index 51050c8..d862c24 100644 --- a/src/measurement/weight.rs +++ b/src/measurement/weight.rs @@ -166,4 +166,20 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let w = Weight::try_from("70 kg").unwrap(); + assert_eq!(w.value(), "70 kg"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Weight::try_from("70").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Weight::try_from("70 stone").is_err()); + } } diff --git a/src/net/http_status_code.rs b/src/net/http_status_code.rs index 9f6cda1..ee7c6a8 100644 --- a/src/net/http_status_code.rs +++ b/src/net/http_status_code.rs @@ -138,4 +138,20 @@ mod tests { let code = HttpStatusCode::new(201).unwrap(); assert_eq!(code.into_inner(), 201); } + + #[test] + fn try_from_parses_valid() { + let c = HttpStatusCode::try_from("200").unwrap(); + assert_eq!(*c.value(), 200); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(HttpStatusCode::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_out_of_range() { + assert!(HttpStatusCode::try_from("99").is_err()); + } } diff --git a/src/net/port.rs b/src/net/port.rs index 0aba13f..38a6114 100644 --- a/src/net/port.rs +++ b/src/net/port.rs @@ -132,4 +132,20 @@ mod tests { let port = Port::new(3000).unwrap(); assert_eq!(port.into_inner(), 3000); } + + #[test] + fn try_from_parses_valid() { + let p = Port::try_from("8080").unwrap(); + assert_eq!(*p.value(), 8080); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(Port::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_zero() { + assert!(Port::try_from("0").is_err()); + } } diff --git a/src/primitives/non_negative_decimal.rs b/src/primitives/non_negative_decimal.rs index bdb94d4..e03e0dc 100644 --- a/src/primitives/non_negative_decimal.rs +++ b/src/primitives/non_negative_decimal.rs @@ -91,4 +91,20 @@ mod tests { fn rejects_negative() { assert!(NonNegativeDecimal::new(Decimal::from_str("-0.01").unwrap()).is_err()); } + + #[test] + fn try_from_parses_valid() { + let v = NonNegativeDecimal::try_from("0.00").unwrap(); + assert_eq!(v.value().to_string(), "0.00"); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(NonNegativeDecimal::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_negative() { + assert!(NonNegativeDecimal::try_from("-1").is_err()); + } } diff --git a/src/primitives/non_negative_int.rs b/src/primitives/non_negative_int.rs index 8947864..b713891 100644 --- a/src/primitives/non_negative_int.rs +++ b/src/primitives/non_negative_int.rs @@ -88,4 +88,20 @@ mod tests { fn rejects_negative() { assert!(NonNegativeInt::new(-1).is_err()); } + + #[test] + fn try_from_parses_valid() { + let v = NonNegativeInt::try_from("0").unwrap(); + assert_eq!(*v.value(), 0); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(NonNegativeInt::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_negative() { + assert!(NonNegativeInt::try_from("-1").is_err()); + } } diff --git a/src/primitives/positive_decimal.rs b/src/primitives/positive_decimal.rs index 91a8c25..40243ec 100644 --- a/src/primitives/positive_decimal.rs +++ b/src/primitives/positive_decimal.rs @@ -90,4 +90,20 @@ mod tests { fn rejects_negative() { assert!(PositiveDecimal::new(Decimal::from_str("-1").unwrap()).is_err()); } + + #[test] + fn try_from_parses_valid() { + let v = PositiveDecimal::try_from("3.14").unwrap(); + assert_eq!(v.value().to_string(), "3.14"); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(PositiveDecimal::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_zero() { + assert!(PositiveDecimal::try_from("0").is_err()); + } } diff --git a/src/primitives/positive_int.rs b/src/primitives/positive_int.rs index 9c9c8a4..7ce975c 100644 --- a/src/primitives/positive_int.rs +++ b/src/primitives/positive_int.rs @@ -94,4 +94,20 @@ mod tests { fn rejects_negative() { assert!(PositiveInt::new(-1).is_err()); } + + #[test] + fn try_from_parses_valid() { + let v = PositiveInt::try_from("42").unwrap(); + assert_eq!(*v.value(), 42); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(PositiveInt::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_zero() { + assert!(PositiveInt::try_from("0").is_err()); + } } diff --git a/src/primitives/probability.rs b/src/primitives/probability.rs index 9de500f..902a59a 100644 --- a/src/primitives/probability.rs +++ b/src/primitives/probability.rs @@ -110,4 +110,20 @@ mod tests { fn rejects_infinity() { assert!(Probability::new(f64::INFINITY).is_err()); } + + #[test] + fn try_from_parses_valid() { + let p = Probability::try_from("0.5").unwrap(); + assert_eq!(*p.value(), 0.5); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(Probability::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_out_of_range() { + assert!(Probability::try_from("1.1").is_err()); + } } diff --git a/src/temporal/birth_date.rs b/src/temporal/birth_date.rs index 90886e8..13ef47a 100644 --- a/src/temporal/birth_date.rs +++ b/src/temporal/birth_date.rs @@ -150,4 +150,20 @@ mod tests { let d = BirthDate::new(past_date()).unwrap(); assert_eq!(d.into_inner(), past_date()); } + + #[test] + fn try_from_parses_valid() { + let d = BirthDate::try_from("1990-06-15").unwrap(); + assert_eq!(d.value().to_string(), "1990-06-15"); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(BirthDate::try_from("15-06-1990").is_err()); + } + + #[test] + fn try_from_rejects_future_date() { + assert!(BirthDate::try_from("2099-01-01").is_err()); + } } diff --git a/src/temporal/business_hours.rs b/src/temporal/business_hours.rs index f538f1f..2aecc4d 100644 --- a/src/temporal/business_hours.rs +++ b/src/temporal/business_hours.rs @@ -297,4 +297,20 @@ mod tests { let h = BusinessHours::new(input.clone()).unwrap(); assert_eq!(h.into_inner(), input); } + + #[test] + fn try_from_parses_valid() { + let h = BusinessHours::try_from("Mon 09:00–17:00").unwrap(); + assert_eq!(h.value(), "Mon 09:00–17:00"); + } + + #[test] + fn try_from_rejects_invalid_day() { + assert!(BusinessHours::try_from("Xyz 09:00–17:00").is_err()); + } + + #[test] + fn try_from_rejects_close_before_open() { + assert!(BusinessHours::try_from("Mon 17:00–09:00").is_err()); + } } diff --git a/src/temporal/expiry_date.rs b/src/temporal/expiry_date.rs index e905dcb..46df1c6 100644 --- a/src/temporal/expiry_date.rs +++ b/src/temporal/expiry_date.rs @@ -123,4 +123,20 @@ mod tests { let d = ExpiryDate::new(future_date()).unwrap(); assert_eq!(d.into_inner(), future_date()); } + + #[test] + fn try_from_parses_valid() { + let d = ExpiryDate::try_from("2030-12-31").unwrap(); + assert_eq!(d.to_string(), "2030-12-31"); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(ExpiryDate::try_from("31-12-2030").is_err()); + } + + #[test] + fn try_from_rejects_past_date() { + assert!(ExpiryDate::try_from("2020-01-01").is_err()); + } } diff --git a/src/temporal/time_range.rs b/src/temporal/time_range.rs index 9e8e7d2..9cd8b98 100644 --- a/src/temporal/time_range.rs +++ b/src/temporal/time_range.rs @@ -260,4 +260,20 @@ mod tests { let r = TimeRange::new(input.clone()).unwrap(); assert_eq!(r.into_inner(), input); } + + #[test] + fn try_from_parses_valid() { + let r = TimeRange::try_from("2025-01-01 10:00:00 UTC / 2025-01-01 12:00:00 UTC").unwrap(); + assert_eq!(r.duration().num_hours(), 2); + } + + #[test] + fn try_from_rejects_no_separator() { + assert!(TimeRange::try_from("2025-01-01T10:00:00Z").is_err()); + } + + #[test] + fn try_from_rejects_end_before_start() { + assert!(TimeRange::try_from("2025-01-01 12:00:00 UTC / 2025-01-01 10:00:00 UTC").is_err()); + } } diff --git a/src/temporal/unix_timestamp.rs b/src/temporal/unix_timestamp.rs index 78df9ba..2fa7b5e 100644 --- a/src/temporal/unix_timestamp.rs +++ b/src/temporal/unix_timestamp.rs @@ -119,4 +119,20 @@ mod tests { let ts = UnixTimestamp::new(1_000).unwrap(); assert_eq!(ts.to_string(), "1000"); } + + #[test] + fn try_from_parses_valid() { + let ts = UnixTimestamp::try_from("1700000000").unwrap(); + assert_eq!(*ts.value(), 1_700_000_000); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(UnixTimestamp::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_negative() { + assert!(UnixTimestamp::try_from("-1").is_err()); + } } From f2f872ade49b16e37438af21cea2a3327d355ff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Wed, 22 Apr 2026 07:31:58 +0200 Subject: [PATCH 08/15] feat: add sqlx PostgreSQL support and document TryFrom<&str> All Value Objects now implement sqlx::Type + Encode + Decode for Postgres via the opt-in `sql` feature. Simple newtypes use transparent mapping to their native DB type; composite types (Money, Coordinate, measurements, etc.) round-trip as TEXT via their canonical string and TryFrom<&str>. Port and HttpStatusCode map to INT4. PhoneNumber and PostalAddress are intentionally excluded (canonical string is not reversible). README gains a "Parsing from strings" section documenting TryFrom<&str> and a "SQL support" section with storage mapping table and usage example. --- Cargo.lock | 7 +++ Cargo.toml | 2 +- README.md | 70 +++++++++++++++++++++++++- src/contact/country_code.rs | 2 + src/contact/email_address.rs | 2 + src/contact/website.rs | 2 + src/finance/bic.rs | 2 + src/finance/card_expiry_date.rs | 2 + src/finance/credit_card_number.rs | 2 + src/finance/currency_code.rs | 2 + src/finance/exchange_rate.rs | 28 +++++++++++ src/finance/iban.rs | 2 + src/finance/money.rs | 28 +++++++++++ src/finance/percentage.rs | 2 + src/finance/vat_number.rs | 2 + src/geo/bounding_box.rs | 28 +++++++++++ src/geo/coordinate.rs | 28 +++++++++++ src/geo/country_region.rs | 2 + src/geo/latitude.rs | 2 + src/geo/longitude.rs | 2 + src/geo/time_zone.rs | 2 + src/identifiers/ean13.rs | 2 + src/identifiers/ean8.rs | 2 + src/identifiers/isbn10.rs | 2 + src/identifiers/isbn13.rs | 2 + src/identifiers/issn.rs | 2 + src/identifiers/slug.rs | 2 + src/identifiers/vin.rs | 2 + src/measurement/area.rs | 28 +++++++++++ src/measurement/energy.rs | 28 +++++++++++ src/measurement/frequency.rs | 28 +++++++++++ src/measurement/length.rs | 28 +++++++++++ src/measurement/power.rs | 28 +++++++++++ src/measurement/pressure.rs | 28 +++++++++++ src/measurement/speed.rs | 28 +++++++++++ src/measurement/temperature.rs | 28 +++++++++++ src/measurement/volume.rs | 28 +++++++++++ src/measurement/weight.rs | 28 +++++++++++ src/net/api_key.rs | 2 + src/net/domain.rs | 2 + src/net/http_status_code.rs | 29 +++++++++++ src/net/ip_address.rs | 2 + src/net/ip_v4_address.rs | 2 + src/net/ip_v6_address.rs | 2 + src/net/mac_address.rs | 2 + src/net/mime_type.rs | 2 + src/net/port.rs | 29 +++++++++++ src/net/url.rs | 2 + src/primitives/base64_string.rs | 2 + src/primitives/bounded_string.rs | 32 ++++++++++++ src/primitives/hex_color.rs | 2 + src/primitives/locale.rs | 2 + src/primitives/non_empty_string.rs | 2 + src/primitives/non_negative_decimal.rs | 2 + src/primitives/non_negative_int.rs | 2 + src/primitives/positive_decimal.rs | 2 + src/primitives/positive_int.rs | 2 + src/primitives/probability.rs | 2 + src/temporal/birth_date.rs | 2 + src/temporal/business_hours.rs | 28 +++++++++++ src/temporal/expiry_date.rs | 2 + src/temporal/time_range.rs | 28 +++++++++++ src/temporal/unix_timestamp.rs | 2 + 63 files changed, 697 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e24c40..c3c6b13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1321,6 +1321,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -1336,6 +1337,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", + "rust_decimal", "serde", "serde_json", "sha2", @@ -1393,6 +1395,7 @@ dependencies = [ "bitflags", "byteorder", "bytes", + "chrono", "crc", "digest", "dotenvy", @@ -1413,6 +1416,7 @@ dependencies = [ "percent-encoding", "rand 0.8.6", "rsa", + "rust_decimal", "serde", "sha1", "sha2", @@ -1434,6 +1438,7 @@ dependencies = [ "base64", "bitflags", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -1450,6 +1455,7 @@ dependencies = [ "memchr", "once_cell", "rand 0.8.6", + "rust_decimal", "serde", "serde_json", "sha2", @@ -1468,6 +1474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", diff --git a/Cargo.toml b/Cargo.toml index cce2acb..472441a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ ulid = { version = "1", optional = true } url = { version = "~2.4", optional = true } base64 = { version = "0.22", optional = true } serde = { version = "1", optional = true, features = ["derive"] } -sqlx = { version = "0.8", optional = true, features = ["postgres"] } +sqlx = { version = "0.8", optional = true, features = ["postgres", "chrono", "rust_decimal"] } [dev-dependencies] serde_json = "1" diff --git a/README.md b/README.md index 7fdceae..31c2f81 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ let email: EmailAddress = "user@example.com".try_into()?; - [The `ValueObject` trait](#the-valueobject-trait) - [Error handling](#error-handling) - [Serde support](#serde-support) +- [SQL support](#sql-support) - [Roadmap](#roadmap) - [Contributing](#contributing) @@ -79,6 +80,7 @@ Enable only the modules you need — unused features add zero dependencies. | `primitives` | `NonEmptyString`, `BoundedString`, `PositiveInt`, `NonNegativeInt`, `PositiveDecimal`, `NonNegativeDecimal`, `Probability`, `HexColor`, `Locale`, `Base64String` | `rust_decimal`, `base64` | | `temporal` | `UnixTimestamp`, `BirthDate`, `ExpiryDate`, `TimeRange`, `BusinessHours` | `chrono` | | `serde` | `Serialize` / `Deserialize` on all types | `serde` | +| `sql` | `sqlx::Type` + `Encode` + `Decode` for PostgreSQL on all types | `sqlx` | | `full` | All domain modules | all of the above | > **Tip:** `serde` and `full` are orthogonal — combine them freely: @@ -92,7 +94,7 @@ Enable only the modules you need — unused features add zero dependencies. use arvo::contact::{CountryCode, PhoneNumber, PhoneNumberInput}; use arvo::prelude::*; -// Simple value object — validated and normalised on construction +// Construct via new() — validates and normalises on construction let email = EmailAddress::new("User@Example.COM".into())?; assert_eq!(email.value(), "user@example.com"); // always lowercase assert_eq!(email.domain(), "example.com"); @@ -185,6 +187,33 @@ match EmailAddress::new("bad".into()) { --- +## Parsing from strings + +Every type implements `TryFrom<&str>` (and therefore `.try_into()`) that parses the canonical string representation and validates in one step: + +```rust,ignore +// Simple types parse their primitive value +let lat: Latitude = "48.8588".try_into()?; +let port: Port = "8080".try_into()?; +let ts: UnixTimestamp = "1700000000".try_into()?; +let dob: BirthDate = "1990-06-15".try_into()?; + +// Composite types parse their canonical string format +let money: Money = "10.50 EUR".try_into()?; +let rate: ExchangeRate = "EUR/USD 1.0850".try_into()?; +let coord: Coordinate = "48.858844, 2.294351".try_into()?; +let len: Length = "1.5 km".try_into()?; +let range: TimeRange = "2025-01-01 10:00:00 UTC / 2025-01-01 12:00:00 UTC".try_into()?; +let hours: BusinessHours = "Mon 09:00–17:00".try_into()?; +``` + +Parsing errors return `ValidationError` just like `::new()`. + +> **Note:** `PhoneNumber` and `PostalAddress` do not implement `TryFrom<&str>` — their +> canonical strings are not unambiguously reversible back to a structured input. + +--- + ## Serde support Enable the `serde` feature. All types serialize as their raw primitive (transparent newtype): @@ -203,6 +232,45 @@ let parsed: EmailAddress = serde_json::from_str(r#""hello@example.com""#)?; --- +## SQL support + +Enable the `sql` feature. All types implement `sqlx::Type`, `sqlx::Encode`, and `sqlx::Decode` for PostgreSQL: + +```toml +arvo = { version = "0.9", features = ["finance", "sql"] } +``` + +```rust,ignore +// Use arvo types directly in sqlx queries +let money: Money = sqlx::query_scalar("SELECT price FROM products WHERE id = $1") + .bind(id) + .fetch_one(&pool) + .await?; + +// Bind arvo types as query parameters +sqlx::query("INSERT INTO orders (amount) VALUES ($1)") + .bind(Money::new(MoneyInput { amount: "9.99".parse()?, currency })?) + .execute(&pool) + .await?; +``` + +**Storage mapping:** + +| Type category | PostgreSQL type | +|:---|:---| +| `String`-based newtypes (`EmailAddress`, `Iban`, `Slug`, …) | `TEXT` | +| `f64` newtypes (`Latitude`, `Longitude`, `Percentage`, …) | `FLOAT8` | +| `i64` newtypes (`PositiveInt`, `UnixTimestamp`, …) | `INT8` | +| `Decimal` newtypes (`PositiveDecimal`, `NonNegativeDecimal`) | `NUMERIC` | +| `NaiveDate` newtypes (`BirthDate`, `ExpiryDate`) | `DATE` | +| `Port`, `HttpStatusCode` (u16) | `INT4` | +| Composite types (`Money`, `Coordinate`, `Length`, …) | `TEXT` (canonical string) | + +> **Note:** `PhoneNumber` and `PostalAddress` do not implement sqlx traits — +> their canonical strings cannot be unambiguously decoded back to a structured value. + +--- + ## Roadmap 62 value object types planned across 8 domain modules. Types are only added when they bring validation, normalisation, or domain semantics that existing crates don't already provide. diff --git a/src/contact/country_code.rs b/src/contact/country_code.rs index 2d33f63..2d6f78c 100644 --- a/src/contact/country_code.rs +++ b/src/contact/country_code.rs @@ -28,6 +28,8 @@ pub type CountryCodeOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CountryCode(String); impl ValueObject for CountryCode { diff --git a/src/contact/email_address.rs b/src/contact/email_address.rs index 578c671..37a8a50 100644 --- a/src/contact/email_address.rs +++ b/src/contact/email_address.rs @@ -35,6 +35,8 @@ static EMAIL_REGEX: Lazy = #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct EmailAddress(String); impl ValueObject for EmailAddress { diff --git a/src/contact/website.rs b/src/contact/website.rs index 3cab77a..778e041 100644 --- a/src/contact/website.rs +++ b/src/contact/website.rs @@ -29,6 +29,8 @@ pub type WebsiteOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Website(String); impl ValueObject for Website { diff --git a/src/finance/bic.rs b/src/finance/bic.rs index 2aa50d6..3832869 100644 --- a/src/finance/bic.rs +++ b/src/finance/bic.rs @@ -34,6 +34,8 @@ pub type BicOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Bic(String); impl ValueObject for Bic { diff --git a/src/finance/card_expiry_date.rs b/src/finance/card_expiry_date.rs index c185718..a80419b 100644 --- a/src/finance/card_expiry_date.rs +++ b/src/finance/card_expiry_date.rs @@ -32,6 +32,8 @@ pub type CardExpiryDateOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CardExpiryDate(String); impl ValueObject for CardExpiryDate { diff --git a/src/finance/credit_card_number.rs b/src/finance/credit_card_number.rs index 2ac5906..5094191 100644 --- a/src/finance/credit_card_number.rs +++ b/src/finance/credit_card_number.rs @@ -30,6 +30,8 @@ pub type CreditCardNumberOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CreditCardNumber(String); impl ValueObject for CreditCardNumber { diff --git a/src/finance/currency_code.rs b/src/finance/currency_code.rs index d5bf968..c452a11 100644 --- a/src/finance/currency_code.rs +++ b/src/finance/currency_code.rs @@ -45,6 +45,8 @@ static ISO_4217: &[&str] = &[ #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CurrencyCode(String); impl ValueObject for CurrencyCode { diff --git a/src/finance/exchange_rate.rs b/src/finance/exchange_rate.rs index 007b5dc..26dd8d9 100644 --- a/src/finance/exchange_rate.rs +++ b/src/finance/exchange_rate.rs @@ -124,6 +124,34 @@ impl TryFrom<&str> for ExchangeRate { } } + +#[cfg(feature = "sql")] +impl sqlx::Type for ExchangeRate { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for ExchangeRate { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for ExchangeRate { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for ExchangeRate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/finance/iban.rs b/src/finance/iban.rs index 59d6dc4..264fcc0 100644 --- a/src/finance/iban.rs +++ b/src/finance/iban.rs @@ -28,6 +28,8 @@ pub type IbanOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Iban(String); impl ValueObject for Iban { diff --git a/src/finance/money.rs b/src/finance/money.rs index 24dc15b..faed120 100644 --- a/src/finance/money.rs +++ b/src/finance/money.rs @@ -129,6 +129,34 @@ impl TryFrom<&str> for Money { } } + +#[cfg(feature = "sql")] +impl sqlx::Type for Money { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Money { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Money { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for Money { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/finance/percentage.rs b/src/finance/percentage.rs index 3acac6c..f16b5d5 100644 --- a/src/finance/percentage.rs +++ b/src/finance/percentage.rs @@ -28,6 +28,8 @@ pub type PercentageOutput = f64; #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Percentage(f64); impl ValueObject for Percentage { diff --git a/src/finance/vat_number.rs b/src/finance/vat_number.rs index 5de87f8..b764cb5 100644 --- a/src/finance/vat_number.rs +++ b/src/finance/vat_number.rs @@ -32,6 +32,8 @@ static EU_PREFIXES: &[&str] = &[ #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct VatNumber(String); impl ValueObject for VatNumber { diff --git a/src/geo/bounding_box.rs b/src/geo/bounding_box.rs index 6e0aca2..c1a7257 100644 --- a/src/geo/bounding_box.rs +++ b/src/geo/bounding_box.rs @@ -119,6 +119,34 @@ impl TryFrom<&str> for BoundingBox { } } + +#[cfg(feature = "sql")] +impl sqlx::Type for BoundingBox { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for BoundingBox { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for BoundingBox { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for BoundingBox { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/geo/coordinate.rs b/src/geo/coordinate.rs index fc09c37..abe9f1d 100644 --- a/src/geo/coordinate.rs +++ b/src/geo/coordinate.rs @@ -88,6 +88,34 @@ impl TryFrom<&str> for Coordinate { } } + +#[cfg(feature = "sql")] +impl sqlx::Type for Coordinate { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Coordinate { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Coordinate { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for Coordinate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/geo/country_region.rs b/src/geo/country_region.rs index e4b404f..87fe589 100644 --- a/src/geo/country_region.rs +++ b/src/geo/country_region.rs @@ -31,6 +31,8 @@ pub type CountryRegionOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CountryRegion(String); impl ValueObject for CountryRegion { diff --git a/src/geo/latitude.rs b/src/geo/latitude.rs index 98a913d..d2462bb 100644 --- a/src/geo/latitude.rs +++ b/src/geo/latitude.rs @@ -26,6 +26,8 @@ pub type LatitudeOutput = f64; #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Latitude(f64); impl ValueObject for Latitude { diff --git a/src/geo/longitude.rs b/src/geo/longitude.rs index db2d48c..298ceb5 100644 --- a/src/geo/longitude.rs +++ b/src/geo/longitude.rs @@ -26,6 +26,8 @@ pub type LongitudeOutput = f64; #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Longitude(f64); impl ValueObject for Longitude { diff --git a/src/geo/time_zone.rs b/src/geo/time_zone.rs index b71a913..fe1006d 100644 --- a/src/geo/time_zone.rs +++ b/src/geo/time_zone.rs @@ -457,6 +457,8 @@ static IANA_TIMEZONES: &[&str] = &[ #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct TimeZone(String); impl ValueObject for TimeZone { diff --git a/src/identifiers/ean13.rs b/src/identifiers/ean13.rs index b128a0a..4db132b 100644 --- a/src/identifiers/ean13.rs +++ b/src/identifiers/ean13.rs @@ -26,6 +26,8 @@ pub type Ean13Output = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Ean13(String); fn ean_checksum_valid(digits: &[u8], expected_len: usize) -> bool { diff --git a/src/identifiers/ean8.rs b/src/identifiers/ean8.rs index 5caa7c5..36210c0 100644 --- a/src/identifiers/ean8.rs +++ b/src/identifiers/ean8.rs @@ -26,6 +26,8 @@ pub type Ean8Output = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Ean8(String); fn ean_checksum_valid(digits: &[u8], expected_len: usize) -> bool { diff --git a/src/identifiers/isbn10.rs b/src/identifiers/isbn10.rs index 121f0cb..ba4fd11 100644 --- a/src/identifiers/isbn10.rs +++ b/src/identifiers/isbn10.rs @@ -29,6 +29,8 @@ pub type Isbn10Output = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Isbn10(String); impl ValueObject for Isbn10 { diff --git a/src/identifiers/isbn13.rs b/src/identifiers/isbn13.rs index 6fcc323..a09a316 100644 --- a/src/identifiers/isbn13.rs +++ b/src/identifiers/isbn13.rs @@ -26,6 +26,8 @@ pub type Isbn13Output = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Isbn13(String); impl ValueObject for Isbn13 { diff --git a/src/identifiers/issn.rs b/src/identifiers/issn.rs index 562c784..d48488f 100644 --- a/src/identifiers/issn.rs +++ b/src/identifiers/issn.rs @@ -28,6 +28,8 @@ pub type IssnOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Issn(String); impl ValueObject for Issn { diff --git a/src/identifiers/slug.rs b/src/identifiers/slug.rs index edea05d..207b9cd 100644 --- a/src/identifiers/slug.rs +++ b/src/identifiers/slug.rs @@ -27,6 +27,8 @@ pub type SlugOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Slug(String); impl ValueObject for Slug { diff --git a/src/identifiers/vin.rs b/src/identifiers/vin.rs index 5c175b4..33b86b0 100644 --- a/src/identifiers/vin.rs +++ b/src/identifiers/vin.rs @@ -28,6 +28,8 @@ pub type VinOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Vin(String); fn transliterate(c: char) -> Option { diff --git a/src/measurement/area.rs b/src/measurement/area.rs index 3704d4b..dd68670 100644 --- a/src/measurement/area.rs +++ b/src/measurement/area.rs @@ -14,6 +14,34 @@ pub enum AreaUnit { Ha, } + +#[cfg(feature = "sql")] +impl sqlx::Type for Area { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Area { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Area { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for AreaUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/measurement/energy.rs b/src/measurement/energy.rs index 4d5041d..7bf3e8a 100644 --- a/src/measurement/energy.rs +++ b/src/measurement/energy.rs @@ -13,6 +13,34 @@ pub enum EnergyUnit { Kcal, } + +#[cfg(feature = "sql")] +impl sqlx::Type for Energy { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Energy { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Energy { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for EnergyUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/measurement/frequency.rs b/src/measurement/frequency.rs index d04fad5..c25ca00 100644 --- a/src/measurement/frequency.rs +++ b/src/measurement/frequency.rs @@ -11,6 +11,34 @@ pub enum FrequencyUnit { GHz, } + +#[cfg(feature = "sql")] +impl sqlx::Type for Frequency { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Frequency { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Frequency { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for FrequencyUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/measurement/length.rs b/src/measurement/length.rs index 0a80214..4006508 100644 --- a/src/measurement/length.rs +++ b/src/measurement/length.rs @@ -13,6 +13,34 @@ pub enum LengthUnit { Ft, } + +#[cfg(feature = "sql")] +impl sqlx::Type for Length { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Length { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Length { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for LengthUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/measurement/power.rs b/src/measurement/power.rs index 3c41670..780280a 100644 --- a/src/measurement/power.rs +++ b/src/measurement/power.rs @@ -11,6 +11,34 @@ pub enum PowerUnit { Hp, } + +#[cfg(feature = "sql")] +impl sqlx::Type for Power { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Power { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Power { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for PowerUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/measurement/pressure.rs b/src/measurement/pressure.rs index 5f56a94..2f98b3f 100644 --- a/src/measurement/pressure.rs +++ b/src/measurement/pressure.rs @@ -13,6 +13,34 @@ pub enum PressureUnit { Atm, } + +#[cfg(feature = "sql")] +impl sqlx::Type for Pressure { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Pressure { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Pressure { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for PressureUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/measurement/speed.rs b/src/measurement/speed.rs index f4eae66..566c8d6 100644 --- a/src/measurement/speed.rs +++ b/src/measurement/speed.rs @@ -11,6 +11,34 @@ pub enum SpeedUnit { Kn, } + +#[cfg(feature = "sql")] +impl sqlx::Type for Speed { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Speed { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Speed { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for SpeedUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/measurement/temperature.rs b/src/measurement/temperature.rs index 885bbb5..9518dd7 100644 --- a/src/measurement/temperature.rs +++ b/src/measurement/temperature.rs @@ -10,6 +10,34 @@ pub enum TemperatureUnit { Kelvin, } + +#[cfg(feature = "sql")] +impl sqlx::Type for Temperature { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Temperature { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Temperature { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for TemperatureUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/measurement/volume.rs b/src/measurement/volume.rs index 094d200..51f7933 100644 --- a/src/measurement/volume.rs +++ b/src/measurement/volume.rs @@ -12,6 +12,34 @@ pub enum VolumeUnit { Gal, } + +#[cfg(feature = "sql")] +impl sqlx::Type for Volume { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Volume { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Volume { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for VolumeUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/measurement/weight.rs b/src/measurement/weight.rs index d862c24..69a734a 100644 --- a/src/measurement/weight.rs +++ b/src/measurement/weight.rs @@ -13,6 +13,34 @@ pub enum WeightUnit { Lb, } + +#[cfg(feature = "sql")] +impl sqlx::Type for Weight { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Weight { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Weight { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for WeightUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/net/api_key.rs b/src/net/api_key.rs index 634c0db..5a77925 100644 --- a/src/net/api_key.rs +++ b/src/net/api_key.rs @@ -26,6 +26,8 @@ pub type ApiKeyOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct ApiKey(String); impl ValueObject for ApiKey { diff --git a/src/net/domain.rs b/src/net/domain.rs index 3086bbd..e0fec13 100644 --- a/src/net/domain.rs +++ b/src/net/domain.rs @@ -28,6 +28,8 @@ pub type DomainOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Domain(String); impl ValueObject for Domain { diff --git a/src/net/http_status_code.rs b/src/net/http_status_code.rs index ee7c6a8..ef48947 100644 --- a/src/net/http_status_code.rs +++ b/src/net/http_status_code.rs @@ -86,6 +86,35 @@ impl TryFrom<&str> for HttpStatusCode { } } +#[cfg(feature = "sql")] +impl sqlx::Type for HttpStatusCode { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for HttpStatusCode { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&(self.0 as i32), buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for HttpStatusCode { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let n = >::decode(value)?; + let u = u16::try_from(n).map_err(|e| Box::new(e) as sqlx::error::BoxDynError)?; + Self::new(u).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} + impl std::fmt::Display for HttpStatusCode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/net/ip_address.rs b/src/net/ip_address.rs index 51b2670..74c31b4 100644 --- a/src/net/ip_address.rs +++ b/src/net/ip_address.rs @@ -30,6 +30,8 @@ pub type IpAddressOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct IpAddress(String); impl ValueObject for IpAddress { diff --git a/src/net/ip_v4_address.rs b/src/net/ip_v4_address.rs index a3e2056..f2210e9 100644 --- a/src/net/ip_v4_address.rs +++ b/src/net/ip_v4_address.rs @@ -27,6 +27,8 @@ pub type IpV4AddressOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct IpV4Address(String); impl ValueObject for IpV4Address { diff --git a/src/net/ip_v6_address.rs b/src/net/ip_v6_address.rs index cd9fca4..47d5efa 100644 --- a/src/net/ip_v6_address.rs +++ b/src/net/ip_v6_address.rs @@ -28,6 +28,8 @@ pub type IpV6AddressOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct IpV6Address(String); impl ValueObject for IpV6Address { diff --git a/src/net/mac_address.rs b/src/net/mac_address.rs index 4ac7a1f..9ce866c 100644 --- a/src/net/mac_address.rs +++ b/src/net/mac_address.rs @@ -27,6 +27,8 @@ pub type MacAddressOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct MacAddress(String); impl ValueObject for MacAddress { diff --git a/src/net/mime_type.rs b/src/net/mime_type.rs index b19e828..9f39217 100644 --- a/src/net/mime_type.rs +++ b/src/net/mime_type.rs @@ -30,6 +30,8 @@ pub type MimeTypeOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct MimeType(String); impl ValueObject for MimeType { diff --git a/src/net/port.rs b/src/net/port.rs index 38a6114..dd93c6c 100644 --- a/src/net/port.rs +++ b/src/net/port.rs @@ -74,6 +74,35 @@ impl TryFrom<&str> for Port { } } +#[cfg(feature = "sql")] +impl sqlx::Type for Port { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Port { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&(self.0 as i32), buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Port { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let n = >::decode(value)?; + let u = u16::try_from(n).map_err(|e| Box::new(e) as sqlx::error::BoxDynError)?; + Self::new(u).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} + impl std::fmt::Display for Port { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/net/url.rs b/src/net/url.rs index 38205e9..1d9b15c 100644 --- a/src/net/url.rs +++ b/src/net/url.rs @@ -26,6 +26,8 @@ pub type UrlOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Url(String); const ALLOWED_SCHEMES: &[&str] = &["ftp", "ftps", "http", "https", "ws", "wss"]; diff --git a/src/primitives/base64_string.rs b/src/primitives/base64_string.rs index 0446f12..7cb1dc6 100644 --- a/src/primitives/base64_string.rs +++ b/src/primitives/base64_string.rs @@ -29,6 +29,8 @@ pub type Base64StringOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Base64String(String); impl ValueObject for Base64String { diff --git a/src/primitives/bounded_string.rs b/src/primitives/bounded_string.rs index 2cf21b7..e165370 100644 --- a/src/primitives/bounded_string.rs +++ b/src/primitives/bounded_string.rs @@ -69,6 +69,38 @@ impl TryFrom<&str> for BoundedString sqlx::Type for BoundedString { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q, const MIN: usize, const MAX: usize> sqlx::Encode<'q, sqlx::Postgres> + for BoundedString +{ + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.0, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r, const MIN: usize, const MAX: usize> sqlx::Decode<'r, sqlx::Postgres> + for BoundedString +{ + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::new(s).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} + impl std::fmt::Display for BoundedString { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/primitives/hex_color.rs b/src/primitives/hex_color.rs index 9402300..081dec4 100644 --- a/src/primitives/hex_color.rs +++ b/src/primitives/hex_color.rs @@ -28,6 +28,8 @@ pub type HexColorOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct HexColor(String); impl ValueObject for HexColor { diff --git a/src/primitives/locale.rs b/src/primitives/locale.rs index ebcf6c2..155325d 100644 --- a/src/primitives/locale.rs +++ b/src/primitives/locale.rs @@ -31,6 +31,8 @@ pub type LocaleOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Locale(String); impl ValueObject for Locale { diff --git a/src/primitives/non_empty_string.rs b/src/primitives/non_empty_string.rs index 925b71d..e229ab6 100644 --- a/src/primitives/non_empty_string.rs +++ b/src/primitives/non_empty_string.rs @@ -26,6 +26,8 @@ pub type NonEmptyStringOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct NonEmptyString(String); impl ValueObject for NonEmptyString { diff --git a/src/primitives/non_negative_decimal.rs b/src/primitives/non_negative_decimal.rs index e03e0dc..590ff03 100644 --- a/src/primitives/non_negative_decimal.rs +++ b/src/primitives/non_negative_decimal.rs @@ -27,6 +27,8 @@ pub type NonNegativeDecimalOutput = Decimal; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct NonNegativeDecimal(Decimal); impl ValueObject for NonNegativeDecimal { diff --git a/src/primitives/non_negative_int.rs b/src/primitives/non_negative_int.rs index b713891..e75fcc8 100644 --- a/src/primitives/non_negative_int.rs +++ b/src/primitives/non_negative_int.rs @@ -25,6 +25,8 @@ pub type NonNegativeIntOutput = i64; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct NonNegativeInt(i64); impl ValueObject for NonNegativeInt { diff --git a/src/primitives/positive_decimal.rs b/src/primitives/positive_decimal.rs index 40243ec..54efede 100644 --- a/src/primitives/positive_decimal.rs +++ b/src/primitives/positive_decimal.rs @@ -27,6 +27,8 @@ pub type PositiveDecimalOutput = Decimal; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct PositiveDecimal(Decimal); impl ValueObject for PositiveDecimal { diff --git a/src/primitives/positive_int.rs b/src/primitives/positive_int.rs index 7ce975c..118ded7 100644 --- a/src/primitives/positive_int.rs +++ b/src/primitives/positive_int.rs @@ -26,6 +26,8 @@ pub type PositiveIntOutput = i64; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct PositiveInt(i64); impl ValueObject for PositiveInt { diff --git a/src/primitives/probability.rs b/src/primitives/probability.rs index 902a59a..fd75cd1 100644 --- a/src/primitives/probability.rs +++ b/src/primitives/probability.rs @@ -26,6 +26,8 @@ pub type ProbabilityOutput = f64; #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Probability(f64); impl ValueObject for Probability { diff --git a/src/temporal/birth_date.rs b/src/temporal/birth_date.rs index 13ef47a..890fa1e 100644 --- a/src/temporal/birth_date.rs +++ b/src/temporal/birth_date.rs @@ -28,6 +28,8 @@ pub type BirthDateOutput = NaiveDate; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct BirthDate(NaiveDate); impl ValueObject for BirthDate { diff --git a/src/temporal/business_hours.rs b/src/temporal/business_hours.rs index 2aecc4d..5b8293a 100644 --- a/src/temporal/business_hours.rs +++ b/src/temporal/business_hours.rs @@ -146,6 +146,34 @@ impl TryFrom<&str> for BusinessHours { } } + +#[cfg(feature = "sql")] +impl sqlx::Type for BusinessHours { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for BusinessHours { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for BusinessHours { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for BusinessHours { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/temporal/expiry_date.rs b/src/temporal/expiry_date.rs index 46df1c6..e52116f 100644 --- a/src/temporal/expiry_date.rs +++ b/src/temporal/expiry_date.rs @@ -26,6 +26,8 @@ pub type ExpiryDateOutput = NaiveDate; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct ExpiryDate(NaiveDate); impl ValueObject for ExpiryDate { diff --git a/src/temporal/time_range.rs b/src/temporal/time_range.rs index 9cd8b98..a707468 100644 --- a/src/temporal/time_range.rs +++ b/src/temporal/time_range.rs @@ -116,6 +116,34 @@ impl TryFrom<&str> for TimeRange { } } + +#[cfg(feature = "sql")] +impl sqlx::Type for TimeRange { + fn type_info() -> sqlx::postgres::PgTypeInfo { + >::type_info() + } + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { + >::compatible(ty) + } +} + +#[cfg(feature = "sql")] +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for TimeRange { + fn encode_by_ref( + &self, + buf: &mut sqlx::postgres::PgArgumentBuffer, + ) -> Result { + >::encode_by_ref(&self.canonical, buf) + } +} + +#[cfg(feature = "sql")] +impl<'r> sqlx::Decode<'r, sqlx::Postgres> for TimeRange { + fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { + let s = >::decode(value)?; + Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) + } +} impl std::fmt::Display for TimeRange { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) diff --git a/src/temporal/unix_timestamp.rs b/src/temporal/unix_timestamp.rs index 2fa7b5e..1304fac 100644 --- a/src/temporal/unix_timestamp.rs +++ b/src/temporal/unix_timestamp.rs @@ -27,6 +27,8 @@ pub type UnixTimestampOutput = i64; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "sql", derive(sqlx::Type))] +#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct UnixTimestamp(i64); impl ValueObject for UnixTimestamp { From 04e0491f03e0744f16a80716cb55b290c82cddaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Wed, 22 Apr 2026 07:48:20 +0200 Subject: [PATCH 09/15] fix: enforce validation on serde deserialization for all value objects Replace serde(transparent) with serde(try_from = "T", into = "T") on all simple newtypes so that deserialization goes through new() and domain validation is enforced. Add TryFrom and From for T impls for each inner type category (String, f64, i64, u16, Decimal, NaiveDate). All 689 tests now pass including the serde_deserialize_validates suite. --- src/contact/country_code.rs | 32 +++++++++++++++++++++++- src/contact/email_address.rs | 32 +++++++++++++++++++++++- src/contact/postal_address.rs | 34 ++++++++++++++++++++++++-- src/contact/website.rs | 32 +++++++++++++++++++++++- src/finance/bic.rs | 32 +++++++++++++++++++++++- src/finance/card_expiry_date.rs | 16 +++++++++++- src/finance/credit_card_number.rs | 16 +++++++++++- src/finance/currency_code.rs | 32 +++++++++++++++++++++++- src/finance/exchange_rate.rs | 33 ++++++++++++++++++++++++- src/finance/iban.rs | 32 +++++++++++++++++++++++- src/finance/money.rs | 33 ++++++++++++++++++++++++- src/finance/percentage.rs | 33 ++++++++++++++++++++++++- src/finance/vat_number.rs | 16 +++++++++++- src/geo/bounding_box.rs | 33 ++++++++++++++++++++++++- src/geo/coordinate.rs | 33 ++++++++++++++++++++++++- src/geo/country_region.rs | 32 +++++++++++++++++++++++- src/geo/latitude.rs | 33 ++++++++++++++++++++++++- src/geo/longitude.rs | 33 ++++++++++++++++++++++++- src/geo/time_zone.rs | 32 +++++++++++++++++++++++- src/identifiers/ean13.rs | 32 +++++++++++++++++++++++- src/identifiers/ean8.rs | 32 +++++++++++++++++++++++- src/identifiers/isbn10.rs | 32 +++++++++++++++++++++++- src/identifiers/isbn13.rs | 32 +++++++++++++++++++++++- src/identifiers/issn.rs | 32 +++++++++++++++++++++++- src/identifiers/slug.rs | 32 +++++++++++++++++++++++- src/identifiers/vin.rs | 32 +++++++++++++++++++++++- src/measurement/area.rs | 33 ++++++++++++++++++++++++- src/measurement/energy.rs | 33 ++++++++++++++++++++++++- src/measurement/frequency.rs | 33 ++++++++++++++++++++++++- src/measurement/length.rs | 33 ++++++++++++++++++++++++- src/measurement/power.rs | 33 ++++++++++++++++++++++++- src/measurement/pressure.rs | 33 ++++++++++++++++++++++++- src/measurement/speed.rs | 33 ++++++++++++++++++++++++- src/measurement/temperature.rs | 33 ++++++++++++++++++++++++- src/measurement/volume.rs | 33 ++++++++++++++++++++++++- src/measurement/weight.rs | 33 ++++++++++++++++++++++++- src/net/api_key.rs | 32 +++++++++++++++++++++++- src/net/domain.rs | 32 +++++++++++++++++++++++- src/net/http_status_code.rs | 33 ++++++++++++++++++++++++- src/net/ip_address.rs | 16 +++++++++++- src/net/ip_v4_address.rs | 32 +++++++++++++++++++++++- src/net/ip_v6_address.rs | 32 +++++++++++++++++++++++- src/net/mac_address.rs | 32 +++++++++++++++++++++++- src/net/mime_type.rs | 32 +++++++++++++++++++++++- src/net/port.rs | 33 ++++++++++++++++++++++++- src/net/url.rs | 16 +++++++++++- src/primitives/base64_string.rs | 32 +++++++++++++++++++++++- src/primitives/bounded_string.rs | 17 ++++++++++++- src/primitives/hex_color.rs | 32 +++++++++++++++++++++++- src/primitives/locale.rs | 32 +++++++++++++++++++++++- src/primitives/non_empty_string.rs | 32 +++++++++++++++++++++++- src/primitives/non_negative_decimal.rs | 32 +++++++++++++++++++++++- src/primitives/non_negative_int.rs | 33 ++++++++++++++++++++++++- src/primitives/positive_decimal.rs | 32 +++++++++++++++++++++++- src/primitives/positive_int.rs | 33 ++++++++++++++++++++++++- src/primitives/probability.rs | 33 ++++++++++++++++++++++++- src/temporal/birth_date.rs | 33 ++++++++++++++++++++++++- src/temporal/business_hours.rs | 33 ++++++++++++++++++++++++- src/temporal/expiry_date.rs | 33 ++++++++++++++++++++++++- src/temporal/time_range.rs | 33 ++++++++++++++++++++++++- src/temporal/unix_timestamp.rs | 33 ++++++++++++++++++++++++- 61 files changed, 1824 insertions(+), 62 deletions(-) diff --git a/src/contact/country_code.rs b/src/contact/country_code.rs index 2d6f78c..4802038 100644 --- a/src/contact/country_code.rs +++ b/src/contact/country_code.rs @@ -27,7 +27,7 @@ pub type CountryCodeOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CountryCode(String); @@ -60,6 +60,20 @@ impl ValueObject for CountryCode { } /// Allows ergonomic construction from a string literal: `"CZ".try_into()` + +impl TryFrom for CountryCode { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: CountryCode) -> String { + v.0 + } +} impl TryFrom<&str> for CountryCode { type Error = ValidationError; @@ -123,4 +137,20 @@ mod tests { let c: CountryCode = "DE".try_into().unwrap(); assert_eq!(c.value(), "DE"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = CountryCode::try_from("CZ").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: CountryCode = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/contact/email_address.rs b/src/contact/email_address.rs index 37a8a50..3307c6e 100644 --- a/src/contact/email_address.rs +++ b/src/contact/email_address.rs @@ -34,7 +34,7 @@ static EMAIL_REGEX: Lazy = /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct EmailAddress(String); @@ -80,6 +80,20 @@ impl EmailAddress { } /// Allows ergonomic construction from a string literal: `"a@b.com".try_into()` + +impl TryFrom for EmailAddress { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: EmailAddress) -> String { + v.0 + } +} impl TryFrom<&str> for EmailAddress { type Error = ValidationError; @@ -145,4 +159,20 @@ mod tests { let e: EmailAddress = "hello@example.com".try_into().unwrap(); assert_eq!(e.value(), "hello@example.com"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = EmailAddress::try_from("user@example.com").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: EmailAddress = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/contact/postal_address.rs b/src/contact/postal_address.rs index 21ca2a5..385d815 100644 --- a/src/contact/postal_address.rs +++ b/src/contact/postal_address.rs @@ -5,6 +5,7 @@ use super::country_code::CountryCode; /// Input type for [`PostalAddress`] construction. #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct PostalAddressInput { /// Street name and number, e.g. `"Václavské náměstí 1"`. pub street: String, @@ -52,13 +53,12 @@ pub type PostalAddressOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "PostalAddressInput", into = "PostalAddressInput"))] pub struct PostalAddress { street: String, city: String, zip: String, country: CountryCode, - /// Pre-computed display string. - #[cfg_attr(feature = "serde", serde(skip))] formatted: String, } @@ -129,6 +129,20 @@ impl PostalAddress { } } +impl TryFrom for PostalAddress { + type Error = ValidationError; + fn try_from(input: PostalAddressInput) -> Result { + Self::new(input) + } +} + +#[cfg(feature = "serde")] +impl From for PostalAddressInput { + fn from(a: PostalAddress) -> PostalAddressInput { + a.into_inner() + } +} + /// Displays the address in a human-readable multi-line format. impl std::fmt::Display for PostalAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -233,4 +247,20 @@ mod tests { let b = PostalAddress::new(valid_input()).unwrap(); assert_eq!(a, b); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let addr = PostalAddress::new(valid_input()).unwrap(); + let json = serde_json::to_string(&addr).unwrap(); + let back: PostalAddress = serde_json::from_str(&json).unwrap(); + assert_eq!(addr.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str(r#"{"street":"","city":"Prague","zip":"110 00","country":"CZ"}"#); + assert!(result.is_err()); + } } diff --git a/src/contact/website.rs b/src/contact/website.rs index 778e041..99a4d56 100644 --- a/src/contact/website.rs +++ b/src/contact/website.rs @@ -28,7 +28,7 @@ pub type WebsiteOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Website(String); @@ -87,6 +87,20 @@ impl Website { } /// Allows ergonomic construction from a string literal: `"https://example.com".try_into()` + +impl TryFrom for Website { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Website) -> String { + v.0 + } +} impl TryFrom<&str> for Website { type Error = ValidationError; @@ -175,4 +189,20 @@ mod tests { let w: Website = "https://example.com".try_into().unwrap(); assert!(w.is_https()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Website::try_from("https://example.com").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Website = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/finance/bic.rs b/src/finance/bic.rs index 3832869..2f747b9 100644 --- a/src/finance/bic.rs +++ b/src/finance/bic.rs @@ -33,7 +33,7 @@ pub type BicOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Bic(String); @@ -109,6 +109,20 @@ impl Bic { } } + +impl TryFrom for Bic { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Bic) -> String { + v.0 + } +} impl TryFrom<&str> for Bic { type Error = ValidationError; @@ -207,4 +221,20 @@ mod tests { let b: Bic = "DEUTDEDB".try_into().unwrap(); assert_eq!(b.value(), "DEUTDEDB"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Bic::try_from("DEUTDEDB").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Bic = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/finance/card_expiry_date.rs b/src/finance/card_expiry_date.rs index a80419b..29fefc6 100644 --- a/src/finance/card_expiry_date.rs +++ b/src/finance/card_expiry_date.rs @@ -31,7 +31,7 @@ pub type CardExpiryDateOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CardExpiryDate(String); @@ -119,6 +119,20 @@ impl CardExpiryDate { } } + +impl TryFrom for CardExpiryDate { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: CardExpiryDate) -> String { + v.0 + } +} impl TryFrom<&str> for CardExpiryDate { type Error = ValidationError; diff --git a/src/finance/credit_card_number.rs b/src/finance/credit_card_number.rs index 5094191..bb4e5f7 100644 --- a/src/finance/credit_card_number.rs +++ b/src/finance/credit_card_number.rs @@ -29,7 +29,7 @@ pub type CreditCardNumberOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CreditCardNumber(String); @@ -120,6 +120,20 @@ fn luhn_valid(digits: &str) -> bool { sum % 10 == 0 } + +impl TryFrom for CreditCardNumber { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: CreditCardNumber) -> String { + v.0 + } +} impl TryFrom<&str> for CreditCardNumber { type Error = ValidationError; diff --git a/src/finance/currency_code.rs b/src/finance/currency_code.rs index c452a11..20f8afb 100644 --- a/src/finance/currency_code.rs +++ b/src/finance/currency_code.rs @@ -44,7 +44,7 @@ static ISO_4217: &[&str] = &[ /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CurrencyCode(String); @@ -81,6 +81,20 @@ impl ValueObject for CurrencyCode { } } + +impl TryFrom for CurrencyCode { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: CurrencyCode) -> String { + v.0 + } +} impl TryFrom<&str> for CurrencyCode { type Error = ValidationError; @@ -153,4 +167,20 @@ mod tests { let c: CurrencyCode = "GBP".try_into().unwrap(); assert_eq!(c.value(), "GBP"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = CurrencyCode::try_from("EUR").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: CurrencyCode = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/finance/exchange_rate.rs b/src/finance/exchange_rate.rs index 26dd8d9..4b1cc13 100644 --- a/src/finance/exchange_rate.rs +++ b/src/finance/exchange_rate.rs @@ -43,11 +43,11 @@ pub type ExchangeRateOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct ExchangeRate { from: CurrencyCode, to: CurrencyCode, rate: Decimal, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -152,6 +152,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for ExchangeRate { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: ExchangeRate) -> String { + v.canonical + } +} + +impl TryFrom for ExchangeRate { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for ExchangeRate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -278,4 +292,21 @@ mod tests { fn try_from_rejects_missing_slash() { assert!(ExchangeRate::try_from("EURUSD 1.0850").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = ExchangeRate::try_from("EUR/USD 1.0850").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: ExchangeRate = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = ExchangeRate::try_from("EUR/USD 1.0850").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("EUR/USD 1.0850")); + } } diff --git a/src/finance/iban.rs b/src/finance/iban.rs index 264fcc0..a8c79da 100644 --- a/src/finance/iban.rs +++ b/src/finance/iban.rs @@ -27,7 +27,7 @@ pub type IbanOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Iban(String); @@ -97,6 +97,20 @@ impl Iban { } } + +impl TryFrom for Iban { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Iban) -> String { + v.0 + } +} impl TryFrom<&str> for Iban { type Error = ValidationError; @@ -198,4 +212,20 @@ mod tests { let i: Iban = "GB82WEST12345698765432".try_into().unwrap(); assert_eq!(i.value(), "GB82WEST12345698765432"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Iban::try_from("GB82WEST12345698765432").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Iban = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/finance/money.rs b/src/finance/money.rs index faed120..1aec895 100644 --- a/src/finance/money.rs +++ b/src/finance/money.rs @@ -39,10 +39,10 @@ pub type MoneyOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Money { amount: Decimal, currency: CurrencyCode, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -157,6 +157,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Money { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Money) -> String { + v.canonical + } +} + +impl TryFrom for Money { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for Money { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -300,4 +314,21 @@ mod tests { fn try_from_rejects_invalid_currency() { assert!(Money::try_from("10.50 INVALID").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Money::try_from("10.50 EUR").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Money = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Money::try_from("10.50 EUR").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("10.50 EUR")); + } } diff --git a/src/finance/percentage.rs b/src/finance/percentage.rs index f16b5d5..1cf1d48 100644 --- a/src/finance/percentage.rs +++ b/src/finance/percentage.rs @@ -27,7 +27,7 @@ pub type PercentageOutput = f64; /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "f64", into = "f64"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Percentage(f64); @@ -65,6 +65,20 @@ impl Percentage { } } + +impl TryFrom for Percentage { + type Error = ValidationError; + fn try_from(v: f64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for f64 { + fn from(v: Percentage) -> f64 { + v.0 + } +} impl TryFrom<&str> for Percentage { type Error = ValidationError; @@ -150,4 +164,21 @@ mod tests { fn try_from_rejects_out_of_range() { assert!(Percentage::try_from("101.0").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Percentage::new(42.5).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "42.5"); + let back: Percentage = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("101.0"); + assert!(result.is_err()); + } } diff --git a/src/finance/vat_number.rs b/src/finance/vat_number.rs index b764cb5..649c94b 100644 --- a/src/finance/vat_number.rs +++ b/src/finance/vat_number.rs @@ -31,7 +31,7 @@ static EU_PREFIXES: &[&str] = &[ /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct VatNumber(String); @@ -94,6 +94,20 @@ impl VatNumber { } } + +impl TryFrom for VatNumber { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: VatNumber) -> String { + v.0 + } +} impl TryFrom<&str> for VatNumber { type Error = ValidationError; diff --git a/src/geo/bounding_box.rs b/src/geo/bounding_box.rs index c1a7257..327b2fe 100644 --- a/src/geo/bounding_box.rs +++ b/src/geo/bounding_box.rs @@ -38,10 +38,10 @@ pub struct BoundingBoxInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct BoundingBox { sw: Coordinate, ne: Coordinate, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -147,6 +147,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for BoundingBox { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: BoundingBox) -> String { + v.canonical + } +} + +impl TryFrom for BoundingBox { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for BoundingBox { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -278,4 +292,21 @@ mod tests { fn try_from_rejects_sw_north_of_ne() { assert!(BoundingBox::try_from("SW: 52.000000, 14.000000 / NE: 51.000000, 18.000000").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = BoundingBox::try_from("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: BoundingBox = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = BoundingBox::try_from("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000")); + } } diff --git a/src/geo/coordinate.rs b/src/geo/coordinate.rs index abe9f1d..a9e6c44 100644 --- a/src/geo/coordinate.rs +++ b/src/geo/coordinate.rs @@ -31,10 +31,10 @@ pub struct CoordinateInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Coordinate { lat: Latitude, lng: Longitude, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -116,6 +116,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Coordinate { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Coordinate) -> String { + v.canonical + } +} + +impl TryFrom for Coordinate { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for Coordinate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -176,4 +190,21 @@ mod tests { fn try_from_rejects_invalid_lat() { assert!(Coordinate::try_from("91.0, 0.0").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Coordinate::try_from("48.858844, 2.294351").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Coordinate = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Coordinate::try_from("48.858844, 2.294351").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("48.858844, 2.294351")); + } } diff --git a/src/geo/country_region.rs b/src/geo/country_region.rs index 87fe589..4c6a007 100644 --- a/src/geo/country_region.rs +++ b/src/geo/country_region.rs @@ -30,7 +30,7 @@ pub type CountryRegionOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CountryRegion(String); @@ -95,6 +95,20 @@ impl CountryRegion { } } + +impl TryFrom for CountryRegion { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: CountryRegion) -> String { + v.0 + } +} impl TryFrom<&str> for CountryRegion { type Error = ValidationError; @@ -172,4 +186,20 @@ mod tests { let r: CountryRegion = "DE-BY".try_into().unwrap(); assert_eq!(r.value(), "DE-BY"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = CountryRegion::try_from("CZ-PR").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: CountryRegion = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/geo/latitude.rs b/src/geo/latitude.rs index d2462bb..888c651 100644 --- a/src/geo/latitude.rs +++ b/src/geo/latitude.rs @@ -25,7 +25,7 @@ pub type LatitudeOutput = f64; /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "f64", into = "f64"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Latitude(f64); @@ -54,6 +54,20 @@ impl ValueObject for Latitude { } } + +impl TryFrom for Latitude { + type Error = ValidationError; + fn try_from(v: f64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for f64 { + fn from(v: Latitude) -> f64 { + v.0 + } +} impl TryFrom<&str> for Latitude { type Error = ValidationError; @@ -129,4 +143,21 @@ mod tests { fn try_from_rejects_out_of_range() { assert!(Latitude::try_from("91.0").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Latitude::new(48.8588).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "48.8588"); + let back: Latitude = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("91.0"); + assert!(result.is_err()); + } } diff --git a/src/geo/longitude.rs b/src/geo/longitude.rs index 298ceb5..bbea98f 100644 --- a/src/geo/longitude.rs +++ b/src/geo/longitude.rs @@ -25,7 +25,7 @@ pub type LongitudeOutput = f64; /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "f64", into = "f64"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Longitude(f64); @@ -54,6 +54,20 @@ impl ValueObject for Longitude { } } + +impl TryFrom for Longitude { + type Error = ValidationError; + fn try_from(v: f64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for f64 { + fn from(v: Longitude) -> f64 { + v.0 + } +} impl TryFrom<&str> for Longitude { type Error = ValidationError; @@ -129,4 +143,21 @@ mod tests { fn try_from_rejects_out_of_range() { assert!(Longitude::try_from("181.0").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Longitude::new(2.2944).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "2.2944"); + let back: Longitude = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("181.0"); + assert!(result.is_err()); + } } diff --git a/src/geo/time_zone.rs b/src/geo/time_zone.rs index fe1006d..bba64e7 100644 --- a/src/geo/time_zone.rs +++ b/src/geo/time_zone.rs @@ -456,7 +456,7 @@ static IANA_TIMEZONES: &[&str] = &[ /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct TimeZone(String); @@ -489,6 +489,20 @@ impl ValueObject for TimeZone { } } + +impl TryFrom for TimeZone { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: TimeZone) -> String { + v.0 + } +} impl TryFrom<&str> for TimeZone { type Error = ValidationError; @@ -549,4 +563,20 @@ mod tests { let tz: TimeZone = "Asia/Tokyo".try_into().unwrap(); assert_eq!(tz.value(), "Asia/Tokyo"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = TimeZone::try_from("Europe/Prague").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: TimeZone = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/ean13.rs b/src/identifiers/ean13.rs index 4db132b..c13955c 100644 --- a/src/identifiers/ean13.rs +++ b/src/identifiers/ean13.rs @@ -25,7 +25,7 @@ pub type Ean13Output = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Ean13(String); @@ -83,6 +83,20 @@ impl Ean13 { } } + +impl TryFrom for Ean13 { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Ean13) -> String { + v.0 + } +} impl TryFrom<&str> for Ean13 { type Error = ValidationError; @@ -134,4 +148,20 @@ mod tests { let e: Ean13 = "4006381333931".try_into().unwrap(); assert_eq!(e.value(), "4006381333931"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Ean13::try_from("5901234123457").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Ean13 = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/ean8.rs b/src/identifiers/ean8.rs index 36210c0..07e604f 100644 --- a/src/identifiers/ean8.rs +++ b/src/identifiers/ean8.rs @@ -25,7 +25,7 @@ pub type Ean8Output = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Ean8(String); @@ -84,6 +84,20 @@ impl Ean8 { } } + +impl TryFrom for Ean8 { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Ean8) -> String { + v.0 + } +} impl TryFrom<&str> for Ean8 { type Error = ValidationError; @@ -135,4 +149,20 @@ mod tests { let e: Ean8 = "73513537".try_into().unwrap(); assert_eq!(e.value(), "73513537"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Ean8::try_from("96385074").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Ean8 = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/isbn10.rs b/src/identifiers/isbn10.rs index ba4fd11..b27d257 100644 --- a/src/identifiers/isbn10.rs +++ b/src/identifiers/isbn10.rs @@ -28,7 +28,7 @@ pub type Isbn10Output = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Isbn10(String); @@ -89,6 +89,20 @@ impl ValueObject for Isbn10 { } } + +impl TryFrom for Isbn10 { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Isbn10) -> String { + v.0 + } +} impl TryFrom<&str> for Isbn10 { type Error = ValidationError; @@ -146,4 +160,20 @@ mod tests { let i: Isbn10 = "0306406152".try_into().unwrap(); assert_eq!(i.value(), "0306406152"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Isbn10::try_from("0306406152").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Isbn10 = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/isbn13.rs b/src/identifiers/isbn13.rs index a09a316..b504236 100644 --- a/src/identifiers/isbn13.rs +++ b/src/identifiers/isbn13.rs @@ -25,7 +25,7 @@ pub type Isbn13Output = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Isbn13(String); @@ -79,6 +79,20 @@ impl Isbn13 { } } + +impl TryFrom for Isbn13 { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Isbn13) -> String { + v.0 + } +} impl TryFrom<&str> for Isbn13 { type Error = ValidationError; @@ -141,4 +155,20 @@ mod tests { let i: Isbn13 = "9780306406157".try_into().unwrap(); assert_eq!(i.value(), "9780306406157"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Isbn13::try_from("9780306406157").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Isbn13 = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/issn.rs b/src/identifiers/issn.rs index d48488f..232dacd 100644 --- a/src/identifiers/issn.rs +++ b/src/identifiers/issn.rs @@ -27,7 +27,7 @@ pub type IssnOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Issn(String); @@ -88,6 +88,20 @@ impl ValueObject for Issn { } } + +impl TryFrom for Issn { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Issn) -> String { + v.0 + } +} impl TryFrom<&str> for Issn { type Error = ValidationError; @@ -151,4 +165,20 @@ mod tests { let i: Issn = "0317-8471".try_into().unwrap(); assert_eq!(i.value(), "0317-8471"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Issn::try_from("0317-8471").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Issn = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/slug.rs b/src/identifiers/slug.rs index 207b9cd..c1ffb40 100644 --- a/src/identifiers/slug.rs +++ b/src/identifiers/slug.rs @@ -26,7 +26,7 @@ pub type SlugOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Slug(String); @@ -70,6 +70,20 @@ impl ValueObject for Slug { } } + +impl TryFrom for Slug { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Slug) -> String { + v.0 + } +} impl TryFrom<&str> for Slug { type Error = ValidationError; @@ -142,4 +156,20 @@ mod tests { let s: Slug = "my-slug".try_into().unwrap(); assert_eq!(s.value(), "my-slug"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Slug::try_from("hello-world").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Slug = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/vin.rs b/src/identifiers/vin.rs index 33b86b0..b486a3f 100644 --- a/src/identifiers/vin.rs +++ b/src/identifiers/vin.rs @@ -27,7 +27,7 @@ pub type VinOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Vin(String); @@ -148,6 +148,20 @@ impl Vin { } } + +impl TryFrom for Vin { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Vin) -> String { + v.0 + } +} impl TryFrom<&str> for Vin { type Error = ValidationError; @@ -234,4 +248,20 @@ mod tests { let v: Vin = VALID_VIN.try_into().unwrap(); assert_eq!(v.value(), VALID_VIN); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Vin::try_from("1HGBH41JXMN109186").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Vin = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/measurement/area.rs b/src/measurement/area.rs index dd68670..e9fea55 100644 --- a/src/measurement/area.rs +++ b/src/measurement/area.rs @@ -42,6 +42,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Area { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Area) -> String { + v.canonical + } +} + +impl TryFrom for Area { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for AreaUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -78,10 +92,10 @@ pub struct AreaInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Area { value: f64, unit: AreaUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -211,4 +225,21 @@ mod tests { fn try_from_rejects_unknown_unit() { assert!(Area::try_from("1.5 acres").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Area::try_from("1.5 m²").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Area = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Area::try_from("1.5 m²").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("1.5")); + } } diff --git a/src/measurement/energy.rs b/src/measurement/energy.rs index 7bf3e8a..623a442 100644 --- a/src/measurement/energy.rs +++ b/src/measurement/energy.rs @@ -41,6 +41,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Energy { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Energy) -> String { + v.canonical + } +} + +impl TryFrom for Energy { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for EnergyUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -76,10 +90,10 @@ pub struct EnergyInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Energy { value: f64, unit: EnergyUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -208,4 +222,21 @@ mod tests { fn try_from_rejects_unknown_unit() { assert!(Energy::try_from("1.5 BTU").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Energy::try_from("1.5 kJ").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Energy = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Energy::try_from("1.5 kJ").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("1.5 kJ")); + } } diff --git a/src/measurement/frequency.rs b/src/measurement/frequency.rs index c25ca00..6bad88e 100644 --- a/src/measurement/frequency.rs +++ b/src/measurement/frequency.rs @@ -39,6 +39,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Frequency { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Frequency) -> String { + v.canonical + } +} + +impl TryFrom for Frequency { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for FrequencyUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -72,10 +86,10 @@ pub struct FrequencyInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Frequency { value: f64, unit: FrequencyUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -205,4 +219,21 @@ mod tests { fn try_from_rejects_unknown_unit() { assert!(Frequency::try_from("2.4 THz").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Frequency::try_from("2.4 GHz").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Frequency = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Frequency::try_from("2.4 GHz").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("2.4 GHz")); + } } diff --git a/src/measurement/length.rs b/src/measurement/length.rs index 4006508..92301ce 100644 --- a/src/measurement/length.rs +++ b/src/measurement/length.rs @@ -41,6 +41,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Length { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Length) -> String { + v.canonical + } +} + +impl TryFrom for Length { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for LengthUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -77,10 +91,10 @@ pub struct LengthInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Length { value: f64, unit: LengthUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -225,4 +239,21 @@ mod tests { fn try_from_rejects_unknown_unit() { assert!(Length::try_from("1.5 parsec").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Length::try_from("1.5 km").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Length = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Length::try_from("1.5 km").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("1.5 km")); + } } diff --git a/src/measurement/power.rs b/src/measurement/power.rs index 780280a..b63a5c0 100644 --- a/src/measurement/power.rs +++ b/src/measurement/power.rs @@ -39,6 +39,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Power { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Power) -> String { + v.canonical + } +} + +impl TryFrom for Power { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for PowerUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -72,10 +86,10 @@ pub struct PowerInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Power { value: f64, unit: PowerUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -202,4 +216,21 @@ mod tests { fn try_from_rejects_unknown_unit() { assert!(Power::try_from("3.7 CVs").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Power::try_from("3.7 kW").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Power = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Power::try_from("3.7 kW").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("3.7 kW")); + } } diff --git a/src/measurement/pressure.rs b/src/measurement/pressure.rs index 2f98b3f..6a2cf18 100644 --- a/src/measurement/pressure.rs +++ b/src/measurement/pressure.rs @@ -41,6 +41,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Pressure { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Pressure) -> String { + v.canonical + } +} + +impl TryFrom for Pressure { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for PressureUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -76,10 +90,10 @@ pub struct PressureInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Pressure { value: f64, unit: PressureUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -211,4 +225,21 @@ mod tests { fn try_from_rejects_unknown_unit() { assert!(Pressure::try_from("1.0 hg").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Pressure::try_from("101.325 kPa").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Pressure = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Pressure::try_from("101.325 kPa").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("101.325 kPa")); + } } diff --git a/src/measurement/speed.rs b/src/measurement/speed.rs index 566c8d6..f96356d 100644 --- a/src/measurement/speed.rs +++ b/src/measurement/speed.rs @@ -39,6 +39,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Speed { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Speed) -> String { + v.canonical + } +} + +impl TryFrom for Speed { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for SpeedUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -72,10 +86,10 @@ pub struct SpeedInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Speed { value: f64, unit: SpeedUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -202,4 +216,21 @@ mod tests { fn try_from_rejects_unknown_unit() { assert!(Speed::try_from("120 warp").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Speed::try_from("120 km/h").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Speed = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Speed::try_from("120 km/h").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("120 km/h")); + } } diff --git a/src/measurement/temperature.rs b/src/measurement/temperature.rs index 9518dd7..af758d3 100644 --- a/src/measurement/temperature.rs +++ b/src/measurement/temperature.rs @@ -38,6 +38,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Temperature { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Temperature) -> String { + v.canonical + } +} + +impl TryFrom for Temperature { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for TemperatureUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -73,10 +87,10 @@ pub struct TemperatureInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Temperature { value: f64, unit: TemperatureUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -253,4 +267,21 @@ mod tests { fn try_from_rejects_below_absolute_zero() { assert!(Temperature::try_from("-500 K").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Temperature::try_from("100 °C").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Temperature = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Temperature::try_from("100 °C").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("100")); + } } diff --git a/src/measurement/volume.rs b/src/measurement/volume.rs index 51f7933..bc10c8a 100644 --- a/src/measurement/volume.rs +++ b/src/measurement/volume.rs @@ -40,6 +40,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Volume { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Volume) -> String { + v.canonical + } +} + +impl TryFrom for Volume { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for VolumeUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -74,10 +88,10 @@ pub struct VolumeInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Volume { value: f64, unit: VolumeUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -205,4 +219,21 @@ mod tests { fn try_from_rejects_unknown_unit() { assert!(Volume::try_from("1.5 cups").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Volume::try_from("1.5 l").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Volume = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Volume::try_from("1.5 l").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("1.5 l")); + } } diff --git a/src/measurement/weight.rs b/src/measurement/weight.rs index 69a734a..5cd6d3d 100644 --- a/src/measurement/weight.rs +++ b/src/measurement/weight.rs @@ -41,6 +41,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Weight { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Weight) -> String { + v.canonical + } +} + +impl TryFrom for Weight { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for WeightUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -76,10 +90,10 @@ pub struct WeightInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Weight { value: f64, unit: WeightUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -210,4 +224,21 @@ mod tests { fn try_from_rejects_unknown_unit() { assert!(Weight::try_from("70 stone").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Weight::try_from("70 kg").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Weight = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Weight::try_from("70 kg").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("70 kg")); + } } diff --git a/src/net/api_key.rs b/src/net/api_key.rs index 5a77925..628e9c6 100644 --- a/src/net/api_key.rs +++ b/src/net/api_key.rs @@ -25,7 +25,7 @@ pub type ApiKeyOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct ApiKey(String); @@ -79,6 +79,20 @@ impl std::fmt::Display for ApiKey { } } + +impl TryFrom for ApiKey { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: ApiKey) -> String { + v.0 + } +} impl TryFrom<&str> for ApiKey { type Error = ValidationError; @@ -139,4 +153,20 @@ mod tests { assert!(displayed.ends_with("cret")); assert!(displayed.starts_with("**")); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = ApiKey::try_from("sk-test-1234567890abcdef").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: ApiKey = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"\""); + assert!(result.is_err()); + } } diff --git a/src/net/domain.rs b/src/net/domain.rs index e0fec13..719111a 100644 --- a/src/net/domain.rs +++ b/src/net/domain.rs @@ -27,7 +27,7 @@ pub type DomainOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Domain(String); @@ -86,6 +86,20 @@ fn is_valid_domain(s: &str) -> bool { true } + +impl TryFrom for Domain { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Domain) -> String { + v.0 + } +} impl TryFrom<&str> for Domain { type Error = ValidationError; @@ -156,4 +170,20 @@ mod tests { let d: Domain = "example.org".try_into().unwrap(); assert_eq!(d.value(), "example.org"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Domain::try_from("example.com").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Domain = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/net/http_status_code.rs b/src/net/http_status_code.rs index ef48947..8cb1c42 100644 --- a/src/net/http_status_code.rs +++ b/src/net/http_status_code.rs @@ -23,7 +23,7 @@ pub type HttpStatusCodeOutput = u16; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "u16", into = "u16"))] pub struct HttpStatusCode(u16); impl ValueObject for HttpStatusCode { @@ -77,6 +77,20 @@ impl HttpStatusCode { } } + +impl TryFrom for HttpStatusCode { + type Error = ValidationError; + fn try_from(v: u16) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for u16 { + fn from(v: HttpStatusCode) -> u16 { + v.0 + } +} impl TryFrom<&str> for HttpStatusCode { type Error = ValidationError; @@ -183,4 +197,21 @@ mod tests { fn try_from_rejects_out_of_range() { assert!(HttpStatusCode::try_from("99").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = HttpStatusCode::new(200).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "200"); + let back: HttpStatusCode = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("99"); + assert!(result.is_err()); + } } diff --git a/src/net/ip_address.rs b/src/net/ip_address.rs index 74c31b4..1888efe 100644 --- a/src/net/ip_address.rs +++ b/src/net/ip_address.rs @@ -29,7 +29,7 @@ pub type IpAddressOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct IpAddress(String); @@ -78,6 +78,20 @@ impl IpAddress { } } + +impl TryFrom for IpAddress { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: IpAddress) -> String { + v.0 + } +} impl TryFrom<&str> for IpAddress { type Error = ValidationError; diff --git a/src/net/ip_v4_address.rs b/src/net/ip_v4_address.rs index f2210e9..df7d766 100644 --- a/src/net/ip_v4_address.rs +++ b/src/net/ip_v4_address.rs @@ -26,7 +26,7 @@ pub type IpV4AddressOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct IpV4Address(String); @@ -77,6 +77,20 @@ impl IpV4Address { } } + +impl TryFrom for IpV4Address { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: IpV4Address) -> String { + v.0 + } +} impl TryFrom<&str> for IpV4Address { type Error = ValidationError; @@ -160,4 +174,20 @@ mod tests { let ip: IpV4Address = "10.0.0.1".try_into().unwrap(); assert_eq!(ip.value(), "10.0.0.1"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = IpV4Address::try_from("192.168.1.1").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: IpV4Address = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/net/ip_v6_address.rs b/src/net/ip_v6_address.rs index 47d5efa..ebf31d3 100644 --- a/src/net/ip_v6_address.rs +++ b/src/net/ip_v6_address.rs @@ -27,7 +27,7 @@ pub type IpV6AddressOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct IpV6Address(String); @@ -59,6 +59,20 @@ impl ValueObject for IpV6Address { } } + +impl TryFrom for IpV6Address { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: IpV6Address) -> String { + v.0 + } +} impl TryFrom<&str> for IpV6Address { type Error = ValidationError; @@ -114,4 +128,20 @@ mod tests { let ip: IpV6Address = "::1".try_into().unwrap(); assert_eq!(ip.value(), "::1"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = IpV6Address::try_from("::1").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: IpV6Address = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/net/mac_address.rs b/src/net/mac_address.rs index 9ce866c..7eb1df6 100644 --- a/src/net/mac_address.rs +++ b/src/net/mac_address.rs @@ -26,7 +26,7 @@ pub type MacAddressOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct MacAddress(String); @@ -108,6 +108,20 @@ fn parse_mac_bytes(s: &str) -> Option<[u8; 6]> { None } + +impl TryFrom for MacAddress { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: MacAddress) -> String { + v.0 + } +} impl TryFrom<&str> for MacAddress { type Error = ValidationError; @@ -170,4 +184,20 @@ mod tests { let mac: MacAddress = "aa:bb:cc:dd:ee:ff".try_into().unwrap(); assert_eq!(mac.value(), "aa:bb:cc:dd:ee:ff"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = MacAddress::try_from("00:1A:2B:3C:4D:5E").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: MacAddress = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/net/mime_type.rs b/src/net/mime_type.rs index 9f39217..2cf5896 100644 --- a/src/net/mime_type.rs +++ b/src/net/mime_type.rs @@ -29,7 +29,7 @@ pub type MimeTypeOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct MimeType(String); @@ -95,6 +95,20 @@ impl MimeType { } } + +impl TryFrom for MimeType { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: MimeType) -> String { + v.0 + } +} impl TryFrom<&str> for MimeType { type Error = ValidationError; @@ -168,4 +182,20 @@ mod tests { let m: MimeType = "text/plain".try_into().unwrap(); assert_eq!(m.value(), "text/plain"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = MimeType::try_from("image/png").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: MimeType = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/net/port.rs b/src/net/port.rs index dd93c6c..c9d3b9e 100644 --- a/src/net/port.rs +++ b/src/net/port.rs @@ -24,7 +24,7 @@ pub type PortOutput = u16; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "u16", into = "u16"))] pub struct Port(u16); impl ValueObject for Port { @@ -65,6 +65,20 @@ impl Port { } } + +impl TryFrom for Port { + type Error = ValidationError; + fn try_from(v: u16) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for u16 { + fn from(v: Port) -> u16 { + v.0 + } +} impl TryFrom<&str> for Port { type Error = ValidationError; @@ -177,4 +191,21 @@ mod tests { fn try_from_rejects_zero() { assert!(Port::try_from("0").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Port::new(8080).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "8080"); + let back: Port = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("0"); + assert!(result.is_err()); + } } diff --git a/src/net/url.rs b/src/net/url.rs index 1d9b15c..7fd9ae8 100644 --- a/src/net/url.rs +++ b/src/net/url.rs @@ -25,7 +25,7 @@ pub type UrlOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Url(String); @@ -97,6 +97,20 @@ impl Url { } } + +impl TryFrom for Url { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Url) -> String { + v.0 + } +} impl TryFrom<&str> for Url { type Error = ValidationError; diff --git a/src/primitives/base64_string.rs b/src/primitives/base64_string.rs index 7cb1dc6..d1353dc 100644 --- a/src/primitives/base64_string.rs +++ b/src/primitives/base64_string.rs @@ -28,7 +28,7 @@ pub type Base64StringOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Base64String(String); @@ -65,6 +65,20 @@ impl Base64String { } } + +impl TryFrom for Base64String { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Base64String) -> String { + v.0 + } +} impl TryFrom<&str> for Base64String { type Error = ValidationError; @@ -121,4 +135,20 @@ mod tests { let b: Base64String = "aGVsbG8=".try_into().unwrap(); assert_eq!(b.decode(), b"hello"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Base64String::try_from("aGVsbG8=").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Base64String = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/primitives/bounded_string.rs b/src/primitives/bounded_string.rs index e165370..5b39e2a 100644 --- a/src/primitives/bounded_string.rs +++ b/src/primitives/bounded_string.rs @@ -24,7 +24,7 @@ use crate::traits::ValueObject; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct BoundedString(String); impl ValueObject for BoundedString { @@ -61,6 +61,21 @@ impl ValueObject for BoundedString } } +impl TryFrom for BoundedString { + type Error = ValidationError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +#[cfg(feature = "serde")] +impl From> for String { + fn from(v: BoundedString) -> String { + v.0 + } +} + impl TryFrom<&str> for BoundedString { type Error = ValidationError; diff --git a/src/primitives/hex_color.rs b/src/primitives/hex_color.rs index 081dec4..8cecc6f 100644 --- a/src/primitives/hex_color.rs +++ b/src/primitives/hex_color.rs @@ -27,7 +27,7 @@ pub type HexColorOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct HexColor(String); @@ -103,6 +103,20 @@ impl HexColor { } } + +impl TryFrom for HexColor { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: HexColor) -> String { + v.0 + } +} impl TryFrom<&str> for HexColor { type Error = ValidationError; @@ -178,4 +192,20 @@ mod tests { let c: HexColor = "#ABC".try_into().unwrap(); assert_eq!(c.value(), "#AABBCC"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = HexColor::try_from("#ff0000").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: HexColor = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/primitives/locale.rs b/src/primitives/locale.rs index 155325d..5cb0d21 100644 --- a/src/primitives/locale.rs +++ b/src/primitives/locale.rs @@ -30,7 +30,7 @@ pub type LocaleOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Locale(String); @@ -94,6 +94,20 @@ impl Locale { } } + +impl TryFrom for Locale { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Locale) -> String { + v.0 + } +} impl TryFrom<&str> for Locale { type Error = ValidationError; @@ -192,4 +206,20 @@ mod tests { let l: Locale = "cs-CZ".try_into().unwrap(); assert_eq!(l.value(), "cs-CZ"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Locale::try_from("en-US").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Locale = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/primitives/non_empty_string.rs b/src/primitives/non_empty_string.rs index e229ab6..9e6ac9b 100644 --- a/src/primitives/non_empty_string.rs +++ b/src/primitives/non_empty_string.rs @@ -25,7 +25,7 @@ pub type NonEmptyStringOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct NonEmptyString(String); @@ -52,6 +52,20 @@ impl ValueObject for NonEmptyString { } } + +impl TryFrom for NonEmptyString { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: NonEmptyString) -> String { + v.0 + } +} impl TryFrom<&str> for NonEmptyString { type Error = ValidationError; @@ -97,4 +111,20 @@ mod tests { let s: NonEmptyString = "world".try_into().unwrap(); assert_eq!(s.value(), "world"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = NonEmptyString::try_from("hello").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: NonEmptyString = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"\""); + assert!(result.is_err()); + } } diff --git a/src/primitives/non_negative_decimal.rs b/src/primitives/non_negative_decimal.rs index 590ff03..a2894a1 100644 --- a/src/primitives/non_negative_decimal.rs +++ b/src/primitives/non_negative_decimal.rs @@ -26,7 +26,7 @@ pub type NonNegativeDecimalOutput = Decimal; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "Decimal", into = "Decimal"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct NonNegativeDecimal(Decimal); @@ -57,6 +57,20 @@ impl ValueObject for NonNegativeDecimal { } } + +impl TryFrom for NonNegativeDecimal { + type Error = ValidationError; + fn try_from(v: Decimal) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for Decimal { + fn from(v: NonNegativeDecimal) -> Decimal { + v.0 + } +} impl TryFrom<&str> for NonNegativeDecimal { type Error = ValidationError; @@ -109,4 +123,20 @@ mod tests { fn try_from_rejects_negative() { assert!(NonNegativeDecimal::try_from("-1").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = NonNegativeDecimal::try_from("0.00").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: NonNegativeDecimal = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"-1\""); + assert!(result.is_err()); + } } diff --git a/src/primitives/non_negative_int.rs b/src/primitives/non_negative_int.rs index e75fcc8..e0f104c 100644 --- a/src/primitives/non_negative_int.rs +++ b/src/primitives/non_negative_int.rs @@ -24,7 +24,7 @@ pub type NonNegativeIntOutput = i64; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "i64", into = "i64"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct NonNegativeInt(i64); @@ -55,6 +55,20 @@ impl ValueObject for NonNegativeInt { } } + +impl TryFrom for NonNegativeInt { + type Error = ValidationError; + fn try_from(v: i64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for i64 { + fn from(v: NonNegativeInt) -> i64 { + v.0 + } +} impl TryFrom<&str> for NonNegativeInt { type Error = ValidationError; @@ -106,4 +120,21 @@ mod tests { fn try_from_rejects_negative() { assert!(NonNegativeInt::try_from("-1").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = NonNegativeInt::new(0).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "0"); + let back: NonNegativeInt = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("-1"); + assert!(result.is_err()); + } } diff --git a/src/primitives/positive_decimal.rs b/src/primitives/positive_decimal.rs index 54efede..c843e09 100644 --- a/src/primitives/positive_decimal.rs +++ b/src/primitives/positive_decimal.rs @@ -26,7 +26,7 @@ pub type PositiveDecimalOutput = Decimal; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "Decimal", into = "Decimal"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct PositiveDecimal(Decimal); @@ -57,6 +57,20 @@ impl ValueObject for PositiveDecimal { } } + +impl TryFrom for PositiveDecimal { + type Error = ValidationError; + fn try_from(v: Decimal) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for Decimal { + fn from(v: PositiveDecimal) -> Decimal { + v.0 + } +} impl TryFrom<&str> for PositiveDecimal { type Error = ValidationError; @@ -108,4 +122,20 @@ mod tests { fn try_from_rejects_zero() { assert!(PositiveDecimal::try_from("0").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = PositiveDecimal::try_from("3.14").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: PositiveDecimal = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"0\""); + assert!(result.is_err()); + } } diff --git a/src/primitives/positive_int.rs b/src/primitives/positive_int.rs index 118ded7..199aabf 100644 --- a/src/primitives/positive_int.rs +++ b/src/primitives/positive_int.rs @@ -25,7 +25,7 @@ pub type PositiveIntOutput = i64; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "i64", into = "i64"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct PositiveInt(i64); @@ -56,6 +56,20 @@ impl ValueObject for PositiveInt { } } + +impl TryFrom for PositiveInt { + type Error = ValidationError; + fn try_from(v: i64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for i64 { + fn from(v: PositiveInt) -> i64 { + v.0 + } +} impl TryFrom<&str> for PositiveInt { type Error = ValidationError; @@ -112,4 +126,21 @@ mod tests { fn try_from_rejects_zero() { assert!(PositiveInt::try_from("0").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = PositiveInt::new(42).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "42"); + let back: PositiveInt = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("0"); + assert!(result.is_err()); + } } diff --git a/src/primitives/probability.rs b/src/primitives/probability.rs index fd75cd1..008c6cf 100644 --- a/src/primitives/probability.rs +++ b/src/primitives/probability.rs @@ -25,7 +25,7 @@ pub type ProbabilityOutput = f64; /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "f64", into = "f64"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Probability(f64); @@ -56,6 +56,20 @@ impl ValueObject for Probability { } } + +impl TryFrom for Probability { + type Error = ValidationError; + fn try_from(v: f64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for f64 { + fn from(v: Probability) -> f64 { + v.0 + } +} impl TryFrom<&str> for Probability { type Error = ValidationError; @@ -128,4 +142,21 @@ mod tests { fn try_from_rejects_out_of_range() { assert!(Probability::try_from("1.1").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Probability::new(0.5).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "0.5"); + let back: Probability = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("1.1"); + assert!(result.is_err()); + } } diff --git a/src/temporal/birth_date.rs b/src/temporal/birth_date.rs index 890fa1e..7c60e79 100644 --- a/src/temporal/birth_date.rs +++ b/src/temporal/birth_date.rs @@ -27,7 +27,7 @@ pub type BirthDateOutput = NaiveDate; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "chrono::NaiveDate", into = "chrono::NaiveDate"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct BirthDate(NaiveDate); @@ -83,6 +83,20 @@ impl BirthDate { } } + +impl TryFrom for BirthDate { + type Error = ValidationError; + fn try_from(v: chrono::NaiveDate) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for chrono::NaiveDate { + fn from(v: BirthDate) -> chrono::NaiveDate { + v.0 + } +} impl TryFrom<&str> for BirthDate { type Error = ValidationError; @@ -168,4 +182,21 @@ mod tests { fn try_from_rejects_future_date() { assert!(BirthDate::try_from("2099-01-01").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = BirthDate::try_from("1990-06-15").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "\"1990-06-15\""); + let back: BirthDate = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"2099-01-01\""); + assert!(result.is_err()); + } } diff --git a/src/temporal/business_hours.rs b/src/temporal/business_hours.rs index 5b8293a..c545611 100644 --- a/src/temporal/business_hours.rs +++ b/src/temporal/business_hours.rs @@ -40,11 +40,11 @@ pub type BusinessHoursOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct BusinessHours { weekday: Weekday, open: NaiveTime, close: NaiveTime, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -174,6 +174,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for BusinessHours { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: BusinessHours) -> String { + v.canonical + } +} + +impl TryFrom for BusinessHours { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for BusinessHours { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -341,4 +355,21 @@ mod tests { fn try_from_rejects_close_before_open() { assert!(BusinessHours::try_from("Mon 17:00–09:00").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = BusinessHours::try_from("Mon 09:00–17:00").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: BusinessHours = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = BusinessHours::try_from("Mon 09:00–17:00").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("Mon")); + } } diff --git a/src/temporal/expiry_date.rs b/src/temporal/expiry_date.rs index e52116f..766775e 100644 --- a/src/temporal/expiry_date.rs +++ b/src/temporal/expiry_date.rs @@ -25,7 +25,7 @@ pub type ExpiryDateOutput = NaiveDate; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "chrono::NaiveDate", into = "chrono::NaiveDate"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct ExpiryDate(NaiveDate); @@ -62,6 +62,20 @@ impl ExpiryDate { } } + +impl TryFrom for ExpiryDate { + type Error = ValidationError; + fn try_from(v: chrono::NaiveDate) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for chrono::NaiveDate { + fn from(v: ExpiryDate) -> chrono::NaiveDate { + v.0 + } +} impl TryFrom<&str> for ExpiryDate { type Error = ValidationError; @@ -141,4 +155,21 @@ mod tests { fn try_from_rejects_past_date() { assert!(ExpiryDate::try_from("2020-01-01").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = ExpiryDate::try_from("2030-12-31").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "\"2030-12-31\""); + let back: ExpiryDate = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"2020-01-01\""); + assert!(result.is_err()); + } } diff --git a/src/temporal/time_range.rs b/src/temporal/time_range.rs index a707468..007dc44 100644 --- a/src/temporal/time_range.rs +++ b/src/temporal/time_range.rs @@ -37,10 +37,10 @@ pub type TimeRangeOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct TimeRange { start: DateTime, end: DateTime, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } @@ -144,6 +144,20 @@ impl<'r> sqlx::Decode<'r, sqlx::Postgres> for TimeRange { Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: TimeRange) -> String { + v.canonical + } +} + +impl TryFrom for TimeRange { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for TimeRange { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -304,4 +318,21 @@ mod tests { fn try_from_rejects_end_before_start() { assert!(TimeRange::try_from("2025-01-01 12:00:00 UTC / 2025-01-01 10:00:00 UTC").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = TimeRange::try_from("2025-01-01 10:00:00 UTC / 2025-01-01 12:00:00 UTC").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: TimeRange = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = TimeRange::try_from("2025-01-01 10:00:00 UTC / 2025-01-01 12:00:00 UTC").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("2025-01-01 10:00:00 UTC / 2025-01-01 12:00:00 UTC")); + } } diff --git a/src/temporal/unix_timestamp.rs b/src/temporal/unix_timestamp.rs index 1304fac..2d5d132 100644 --- a/src/temporal/unix_timestamp.rs +++ b/src/temporal/unix_timestamp.rs @@ -26,7 +26,7 @@ pub type UnixTimestampOutput = i64; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "i64", into = "i64"))] #[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))] pub struct UnixTimestamp(i64); @@ -62,6 +62,20 @@ impl UnixTimestamp { } } + +impl TryFrom for UnixTimestamp { + type Error = ValidationError; + fn try_from(v: i64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for i64 { + fn from(v: UnixTimestamp) -> i64 { + v.0 + } +} impl TryFrom<&str> for UnixTimestamp { type Error = ValidationError; @@ -137,4 +151,21 @@ mod tests { fn try_from_rejects_negative() { assert!(UnixTimestamp::try_from("-1").is_err()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = UnixTimestamp::new(1_700_000_000).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "1700000000"); + let back: UnixTimestamp = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("-1"); + assert!(result.is_err()); + } } From 28052d6518a5a929bdfa0f3bd45f9b847b5adc2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Wed, 22 Apr 2026 08:08:37 +0200 Subject: [PATCH 10/15] docs: update README and module docs for serde validation and sql coverage - README: clarify that serde deserialisation validates via new() (not transparent) - implementing.md: add serde try_from pattern and sqlx checklist items - contact.md: document PhoneNumber and PostalAddress serde/sql/TryFrom exceptions --- README.md | 11 +++++++++-- docs/contact.md | 8 ++++++++ docs/implementing.md | 10 +++++++--- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 31c2f81..84be5f6 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ Parsing errors return `ValidationError` just like `::new()`. ## Serde support -Enable the `serde` feature. All types serialize as their raw primitive (transparent newtype): +Enable the `serde` feature. All types serialize as their raw primitive and **deserialisation validates** — invalid values are rejected at parse time, not after: ```rust,ignore use arvo::contact::EmailAddress; @@ -226,10 +226,17 @@ let email = EmailAddress::new("user@example.com".into())?; let json = serde_json::to_string(&email)?; // → "\"user@example.com\"" -// Deserialization validates — invalid JSON values are rejected at parse time +// Deserialisation goes through new() — domain validation is enforced let parsed: EmailAddress = serde_json::from_str(r#""hello@example.com""#)?; + +// Invalid values are rejected at parse time +let err: Result = serde_json::from_str(r#""not-an-email""#); +assert!(err.is_err()); ``` +Composite types (`PostalAddress`) serialise as their structured `Input` type (JSON object). +`PhoneNumber` and `PostalAddress` do not support `TryFrom<&str>`, so no canonical-string round-trip is attempted. + --- ## SQL support diff --git a/docs/contact.md b/docs/contact.md index 1700c1a..96addf2 100644 --- a/docs/contact.md +++ b/docs/contact.md @@ -131,6 +131,10 @@ pub struct PhoneNumberInput { | `number: "123"` | `ValidationError::InvalidFormat` (too short, min 4 digits) | | `number: "123456789012345"` | `ValidationError::InvalidFormat` (too long, max 14 digits) | +> **Serde:** serialises as a JSON object `{ "country_code": "CZ", "number": "123456789" }` (the input struct). +> **SQLx:** not supported — the canonical E.164 string cannot be unambiguously decoded back to a structured `PhoneNumberInput`. +> **TryFrom\<&str\>:** not implemented for the same reason. + --- ## Website @@ -227,3 +231,7 @@ pub struct PostalAddressInput { | `street` | `""` or whitespace | `ValidationError::Empty` | | `city` | `""` or whitespace | `ValidationError::Empty` | | `zip` | `""` or whitespace | `ValidationError::Empty` | + +> **Serde:** serialises as a JSON object matching `PostalAddressInput` (the input struct). +> **SQLx:** not supported — the multi-line formatted string cannot be unambiguously decoded back to structured fields. +> **TryFrom\<&str\>:** not implemented for the same reason. diff --git a/docs/implementing.md b/docs/implementing.md index 87a6c07..44764f7 100644 --- a/docs/implementing.md +++ b/docs/implementing.md @@ -103,12 +103,16 @@ impl Coordinate { - [ ] `type Input` and `type Output` type aliases defined and exported - [ ] `#[derive(Debug, Clone, PartialEq, Eq, Hash)]` on the struct -- [ ] `#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]` +- [ ] Serde: `#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]` + + `serde(try_from = "T", into = "T")` so deserialisation validates via `new()` + + `impl TryFrom` delegating to `new()` and `#[cfg(feature = "serde")] impl From for T` +- [ ] SQLx: `#[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))]` + for simple newtypes; manual `Type + Encode + Decode` for composites (store as TEXT via `TryFrom<&str>`) - [ ] `impl ValueObject` with `new`, `value`, `into_inner` -- [ ] `impl TryFrom<&str>` (for string-input types) +- [ ] `impl TryFrom<&str>` (for string-input types and all composite types) - [ ] `impl Display` - [ ] Extra accessors for composite types -- [ ] Unit tests: valid input, empty/invalid input, normalisation, `try_from` +- [ ] Unit tests: valid input, empty/invalid input, normalisation, `try_from`, `serde_roundtrip`, `serde_deserialize_validates` - [ ] Doc comment with `# Example` block - [ ] Registered in `mod.rs` and `prelude` - [ ] Status updated in `ROADMAP.md` From d5c186593d1bb459819426894f528513d7fec878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Wed, 22 Apr 2026 15:11:38 +0200 Subject: [PATCH 11/15] refactor!: split ValueObject trait, add PrimitiveValue, remove SQLx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the ValueObject trait into two: - ValueObject: base trait for all VOs (new, into_inner) - PrimitiveValue: subtrait for simple newtypes (value() -> &Primitive) Composite types retain value() as a concrete inherent method. All 42 simple newtypes implement PrimitiveValue with typed Primitive (String, f64, i64, u16, Decimal, NaiveDate). Remove SQLx support entirely — users integrate with their ORM directly via into_inner() and individual accessors. Remove sql feature flag and all sqlx impl blocks (~260 lines). Remove XxxOutput type aliases from all VO files and re-exports. Export PrimitiveValue from prelude alongside ValueObject. All 689 tests pass. --- Cargo.lock | 968 +------------------------ Cargo.toml | 2 - src/contact/country_code.rs | 16 +- src/contact/email_address.rs | 16 +- src/contact/mod.rs | 10 +- src/contact/phone_number.rs | 14 +- src/contact/postal_address.rs | 14 +- src/contact/website.rs | 16 +- src/finance/bic.rs | 17 +- src/finance/card_expiry_date.rs | 17 +- src/finance/credit_card_number.rs | 17 +- src/finance/currency_code.rs | 17 +- src/finance/exchange_rate.rs | 42 +- src/finance/iban.rs | 17 +- src/finance/mod.rs | 18 +- src/finance/money.rs | 42 +- src/finance/percentage.rs | 17 +- src/finance/vat_number.rs | 17 +- src/geo/bounding_box.rs | 39 +- src/geo/coordinate.rs | 39 +- src/geo/country_region.rs | 17 +- src/geo/latitude.rs | 17 +- src/geo/longitude.rs | 17 +- src/geo/mod.rs | 8 +- src/geo/time_zone.rs | 17 +- src/identifiers/ean13.rs | 17 +- src/identifiers/ean8.rs | 17 +- src/identifiers/isbn10.rs | 17 +- src/identifiers/isbn13.rs | 17 +- src/identifiers/issn.rs | 17 +- src/identifiers/mod.rs | 14 +- src/identifiers/slug.rs | 17 +- src/identifiers/vin.rs | 17 +- src/lib.rs | 123 ++-- src/measurement/area.rs | 36 +- src/measurement/energy.rs | 36 +- src/measurement/frequency.rs | 36 +- src/measurement/length.rs | 37 +- src/measurement/power.rs | 36 +- src/measurement/pressure.rs | 36 +- src/measurement/speed.rs | 36 +- src/measurement/temperature.rs | 37 +- src/measurement/volume.rs | 36 +- src/measurement/weight.rs | 37 +- src/net/api_key.rs | 17 +- src/net/domain.rs | 17 +- src/net/http_status_code.rs | 44 +- src/net/ip_address.rs | 17 +- src/net/ip_v4_address.rs | 17 +- src/net/ip_v6_address.rs | 17 +- src/net/mac_address.rs | 17 +- src/net/mime_type.rs | 17 +- src/net/mod.rs | 20 +- src/net/port.rs | 44 +- src/net/url.rs | 17 +- src/primitives/base64_string.rs | 17 +- src/primitives/bounded_string.rs | 46 +- src/primitives/hex_color.rs | 17 +- src/primitives/locale.rs | 17 +- src/primitives/mod.rs | 20 +- src/primitives/non_empty_string.rs | 17 +- src/primitives/non_negative_decimal.rs | 17 +- src/primitives/non_negative_int.rs | 17 +- src/primitives/positive_decimal.rs | 17 +- src/primitives/positive_int.rs | 17 +- src/primitives/probability.rs | 17 +- src/temporal/birth_date.rs | 17 +- src/temporal/business_hours.rs | 38 +- src/temporal/expiry_date.rs | 17 +- src/temporal/mod.rs | 10 +- src/temporal/time_range.rs | 38 +- src/temporal/unix_timestamp.rs | 17 +- src/traits.rs | 106 ++- 73 files changed, 549 insertions(+), 2207 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3c6b13..4b11c3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,12 +22,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -60,22 +54,12 @@ dependencies = [ "rust_decimal", "serde", "serde_json", - "sqlx", "thiserror", "ulid", "url", "uuid", ] -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -88,20 +72,11 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -dependencies = [ - "serde_core", -] [[package]] name = "bitvec" @@ -115,15 +90,6 @@ dependencies = [ "wyz", ] -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "borsh" version = "1.6.1" @@ -176,12 +142,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.1" @@ -224,159 +184,24 @@ dependencies = [ "windows-link", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", -] - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -dependencies = [ - "serde", -] - [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "etcetera" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.48.0", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - [[package]] name = "foldhash" version = "0.1.5" @@ -398,87 +223,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -530,8 +274,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash", ] @@ -541,54 +283,12 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -657,15 +357,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - [[package]] name = "leb128fmt" version = "0.1.0" @@ -678,101 +369,18 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "bitflags", - "libc", - "plain", - "redox_syscall 0.7.4", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "pkg-config", - "vcpkg", -] - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -780,7 +388,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -789,89 +396,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -999,38 +529,20 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "bitflags", + "getrandom 0.2.17", ] [[package]] -name = "redox_syscall" -version = "0.7.4" +name = "rand_core" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "bitflags", + "getrandom 0.3.4", ] [[package]] @@ -1100,26 +612,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rust_decimal" version = "1.41.0" @@ -1143,18 +635,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "seahash" version = "4.1.0" @@ -1210,305 +690,18 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[package]] name = "simdutf8" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink", - "indexmap", - "log", - "memchr", - "once_cell", - "percent-encoding", - "rust_decimal", - "serde", - "serde_json", - "sha2", - "smallvec", - "thiserror", - "tracing", - "url", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.117", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn 2.0.117", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "bytes", - "chrono", - "crc", - "digest", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "percent-encoding", - "rand 0.8.6", - "rsa", - "rust_decimal", - "serde", - "sha1", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror", - "tracing", - "whoami", -] - -[[package]] -name = "sqlx-postgres" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand 0.8.6", - "rust_decimal", - "serde", - "serde_json", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror", - "tracing", - "whoami", -] - -[[package]] -name = "sqlx-sqlite" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" -dependencies = [ - "atoi", - "chrono", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror", - "tracing", - "url", -] - -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "1.0.109" @@ -1602,44 +795,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - [[package]] name = "ulid" version = "1.2.1" @@ -1671,12 +826,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-xid" version = "0.2.6" @@ -1705,12 +854,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -1741,12 +884,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" version = "0.2.118" @@ -1837,16 +974,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -1906,81 +1033,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "winnow" version = "1.0.1" @@ -2107,12 +1159,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 472441a..0c47d8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,6 @@ temporal = ["dep:chrono"] # Cross-cutting concerns can be combined with any module serde = ["dep:serde"] -sql = ["dep:sqlx"] # Everything at once full = [ @@ -54,7 +53,6 @@ ulid = { version = "1", optional = true } url = { version = "~2.4", optional = true } base64 = { version = "0.22", optional = true } serde = { version = "1", optional = true, features = ["derive"] } -sqlx = { version = "0.8", optional = true, features = ["postgres", "chrono", "rust_decimal"] } [dev-dependencies] serde_json = "1" diff --git a/src/contact/country_code.rs b/src/contact/country_code.rs index 4802038..c644314 100644 --- a/src/contact/country_code.rs +++ b/src/contact/country_code.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CountryCode`] — a raw string before validation. pub type CountryCodeInput = String; /// Output type for [`CountryCode`] — a normalised uppercase string. -pub type CountryCodeOutput = String; /// A validated ISO 3166-1 alpha-2 country code. /// @@ -28,13 +27,10 @@ pub type CountryCodeOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CountryCode(String); impl ValueObject for CountryCode { type Input = CountryCodeInput; - type Output = CountryCodeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -50,14 +46,16 @@ impl ValueObject for CountryCode { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for CountryCode { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} /// Allows ergonomic construction from a string literal: `"CZ".try_into()` diff --git a/src/contact/email_address.rs b/src/contact/email_address.rs index 3307c6e..196134f 100644 --- a/src/contact/email_address.rs +++ b/src/contact/email_address.rs @@ -1,5 +1,5 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use once_cell::sync::Lazy; use regex::Regex; @@ -7,7 +7,6 @@ use regex::Regex; pub type EmailAddressInput = String; /// Output type for [`EmailAddress`] — a normalised lowercase string. -pub type EmailAddressOutput = String; /// Compiled email regex — evaluated once at first use. /// @@ -35,13 +34,10 @@ static EMAIL_REGEX: Lazy = #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct EmailAddress(String); impl ValueObject for EmailAddress { type Input = EmailAddressInput; - type Output = EmailAddressOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -58,14 +54,16 @@ impl ValueObject for EmailAddress { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for EmailAddress { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl EmailAddress { /// Returns the local part of the email address (before `@`), e.g. `"user"`. diff --git a/src/contact/mod.rs b/src/contact/mod.rs index 12636e3..694464b 100644 --- a/src/contact/mod.rs +++ b/src/contact/mod.rs @@ -5,8 +5,8 @@ mod phone_number; mod postal_address; mod website; -pub use country_code::{CountryCode, CountryCodeInput, CountryCodeOutput}; -pub use email_address::{EmailAddress, EmailAddressInput, EmailAddressOutput}; -pub use phone_number::{PhoneNumber, PhoneNumberInput, PhoneNumberOutput}; -pub use postal_address::{PostalAddress, PostalAddressInput, PostalAddressOutput}; -pub use website::{Website, WebsiteInput, WebsiteOutput}; +pub use country_code::{CountryCode, CountryCodeInput}; +pub use email_address::{EmailAddress, EmailAddressInput}; +pub use phone_number::{PhoneNumber, PhoneNumberInput}; +pub use postal_address::{PostalAddress, PostalAddressInput}; +pub use website::{Website, WebsiteInput}; \ No newline at end of file diff --git a/src/contact/phone_number.rs b/src/contact/phone_number.rs index 0374584..0c758b9 100644 --- a/src/contact/phone_number.rs +++ b/src/contact/phone_number.rs @@ -1,5 +1,5 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use once_cell::sync::Lazy; use regex::Regex; @@ -15,7 +15,6 @@ pub struct PhoneNumberInput { } /// Output type for [`PhoneNumber`] — canonical E.164 string, e.g. `"+420123456789"`. -pub type PhoneNumberOutput = String; /// Validates the local number part: digits only, 4–14 characters. static NUMBER_REGEX: Lazy = Lazy::new(|| Regex::new(r"^\d{4,14}$").unwrap()); @@ -61,7 +60,6 @@ impl From for String { impl ValueObject for PhoneNumber { type Input = PhoneNumberInput; - type Output = PhoneNumberOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -90,16 +88,16 @@ impl ValueObject for PhoneNumber { }) } - fn value(&self) -> &Self::Output { - &self.e164 - } - fn into_inner(self) -> Self::Input { self.input } } impl PhoneNumber { + pub fn value(&self) -> &str { + &self.e164 + } + /// Returns the ITU calling code prefix, e.g. `"+420"`. pub fn calling_code(&self) -> &str { calling_code(self.input.country_code.value()).unwrap_or("+0") @@ -382,7 +380,7 @@ fn calling_code(country: &str) -> Option<&'static str> { #[cfg(test)] mod tests { use super::*; - use crate::traits::ValueObject; + use crate::traits::{PrimitiveValue, ValueObject}; fn cz() -> CountryCode { CountryCode::new("CZ".into()).unwrap() diff --git a/src/contact/postal_address.rs b/src/contact/postal_address.rs index 385d815..7d09ace 100644 --- a/src/contact/postal_address.rs +++ b/src/contact/postal_address.rs @@ -1,5 +1,5 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use super::country_code::CountryCode; @@ -18,7 +18,6 @@ pub struct PostalAddressInput { } /// Output type for [`PostalAddress`] — a human-readable multi-line string. -pub type PostalAddressOutput = String; /// A validated postal address. /// @@ -64,7 +63,6 @@ pub struct PostalAddress { impl ValueObject for PostalAddress { type Input = PostalAddressInput; - type Output = PostalAddressOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -93,10 +91,6 @@ impl ValueObject for PostalAddress { }) } - fn value(&self) -> &Self::Output { - &self.formatted - } - fn into_inner(self) -> Self::Input { PostalAddressInput { street: self.street, @@ -108,6 +102,10 @@ impl ValueObject for PostalAddress { } impl PostalAddress { + pub fn value(&self) -> &str { + &self.formatted + } + /// Returns the street field, e.g. `"Václavské náměstí 1"`. pub fn street(&self) -> &str { &self.street @@ -153,7 +151,7 @@ impl std::fmt::Display for PostalAddress { #[cfg(test)] mod tests { use super::*; - use crate::traits::ValueObject; + use crate::traits::{PrimitiveValue, ValueObject}; fn cz() -> CountryCode { CountryCode::new("CZ".into()).unwrap() diff --git a/src/contact/website.rs b/src/contact/website.rs index 99a4d56..907baa0 100644 --- a/src/contact/website.rs +++ b/src/contact/website.rs @@ -1,12 +1,11 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use url::Url; /// Input type for [`Website`] — a raw string before validation. pub type WebsiteInput = String; /// Output type for [`Website`] — a normalised URL string. -pub type WebsiteOutput = String; /// A validated website URL. /// @@ -29,13 +28,10 @@ pub type WebsiteOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Website(String); impl ValueObject for Website { type Input = WebsiteInput; - type Output = WebsiteOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -60,14 +56,16 @@ impl ValueObject for Website { Ok(Self(parsed.to_string())) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Website { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Website { /// Returns `true` if the scheme is `https`. diff --git a/src/finance/bic.rs b/src/finance/bic.rs index 2f747b9..8aa6a29 100644 --- a/src/finance/bic.rs +++ b/src/finance/bic.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Bic`]. pub type BicInput = String; /// Output type for [`Bic`] — canonical uppercase string. -pub type BicOutput = String; /// A validated BIC (Bank Identifier Code), also known as SWIFT code. /// @@ -34,13 +33,10 @@ pub type BicOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Bic(String); impl ValueObject for Bic { type Input = BicInput; - type Output = BicOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -74,14 +70,16 @@ impl ValueObject for Bic { Ok(Self(upper)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Bic { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Bic { /// Returns the 4-letter bank code (positions 1–4). @@ -109,7 +107,6 @@ impl Bic { } } - impl TryFrom for Bic { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/finance/card_expiry_date.rs b/src/finance/card_expiry_date.rs index 29fefc6..4adcb64 100644 --- a/src/finance/card_expiry_date.rs +++ b/src/finance/card_expiry_date.rs @@ -1,13 +1,12 @@ use chrono::{Datelike, Local}; use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CardExpiryDate`] — accepts `"MM/YY"` or `"MM/YYYY"`. pub type CardExpiryDateInput = String; /// Output type for [`CardExpiryDate`] — normalised `"MM/YY"` string. -pub type CardExpiryDateOutput = String; /// A validated credit/debit card expiry date. /// @@ -32,13 +31,10 @@ pub type CardExpiryDateOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CardExpiryDate(String); impl ValueObject for CardExpiryDate { type Input = CardExpiryDateInput; - type Output = CardExpiryDateOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -87,14 +83,16 @@ impl ValueObject for CardExpiryDate { Ok(Self(canonical)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for CardExpiryDate { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl CardExpiryDate { /// Returns the expiry month as a number (1–12). @@ -119,7 +117,6 @@ impl CardExpiryDate { } } - impl TryFrom for CardExpiryDate { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/finance/credit_card_number.rs b/src/finance/credit_card_number.rs index bb4e5f7..74c8356 100644 --- a/src/finance/credit_card_number.rs +++ b/src/finance/credit_card_number.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CreditCardNumber`]. pub type CreditCardNumberInput = String; /// Output type for [`CreditCardNumber`] — digits only, no separators. -pub type CreditCardNumberOutput = String; /// A validated credit card number using the Luhn algorithm. /// @@ -30,13 +29,10 @@ pub type CreditCardNumberOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CreditCardNumber(String); impl ValueObject for CreditCardNumber { type Input = CreditCardNumberInput; - type Output = CreditCardNumberOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -58,14 +54,16 @@ impl ValueObject for CreditCardNumber { Ok(Self(digits)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for CreditCardNumber { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl CreditCardNumber { /// Returns the last 4 digits, e.g. `"0366"`. @@ -120,7 +118,6 @@ fn luhn_valid(digits: &str) -> bool { sum % 10 == 0 } - impl TryFrom for CreditCardNumber { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/finance/currency_code.rs b/src/finance/currency_code.rs index 20f8afb..764fb66 100644 --- a/src/finance/currency_code.rs +++ b/src/finance/currency_code.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CurrencyCode`]. pub type CurrencyCodeInput = String; /// Output type for [`CurrencyCode`]. -pub type CurrencyCodeOutput = String; /// Active ISO 4217 alphabetic currency codes, sorted for binary search. static ISO_4217: &[&str] = &[ @@ -45,13 +44,10 @@ static ISO_4217: &[&str] = &[ #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CurrencyCode(String); impl ValueObject for CurrencyCode { type Input = CurrencyCodeInput; - type Output = CurrencyCodeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -72,15 +68,16 @@ impl ValueObject for CurrencyCode { Ok(Self(upper)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for CurrencyCode { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl TryFrom for CurrencyCode { type Error = ValidationError; diff --git a/src/finance/exchange_rate.rs b/src/finance/exchange_rate.rs index 4b1cc13..184943c 100644 --- a/src/finance/exchange_rate.rs +++ b/src/finance/exchange_rate.rs @@ -1,7 +1,7 @@ use rust_decimal::Decimal; use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use super::currency_code::CurrencyCode; @@ -17,7 +17,6 @@ pub struct ExchangeRateInput { } /// Output type for [`ExchangeRate`] — canonical `"/ "` string. -pub type ExchangeRateOutput = String; /// A validated currency exchange rate. /// @@ -53,7 +52,6 @@ pub struct ExchangeRate { impl ValueObject for ExchangeRate { type Input = ExchangeRateInput; - type Output = ExchangeRateOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -80,10 +78,6 @@ impl ValueObject for ExchangeRate { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { ExchangeRateInput { from: self.from, @@ -94,6 +88,10 @@ impl ValueObject for ExchangeRate { } impl ExchangeRate { + pub fn value(&self) -> &str { + &self.canonical + } + /// Returns the source currency. pub fn from(&self) -> &CurrencyCode { &self.from @@ -124,34 +122,6 @@ impl TryFrom<&str> for ExchangeRate { } } - -#[cfg(feature = "sql")] -impl sqlx::Type for ExchangeRate { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for ExchangeRate { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for ExchangeRate { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: ExchangeRate) -> String { @@ -175,7 +145,7 @@ impl std::fmt::Display for ExchangeRate { #[cfg(test)] mod tests { use super::*; - use crate::traits::ValueObject; + use crate::traits::{PrimitiveValue, ValueObject}; fn eur() -> CurrencyCode { CurrencyCode::new("EUR".into()).unwrap() diff --git a/src/finance/iban.rs b/src/finance/iban.rs index a8c79da..d7f2199 100644 --- a/src/finance/iban.rs +++ b/src/finance/iban.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Iban`]. pub type IbanInput = String; /// Output type for [`Iban`] — canonical uppercase string without spaces. -pub type IbanOutput = String; /// A validated IBAN (International Bank Account Number). /// @@ -28,13 +27,10 @@ pub type IbanOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Iban(String); impl ValueObject for Iban { type Input = IbanInput; - type Output = IbanOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -71,14 +67,16 @@ impl ValueObject for Iban { Ok(Self(stripped)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Iban { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Iban { /// Returns the 2-letter country code, e.g. `"GB"`. @@ -97,7 +95,6 @@ impl Iban { } } - impl TryFrom for Iban { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/finance/mod.rs b/src/finance/mod.rs index 666a85a..76b5c66 100644 --- a/src/finance/mod.rs +++ b/src/finance/mod.rs @@ -8,12 +8,12 @@ mod money; mod percentage; mod vat_number; -pub use bic::{Bic, BicInput, BicOutput}; -pub use card_expiry_date::{CardExpiryDate, CardExpiryDateInput, CardExpiryDateOutput}; -pub use credit_card_number::{CreditCardNumber, CreditCardNumberInput, CreditCardNumberOutput}; -pub use currency_code::{CurrencyCode, CurrencyCodeInput, CurrencyCodeOutput}; -pub use exchange_rate::{ExchangeRate, ExchangeRateInput, ExchangeRateOutput}; -pub use iban::{Iban, IbanInput, IbanOutput}; -pub use money::{Money, MoneyInput, MoneyOutput}; -pub use percentage::{Percentage, PercentageInput, PercentageOutput}; -pub use vat_number::{VatNumber, VatNumberInput, VatNumberOutput}; +pub use bic::{Bic, BicInput}; +pub use card_expiry_date::{CardExpiryDate, CardExpiryDateInput}; +pub use credit_card_number::{CreditCardNumber, CreditCardNumberInput}; +pub use currency_code::{CurrencyCode, CurrencyCodeInput}; +pub use exchange_rate::{ExchangeRate, ExchangeRateInput}; +pub use iban::{Iban, IbanInput}; +pub use money::{Money, MoneyInput}; +pub use percentage::{Percentage, PercentageInput}; +pub use vat_number::{VatNumber, VatNumberInput}; \ No newline at end of file diff --git a/src/finance/money.rs b/src/finance/money.rs index 1aec895..9a18499 100644 --- a/src/finance/money.rs +++ b/src/finance/money.rs @@ -1,7 +1,7 @@ use rust_decimal::Decimal; use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use super::currency_code::CurrencyCode; @@ -15,7 +15,6 @@ pub struct MoneyInput { } /// Output type for [`Money`] — canonical `" "` string. -pub type MoneyOutput = String; /// A validated monetary amount with an associated currency. /// @@ -48,7 +47,6 @@ pub struct Money { impl ValueObject for Money { type Input = MoneyInput; - type Output = MoneyOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -60,10 +58,6 @@ impl ValueObject for Money { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { MoneyInput { amount: self.amount, @@ -73,6 +67,10 @@ impl ValueObject for Money { } impl Money { + pub fn value(&self) -> &str { + &self.canonical + } + /// Returns the monetary amount. pub fn amount(&self) -> &Decimal { &self.amount @@ -129,34 +127,6 @@ impl TryFrom<&str> for Money { } } - -#[cfg(feature = "sql")] -impl sqlx::Type for Money { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Money { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Money { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: Money) -> String { @@ -180,7 +150,7 @@ impl std::fmt::Display for Money { #[cfg(test)] mod tests { use super::*; - use crate::traits::ValueObject; + use crate::traits::{PrimitiveValue, ValueObject}; fn eur() -> CurrencyCode { CurrencyCode::new("EUR".into()).unwrap() diff --git a/src/finance/percentage.rs b/src/finance/percentage.rs index 1cf1d48..7f430bb 100644 --- a/src/finance/percentage.rs +++ b/src/finance/percentage.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Percentage`]. pub type PercentageInput = f64; /// Output type for [`Percentage`]. -pub type PercentageOutput = f64; /// A validated percentage value in the range `0.0..=100.0`. /// @@ -28,13 +27,10 @@ pub type PercentageOutput = f64; #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "f64", into = "f64"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Percentage(f64); impl ValueObject for Percentage { type Input = PercentageInput; - type Output = PercentageOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -49,14 +45,16 @@ impl ValueObject for Percentage { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Percentage { + type Primitive = f64; + fn value(&self) -> &f64 { + &self.0 + } +} impl Percentage { /// Returns the value as a fraction in `0.0..=1.0` (e.g. `42.5` → `0.425`). @@ -65,7 +63,6 @@ impl Percentage { } } - impl TryFrom for Percentage { type Error = ValidationError; fn try_from(v: f64) -> Result { diff --git a/src/finance/vat_number.rs b/src/finance/vat_number.rs index 649c94b..50930c5 100644 --- a/src/finance/vat_number.rs +++ b/src/finance/vat_number.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`VatNumber`]. pub type VatNumberInput = String; /// Output type for [`VatNumber`] — canonical uppercase string without spaces. -pub type VatNumberOutput = String; /// EU VAT country prefixes (sorted for binary search). static EU_PREFIXES: &[&str] = &[ @@ -32,13 +31,10 @@ static EU_PREFIXES: &[&str] = &[ #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct VatNumber(String); impl ValueObject for VatNumber { type Input = VatNumberInput; - type Output = VatNumberOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -78,14 +74,16 @@ impl ValueObject for VatNumber { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for VatNumber { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl VatNumber { /// Returns the 2-letter EU country prefix, e.g. `"CZ"`. @@ -94,7 +92,6 @@ impl VatNumber { } } - impl TryFrom for VatNumber { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/geo/bounding_box.rs b/src/geo/bounding_box.rs index 327b2fe..eb50e4c 100644 --- a/src/geo/bounding_box.rs +++ b/src/geo/bounding_box.rs @@ -1,5 +1,5 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use super::Coordinate; @@ -47,7 +47,6 @@ pub struct BoundingBox { impl ValueObject for BoundingBox { type Input = BoundingBoxInput; - type Output = str; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -71,10 +70,6 @@ impl ValueObject for BoundingBox { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { BoundingBoxInput { sw: self.sw, @@ -84,6 +79,10 @@ impl ValueObject for BoundingBox { } impl BoundingBox { + pub fn value(&self) -> &str { + &self.canonical + } + /// Returns the south-west corner. pub fn sw(&self) -> &Coordinate { &self.sw @@ -119,34 +118,6 @@ impl TryFrom<&str> for BoundingBox { } } - -#[cfg(feature = "sql")] -impl sqlx::Type for BoundingBox { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for BoundingBox { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for BoundingBox { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: BoundingBox) -> String { diff --git a/src/geo/coordinate.rs b/src/geo/coordinate.rs index a9e6c44..02da8ee 100644 --- a/src/geo/coordinate.rs +++ b/src/geo/coordinate.rs @@ -1,5 +1,5 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use super::{Latitude, Longitude}; @@ -40,7 +40,6 @@ pub struct Coordinate { impl ValueObject for Coordinate { type Input = CoordinateInput; - type Output = str; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -52,10 +51,6 @@ impl ValueObject for Coordinate { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { CoordinateInput { lat: self.lat, @@ -65,6 +60,10 @@ impl ValueObject for Coordinate { } impl Coordinate { + pub fn value(&self) -> &str { + &self.canonical + } + /// Returns the latitude component. pub fn lat(&self) -> &Latitude { &self.lat @@ -88,34 +87,6 @@ impl TryFrom<&str> for Coordinate { } } - -#[cfg(feature = "sql")] -impl sqlx::Type for Coordinate { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Coordinate { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Coordinate { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: Coordinate) -> String { diff --git a/src/geo/country_region.rs b/src/geo/country_region.rs index 4c6a007..a3cadf9 100644 --- a/src/geo/country_region.rs +++ b/src/geo/country_region.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CountryRegion`]. pub type CountryRegionInput = String; /// Output type for [`CountryRegion`]. -pub type CountryRegionOutput = String; /// A validated ISO 3166-2 subdivision code. /// @@ -31,13 +30,10 @@ pub type CountryRegionOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct CountryRegion(String); impl ValueObject for CountryRegion { type Input = CountryRegionInput; - type Output = CountryRegionOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -54,14 +50,16 @@ impl ValueObject for CountryRegion { Ok(Self(upper)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for CountryRegion { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} fn is_valid_iso3166_2(s: &str) -> bool { let Some(dash) = s.find('-') else { @@ -95,7 +93,6 @@ impl CountryRegion { } } - impl TryFrom for CountryRegion { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/geo/latitude.rs b/src/geo/latitude.rs index 888c651..66bb3ad 100644 --- a/src/geo/latitude.rs +++ b/src/geo/latitude.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Latitude`]. pub type LatitudeInput = f64; /// Output type for [`Latitude`]. -pub type LatitudeOutput = f64; /// A validated geographic latitude in decimal degrees. /// @@ -26,13 +25,10 @@ pub type LatitudeOutput = f64; #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "f64", into = "f64"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Latitude(f64); impl ValueObject for Latitude { type Input = LatitudeInput; - type Output = LatitudeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -45,15 +41,16 @@ impl ValueObject for Latitude { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for Latitude { + type Primitive = f64; + fn value(&self) -> &f64 { + &self.0 + } +} impl TryFrom for Latitude { type Error = ValidationError; diff --git a/src/geo/longitude.rs b/src/geo/longitude.rs index bbea98f..12ecf4e 100644 --- a/src/geo/longitude.rs +++ b/src/geo/longitude.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Longitude`]. pub type LongitudeInput = f64; /// Output type for [`Longitude`]. -pub type LongitudeOutput = f64; /// A validated geographic longitude in decimal degrees. /// @@ -26,13 +25,10 @@ pub type LongitudeOutput = f64; #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "f64", into = "f64"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Longitude(f64); impl ValueObject for Longitude { type Input = LongitudeInput; - type Output = LongitudeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -45,15 +41,16 @@ impl ValueObject for Longitude { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for Longitude { + type Primitive = f64; + fn value(&self) -> &f64 { + &self.0 + } +} impl TryFrom for Longitude { type Error = ValidationError; diff --git a/src/geo/mod.rs b/src/geo/mod.rs index 6ac3a39..442980e 100644 --- a/src/geo/mod.rs +++ b/src/geo/mod.rs @@ -7,7 +7,7 @@ mod time_zone; pub use bounding_box::{BoundingBox, BoundingBoxInput}; pub use coordinate::{Coordinate, CoordinateInput}; -pub use country_region::{CountryRegion, CountryRegionInput, CountryRegionOutput}; -pub use latitude::{Latitude, LatitudeInput, LatitudeOutput}; -pub use longitude::{Longitude, LongitudeInput, LongitudeOutput}; -pub use time_zone::{TimeZone, TimeZoneInput, TimeZoneOutput}; +pub use country_region::{CountryRegion, CountryRegionInput}; +pub use latitude::{Latitude, LatitudeInput}; +pub use longitude::{Longitude, LongitudeInput}; +pub use time_zone::{TimeZone, TimeZoneInput}; \ No newline at end of file diff --git a/src/geo/time_zone.rs b/src/geo/time_zone.rs index bba64e7..fd1744e 100644 --- a/src/geo/time_zone.rs +++ b/src/geo/time_zone.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`TimeZone`]. pub type TimeZoneInput = String; /// Output type for [`TimeZone`]. -pub type TimeZoneOutput = String; /// Sorted list of canonical IANA timezone names. static IANA_TIMEZONES: &[&str] = &[ @@ -457,13 +456,10 @@ static IANA_TIMEZONES: &[&str] = &[ #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct TimeZone(String); impl ValueObject for TimeZone { type Input = TimeZoneInput; - type Output = TimeZoneOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -480,15 +476,16 @@ impl ValueObject for TimeZone { Ok(Self(trimmed.to_owned())) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for TimeZone { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl TryFrom for TimeZone { type Error = ValidationError; diff --git a/src/identifiers/ean13.rs b/src/identifiers/ean13.rs index c13955c..3936cf1 100644 --- a/src/identifiers/ean13.rs +++ b/src/identifiers/ean13.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Ean13`]. pub type Ean13Input = String; /// Output type for [`Ean13`] — 13 bare digits. -pub type Ean13Output = String; /// A validated EAN-13 barcode number. /// @@ -26,8 +25,6 @@ pub type Ean13Output = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Ean13(String); fn ean_checksum_valid(digits: &[u8], expected_len: usize) -> bool { @@ -48,7 +45,6 @@ fn ean_checksum_valid(digits: &[u8], expected_len: usize) -> bool { impl ValueObject for Ean13 { type Input = Ean13Input; - type Output = Ean13Output; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -67,14 +63,16 @@ impl ValueObject for Ean13 { Ok(Self(stripped)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Ean13 { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Ean13 { /// Returns the check digit (last digit). @@ -83,7 +81,6 @@ impl Ean13 { } } - impl TryFrom for Ean13 { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/identifiers/ean8.rs b/src/identifiers/ean8.rs index 07e604f..d64add3 100644 --- a/src/identifiers/ean8.rs +++ b/src/identifiers/ean8.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Ean8`]. pub type Ean8Input = String; /// Output type for [`Ean8`] — 8 bare digits. -pub type Ean8Output = String; /// A validated EAN-8 barcode number. /// @@ -26,8 +25,6 @@ pub type Ean8Output = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Ean8(String); fn ean_checksum_valid(digits: &[u8], expected_len: usize) -> bool { @@ -49,7 +46,6 @@ fn ean_checksum_valid(digits: &[u8], expected_len: usize) -> bool { impl ValueObject for Ean8 { type Input = Ean8Input; - type Output = Ean8Output; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -68,14 +64,16 @@ impl ValueObject for Ean8 { Ok(Self(stripped)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Ean8 { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Ean8 { /// Returns the check digit (last digit). @@ -84,7 +82,6 @@ impl Ean8 { } } - impl TryFrom for Ean8 { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/identifiers/isbn10.rs b/src/identifiers/isbn10.rs index b27d257..63b9a20 100644 --- a/src/identifiers/isbn10.rs +++ b/src/identifiers/isbn10.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Isbn10`]. pub type Isbn10Input = String; /// Output type for [`Isbn10`] — 10 characters (9 digits + check char `0–9` or `X`). -pub type Isbn10Output = String; /// A validated ISBN-10 number. /// @@ -29,13 +28,10 @@ pub type Isbn10Output = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Isbn10(String); impl ValueObject for Isbn10 { type Input = Isbn10Input; - type Output = Isbn10Output; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -80,15 +76,16 @@ impl ValueObject for Isbn10 { Ok(Self(stripped)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for Isbn10 { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl TryFrom for Isbn10 { type Error = ValidationError; diff --git a/src/identifiers/isbn13.rs b/src/identifiers/isbn13.rs index b504236..9682ab4 100644 --- a/src/identifiers/isbn13.rs +++ b/src/identifiers/isbn13.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Isbn13`]. pub type Isbn13Input = String; /// Output type for [`Isbn13`] — 13 bare digits. -pub type Isbn13Output = String; /// A validated ISBN-13 number. /// @@ -26,13 +25,10 @@ pub type Isbn13Output = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Isbn13(String); impl ValueObject for Isbn13 { type Input = Isbn13Input; - type Output = Isbn13Output; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -63,14 +59,16 @@ impl ValueObject for Isbn13 { Ok(Self(stripped)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Isbn13 { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Isbn13 { /// Returns the GS1 prefix — `"978"` or `"979"`. @@ -79,7 +77,6 @@ impl Isbn13 { } } - impl TryFrom for Isbn13 { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/identifiers/issn.rs b/src/identifiers/issn.rs index 232dacd..39e6e6e 100644 --- a/src/identifiers/issn.rs +++ b/src/identifiers/issn.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Issn`]. pub type IssnInput = String; /// Output type for [`Issn`] — canonical `XXXX-XXXX` form. -pub type IssnOutput = String; /// A validated ISSN (International Standard Serial Number). /// @@ -28,13 +27,10 @@ pub type IssnOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Issn(String); impl ValueObject for Issn { type Input = IssnInput; - type Output = IssnOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -79,15 +75,16 @@ impl ValueObject for Issn { Ok(Self(canonical)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for Issn { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl TryFrom for Issn { type Error = ValidationError; diff --git a/src/identifiers/mod.rs b/src/identifiers/mod.rs index f848587..d75e89e 100644 --- a/src/identifiers/mod.rs +++ b/src/identifiers/mod.rs @@ -6,10 +6,10 @@ mod issn; mod slug; mod vin; -pub use ean8::{Ean8, Ean8Input, Ean8Output}; -pub use ean13::{Ean13, Ean13Input, Ean13Output}; -pub use isbn10::{Isbn10, Isbn10Input, Isbn10Output}; -pub use isbn13::{Isbn13, Isbn13Input, Isbn13Output}; -pub use issn::{Issn, IssnInput, IssnOutput}; -pub use slug::{Slug, SlugInput, SlugOutput}; -pub use vin::{Vin, VinInput, VinOutput}; +pub use ean8::{Ean8, Ean8Input}; +pub use ean13::{Ean13, Ean13Input}; +pub use isbn10::{Isbn10, Isbn10Input}; +pub use isbn13::{Isbn13, Isbn13Input}; +pub use issn::{Issn, IssnInput}; +pub use slug::{Slug, SlugInput}; +pub use vin::{Vin, VinInput}; \ No newline at end of file diff --git a/src/identifiers/slug.rs b/src/identifiers/slug.rs index c1ffb40..9cca0b3 100644 --- a/src/identifiers/slug.rs +++ b/src/identifiers/slug.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Slug`]. pub type SlugInput = String; /// Output type for [`Slug`]. -pub type SlugOutput = String; /// A URL-safe slug: lowercase alphanumeric characters and hyphens only. /// @@ -27,13 +26,10 @@ pub type SlugOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Slug(String); impl ValueObject for Slug { type Input = SlugInput; - type Output = SlugOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -61,15 +57,16 @@ impl ValueObject for Slug { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for Slug { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl TryFrom for Slug { type Error = ValidationError; diff --git a/src/identifiers/vin.rs b/src/identifiers/vin.rs index b486a3f..21f0235 100644 --- a/src/identifiers/vin.rs +++ b/src/identifiers/vin.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Vin`]. pub type VinInput = String; /// Output type for [`Vin`] — 17 uppercase characters. -pub type VinOutput = String; /// A validated Vehicle Identification Number (VIN) per ISO 3779. /// @@ -28,8 +27,6 @@ pub type VinOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Vin(String); fn transliterate(c: char) -> Option { @@ -75,7 +72,6 @@ const WEIGHTS: [u32; 17] = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]; impl ValueObject for Vin { type Input = VinInput; - type Output = VinOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -117,14 +113,16 @@ impl ValueObject for Vin { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Vin { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Vin { /// World Manufacturer Identifier — first 3 characters. @@ -148,7 +146,6 @@ impl Vin { } } - impl TryFrom for Vin { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/lib.rs b/src/lib.rs index b32c42c..5e0212b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,8 +32,8 @@ //! //! // Composite value object — structured input, canonical output //! let phone = PhoneNumber::new(PhoneNumberInput { -//! country_code: CountryCode::new("CZ".into())?, -//! number: "123456789".into(), +//! country_code: CountryCode::new("CZ".into())?, +//! number: "123456789".into(), //! })?; //! assert_eq!(phone.value(), "+420123456789"); //! # Ok::<(), arvo::errors::ValidationError>(()) @@ -71,70 +71,57 @@ pub mod temporal; /// Add `use arvo::prelude::*;` to bring the `ValueObject` trait and /// the most common value object types into scope without long paths. pub mod prelude { - pub use crate::errors::ValidationError; - pub use crate::traits::ValueObject; - - #[cfg(feature = "contact")] - pub use crate::contact::{ - CountryCode, CountryCodeInput, CountryCodeOutput, EmailAddress, EmailAddressInput, - EmailAddressOutput, PhoneNumber, PhoneNumberInput, PhoneNumberOutput, PostalAddress, - PostalAddressInput, PostalAddressOutput, Website, WebsiteInput, WebsiteOutput, - }; - - #[cfg(feature = "finance")] - pub use crate::finance::{ - Bic, BicInput, BicOutput, CardExpiryDate, CardExpiryDateInput, CardExpiryDateOutput, - CreditCardNumber, CreditCardNumberInput, CreditCardNumberOutput, CurrencyCode, - CurrencyCodeInput, CurrencyCodeOutput, ExchangeRate, ExchangeRateInput, ExchangeRateOutput, - Iban, IbanInput, IbanOutput, Money, MoneyInput, MoneyOutput, Percentage, PercentageInput, - PercentageOutput, VatNumber, VatNumberInput, VatNumberOutput, - }; - - #[cfg(feature = "geo")] - pub use crate::geo::{ - BoundingBox, BoundingBoxInput, Coordinate, CoordinateInput, CountryRegion, - CountryRegionInput, CountryRegionOutput, Latitude, LatitudeInput, LatitudeOutput, - Longitude, LongitudeInput, LongitudeOutput, TimeZone, TimeZoneInput, TimeZoneOutput, - }; - - #[cfg(feature = "identifiers")] - pub use crate::identifiers::{ - Ean8, Ean8Input, Ean8Output, Ean13, Ean13Input, Ean13Output, Isbn10, Isbn10Input, - Isbn10Output, Isbn13, Isbn13Input, Isbn13Output, Issn, IssnInput, IssnOutput, Slug, - SlugInput, SlugOutput, Vin, VinInput, VinOutput, - }; - - #[cfg(feature = "measurement")] - pub use crate::measurement::{ - Area, AreaInput, AreaUnit, Energy, EnergyInput, EnergyUnit, Frequency, FrequencyInput, - FrequencyUnit, Length, LengthInput, LengthUnit, Power, PowerInput, PowerUnit, Pressure, - PressureInput, PressureUnit, Speed, SpeedInput, SpeedUnit, Temperature, TemperatureInput, - TemperatureUnit, Volume, VolumeInput, VolumeUnit, Weight, WeightInput, WeightUnit, - }; - - #[cfg(feature = "net")] - pub use crate::net::{ - ApiKey, ApiKeyInput, ApiKeyOutput, Domain, DomainInput, DomainOutput, HttpStatusCode, - HttpStatusCodeInput, HttpStatusCodeOutput, IpAddress, IpAddressInput, IpAddressOutput, - IpV4Address, IpV4AddressInput, IpV4AddressOutput, IpV6Address, IpV6AddressInput, - IpV6AddressOutput, MacAddress, MacAddressInput, MacAddressOutput, MimeType, MimeTypeInput, - MimeTypeOutput, Port, PortInput, PortOutput, Url, UrlInput, UrlOutput, - }; - - #[cfg(feature = "primitives")] - pub use crate::primitives::{ - Base64String, Base64StringInput, Base64StringOutput, BoundedString, HexColor, HexColorInput, - HexColorOutput, Locale, LocaleInput, LocaleOutput, NonEmptyString, NonEmptyStringInput, - NonEmptyStringOutput, NonNegativeDecimal, NonNegativeDecimalInput, NonNegativeDecimalOutput, - NonNegativeInt, NonNegativeIntInput, NonNegativeIntOutput, PositiveDecimal, - PositiveDecimalInput, PositiveDecimalOutput, PositiveInt, PositiveIntInput, - PositiveIntOutput, Probability, ProbabilityInput, ProbabilityOutput, - }; - - #[cfg(feature = "temporal")] - pub use crate::temporal::{ - BirthDate, BirthDateInput, BirthDateOutput, BusinessHours, BusinessHoursInput, - BusinessHoursOutput, ExpiryDate, ExpiryDateInput, ExpiryDateOutput, TimeRange, - TimeRangeInput, TimeRangeOutput, UnixTimestamp, UnixTimestampInput, UnixTimestampOutput, - }; + pub use crate::errors::ValidationError; + pub use crate::traits::{PrimitiveValue, ValueObject}; + + #[cfg(feature = "contact")] + pub use crate::contact::{ + CountryCode, CountryCodeInput, EmailAddress, EmailAddressInput, + PhoneNumber, PhoneNumberInput, PostalAddress, + PostalAddressInput, Website, WebsiteInput }; + + #[cfg(feature = "finance")] + pub use crate::finance::{ + Bic, BicInput, CardExpiryDate, CardExpiryDateInput, CreditCardNumber, CreditCardNumberInput, CurrencyCode, + CurrencyCodeInput, ExchangeRate, ExchangeRateInput, Iban, IbanInput, Money, MoneyInput, Percentage, PercentageInput, + VatNumber, VatNumberInput }; + + #[cfg(feature = "geo")] + pub use crate::geo::{ + BoundingBox, BoundingBoxInput, Coordinate, CoordinateInput, CountryRegion, + CountryRegionInput, Latitude, LatitudeInput, Longitude, LongitudeInput, TimeZone, TimeZoneInput }; + + #[cfg(feature = "identifiers")] + pub use crate::identifiers::{ + Ean8, Ean8Input, Ean13, Ean13Input, Isbn10, Isbn10Input, + Isbn13, Isbn13Input, Issn, IssnInput, Slug, + SlugInput, Vin, VinInput }; + + #[cfg(feature = "measurement")] + pub use crate::measurement::{ + Area, AreaInput, AreaUnit, Energy, EnergyInput, EnergyUnit, Frequency, FrequencyInput, + FrequencyUnit, Length, LengthInput, LengthUnit, Power, PowerInput, PowerUnit, Pressure, + PressureInput, PressureUnit, Speed, SpeedInput, SpeedUnit, Temperature, TemperatureInput, + TemperatureUnit, Volume, VolumeInput, VolumeUnit, Weight, WeightInput, WeightUnit }; + + #[cfg(feature = "net")] + pub use crate::net::{ + ApiKey, ApiKeyInput, Domain, DomainInput, HttpStatusCode, + HttpStatusCodeInput, IpAddress, IpAddressInput, IpV4Address, IpV4AddressInput, IpV6Address, IpV6AddressInput, + MacAddress, MacAddressInput, MimeType, MimeTypeInput, + Port, PortInput, Url, UrlInput }; + + #[cfg(feature = "primitives")] + pub use crate::primitives::{ + Base64String, Base64StringInput, BoundedString, HexColor, HexColorInput, + Locale, LocaleInput, NonEmptyString, NonEmptyStringInput, + NonNegativeDecimal, NonNegativeDecimalInput, NonNegativeInt, NonNegativeIntInput, PositiveDecimal, + PositiveDecimalInput, PositiveInt, PositiveIntInput, + Probability, ProbabilityInput }; + + #[cfg(feature = "temporal")] + pub use crate::temporal::{ + BirthDate, BirthDateInput, BusinessHours, BusinessHoursInput, + ExpiryDate, ExpiryDateInput, TimeRange, + TimeRangeInput, UnixTimestamp, UnixTimestampInput }; } diff --git a/src/measurement/area.rs b/src/measurement/area.rs index e9fea55..0224b33 100644 --- a/src/measurement/area.rs +++ b/src/measurement/area.rs @@ -14,34 +14,6 @@ pub enum AreaUnit { Ha, } - -#[cfg(feature = "sql")] -impl sqlx::Type for Area { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Area { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Area { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: Area) -> String { @@ -101,7 +73,6 @@ pub struct Area { impl ValueObject for Area { type Input = AreaInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -116,9 +87,6 @@ impl ValueObject for Area { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { AreaInput { value: self.value, @@ -128,6 +96,10 @@ impl ValueObject for Area { } impl Area { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } diff --git a/src/measurement/energy.rs b/src/measurement/energy.rs index 623a442..ed5e933 100644 --- a/src/measurement/energy.rs +++ b/src/measurement/energy.rs @@ -13,34 +13,6 @@ pub enum EnergyUnit { Kcal, } - -#[cfg(feature = "sql")] -impl sqlx::Type for Energy { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Energy { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Energy { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: Energy) -> String { @@ -99,7 +71,6 @@ pub struct Energy { impl ValueObject for Energy { type Input = EnergyInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -114,9 +85,6 @@ impl ValueObject for Energy { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { EnergyInput { value: self.value, @@ -126,6 +94,10 @@ impl ValueObject for Energy { } impl Energy { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } diff --git a/src/measurement/frequency.rs b/src/measurement/frequency.rs index 6bad88e..fbbdf79 100644 --- a/src/measurement/frequency.rs +++ b/src/measurement/frequency.rs @@ -11,34 +11,6 @@ pub enum FrequencyUnit { GHz, } - -#[cfg(feature = "sql")] -impl sqlx::Type for Frequency { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Frequency { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Frequency { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: Frequency) -> String { @@ -95,7 +67,6 @@ pub struct Frequency { impl ValueObject for Frequency { type Input = FrequencyInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -113,9 +84,6 @@ impl ValueObject for Frequency { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { FrequencyInput { value: self.value, @@ -125,6 +93,10 @@ impl ValueObject for Frequency { } impl Frequency { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } diff --git a/src/measurement/length.rs b/src/measurement/length.rs index 92301ce..4128b7a 100644 --- a/src/measurement/length.rs +++ b/src/measurement/length.rs @@ -13,34 +13,6 @@ pub enum LengthUnit { Ft, } - -#[cfg(feature = "sql")] -impl sqlx::Type for Length { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Length { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Length { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: Length) -> String { @@ -100,7 +72,6 @@ pub struct Length { impl ValueObject for Length { type Input = LengthInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -115,10 +86,6 @@ impl ValueObject for Length { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { LengthInput { value: self.value, @@ -128,6 +95,10 @@ impl ValueObject for Length { } impl Length { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } diff --git a/src/measurement/power.rs b/src/measurement/power.rs index b63a5c0..adb7ac2 100644 --- a/src/measurement/power.rs +++ b/src/measurement/power.rs @@ -11,34 +11,6 @@ pub enum PowerUnit { Hp, } - -#[cfg(feature = "sql")] -impl sqlx::Type for Power { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Power { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Power { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: Power) -> String { @@ -95,7 +67,6 @@ pub struct Power { impl ValueObject for Power { type Input = PowerInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -110,9 +81,6 @@ impl ValueObject for Power { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { PowerInput { value: self.value, @@ -122,6 +90,10 @@ impl ValueObject for Power { } impl Power { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } diff --git a/src/measurement/pressure.rs b/src/measurement/pressure.rs index 6a2cf18..84631a5 100644 --- a/src/measurement/pressure.rs +++ b/src/measurement/pressure.rs @@ -13,34 +13,6 @@ pub enum PressureUnit { Atm, } - -#[cfg(feature = "sql")] -impl sqlx::Type for Pressure { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Pressure { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Pressure { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: Pressure) -> String { @@ -99,7 +71,6 @@ pub struct Pressure { impl ValueObject for Pressure { type Input = PressureInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -117,9 +88,6 @@ impl ValueObject for Pressure { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { PressureInput { value: self.value, @@ -129,6 +97,10 @@ impl ValueObject for Pressure { } impl Pressure { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } diff --git a/src/measurement/speed.rs b/src/measurement/speed.rs index f96356d..0aaf7c3 100644 --- a/src/measurement/speed.rs +++ b/src/measurement/speed.rs @@ -11,34 +11,6 @@ pub enum SpeedUnit { Kn, } - -#[cfg(feature = "sql")] -impl sqlx::Type for Speed { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Speed { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Speed { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: Speed) -> String { @@ -95,7 +67,6 @@ pub struct Speed { impl ValueObject for Speed { type Input = SpeedInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -110,9 +81,6 @@ impl ValueObject for Speed { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { SpeedInput { value: self.value, @@ -122,6 +90,10 @@ impl ValueObject for Speed { } impl Speed { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } diff --git a/src/measurement/temperature.rs b/src/measurement/temperature.rs index af758d3..fc4b6ff 100644 --- a/src/measurement/temperature.rs +++ b/src/measurement/temperature.rs @@ -10,34 +10,6 @@ pub enum TemperatureUnit { Kelvin, } - -#[cfg(feature = "sql")] -impl sqlx::Type for Temperature { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Temperature { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Temperature { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: Temperature) -> String { @@ -96,7 +68,6 @@ pub struct Temperature { impl ValueObject for Temperature { type Input = TemperatureInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -128,10 +99,6 @@ impl ValueObject for Temperature { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { TemperatureInput { value: self.value, @@ -141,6 +108,10 @@ impl ValueObject for Temperature { } impl Temperature { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } diff --git a/src/measurement/volume.rs b/src/measurement/volume.rs index bc10c8a..8412f1d 100644 --- a/src/measurement/volume.rs +++ b/src/measurement/volume.rs @@ -12,34 +12,6 @@ pub enum VolumeUnit { Gal, } - -#[cfg(feature = "sql")] -impl sqlx::Type for Volume { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Volume { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Volume { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: Volume) -> String { @@ -97,7 +69,6 @@ pub struct Volume { impl ValueObject for Volume { type Input = VolumeInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -112,9 +83,6 @@ impl ValueObject for Volume { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { VolumeInput { value: self.value, @@ -124,6 +92,10 @@ impl ValueObject for Volume { } impl Volume { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } diff --git a/src/measurement/weight.rs b/src/measurement/weight.rs index 5cd6d3d..e8e1355 100644 --- a/src/measurement/weight.rs +++ b/src/measurement/weight.rs @@ -13,34 +13,6 @@ pub enum WeightUnit { Lb, } - -#[cfg(feature = "sql")] -impl sqlx::Type for Weight { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Weight { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Weight { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: Weight) -> String { @@ -99,7 +71,6 @@ pub struct Weight { impl ValueObject for Weight { type Input = WeightInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -114,10 +85,6 @@ impl ValueObject for Weight { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { WeightInput { value: self.value, @@ -127,6 +94,10 @@ impl ValueObject for Weight { } impl Weight { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } diff --git a/src/net/api_key.rs b/src/net/api_key.rs index 628e9c6..3aca626 100644 --- a/src/net/api_key.rs +++ b/src/net/api_key.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`ApiKey`]. pub type ApiKeyInput = String; /// Output type for [`ApiKey`]. -pub type ApiKeyOutput = String; /// A validated API key — non-empty, trimmed. /// @@ -26,13 +25,10 @@ pub type ApiKeyOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct ApiKey(String); impl ValueObject for ApiKey { type Input = ApiKeyInput; - type Output = ApiKeyOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -45,14 +41,16 @@ impl ValueObject for ApiKey { Ok(Self(trimmed)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for ApiKey { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl ApiKey { /// Returns the last 4 characters of the key. @@ -79,7 +77,6 @@ impl std::fmt::Display for ApiKey { } } - impl TryFrom for ApiKey { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/net/domain.rs b/src/net/domain.rs index 719111a..25ba61f 100644 --- a/src/net/domain.rs +++ b/src/net/domain.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Domain`]. pub type DomainInput = String; /// Output type for [`Domain`]. -pub type DomainOutput = String; /// A validated domain name without a scheme (e.g. `"example.com"`). /// @@ -28,13 +27,10 @@ pub type DomainOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Domain(String); impl ValueObject for Domain { type Input = DomainInput; - type Output = DomainOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -51,14 +47,16 @@ impl ValueObject for Domain { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Domain { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} fn is_valid_domain(s: &str) -> bool { if s.len() > 253 { @@ -86,7 +84,6 @@ fn is_valid_domain(s: &str) -> bool { true } - impl TryFrom for Domain { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/net/http_status_code.rs b/src/net/http_status_code.rs index 8cb1c42..f33f821 100644 --- a/src/net/http_status_code.rs +++ b/src/net/http_status_code.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`HttpStatusCode`]. pub type HttpStatusCodeInput = u16; /// Output type for [`HttpStatusCode`]. -pub type HttpStatusCodeOutput = u16; /// A validated HTTP status code in the range `100..=599`. /// @@ -28,7 +27,6 @@ pub struct HttpStatusCode(u16); impl ValueObject for HttpStatusCode { type Input = HttpStatusCodeInput; - type Output = HttpStatusCodeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -41,14 +39,16 @@ impl ValueObject for HttpStatusCode { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for HttpStatusCode { + type Primitive = u16; + fn value(&self) -> &u16 { + &self.0 + } +} impl HttpStatusCode { /// Returns `true` for 1xx informational codes. @@ -77,7 +77,6 @@ impl HttpStatusCode { } } - impl TryFrom for HttpStatusCode { type Error = ValidationError; fn try_from(v: u16) -> Result { @@ -100,35 +99,6 @@ impl TryFrom<&str> for HttpStatusCode { } } -#[cfg(feature = "sql")] -impl sqlx::Type for HttpStatusCode { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for HttpStatusCode { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&(self.0 as i32), buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for HttpStatusCode { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let n = >::decode(value)?; - let u = u16::try_from(n).map_err(|e| Box::new(e) as sqlx::error::BoxDynError)?; - Self::new(u).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} - impl std::fmt::Display for HttpStatusCode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/net/ip_address.rs b/src/net/ip_address.rs index 1888efe..074a3b7 100644 --- a/src/net/ip_address.rs +++ b/src/net/ip_address.rs @@ -1,5 +1,5 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use super::{IpV4Address, IpV6Address}; @@ -7,7 +7,6 @@ use super::{IpV4Address, IpV6Address}; pub type IpAddressInput = String; /// Output type for [`IpAddress`]. -pub type IpAddressOutput = String; /// A validated IP address — either IPv4 or IPv6. /// @@ -30,13 +29,10 @@ pub type IpAddressOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct IpAddress(String); impl ValueObject for IpAddress { type Input = IpAddressInput; - type Output = IpAddressOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -57,14 +53,16 @@ impl ValueObject for IpAddress { Err(ValidationError::invalid("IpAddress", trimmed)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for IpAddress { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl IpAddress { /// Returns `true` if the address is IPv4. @@ -78,7 +76,6 @@ impl IpAddress { } } - impl TryFrom for IpAddress { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/net/ip_v4_address.rs b/src/net/ip_v4_address.rs index df7d766..2f39e2f 100644 --- a/src/net/ip_v4_address.rs +++ b/src/net/ip_v4_address.rs @@ -1,12 +1,11 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use std::net::Ipv4Addr; /// Input type for [`IpV4Address`]. pub type IpV4AddressInput = String; /// Output type for [`IpV4Address`]. -pub type IpV4AddressOutput = String; /// A validated IPv4 address (e.g. `"192.168.1.1"`). /// @@ -27,13 +26,10 @@ pub type IpV4AddressOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct IpV4Address(String); impl ValueObject for IpV4Address { type Input = IpV4AddressInput; - type Output = IpV4AddressOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -56,14 +52,16 @@ impl ValueObject for IpV4Address { .map_err(|_| ValidationError::invalid("IpV4Address", trimmed)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for IpV4Address { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl IpV4Address { /// Returns `true` for loopback addresses (`127.0.0.0/8`). @@ -77,7 +75,6 @@ impl IpV4Address { } } - impl TryFrom for IpV4Address { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/net/ip_v6_address.rs b/src/net/ip_v6_address.rs index ebf31d3..fc27335 100644 --- a/src/net/ip_v6_address.rs +++ b/src/net/ip_v6_address.rs @@ -1,12 +1,11 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use std::net::Ipv6Addr; /// Input type for [`IpV6Address`]. pub type IpV6AddressInput = String; /// Output type for [`IpV6Address`]. -pub type IpV6AddressOutput = String; /// A validated IPv6 address (e.g. `"2001:db8::1"`). /// @@ -28,13 +27,10 @@ pub type IpV6AddressOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct IpV6Address(String); impl ValueObject for IpV6Address { type Input = IpV6AddressInput; - type Output = IpV6AddressOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -50,15 +46,16 @@ impl ValueObject for IpV6Address { .map_err(|_| ValidationError::invalid("IpV6Address", trimmed)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for IpV6Address { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl TryFrom for IpV6Address { type Error = ValidationError; diff --git a/src/net/mac_address.rs b/src/net/mac_address.rs index 7eb1df6..35aefce 100644 --- a/src/net/mac_address.rs +++ b/src/net/mac_address.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`MacAddress`]. pub type MacAddressInput = String; /// Output type for [`MacAddress`]. -pub type MacAddressOutput = String; /// A validated MAC address, normalised to lowercase colon-separated hex. /// @@ -27,13 +26,10 @@ pub type MacAddressOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct MacAddress(String); impl ValueObject for MacAddress { type Input = MacAddressInput; - type Output = MacAddressOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -54,14 +50,16 @@ impl ValueObject for MacAddress { Ok(Self(canonical)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for MacAddress { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} fn parse_mac_bytes(s: &str) -> Option<[u8; 6]> { // colon or hyphen separated: XX:XX:XX:XX:XX:XX or XX-XX-XX-XX-XX-XX @@ -108,7 +106,6 @@ fn parse_mac_bytes(s: &str) -> Option<[u8; 6]> { None } - impl TryFrom for MacAddress { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/net/mime_type.rs b/src/net/mime_type.rs index 2cf5896..84c5851 100644 --- a/src/net/mime_type.rs +++ b/src/net/mime_type.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`MimeType`]. pub type MimeTypeInput = String; /// Output type for [`MimeType`]. -pub type MimeTypeOutput = String; /// A validated MIME type (e.g. `"image/png"`, `"application/json"`). /// @@ -30,13 +29,10 @@ pub type MimeTypeOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct MimeType(String); impl ValueObject for MimeType { type Input = MimeTypeInput; - type Output = MimeTypeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -53,14 +49,16 @@ impl ValueObject for MimeType { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for MimeType { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} fn is_valid_mime(s: &str) -> bool { // Split off optional parameters (; charset=utf-8) @@ -95,7 +93,6 @@ impl MimeType { } } - impl TryFrom for MimeType { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/net/mod.rs b/src/net/mod.rs index 8726b0e..4813db8 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -9,13 +9,13 @@ mod mime_type; mod port; mod url; -pub use api_key::{ApiKey, ApiKeyInput, ApiKeyOutput}; -pub use domain::{Domain, DomainInput, DomainOutput}; -pub use http_status_code::{HttpStatusCode, HttpStatusCodeInput, HttpStatusCodeOutput}; -pub use ip_address::{IpAddress, IpAddressInput, IpAddressOutput}; -pub use ip_v4_address::{IpV4Address, IpV4AddressInput, IpV4AddressOutput}; -pub use ip_v6_address::{IpV6Address, IpV6AddressInput, IpV6AddressOutput}; -pub use mac_address::{MacAddress, MacAddressInput, MacAddressOutput}; -pub use mime_type::{MimeType, MimeTypeInput, MimeTypeOutput}; -pub use port::{Port, PortInput, PortOutput}; -pub use url::{Url, UrlInput, UrlOutput}; +pub use api_key::{ApiKey, ApiKeyInput}; +pub use domain::{Domain, DomainInput}; +pub use http_status_code::{HttpStatusCode, HttpStatusCodeInput}; +pub use ip_address::{IpAddress, IpAddressInput}; +pub use ip_v4_address::{IpV4Address, IpV4AddressInput}; +pub use ip_v6_address::{IpV6Address, IpV6AddressInput}; +pub use mac_address::{MacAddress, MacAddressInput}; +pub use mime_type::{MimeType, MimeTypeInput}; +pub use port::{Port, PortInput}; +pub use url::{Url, UrlInput}; \ No newline at end of file diff --git a/src/net/port.rs b/src/net/port.rs index c9d3b9e..92926da 100644 --- a/src/net/port.rs +++ b/src/net/port.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Port`]. pub type PortInput = u16; /// Output type for [`Port`]. -pub type PortOutput = u16; /// A validated network port number in the range `1..=65535`. /// @@ -29,7 +28,6 @@ pub struct Port(u16); impl ValueObject for Port { type Input = PortInput; - type Output = PortOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -39,14 +37,16 @@ impl ValueObject for Port { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Port { + type Primitive = u16; + fn value(&self) -> &u16 { + &self.0 + } +} impl Port { /// Returns `true` for well-known ports (1–1023). @@ -65,7 +65,6 @@ impl Port { } } - impl TryFrom for Port { type Error = ValidationError; fn try_from(v: u16) -> Result { @@ -88,35 +87,6 @@ impl TryFrom<&str> for Port { } } -#[cfg(feature = "sql")] -impl sqlx::Type for Port { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for Port { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&(self.0 as i32), buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for Port { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let n = >::decode(value)?; - let u = u16::try_from(n).map_err(|e| Box::new(e) as sqlx::error::BoxDynError)?; - Self::new(u).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} - impl std::fmt::Display for Port { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/net/url.rs b/src/net/url.rs index 7fd9ae8..bf6ab58 100644 --- a/src/net/url.rs +++ b/src/net/url.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Url`]. pub type UrlInput = String; /// Output type for [`Url`]. -pub type UrlOutput = String; /// A validated URL. Accepts `http`, `https`, `ftp`, `ftps`, `ws`, and `wss` schemes. /// Scheme and host are normalised to lowercase on construction. @@ -26,15 +25,12 @@ pub type UrlOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Url(String); const ALLOWED_SCHEMES: &[&str] = &["ftp", "ftps", "http", "https", "ws", "wss"]; impl ValueObject for Url { type Input = UrlInput; - type Output = UrlOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -61,14 +57,16 @@ impl ValueObject for Url { Ok(Self(canonical)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Url { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Url { /// Returns the scheme, e.g. `"https"`. @@ -97,7 +95,6 @@ impl Url { } } - impl TryFrom for Url { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/primitives/base64_string.rs b/src/primitives/base64_string.rs index d1353dc..55f1bb3 100644 --- a/src/primitives/base64_string.rs +++ b/src/primitives/base64_string.rs @@ -1,5 +1,5 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD; @@ -7,7 +7,6 @@ use base64::engine::general_purpose::STANDARD; pub type Base64StringInput = String; /// Output type for [`Base64String`]. -pub type Base64StringOutput = String; /// A validated standard Base64-encoded string. /// @@ -29,13 +28,10 @@ pub type Base64StringOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Base64String(String); impl ValueObject for Base64String { type Input = Base64StringInput; - type Output = Base64StringOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -49,14 +45,16 @@ impl ValueObject for Base64String { Ok(Self(trimmed)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Base64String { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Base64String { /// Decodes the Base64 string and returns the raw bytes. @@ -65,7 +63,6 @@ impl Base64String { } } - impl TryFrom for Base64String { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/primitives/bounded_string.rs b/src/primitives/bounded_string.rs index 5b39e2a..76d263f 100644 --- a/src/primitives/bounded_string.rs +++ b/src/primitives/bounded_string.rs @@ -1,5 +1,5 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// A string whose length (in Unicode characters) is constrained to `MIN..=MAX`. /// @@ -29,7 +29,6 @@ pub struct BoundedString(String); impl ValueObject for BoundedString { type Input = String; - type Output = String; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -52,15 +51,18 @@ impl ValueObject for BoundedString Ok(Self(trimmed)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for BoundedString { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} + impl TryFrom for BoundedString { type Error = ValidationError; @@ -84,38 +86,6 @@ impl TryFrom<&str> for BoundedString sqlx::Type for BoundedString { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q, const MIN: usize, const MAX: usize> sqlx::Encode<'q, sqlx::Postgres> - for BoundedString -{ - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.0, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r, const MIN: usize, const MAX: usize> sqlx::Decode<'r, sqlx::Postgres> - for BoundedString -{ - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::new(s).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} - impl std::fmt::Display for BoundedString { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/src/primitives/hex_color.rs b/src/primitives/hex_color.rs index 8cecc6f..f9d311f 100644 --- a/src/primitives/hex_color.rs +++ b/src/primitives/hex_color.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`HexColor`]. pub type HexColorInput = String; /// Output type for [`HexColor`] — always a 7-character `#RRGGBB` string. -pub type HexColorOutput = String; /// A CSS hex color in canonical `#RRGGBB` form, normalised to uppercase. /// @@ -28,13 +27,10 @@ pub type HexColorOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct HexColor(String); impl ValueObject for HexColor { type Input = HexColorInput; - type Output = HexColorOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -68,14 +64,16 @@ impl ValueObject for HexColor { Ok(Self(expanded)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for HexColor { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl HexColor { fn channel(s: &str, offset: usize) -> u8 { @@ -103,7 +101,6 @@ impl HexColor { } } - impl TryFrom for HexColor { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/primitives/locale.rs b/src/primitives/locale.rs index 5cb0d21..1ab36c7 100644 --- a/src/primitives/locale.rs +++ b/src/primitives/locale.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Locale`]. pub type LocaleInput = String; /// Output type for [`Locale`] — BCP 47 canonical form, e.g. `"en-US"`. -pub type LocaleOutput = String; /// A BCP 47 language tag (e.g. `"en-US"`, `"cs-CZ"`, `"fr"`). /// @@ -31,13 +30,10 @@ pub type LocaleOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Locale(String); impl ValueObject for Locale { type Input = LocaleInput; - type Output = LocaleOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -71,14 +67,16 @@ impl ValueObject for Locale { Ok(Self(canonical)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Locale { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Locale { /// Returns the language subtag, e.g. `"en"` from `"en-US"`. @@ -94,7 +92,6 @@ impl Locale { } } - impl TryFrom for Locale { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index 8d45a4c..d962eb9 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -9,15 +9,13 @@ mod positive_decimal; mod positive_int; mod probability; -pub use base64_string::{Base64String, Base64StringInput, Base64StringOutput}; +pub use base64_string::{Base64String, Base64StringInput}; pub use bounded_string::BoundedString; -pub use hex_color::{HexColor, HexColorInput, HexColorOutput}; -pub use locale::{Locale, LocaleInput, LocaleOutput}; -pub use non_empty_string::{NonEmptyString, NonEmptyStringInput, NonEmptyStringOutput}; -pub use non_negative_decimal::{ - NonNegativeDecimal, NonNegativeDecimalInput, NonNegativeDecimalOutput, -}; -pub use non_negative_int::{NonNegativeInt, NonNegativeIntInput, NonNegativeIntOutput}; -pub use positive_decimal::{PositiveDecimal, PositiveDecimalInput, PositiveDecimalOutput}; -pub use positive_int::{PositiveInt, PositiveIntInput, PositiveIntOutput}; -pub use probability::{Probability, ProbabilityInput, ProbabilityOutput}; +pub use hex_color::{HexColor, HexColorInput}; +pub use locale::{Locale, LocaleInput}; +pub use non_empty_string::{NonEmptyString, NonEmptyStringInput}; +pub use non_negative_decimal::{NonNegativeDecimal, NonNegativeDecimalInput}; +pub use non_negative_int::{NonNegativeInt, NonNegativeIntInput}; +pub use positive_decimal::{PositiveDecimal, PositiveDecimalInput}; +pub use positive_int::{PositiveInt, PositiveIntInput}; +pub use probability::{Probability, ProbabilityInput}; \ No newline at end of file diff --git a/src/primitives/non_empty_string.rs b/src/primitives/non_empty_string.rs index 9e6ac9b..bb6a343 100644 --- a/src/primitives/non_empty_string.rs +++ b/src/primitives/non_empty_string.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`NonEmptyString`]. pub type NonEmptyStringInput = String; /// Output type for [`NonEmptyString`]. -pub type NonEmptyStringOutput = String; /// A non-empty, trimmed string. /// @@ -26,13 +25,10 @@ pub type NonEmptyStringOutput = String; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct NonEmptyString(String); impl ValueObject for NonEmptyString { type Input = NonEmptyStringInput; - type Output = NonEmptyStringOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -43,15 +39,16 @@ impl ValueObject for NonEmptyString { Ok(Self(trimmed)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for NonEmptyString { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl TryFrom for NonEmptyString { type Error = ValidationError; diff --git a/src/primitives/non_negative_decimal.rs b/src/primitives/non_negative_decimal.rs index a2894a1..0d45078 100644 --- a/src/primitives/non_negative_decimal.rs +++ b/src/primitives/non_negative_decimal.rs @@ -1,12 +1,11 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use rust_decimal::Decimal; /// Input type for [`NonNegativeDecimal`]. pub type NonNegativeDecimalInput = Decimal; /// Output type for [`NonNegativeDecimal`]. -pub type NonNegativeDecimalOutput = Decimal; /// A non-negative decimal number (`Decimal >= 0`). /// @@ -27,13 +26,10 @@ pub type NonNegativeDecimalOutput = Decimal; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "Decimal", into = "Decimal"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct NonNegativeDecimal(Decimal); impl ValueObject for NonNegativeDecimal { type Input = NonNegativeDecimalInput; - type Output = NonNegativeDecimalOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -48,15 +44,16 @@ impl ValueObject for NonNegativeDecimal { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for NonNegativeDecimal { + type Primitive = Decimal; + fn value(&self) -> &Decimal { + &self.0 + } +} impl TryFrom for NonNegativeDecimal { type Error = ValidationError; diff --git a/src/primitives/non_negative_int.rs b/src/primitives/non_negative_int.rs index e0f104c..f43b9a8 100644 --- a/src/primitives/non_negative_int.rs +++ b/src/primitives/non_negative_int.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`NonNegativeInt`]. pub type NonNegativeIntInput = i64; /// Output type for [`NonNegativeInt`]. -pub type NonNegativeIntOutput = i64; /// A non-negative integer (`i64 >= 0`). /// @@ -25,13 +24,10 @@ pub type NonNegativeIntOutput = i64; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "i64", into = "i64"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct NonNegativeInt(i64); impl ValueObject for NonNegativeInt { type Input = NonNegativeIntInput; - type Output = NonNegativeIntOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -46,15 +42,16 @@ impl ValueObject for NonNegativeInt { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for NonNegativeInt { + type Primitive = i64; + fn value(&self) -> &i64 { + &self.0 + } +} impl TryFrom for NonNegativeInt { type Error = ValidationError; diff --git a/src/primitives/positive_decimal.rs b/src/primitives/positive_decimal.rs index c843e09..9e0b3f3 100644 --- a/src/primitives/positive_decimal.rs +++ b/src/primitives/positive_decimal.rs @@ -1,12 +1,11 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use rust_decimal::Decimal; /// Input type for [`PositiveDecimal`]. pub type PositiveDecimalInput = Decimal; /// Output type for [`PositiveDecimal`]. -pub type PositiveDecimalOutput = Decimal; /// A strictly positive decimal number (`Decimal > 0`). /// @@ -27,13 +26,10 @@ pub type PositiveDecimalOutput = Decimal; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "Decimal", into = "Decimal"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct PositiveDecimal(Decimal); impl ValueObject for PositiveDecimal { type Input = PositiveDecimalInput; - type Output = PositiveDecimalOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -48,15 +44,16 @@ impl ValueObject for PositiveDecimal { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for PositiveDecimal { + type Primitive = Decimal; + fn value(&self) -> &Decimal { + &self.0 + } +} impl TryFrom for PositiveDecimal { type Error = ValidationError; diff --git a/src/primitives/positive_int.rs b/src/primitives/positive_int.rs index 199aabf..c9bade5 100644 --- a/src/primitives/positive_int.rs +++ b/src/primitives/positive_int.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`PositiveInt`]. pub type PositiveIntInput = i64; /// Output type for [`PositiveInt`]. -pub type PositiveIntOutput = i64; /// A strictly positive integer (`i64 > 0`). /// @@ -26,13 +25,10 @@ pub type PositiveIntOutput = i64; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "i64", into = "i64"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct PositiveInt(i64); impl ValueObject for PositiveInt { type Input = PositiveIntInput; - type Output = PositiveIntOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -47,15 +43,16 @@ impl ValueObject for PositiveInt { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for PositiveInt { + type Primitive = i64; + fn value(&self) -> &i64 { + &self.0 + } +} impl TryFrom for PositiveInt { type Error = ValidationError; diff --git a/src/primitives/probability.rs b/src/primitives/probability.rs index 008c6cf..43b62a2 100644 --- a/src/primitives/probability.rs +++ b/src/primitives/probability.rs @@ -1,11 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Probability`]. pub type ProbabilityInput = f64; /// Output type for [`Probability`]. -pub type ProbabilityOutput = f64; /// A probability value in the range `0.0..=1.0`. /// @@ -26,13 +25,10 @@ pub type ProbabilityOutput = f64; #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "f64", into = "f64"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct Probability(f64); impl ValueObject for Probability { type Input = ProbabilityInput; - type Output = ProbabilityOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -47,15 +43,16 @@ impl ValueObject for Probability { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } - +impl PrimitiveValue for Probability { + type Primitive = f64; + fn value(&self) -> &f64 { + &self.0 + } +} impl TryFrom for Probability { type Error = ValidationError; diff --git a/src/temporal/birth_date.rs b/src/temporal/birth_date.rs index 7c60e79..7b2ca2e 100644 --- a/src/temporal/birth_date.rs +++ b/src/temporal/birth_date.rs @@ -1,13 +1,12 @@ use chrono::{Datelike, Local, NaiveDate}; use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`BirthDate`]. pub type BirthDateInput = NaiveDate; /// Output type for [`BirthDate`]. -pub type BirthDateOutput = NaiveDate; /// A validated date of birth. /// @@ -28,13 +27,10 @@ pub type BirthDateOutput = NaiveDate; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "chrono::NaiveDate", into = "chrono::NaiveDate"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct BirthDate(NaiveDate); impl ValueObject for BirthDate { type Input = BirthDateInput; - type Output = BirthDateOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -55,14 +51,16 @@ impl ValueObject for BirthDate { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for BirthDate { + type Primitive = chrono::NaiveDate; + fn value(&self) -> &chrono::NaiveDate { + &self.0 + } +} impl BirthDate { /// Returns the person's age in full completed years as of today. @@ -83,7 +81,6 @@ impl BirthDate { } } - impl TryFrom for BirthDate { type Error = ValidationError; fn try_from(v: chrono::NaiveDate) -> Result { diff --git a/src/temporal/business_hours.rs b/src/temporal/business_hours.rs index c545611..ae4b041 100644 --- a/src/temporal/business_hours.rs +++ b/src/temporal/business_hours.rs @@ -15,7 +15,6 @@ pub struct BusinessHoursInput { } /// Output type for [`BusinessHours`] — canonical `" HH:MM–HH:MM"` string. -pub type BusinessHoursOutput = String; /// Validated business hours for a single weekday. /// @@ -50,7 +49,6 @@ pub struct BusinessHours { impl ValueObject for BusinessHours { type Input = BusinessHoursInput; - type Output = BusinessHoursOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -83,10 +81,6 @@ impl ValueObject for BusinessHours { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { BusinessHoursInput { weekday: self.weekday, @@ -97,6 +91,10 @@ impl ValueObject for BusinessHours { } impl BusinessHours { + pub fn value(&self) -> &str { + &self.canonical + } + /// Returns the weekday. pub fn weekday(&self) -> Weekday { self.weekday @@ -146,34 +144,6 @@ impl TryFrom<&str> for BusinessHours { } } - -#[cfg(feature = "sql")] -impl sqlx::Type for BusinessHours { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for BusinessHours { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for BusinessHours { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: BusinessHours) -> String { diff --git a/src/temporal/expiry_date.rs b/src/temporal/expiry_date.rs index 766775e..90943d4 100644 --- a/src/temporal/expiry_date.rs +++ b/src/temporal/expiry_date.rs @@ -1,13 +1,12 @@ use chrono::{Local, NaiveDate}; use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`ExpiryDate`]. pub type ExpiryDateInput = NaiveDate; /// Output type for [`ExpiryDate`]. -pub type ExpiryDateOutput = NaiveDate; /// A validated expiry date that is strictly in the future. /// @@ -26,13 +25,10 @@ pub type ExpiryDateOutput = NaiveDate; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "chrono::NaiveDate", into = "chrono::NaiveDate"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct ExpiryDate(NaiveDate); impl ValueObject for ExpiryDate { type Input = ExpiryDateInput; - type Output = ExpiryDateOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -45,14 +41,16 @@ impl ValueObject for ExpiryDate { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for ExpiryDate { + type Primitive = chrono::NaiveDate; + fn value(&self) -> &chrono::NaiveDate { + &self.0 + } +} impl ExpiryDate { /// Returns the number of days from today until the expiry date. @@ -62,7 +60,6 @@ impl ExpiryDate { } } - impl TryFrom for ExpiryDate { type Error = ValidationError; fn try_from(v: chrono::NaiveDate) -> Result { diff --git a/src/temporal/mod.rs b/src/temporal/mod.rs index b3bd6e4..00e022b 100644 --- a/src/temporal/mod.rs +++ b/src/temporal/mod.rs @@ -4,8 +4,8 @@ mod expiry_date; mod time_range; mod unix_timestamp; -pub use birth_date::{BirthDate, BirthDateInput, BirthDateOutput}; -pub use business_hours::{BusinessHours, BusinessHoursInput, BusinessHoursOutput}; -pub use expiry_date::{ExpiryDate, ExpiryDateInput, ExpiryDateOutput}; -pub use time_range::{TimeRange, TimeRangeInput, TimeRangeOutput}; -pub use unix_timestamp::{UnixTimestamp, UnixTimestampInput, UnixTimestampOutput}; +pub use birth_date::{BirthDate, BirthDateInput}; +pub use business_hours::{BusinessHours, BusinessHoursInput}; +pub use expiry_date::{ExpiryDate, ExpiryDateInput}; +pub use time_range::{TimeRange, TimeRangeInput}; +pub use unix_timestamp::{UnixTimestamp, UnixTimestampInput}; \ No newline at end of file diff --git a/src/temporal/time_range.rs b/src/temporal/time_range.rs index 007dc44..5176e29 100644 --- a/src/temporal/time_range.rs +++ b/src/temporal/time_range.rs @@ -13,7 +13,6 @@ pub struct TimeRangeInput { } /// Output type for [`TimeRange`] — canonical `" / "` string. -pub type TimeRangeOutput = String; /// A validated time range with a start strictly before its end. /// @@ -46,7 +45,6 @@ pub struct TimeRange { impl ValueObject for TimeRange { type Input = TimeRangeInput; - type Output = TimeRangeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -65,10 +63,6 @@ impl ValueObject for TimeRange { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { TimeRangeInput { start: self.start, @@ -78,6 +72,10 @@ impl ValueObject for TimeRange { } impl TimeRange { + pub fn value(&self) -> &str { + &self.canonical + } + /// Returns the start of the range. pub fn start(&self) -> &DateTime { &self.start @@ -116,34 +114,6 @@ impl TryFrom<&str> for TimeRange { } } - -#[cfg(feature = "sql")] -impl sqlx::Type for TimeRange { - fn type_info() -> sqlx::postgres::PgTypeInfo { - >::type_info() - } - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { - >::compatible(ty) - } -} - -#[cfg(feature = "sql")] -impl<'q> sqlx::Encode<'q, sqlx::Postgres> for TimeRange { - fn encode_by_ref( - &self, - buf: &mut sqlx::postgres::PgArgumentBuffer, - ) -> Result { - >::encode_by_ref(&self.canonical, buf) - } -} - -#[cfg(feature = "sql")] -impl<'r> sqlx::Decode<'r, sqlx::Postgres> for TimeRange { - fn decode(value: sqlx::postgres::PgValueRef<'r>) -> Result { - let s = >::decode(value)?; - Self::try_from(s.as_str()).map_err(|e| Box::new(e) as sqlx::error::BoxDynError) - } -} #[cfg(feature = "serde")] impl From for String { fn from(v: TimeRange) -> String { diff --git a/src/temporal/unix_timestamp.rs b/src/temporal/unix_timestamp.rs index 2d5d132..f4659d5 100644 --- a/src/temporal/unix_timestamp.rs +++ b/src/temporal/unix_timestamp.rs @@ -1,13 +1,12 @@ use chrono::{DateTime, TimeZone, Utc}; use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`UnixTimestamp`]. pub type UnixTimestampInput = i64; /// Output type for [`UnixTimestamp`]. -pub type UnixTimestampOutput = i64; /// A validated Unix timestamp — non-negative seconds since the Unix epoch. /// @@ -27,13 +26,10 @@ pub type UnixTimestampOutput = i64; #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(try_from = "i64", into = "i64"))] -#[cfg_attr(feature = "sql", derive(sqlx::Type))] -#[cfg_attr(feature = "sql", sqlx(transparent))] pub struct UnixTimestamp(i64); impl ValueObject for UnixTimestamp { type Input = UnixTimestampInput; - type Output = UnixTimestampOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -46,14 +42,16 @@ impl ValueObject for UnixTimestamp { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for UnixTimestamp { + type Primitive = i64; + fn value(&self) -> &i64 { + &self.0 + } +} impl UnixTimestamp { /// Converts to a `DateTime`. @@ -62,7 +60,6 @@ impl UnixTimestamp { } } - impl TryFrom for UnixTimestamp { type Error = ValidationError; fn try_from(v: i64) -> Result { diff --git a/src/traits.rs b/src/traits.rs index 41dfca3..1cf7a18 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -1,100 +1,88 @@ /// Core trait for all value objects in arvo. /// /// A value object is an immutable, validated wrapper around a raw value. -/// It guarantees that once constructed, the inner value always satisfies -/// the domain rules defined in [`ValueObject::new`]. +/// Construction via [`new`](ValueObject::new) is the **only** way to obtain +/// a valid instance — invalid states are unrepresentable at the type level. /// /// # Type parameters /// /// - `Input` — the type accepted by [`new`](ValueObject::new). /// For simple types this is the raw primitive (e.g. `String`). /// For composite types this is a dedicated input struct. -/// - `Output` — the type returned by [`value`](ValueObject::value). -/// For simple types `Input` and `Output` are the same. -/// For composite types `Output` is the canonical representation -/// (e.g. an E.164 string for a phone number). /// - `Error` — the error returned when validation fails. /// -/// # Simple type example +/// Simple types (single-primitive wrappers) additionally implement +/// [`PrimitiveValue`], which exposes the inner value via [`value()`](PrimitiveValue::value). +/// Composite types expose their data through dedicated accessor methods instead. +/// +/// # Example /// /// ```rust,ignore -/// use arvo::traits::ValueObject; +/// use arvo::traits::{ValueObject, PrimitiveValue}; /// use arvo::errors::ValidationError; /// -/// pub type PercentageInput = f64; -/// pub type PercentageOutput = f64; -/// -/// pub struct Percentage(f64); +/// pub struct NonNegative(f64); /// -/// impl ValueObject for Percentage { -/// type Input = PercentageInput; -/// type Output = PercentageOutput; -/// type Error = ValidationError; +/// impl ValueObject for NonNegative { +/// type Input = f64; +/// type Error = ValidationError; /// /// fn new(value: f64) -> Result { -/// if !(0.0..=100.0).contains(&value) { -/// return Err(ValidationError::OutOfRange { -/// type_name: "Percentage", -/// min: "0".into(), -/// max: "100".into(), -/// actual: value.to_string(), -/// }); +/// if value < 0.0 { +/// return Err(ValidationError::invalid("NonNegative", &value.to_string())); /// } /// Ok(Self(value)) /// } /// -/// fn value(&self) -> &f64 { &self.0 } /// fn into_inner(self) -> f64 { self.0 } /// } -/// ``` -/// -/// # Composite type example -/// -/// ```rust,ignore -/// use arvo::traits::ValueObject; -/// use arvo::errors::ValidationError; /// -/// pub struct PhoneNumberInput { -/// pub country_code: CountryCode, -/// pub number: String, -/// } -/// pub type PhoneNumberOutput = String; // canonical E.164: "+420123456789" -/// -/// pub struct PhoneNumber { -/// input: PhoneNumberInput, -/// e164: String, -/// } -/// -/// impl ValueObject for PhoneNumber { -/// type Input = PhoneNumberInput; -/// type Output = PhoneNumberOutput; -/// type Error = ValidationError; -/// -/// fn new(value: PhoneNumberInput) -> Result { /* ... */ } -/// fn value(&self) -> &String { &self.e164 } // "+420123456789" -/// fn into_inner(self) -> PhoneNumberInput { self.input } +/// impl PrimitiveValue for NonNegative { +/// type Primitive = f64; +/// fn value(&self) -> &f64 { &self.0 } /// } /// ``` pub trait ValueObject: Sized + Clone + PartialEq { /// The type accepted by [`new`](ValueObject::new). type Input; - /// The type returned by [`value`](ValueObject::value). - type Output: ?Sized; - /// The error produced when validation fails. type Error: std::error::Error; - /// Constructs a new value object, validating the input. + /// Constructs a new value object, validating and normalising the input. /// /// Returns `Err` if the value does not satisfy domain constraints. - /// This is the **only** way to create a valid instance — there is - /// no public struct constructor. fn new(value: Self::Input) -> Result; - /// Returns a reference to the validated output value. - fn value(&self) -> &Self::Output; - /// Consumes the value object and returns the original input value. fn into_inner(self) -> Self::Input; } + +/// Extension of [`ValueObject`] for simple single-primitive newtypes. +/// +/// Implemented by every type whose validated representation is a single +/// primitive value (e.g. `EmailAddress` wraps `String`, `Latitude` wraps `f64`). +/// Composite types (e.g. `Money`, `PostalAddress`) do **not** implement this +/// trait — they expose their data through dedicated accessor methods. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::contact::EmailAddress; +/// use arvo::traits::{ValueObject, PrimitiveValue}; +/// +/// let email = EmailAddress::new("user@example.com".into())?; +/// assert_eq!(email.value(), "user@example.com"); +/// +/// // Generic bound for code that only needs the inner primitive: +/// fn print_value>(v: &T) { +/// println!("{}", v.value()); +/// } +/// ``` +pub trait PrimitiveValue: ValueObject { + /// The primitive type wrapped by this value object. + type Primitive: ?Sized; + + /// Returns a reference to the validated inner value. + fn value(&self) -> &Self::Primitive; +} From c86b97efa9c064513d809b5fd12668af8e7d24a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Wed, 22 Apr 2026 15:33:22 +0200 Subject: [PATCH 12/15] =?UTF-8?q?docs:=20update=20for=201.0=20=E2=80=94=20?= =?UTF-8?q?new=20trait=20hierarchy,=20remove=20SQLx,=20add=20ORM=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: replace SQL support section with ORM integration guide; document PrimitiveValue trait; add 0.x → 1.0 migration table - docs/value-objects.md: document PrimitiveValue subtrait and when to use it - docs/implementing.md: update checklist and examples for new trait design; add ORM integration examples with sqlx and SeaORM - docs/contact.md: remove SQLx notes from PhoneNumber and PostalAddress --- README.md | 128 ++++++++++++++++++++++-------------------- docs/contact.md | 6 +- docs/implementing.md | 94 +++++++++++++++++++++---------- docs/value-objects.md | 40 +++++++++---- 4 files changed, 160 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 84be5f6..2ff1051 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,11 @@ let email: EmailAddress = "user@example.com".try_into()?; - [Installation](#installation) - [Feature flags](#feature-flags) - [Quick start](#quick-start) -- [The `ValueObject` trait](#the-valueobject-trait) +- [The trait hierarchy](#the-trait-hierarchy) - [Error handling](#error-handling) +- [Parsing from strings](#parsing-from-strings) - [Serde support](#serde-support) -- [SQL support](#sql-support) +- [Database / ORM integration](#database--orm-integration) - [Roadmap](#roadmap) - [Contributing](#contributing) @@ -44,7 +45,7 @@ let email: EmailAddress = "user@example.com".try_into()?; | Document | Description | |---|---| | [docs/value-objects.md](docs/value-objects.md) | What value objects are, simple vs composite, normalisation | -| [docs/implementing.md](docs/implementing.md) | How to implement the `ValueObject` trait for custom types | +| [docs/implementing.md](docs/implementing.md) | How to implement the traits for custom types | | [docs/contact.md](docs/contact.md) | Reference for all `contact` module types | | [docs/finance.md](docs/finance.md) | Reference for all `finance` module types | | [docs/geo.md](docs/geo.md) | Reference for all `geo` module types | @@ -60,7 +61,7 @@ let email: EmailAddress = "user@example.com".try_into()?; ```toml [dependencies] -arvo = { version = "0.9", features = ["contact", "serde"] } +arvo = { version = "1.0", features = ["contact", "serde"] } ``` Enable only the modules you need — unused features add zero dependencies. @@ -79,8 +80,7 @@ Enable only the modules you need — unused features add zero dependencies. | `identifiers` | `Slug`, `Ean13`, `Ean8`, `Isbn13`, `Isbn10`, `Issn`, `Vin` | — | | `primitives` | `NonEmptyString`, `BoundedString`, `PositiveInt`, `NonNegativeInt`, `PositiveDecimal`, `NonNegativeDecimal`, `Probability`, `HexColor`, `Locale`, `Base64String` | `rust_decimal`, `base64` | | `temporal` | `UnixTimestamp`, `BirthDate`, `ExpiryDate`, `TimeRange`, `BusinessHours` | `chrono` | -| `serde` | `Serialize` / `Deserialize` on all types | `serde` | -| `sql` | `sqlx::Type` + `Encode` + `Decode` for PostgreSQL on all types | `sqlx` | +| `serde` | `Serialize` / `Deserialize` on all types — deserialisation validates | `serde` | | `full` | All domain modules | all of the above | > **Tip:** `serde` and `full` are orthogonal — combine them freely: @@ -106,7 +106,7 @@ let email: EmailAddress = "hello@example.com".try_into()?; let country = CountryCode::new("cz".into())?; assert_eq!(country.value(), "CZ"); -// Composite value object — structured input, canonical E.164 output +// Composite value object — structured input, multiple accessors let phone = PhoneNumber::new(PhoneNumberInput { country_code: CountryCode::new("CZ".into())?, number: "123 456 789".into(), // formatting stripped automatically @@ -121,48 +121,48 @@ println!("{err}"); // 'not-an-email' is not a valid EmailAddress --- -## The `ValueObject` trait +## The trait hierarchy -Every type in arvo implements the same core interface: +arvo uses two traits: ```rust,ignore +// Base trait — all value objects pub trait ValueObject: Sized + Clone + PartialEq { - /// What `new()` accepts — raw primitive for simple types, - /// a dedicated input struct for composites. type Input; - - /// What `value()` returns — same as `Input` for simple types, - /// canonical representation (e.g. E.164 string) for composites. - type Output: ?Sized; - type Error: std::error::Error; - /// Only way to construct — validates and normalises the input. fn new(value: Self::Input) -> Result; - - /// Returns the validated output value. - fn value(&self) -> &Self::Output; - - /// Consumes and returns the original input. fn into_inner(self) -> Self::Input; } + +// Subtrait — simple single-primitive newtypes only +pub trait PrimitiveValue: ValueObject { + type Primitive: ?Sized; + fn value(&self) -> &Self::Primitive; +} ``` -**Simple type** — `Input` and `Output` are the same (`String`): +**Simple types** implement both — `value()` returns the inner primitive: ```rust,ignore let email = EmailAddress::new("user@example.com".into())?; email.value() // &String → "user@example.com" email.into_inner() // String → "user@example.com" ``` -**Composite type** — `Input` is a struct, `Output` is canonical string: +**Composite types** implement only `ValueObject` — data is accessed through dedicated methods: ```rust,ignore let phone = PhoneNumber::new(PhoneNumberInput { country_code, number })?; -phone.value() // &String → "+420123456789" (E.164) -phone.into_inner() // PhoneNumberInput { country_code, number } +phone.value() // &str → "+420123456789" (inherent method, not trait) +phone.calling_code() // &str → "+420" +phone.into_inner() // PhoneNumberInput { country_code, number } ``` -You can implement it for your own domain types using the provided implementations as a reference. +Use `PrimitiveValue` as a generic bound when you need access to the inner value: +```rust,ignore +fn print_value>(v: &T) { + println!("{}", v.value()); +} +``` --- @@ -216,13 +216,12 @@ Parsing errors return `ValidationError` just like `::new()`. ## Serde support -Enable the `serde` feature. All types serialize as their raw primitive and **deserialisation validates** — invalid values are rejected at parse time, not after: +Enable the `serde` feature. All types serialize as their raw primitive and **deserialisation validates** — invalid values are rejected at parse time: ```rust,ignore use arvo::contact::EmailAddress; let email = EmailAddress::new("user@example.com".into())?; - let json = serde_json::to_string(&email)?; // → "\"user@example.com\"" @@ -234,47 +233,43 @@ let err: Result = serde_json::from_str(r#""not-an-email""#); assert!(err.is_err()); ``` -Composite types (`PostalAddress`) serialise as their structured `Input` type (JSON object). -`PhoneNumber` and `PostalAddress` do not support `TryFrom<&str>`, so no canonical-string round-trip is attempted. +Composite types (`PostalAddress`) serialise as their structured `Input` type (JSON object). --- -## SQL support +## Database / ORM integration -Enable the `sql` feature. All types implement `sqlx::Type`, `sqlx::Encode`, and `sqlx::Decode` for PostgreSQL: - -```toml -arvo = { version = "0.9", features = ["finance", "sql"] } -``` +arvo intentionally has no database dependency. Integrate using the accessors arvo provides — this works with any ORM and enables multi-column storage for composite types: +**Raw sqlx — simple types:** ```rust,ignore -// Use arvo types directly in sqlx queries -let money: Money = sqlx::query_scalar("SELECT price FROM products WHERE id = $1") - .bind(id) - .fetch_one(&pool) - .await?; - -// Bind arvo types as query parameters -sqlx::query("INSERT INTO orders (amount) VALUES ($1)") - .bind(Money::new(MoneyInput { amount: "9.99".parse()?, currency })?) - .execute(&pool) - .await?; -``` +// Bind — extract the primitive +query.bind(email.value()) +query.bind(country.value()) -**Storage mapping:** +// Read back — construct via new() +let s: String = row.get("email"); +let email = EmailAddress::new(s)?; +``` -| Type category | PostgreSQL type | -|:---|:---| -| `String`-based newtypes (`EmailAddress`, `Iban`, `Slug`, …) | `TEXT` | -| `f64` newtypes (`Latitude`, `Longitude`, `Percentage`, …) | `FLOAT8` | -| `i64` newtypes (`PositiveInt`, `UnixTimestamp`, …) | `INT8` | -| `Decimal` newtypes (`PositiveDecimal`, `NonNegativeDecimal`) | `NUMERIC` | -| `NaiveDate` newtypes (`BirthDate`, `ExpiryDate`) | `DATE` | -| `Port`, `HttpStatusCode` (u16) | `INT4` | -| Composite types (`Money`, `Coordinate`, `Length`, …) | `TEXT` (canonical string) | +**SeaORM / Diesel — composite types as multiple columns:** +```rust,ignore +// Define your own entity with individual columns +#[derive(DeriveEntityModel)] +pub struct Model { + pub street: String, pub city: String, + pub zip: String, pub country: String, +} -> **Note:** `PhoneNumber` and `PostalAddress` do not implement sqlx traits — -> their canonical strings cannot be unambiguously decoded back to a structured value. +// Convert via into_inner() +impl From for Model { + fn from(addr: PostalAddress) -> Self { + let i = addr.into_inner(); + Model { street: i.street, city: i.city, + zip: i.zip, country: i.country.into_inner() } + } +} +``` --- @@ -297,6 +292,17 @@ sqlx::query("INSERT INTO orders (amount) VALUES ($1)") --- +## Migration from 0.x to 1.0 + +| What changed | Migration | +|---|---| +| `ValueObject::value()` moved to `PrimitiveValue` | Change `T: ValueObject` to `T: PrimitiveValue` if you call `.value()` generically | +| `type Output` removed from `ValueObject` | Replace `::Output` with the concrete type | +| `XxxOutput` type aliases removed | Replace `EmailAddressOutput` with `String`, `PortOutput` with `u16`, etc. | +| `sql` feature removed | Use `.value()` / `.into_inner()` to bind primitives; implement sqlx traits yourself if needed | + +--- + ## Contributing Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR. diff --git a/docs/contact.md b/docs/contact.md index 96addf2..6f6c9c6 100644 --- a/docs/contact.md +++ b/docs/contact.md @@ -132,8 +132,7 @@ pub struct PhoneNumberInput { | `number: "123456789012345"` | `ValidationError::InvalidFormat` (too long, max 14 digits) | > **Serde:** serialises as a JSON object `{ "country_code": "CZ", "number": "123456789" }` (the input struct). -> **SQLx:** not supported — the canonical E.164 string cannot be unambiguously decoded back to a structured `PhoneNumberInput`. -> **TryFrom\<&str\>:** not implemented for the same reason. +> **TryFrom\<&str\>:** not implemented — the E.164 canonical string cannot be unambiguously decoded back to structured `PhoneNumberInput`. --- @@ -233,5 +232,4 @@ pub struct PostalAddressInput { | `zip` | `""` or whitespace | `ValidationError::Empty` | > **Serde:** serialises as a JSON object matching `PostalAddressInput` (the input struct). -> **SQLx:** not supported — the multi-line formatted string cannot be unambiguously decoded back to structured fields. -> **TryFrom\<&str\>:** not implemented for the same reason. +> **TryFrom\<&str\>:** not implemented — the formatted multi-line string cannot be unambiguously decoded back to structured fields. diff --git a/docs/implementing.md b/docs/implementing.md index 44764f7..37b4105 100644 --- a/docs/implementing.md +++ b/docs/implementing.md @@ -1,25 +1,22 @@ # Implementing custom value objects -You can implement the `ValueObject` trait for your own domain types. Use the existing types in `src/contact/` as reference implementations. +You can implement the `ValueObject` and `PrimitiveValue` traits for your own domain types. Use the existing types in `src/` as reference implementations. ## Simple value object -A simple VO wraps one raw primitive. `Input` and `Output` are the same type. +A simple VO wraps one raw primitive. Implement both `ValueObject` (construction + deconstruction) and `PrimitiveValue` (typed accessor). ```rust,ignore use arvo::errors::ValidationError; -use arvo::traits::ValueObject; +use arvo::traits::{PrimitiveValue, ValueObject}; -pub type PercentageInput = f64; -pub type PercentageOutput = f64; +pub type PercentageInput = f64; -#[derive(Debug, Clone, PartialEq)] pub struct Percentage(f64); impl ValueObject for Percentage { - type Input = PercentageInput; - type Output = PercentageOutput; - type Error = ValidationError; + type Input = f64; + type Error = ValidationError; fn new(value: f64) -> Result { if !(0.0..=100.0).contains(&value) { @@ -33,10 +30,14 @@ impl ValueObject for Percentage { Ok(Self(value)) } - fn value(&self) -> &f64 { &self.0 } fn into_inner(self) -> f64 { self.0 } } +impl PrimitiveValue for Percentage { + type Primitive = f64; + fn value(&self) -> &f64 { &self.0 } +} + impl TryFrom for Percentage { type Error = ValidationError; fn try_from(v: f64) -> Result { Self::new(v) } @@ -51,31 +52,25 @@ impl std::fmt::Display for Percentage { ## Composite value object -A composite VO accepts multiple typed inputs and returns a canonical representation. `Input` is a dedicated struct; `Output` is typically `String`. +A composite VO accepts multiple typed inputs. Implement only `ValueObject`. Expose data through dedicated accessor methods and `Display`. Provide `value()` as an inherent method returning the canonical string if useful. ```rust,ignore use arvo::errors::ValidationError; use arvo::traits::ValueObject; -// Dedicated input struct — one field per component -#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct CoordinateInput { pub latitude: f64, pub longitude: f64, } -pub type CoordinateOutput = String; // canonical: "48.8566,2.3522" - -#[derive(Debug, Clone, PartialEq)] pub struct Coordinate { - input: CoordinateInput, - canonical: String, + input: CoordinateInput, + canonical: String, } impl ValueObject for Coordinate { - type Input = CoordinateInput; - type Output = CoordinateOutput; - type Error = ValidationError; + type Input = CoordinateInput; + type Error = ValidationError; fn new(value: CoordinateInput) -> Result { if !(-90.0..=90.0).contains(&value.latitude) { @@ -84,32 +79,42 @@ impl ValueObject for Coordinate { if !(-180.0..=180.0).contains(&value.longitude) { return Err(ValidationError::invalid("Coordinate.longitude", &value.longitude.to_string())); } - let canonical = format!("{},{}", value.latitude, value.longitude); + let canonical = format!("{}, {}", value.latitude, value.longitude); Ok(Self { input: value, canonical }) } - fn value(&self) -> &String { &self.canonical } fn into_inner(self) -> CoordinateInput { self.input } } -// Extra accessors beyond the trait impl Coordinate { - pub fn latitude(&self) -> f64 { self.input.latitude } - pub fn longitude(&self) -> f64 { self.input.longitude } + pub fn value(&self) -> &str { &self.canonical } + pub fn latitude(&self) -> f64 { self.input.latitude } + pub fn longitude(&self) -> f64 { self.input.longitude } +} + +impl TryFrom<&str> for Coordinate { + type Error = ValidationError; + fn try_from(s: &str) -> Result { /* parse canonical */ todo!() } +} + +impl std::fmt::Display for Coordinate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } } ``` ## Checklist for every new type -- [ ] `type Input` and `type Output` type aliases defined and exported +- [ ] `type Input` type alias defined and exported - [ ] `#[derive(Debug, Clone, PartialEq, Eq, Hash)]` on the struct - [ ] Serde: `#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]` + `serde(try_from = "T", into = "T")` so deserialisation validates via `new()` + `impl TryFrom` delegating to `new()` and `#[cfg(feature = "serde")] impl From for T` -- [ ] SQLx: `#[cfg_attr(feature = "sql", derive(sqlx::Type))] #[cfg_attr(feature = "sql", sqlx(transparent))]` - for simple newtypes; manual `Type + Encode + Decode` for composites (store as TEXT via `TryFrom<&str>`) -- [ ] `impl ValueObject` with `new`, `value`, `into_inner` -- [ ] `impl TryFrom<&str>` (for string-input types and all composite types) +- [ ] `impl ValueObject` with `new` and `into_inner` +- [ ] For simple types: `impl PrimitiveValue` with `type Primitive` and `value()` +- [ ] For composite types: inherent `pub fn value(&self) -> &str` (if canonical string exists) +- [ ] `impl TryFrom<&str>` (for string-input types and composite types with reversible canonical format) - [ ] `impl Display` - [ ] Extra accessors for composite types - [ ] Unit tests: valid input, empty/invalid input, normalisation, `try_from`, `serde_roundtrip`, `serde_deserialize_validates` @@ -117,4 +122,31 @@ impl Coordinate { - [ ] Registered in `mod.rs` and `prelude` - [ ] Status updated in `ROADMAP.md` +## ORM / database integration + +arvo does not bundle database integration — this keeps dependencies minimal and lets you use any framework. Integrate using the accessors arvo already provides: + +**Raw sqlx:** +```rust,ignore +// Bind — extract the primitive +query.bind(email.value()) +query.bind(addr.street()) + +// Read back — construct via new() +let s: String = row.get("email"); +let email = EmailAddress::new(s)?; +``` + +**SeaORM / Diesel — composite types as multiple columns:** +```rust,ignore +// Define your own entity with individual columns +impl From for AddressModel { + fn from(addr: PostalAddress) -> Self { + let i = addr.into_inner(); + AddressModel { street: i.street, city: i.city, zip: i.zip, + country: i.country.into_inner() } + } +} +``` + See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full development workflow. diff --git a/docs/value-objects.md b/docs/value-objects.md index 040bf8e..bc58dc8 100644 --- a/docs/value-objects.md +++ b/docs/value-objects.md @@ -18,46 +18,62 @@ fn send_email(address: EmailAddress) { /* always valid */ } ### Simple value objects -A simple VO wraps a single raw primitive. `Input` and `Output` are the same type. +A simple VO wraps a single raw primitive. Implements both `ValueObject` and `PrimitiveValue`. ``` "User@Example.COM" → EmailAddress("user@example.com") ↑ ↑ - Input Output (&String) + Input PrimitiveValue::value() → &String ``` -Examples: `EmailAddress`, `CountryCode`. +Examples: `EmailAddress`, `CountryCode`, `Latitude`, `Port`. ### Composite value objects -A composite VO is constructed from multiple typed inputs and returns a canonical representation. +A composite VO is constructed from multiple typed inputs and exposes data through dedicated accessor methods. Implements only `ValueObject`. ``` PhoneNumberInput { country_code: "CZ", number: "123456789" } ↓ PhoneNumber { e164: "+420123456789" } ↓ -value() → &String → "+420123456789" +value() → &str → "+420123456789" (inherent method) +calling_code() → &str → "+420" +number() → &str → "123456789" +country_code() → &CountryCode ``` -Examples: `PhoneNumber`. +Examples: `PhoneNumber`, `Money`, `PostalAddress`. -## The `ValueObject` trait - -All types implement the same interface: +## The trait hierarchy ```rust,ignore +// Base trait — all value objects pub trait ValueObject: Sized + Clone + PartialEq { - type Input; // what new() accepts - type Output: ?Sized; // what value() returns + type Input; type Error: std::error::Error; fn new(value: Self::Input) -> Result; - fn value(&self) -> &Self::Output; fn into_inner(self) -> Self::Input; } + +// Subtrait — simple single-primitive newtypes only +pub trait PrimitiveValue: ValueObject { + type Primitive: ?Sized; + fn value(&self) -> &Self::Primitive; +} ``` +Use `PrimitiveValue` as a bound when you need generic access to the inner value: + +```rust,ignore +fn print_value>(v: &T) { + println!("{}", v.value()); +} +``` + +For composite types, use the concrete type and its specific accessors. + ## Why immutability matters Once constructed, a value object never changes. There are no setters. If you need a different value, you construct a new instance. This means: From 885a4c0b0ec3ce0c107bea1484b92813e2e5f1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Wed, 22 Apr 2026 15:42:20 +0200 Subject: [PATCH 13/15] =?UTF-8?q?fix:=20resolve=20CI=20warnings=20?= =?UTF-8?q?=E2=80=94=20unused=20imports,=20orphaned=20doc=20comments,=20ru?= =?UTF-8?q?stfmt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/contact/country_code.rs | 3 - src/contact/email_address.rs | 3 - src/contact/mod.rs | 2 +- src/contact/phone_number.rs | 7 +- src/contact/postal_address.rs | 10 ++- src/contact/website.rs | 3 - src/finance/bic.rs | 2 - src/finance/card_expiry_date.rs | 2 - src/finance/credit_card_number.rs | 2 - src/finance/currency_code.rs | 2 - src/finance/exchange_rate.rs | 4 +- src/finance/iban.rs | 2 - src/finance/mod.rs | 2 +- src/finance/money.rs | 64 +++++++++++--- src/finance/percentage.rs | 7 +- src/finance/vat_number.rs | 2 - src/geo/bounding_box.rs | 13 ++- src/geo/coordinate.rs | 6 +- src/geo/country_region.rs | 2 - src/geo/latitude.rs | 7 +- src/geo/longitude.rs | 7 +- src/geo/mod.rs | 2 +- src/geo/time_zone.rs | 2 - src/identifiers/ean13.rs | 2 - src/identifiers/ean8.rs | 2 - src/identifiers/isbn10.rs | 2 - src/identifiers/isbn13.rs | 2 - src/identifiers/issn.rs | 2 - src/identifiers/mod.rs | 2 +- src/identifiers/slug.rs | 2 - src/identifiers/vin.rs | 2 - src/lib.rs | 110 +++++++++++++------------ src/net/api_key.rs | 2 - src/net/domain.rs | 2 - src/net/http_status_code.rs | 7 +- src/net/ip_address.rs | 2 - src/net/ip_v4_address.rs | 18 ++-- src/net/ip_v6_address.rs | 2 - src/net/mac_address.rs | 2 - src/net/mime_type.rs | 2 - src/net/mod.rs | 2 +- src/net/port.rs | 7 +- src/net/url.rs | 2 - src/primitives/base64_string.rs | 2 - src/primitives/hex_color.rs | 2 - src/primitives/locale.rs | 2 - src/primitives/mod.rs | 2 +- src/primitives/non_empty_string.rs | 2 - src/primitives/non_negative_decimal.rs | 7 +- src/primitives/non_negative_int.rs | 7 +- src/primitives/positive_decimal.rs | 7 +- src/primitives/positive_int.rs | 7 +- src/primitives/probability.rs | 7 +- src/temporal/birth_date.rs | 10 ++- src/temporal/business_hours.rs | 14 ++-- src/temporal/expiry_date.rs | 10 ++- src/temporal/mod.rs | 2 +- src/temporal/time_range.rs | 50 ++++++++--- src/temporal/unix_timestamp.rs | 11 ++- 59 files changed, 258 insertions(+), 214 deletions(-) diff --git a/src/contact/country_code.rs b/src/contact/country_code.rs index c644314..c53d6b4 100644 --- a/src/contact/country_code.rs +++ b/src/contact/country_code.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CountryCode`] — a raw string before validation. pub type CountryCodeInput = String; -/// Output type for [`CountryCode`] — a normalised uppercase string. - /// A validated ISO 3166-1 alpha-2 country code. /// /// On construction the value is trimmed and uppercased, so `"cz"` and `"CZ"` @@ -58,7 +56,6 @@ impl PrimitiveValue for CountryCode { } /// Allows ergonomic construction from a string literal: `"CZ".try_into()` - impl TryFrom for CountryCode { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/contact/email_address.rs b/src/contact/email_address.rs index 196134f..d63c664 100644 --- a/src/contact/email_address.rs +++ b/src/contact/email_address.rs @@ -6,8 +6,6 @@ use regex::Regex; /// Input type for [`EmailAddress`] — a raw string before validation. pub type EmailAddressInput = String; -/// Output type for [`EmailAddress`] — a normalised lowercase string. - /// Compiled email regex — evaluated once at first use. /// /// Pattern checks for a local part, `@`, a domain, and a TLD of at least @@ -78,7 +76,6 @@ impl EmailAddress { } /// Allows ergonomic construction from a string literal: `"a@b.com".try_into()` - impl TryFrom for EmailAddress { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/contact/mod.rs b/src/contact/mod.rs index 694464b..a871bca 100644 --- a/src/contact/mod.rs +++ b/src/contact/mod.rs @@ -9,4 +9,4 @@ pub use country_code::{CountryCode, CountryCodeInput}; pub use email_address::{EmailAddress, EmailAddressInput}; pub use phone_number::{PhoneNumber, PhoneNumberInput}; pub use postal_address::{PostalAddress, PostalAddressInput}; -pub use website::{Website, WebsiteInput}; \ No newline at end of file +pub use website::{Website, WebsiteInput}; diff --git a/src/contact/phone_number.rs b/src/contact/phone_number.rs index 0c758b9..8543b27 100644 --- a/src/contact/phone_number.rs +++ b/src/contact/phone_number.rs @@ -14,8 +14,6 @@ pub struct PhoneNumberInput { pub number: String, } -/// Output type for [`PhoneNumber`] — canonical E.164 string, e.g. `"+420123456789"`. - /// Validates the local number part: digits only, 4–14 characters. static NUMBER_REGEX: Lazy = Lazy::new(|| Regex::new(r"^\d{4,14}$").unwrap()); @@ -74,9 +72,8 @@ impl ValueObject for PhoneNumber { return Err(ValidationError::invalid("PhoneNumber", &number)); } - let prefix = calling_code(value.country_code.value()).ok_or_else(|| { - ValidationError::invalid("PhoneNumber", value.country_code.value()) - })?; + let prefix = calling_code(value.country_code.value()) + .ok_or_else(|| ValidationError::invalid("PhoneNumber", value.country_code.value()))?; let e164 = format!("{}{}", prefix, number); Ok(Self { diff --git a/src/contact/postal_address.rs b/src/contact/postal_address.rs index 7d09ace..65dbdd4 100644 --- a/src/contact/postal_address.rs +++ b/src/contact/postal_address.rs @@ -17,8 +17,6 @@ pub struct PostalAddressInput { pub country: CountryCode, } -/// Output type for [`PostalAddress`] — a human-readable multi-line string. - /// A validated postal address. /// /// All text fields (`street`, `city`, `zip`) must be non-empty after trimming. @@ -52,7 +50,10 @@ pub struct PostalAddressInput { /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(try_from = "PostalAddressInput", into = "PostalAddressInput"))] +#[cfg_attr( + feature = "serde", + serde(try_from = "PostalAddressInput", into = "PostalAddressInput") +)] pub struct PostalAddress { street: String, city: String, @@ -258,7 +259,8 @@ mod tests { #[cfg(feature = "serde")] #[test] fn serde_deserialize_validates() { - let result: Result = serde_json::from_str(r#"{"street":"","city":"Prague","zip":"110 00","country":"CZ"}"#); + let result: Result = + serde_json::from_str(r#"{"street":"","city":"Prague","zip":"110 00","country":"CZ"}"#); assert!(result.is_err()); } } diff --git a/src/contact/website.rs b/src/contact/website.rs index 907baa0..9133629 100644 --- a/src/contact/website.rs +++ b/src/contact/website.rs @@ -5,8 +5,6 @@ use url::Url; /// Input type for [`Website`] — a raw string before validation. pub type WebsiteInput = String; -/// Output type for [`Website`] — a normalised URL string. - /// A validated website URL. /// /// Accepts `http` and `https` schemes only. On construction the value is @@ -85,7 +83,6 @@ impl Website { } /// Allows ergonomic construction from a string literal: `"https://example.com".try_into()` - impl TryFrom for Website { type Error = ValidationError; fn try_from(s: String) -> Result { diff --git a/src/finance/bic.rs b/src/finance/bic.rs index 8aa6a29..52e902c 100644 --- a/src/finance/bic.rs +++ b/src/finance/bic.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Bic`]. pub type BicInput = String; -/// Output type for [`Bic`] — canonical uppercase string. - /// A validated BIC (Bank Identifier Code), also known as SWIFT code. /// /// On construction the input is trimmed and uppercased. A BIC is either 8 or diff --git a/src/finance/card_expiry_date.rs b/src/finance/card_expiry_date.rs index 4adcb64..38eba7e 100644 --- a/src/finance/card_expiry_date.rs +++ b/src/finance/card_expiry_date.rs @@ -6,8 +6,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CardExpiryDate`] — accepts `"MM/YY"` or `"MM/YYYY"`. pub type CardExpiryDateInput = String; -/// Output type for [`CardExpiryDate`] — normalised `"MM/YY"` string. - /// A validated credit/debit card expiry date. /// /// Accepts `"MM/YY"` or `"MM/YYYY"` format. The month must be 01–12 and the diff --git a/src/finance/credit_card_number.rs b/src/finance/credit_card_number.rs index 74c8356..d27d0fe 100644 --- a/src/finance/credit_card_number.rs +++ b/src/finance/credit_card_number.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CreditCardNumber`]. pub type CreditCardNumberInput = String; -/// Output type for [`CreditCardNumber`] — digits only, no separators. - /// A validated credit card number using the Luhn algorithm. /// /// On construction spaces and hyphens are stripped; only digits are kept. diff --git a/src/finance/currency_code.rs b/src/finance/currency_code.rs index 764fb66..69446fb 100644 --- a/src/finance/currency_code.rs +++ b/src/finance/currency_code.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CurrencyCode`]. pub type CurrencyCodeInput = String; -/// Output type for [`CurrencyCode`]. - /// Active ISO 4217 alphabetic currency codes, sorted for binary search. static ISO_4217: &[&str] = &[ "AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", diff --git a/src/finance/exchange_rate.rs b/src/finance/exchange_rate.rs index 184943c..77e4499 100644 --- a/src/finance/exchange_rate.rs +++ b/src/finance/exchange_rate.rs @@ -1,7 +1,7 @@ use rust_decimal::Decimal; use crate::errors::ValidationError; -use crate::traits::{PrimitiveValue, ValueObject}; +use crate::traits::ValueObject; use super::currency_code::CurrencyCode; @@ -16,8 +16,6 @@ pub struct ExchangeRateInput { pub rate: Decimal, } -/// Output type for [`ExchangeRate`] — canonical `"/ "` string. - /// A validated currency exchange rate. /// /// The `rate` must be strictly positive (> 0) and `from` must differ from `to`. diff --git a/src/finance/iban.rs b/src/finance/iban.rs index d7f2199..03a0e7e 100644 --- a/src/finance/iban.rs +++ b/src/finance/iban.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Iban`]. pub type IbanInput = String; -/// Output type for [`Iban`] — canonical uppercase string without spaces. - /// A validated IBAN (International Bank Account Number). /// /// On construction all spaces are stripped and the value is uppercased. The diff --git a/src/finance/mod.rs b/src/finance/mod.rs index 76b5c66..3794f6e 100644 --- a/src/finance/mod.rs +++ b/src/finance/mod.rs @@ -16,4 +16,4 @@ pub use exchange_rate::{ExchangeRate, ExchangeRateInput}; pub use iban::{Iban, IbanInput}; pub use money::{Money, MoneyInput}; pub use percentage::{Percentage, PercentageInput}; -pub use vat_number::{VatNumber, VatNumberInput}; \ No newline at end of file +pub use vat_number::{VatNumber, VatNumberInput}; diff --git a/src/finance/money.rs b/src/finance/money.rs index 9a18499..5c15903 100644 --- a/src/finance/money.rs +++ b/src/finance/money.rs @@ -1,7 +1,7 @@ use rust_decimal::Decimal; use crate::errors::ValidationError; -use crate::traits::{PrimitiveValue, ValueObject}; +use crate::traits::ValueObject; use super::currency_code::CurrencyCode; @@ -14,8 +14,6 @@ pub struct MoneyInput { pub currency: CurrencyCode, } -/// Output type for [`Money`] — canonical `" "` string. - /// A validated monetary amount with an associated currency. /// /// `amount` may be any finite `Decimal` value; negative amounts represent debts. @@ -91,7 +89,11 @@ impl Money { } let sum = self.amount + other.amount; let canonical = format!("{:.2} {}", sum, self.currency); - Ok(Money { amount: sum, currency: self.currency.clone(), canonical }) + Ok(Money { + amount: sum, + currency: self.currency.clone(), + canonical, + }) } /// Returns the difference `self - other`. Fails if currencies differ. @@ -104,14 +106,22 @@ impl Money { } let diff = self.amount - other.amount; let canonical = format!("{:.2} {}", diff, self.currency); - Ok(Money { amount: diff, currency: self.currency.clone(), canonical }) + Ok(Money { + amount: diff, + currency: self.currency.clone(), + canonical, + }) } /// Returns the negation of this amount (e.g. a debt). pub fn neg(&self) -> Money { let negated = -self.amount; let canonical = format!("{:.2} {}", negated, self.currency); - Money { amount: negated, currency: self.currency.clone(), canonical } + Money { + amount: negated, + currency: self.currency.clone(), + canonical, + } } } @@ -232,30 +242,58 @@ mod tests { #[test] fn add_same_currency() { - let a = Money::new(MoneyInput { amount: "10.00".parse().unwrap(), currency: eur() }).unwrap(); - let b = Money::new(MoneyInput { amount: "5.50".parse().unwrap(), currency: eur() }).unwrap(); + let a = Money::new(MoneyInput { + amount: "10.00".parse().unwrap(), + currency: eur(), + }) + .unwrap(); + let b = Money::new(MoneyInput { + amount: "5.50".parse().unwrap(), + currency: eur(), + }) + .unwrap(); let result = a.add(&b).unwrap(); assert_eq!(result.value(), "15.50 EUR"); } #[test] fn add_different_currencies_fails() { - let a = Money::new(MoneyInput { amount: "10.00".parse().unwrap(), currency: eur() }).unwrap(); - let b = Money::new(MoneyInput { amount: "5.00".parse().unwrap(), currency: usd() }).unwrap(); + let a = Money::new(MoneyInput { + amount: "10.00".parse().unwrap(), + currency: eur(), + }) + .unwrap(); + let b = Money::new(MoneyInput { + amount: "5.00".parse().unwrap(), + currency: usd(), + }) + .unwrap(); assert!(a.add(&b).is_err()); } #[test] fn sub_same_currency() { - let a = Money::new(MoneyInput { amount: "10.00".parse().unwrap(), currency: eur() }).unwrap(); - let b = Money::new(MoneyInput { amount: "3.00".parse().unwrap(), currency: eur() }).unwrap(); + let a = Money::new(MoneyInput { + amount: "10.00".parse().unwrap(), + currency: eur(), + }) + .unwrap(); + let b = Money::new(MoneyInput { + amount: "3.00".parse().unwrap(), + currency: eur(), + }) + .unwrap(); let result = a.sub(&b).unwrap(); assert_eq!(result.value(), "7.00 EUR"); } #[test] fn neg_returns_negated_amount() { - let m = Money::new(MoneyInput { amount: "10.00".parse().unwrap(), currency: eur() }).unwrap(); + let m = Money::new(MoneyInput { + amount: "10.00".parse().unwrap(), + currency: eur(), + }) + .unwrap(); assert_eq!(m.neg().value(), "-10.00 EUR"); } diff --git a/src/finance/percentage.rs b/src/finance/percentage.rs index 7f430bb..0d17f29 100644 --- a/src/finance/percentage.rs +++ b/src/finance/percentage.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Percentage`]. pub type PercentageInput = f64; -/// Output type for [`Percentage`]. - /// A validated percentage value in the range `0.0..=100.0`. /// /// The value must be finite (not NaN, not infinite) and within the inclusive @@ -80,7 +78,10 @@ impl TryFrom<&str> for Percentage { type Error = ValidationError; fn try_from(value: &str) -> Result { - let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("Percentage", value))?; + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("Percentage", value))?; Self::new(parsed) } } diff --git a/src/finance/vat_number.rs b/src/finance/vat_number.rs index 50930c5..a7b77e0 100644 --- a/src/finance/vat_number.rs +++ b/src/finance/vat_number.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`VatNumber`]. pub type VatNumberInput = String; -/// Output type for [`VatNumber`] — canonical uppercase string without spaces. - /// EU VAT country prefixes (sorted for binary search). static EU_PREFIXES: &[&str] = &[ "AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "EL", "ES", "FI", "FR", "HR", "HU", "IE", "IT", diff --git a/src/geo/bounding_box.rs b/src/geo/bounding_box.rs index eb50e4c..d68987e 100644 --- a/src/geo/bounding_box.rs +++ b/src/geo/bounding_box.rs @@ -249,7 +249,8 @@ mod tests { #[test] fn try_from_parses_valid() { - let bbox = BoundingBox::try_from("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000").unwrap(); + let bbox = + BoundingBox::try_from("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000").unwrap(); assert!(bbox.value().starts_with("SW:")); assert!(bbox.value().contains("NE:")); } @@ -261,13 +262,16 @@ mod tests { #[test] fn try_from_rejects_sw_north_of_ne() { - assert!(BoundingBox::try_from("SW: 52.000000, 14.000000 / NE: 51.000000, 18.000000").is_err()); + assert!( + BoundingBox::try_from("SW: 52.000000, 14.000000 / NE: 51.000000, 18.000000").is_err() + ); } #[cfg(feature = "serde")] #[test] fn serde_roundtrip() { - let v = BoundingBox::try_from("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000").unwrap(); + let v = + BoundingBox::try_from("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000").unwrap(); let json = serde_json::to_string(&v).unwrap(); let back: BoundingBox = serde_json::from_str(&json).unwrap(); assert_eq!(v.value(), back.value()); @@ -276,7 +280,8 @@ mod tests { #[cfg(feature = "serde")] #[test] fn serde_serializes_as_canonical_string() { - let v = BoundingBox::try_from("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000").unwrap(); + let v = + BoundingBox::try_from("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000").unwrap(); let json = serde_json::to_string(&v).unwrap(); assert!(json.contains("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000")); } diff --git a/src/geo/coordinate.rs b/src/geo/coordinate.rs index 02da8ee..8007890 100644 --- a/src/geo/coordinate.rs +++ b/src/geo/coordinate.rs @@ -81,8 +81,10 @@ impl TryFrom<&str> for Coordinate { fn try_from(value: &str) -> Result { let err = || ValidationError::invalid("Coordinate", value); let (lat_str, lng_str) = value.trim().split_once(", ").ok_or_else(err)?; - let lat = Latitude::new(lat_str.trim().parse::().map_err(|_| err())?).map_err(|_| err())?; - let lng = Longitude::new(lng_str.trim().parse::().map_err(|_| err())?).map_err(|_| err())?; + let lat = + Latitude::new(lat_str.trim().parse::().map_err(|_| err())?).map_err(|_| err())?; + let lng = + Longitude::new(lng_str.trim().parse::().map_err(|_| err())?).map_err(|_| err())?; Self::new(CoordinateInput { lat, lng }) } } diff --git a/src/geo/country_region.rs b/src/geo/country_region.rs index a3cadf9..89f6eeb 100644 --- a/src/geo/country_region.rs +++ b/src/geo/country_region.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CountryRegion`]. pub type CountryRegionInput = String; -/// Output type for [`CountryRegion`]. - /// A validated ISO 3166-2 subdivision code. /// /// **Format:** two uppercase ASCII letters (country code), a hyphen, then diff --git a/src/geo/latitude.rs b/src/geo/latitude.rs index 66bb3ad..4969052 100644 --- a/src/geo/latitude.rs +++ b/src/geo/latitude.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Latitude`]. pub type LatitudeInput = f64; -/// Output type for [`Latitude`]. - /// A validated geographic latitude in decimal degrees. /// /// The value must be finite and in the inclusive range `−90.0..=90.0`. @@ -69,7 +67,10 @@ impl TryFrom<&str> for Latitude { type Error = ValidationError; fn try_from(value: &str) -> Result { - let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("Latitude", value))?; + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("Latitude", value))?; Self::new(parsed) } } diff --git a/src/geo/longitude.rs b/src/geo/longitude.rs index 12ecf4e..396a23f 100644 --- a/src/geo/longitude.rs +++ b/src/geo/longitude.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Longitude`]. pub type LongitudeInput = f64; -/// Output type for [`Longitude`]. - /// A validated geographic longitude in decimal degrees. /// /// The value must be finite and in the inclusive range `−180.0..=180.0`. @@ -69,7 +67,10 @@ impl TryFrom<&str> for Longitude { type Error = ValidationError; fn try_from(value: &str) -> Result { - let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("Longitude", value))?; + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("Longitude", value))?; Self::new(parsed) } } diff --git a/src/geo/mod.rs b/src/geo/mod.rs index 442980e..a7f6254 100644 --- a/src/geo/mod.rs +++ b/src/geo/mod.rs @@ -10,4 +10,4 @@ pub use coordinate::{Coordinate, CoordinateInput}; pub use country_region::{CountryRegion, CountryRegionInput}; pub use latitude::{Latitude, LatitudeInput}; pub use longitude::{Longitude, LongitudeInput}; -pub use time_zone::{TimeZone, TimeZoneInput}; \ No newline at end of file +pub use time_zone::{TimeZone, TimeZoneInput}; diff --git a/src/geo/time_zone.rs b/src/geo/time_zone.rs index fd1744e..04e3c36 100644 --- a/src/geo/time_zone.rs +++ b/src/geo/time_zone.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`TimeZone`]. pub type TimeZoneInput = String; -/// Output type for [`TimeZone`]. - /// Sorted list of canonical IANA timezone names. static IANA_TIMEZONES: &[&str] = &[ "Africa/Abidjan", diff --git a/src/identifiers/ean13.rs b/src/identifiers/ean13.rs index 3936cf1..932c9d0 100644 --- a/src/identifiers/ean13.rs +++ b/src/identifiers/ean13.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Ean13`]. pub type Ean13Input = String; -/// Output type for [`Ean13`] — 13 bare digits. - /// A validated EAN-13 barcode number. /// /// Spaces and hyphens are stripped on construction. The 13th digit is the diff --git a/src/identifiers/ean8.rs b/src/identifiers/ean8.rs index d64add3..3f84ce2 100644 --- a/src/identifiers/ean8.rs +++ b/src/identifiers/ean8.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Ean8`]. pub type Ean8Input = String; -/// Output type for [`Ean8`] — 8 bare digits. - /// A validated EAN-8 barcode number. /// /// Spaces and hyphens are stripped on construction. The 8th digit is the diff --git a/src/identifiers/isbn10.rs b/src/identifiers/isbn10.rs index 63b9a20..573ece7 100644 --- a/src/identifiers/isbn10.rs +++ b/src/identifiers/isbn10.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Isbn10`]. pub type Isbn10Input = String; -/// Output type for [`Isbn10`] — 10 characters (9 digits + check char `0–9` or `X`). - /// A validated ISBN-10 number. /// /// Hyphens and spaces are stripped on construction. The check character diff --git a/src/identifiers/isbn13.rs b/src/identifiers/isbn13.rs index 9682ab4..2917ad0 100644 --- a/src/identifiers/isbn13.rs +++ b/src/identifiers/isbn13.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Isbn13`]. pub type Isbn13Input = String; -/// Output type for [`Isbn13`] — 13 bare digits. - /// A validated ISBN-13 number. /// /// Hyphens and spaces are stripped on construction. Must start with `978` diff --git a/src/identifiers/issn.rs b/src/identifiers/issn.rs index 39e6e6e..f791bfb 100644 --- a/src/identifiers/issn.rs +++ b/src/identifiers/issn.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Issn`]. pub type IssnInput = String; -/// Output type for [`Issn`] — canonical `XXXX-XXXX` form. - /// A validated ISSN (International Standard Serial Number). /// /// Spaces and hyphens are stripped on construction. The check character diff --git a/src/identifiers/mod.rs b/src/identifiers/mod.rs index d75e89e..079cfcd 100644 --- a/src/identifiers/mod.rs +++ b/src/identifiers/mod.rs @@ -12,4 +12,4 @@ pub use isbn10::{Isbn10, Isbn10Input}; pub use isbn13::{Isbn13, Isbn13Input}; pub use issn::{Issn, IssnInput}; pub use slug::{Slug, SlugInput}; -pub use vin::{Vin, VinInput}; \ No newline at end of file +pub use vin::{Vin, VinInput}; diff --git a/src/identifiers/slug.rs b/src/identifiers/slug.rs index 9cca0b3..af0cae1 100644 --- a/src/identifiers/slug.rs +++ b/src/identifiers/slug.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Slug`]. pub type SlugInput = String; -/// Output type for [`Slug`]. - /// A URL-safe slug: lowercase alphanumeric characters and hyphens only. /// /// On construction the value is trimmed and lowercased. Consecutive hyphens diff --git a/src/identifiers/vin.rs b/src/identifiers/vin.rs index 21f0235..bef2ba9 100644 --- a/src/identifiers/vin.rs +++ b/src/identifiers/vin.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Vin`]. pub type VinInput = String; -/// Output type for [`Vin`] — 17 uppercase characters. - /// A validated Vehicle Identification Number (VIN) per ISO 3779. /// /// Trimmed and uppercased on construction. Must be exactly 17 characters diff --git a/src/lib.rs b/src/lib.rs index 5e0212b..200c0a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -71,57 +71,61 @@ pub mod temporal; /// Add `use arvo::prelude::*;` to bring the `ValueObject` trait and /// the most common value object types into scope without long paths. pub mod prelude { - pub use crate::errors::ValidationError; - pub use crate::traits::{PrimitiveValue, ValueObject}; - - #[cfg(feature = "contact")] - pub use crate::contact::{ - CountryCode, CountryCodeInput, EmailAddress, EmailAddressInput, - PhoneNumber, PhoneNumberInput, PostalAddress, - PostalAddressInput, Website, WebsiteInput }; - - #[cfg(feature = "finance")] - pub use crate::finance::{ - Bic, BicInput, CardExpiryDate, CardExpiryDateInput, CreditCardNumber, CreditCardNumberInput, CurrencyCode, - CurrencyCodeInput, ExchangeRate, ExchangeRateInput, Iban, IbanInput, Money, MoneyInput, Percentage, PercentageInput, - VatNumber, VatNumberInput }; - - #[cfg(feature = "geo")] - pub use crate::geo::{ - BoundingBox, BoundingBoxInput, Coordinate, CoordinateInput, CountryRegion, - CountryRegionInput, Latitude, LatitudeInput, Longitude, LongitudeInput, TimeZone, TimeZoneInput }; - - #[cfg(feature = "identifiers")] - pub use crate::identifiers::{ - Ean8, Ean8Input, Ean13, Ean13Input, Isbn10, Isbn10Input, - Isbn13, Isbn13Input, Issn, IssnInput, Slug, - SlugInput, Vin, VinInput }; - - #[cfg(feature = "measurement")] - pub use crate::measurement::{ - Area, AreaInput, AreaUnit, Energy, EnergyInput, EnergyUnit, Frequency, FrequencyInput, - FrequencyUnit, Length, LengthInput, LengthUnit, Power, PowerInput, PowerUnit, Pressure, - PressureInput, PressureUnit, Speed, SpeedInput, SpeedUnit, Temperature, TemperatureInput, - TemperatureUnit, Volume, VolumeInput, VolumeUnit, Weight, WeightInput, WeightUnit }; - - #[cfg(feature = "net")] - pub use crate::net::{ - ApiKey, ApiKeyInput, Domain, DomainInput, HttpStatusCode, - HttpStatusCodeInput, IpAddress, IpAddressInput, IpV4Address, IpV4AddressInput, IpV6Address, IpV6AddressInput, - MacAddress, MacAddressInput, MimeType, MimeTypeInput, - Port, PortInput, Url, UrlInput }; - - #[cfg(feature = "primitives")] - pub use crate::primitives::{ - Base64String, Base64StringInput, BoundedString, HexColor, HexColorInput, - Locale, LocaleInput, NonEmptyString, NonEmptyStringInput, - NonNegativeDecimal, NonNegativeDecimalInput, NonNegativeInt, NonNegativeIntInput, PositiveDecimal, - PositiveDecimalInput, PositiveInt, PositiveIntInput, - Probability, ProbabilityInput }; - - #[cfg(feature = "temporal")] - pub use crate::temporal::{ - BirthDate, BirthDateInput, BusinessHours, BusinessHoursInput, - ExpiryDate, ExpiryDateInput, TimeRange, - TimeRangeInput, UnixTimestamp, UnixTimestampInput }; + pub use crate::errors::ValidationError; + pub use crate::traits::{PrimitiveValue, ValueObject}; + + #[cfg(feature = "contact")] + pub use crate::contact::{ + CountryCode, CountryCodeInput, EmailAddress, EmailAddressInput, PhoneNumber, + PhoneNumberInput, PostalAddress, PostalAddressInput, Website, WebsiteInput, + }; + + #[cfg(feature = "finance")] + pub use crate::finance::{ + Bic, BicInput, CardExpiryDate, CardExpiryDateInput, CreditCardNumber, + CreditCardNumberInput, CurrencyCode, CurrencyCodeInput, ExchangeRate, ExchangeRateInput, + Iban, IbanInput, Money, MoneyInput, Percentage, PercentageInput, VatNumber, VatNumberInput, + }; + + #[cfg(feature = "geo")] + pub use crate::geo::{ + BoundingBox, BoundingBoxInput, Coordinate, CoordinateInput, CountryRegion, + CountryRegionInput, Latitude, LatitudeInput, Longitude, LongitudeInput, TimeZone, + TimeZoneInput, + }; + + #[cfg(feature = "identifiers")] + pub use crate::identifiers::{ + Ean8, Ean8Input, Ean13, Ean13Input, Isbn10, Isbn10Input, Isbn13, Isbn13Input, Issn, + IssnInput, Slug, SlugInput, Vin, VinInput, + }; + + #[cfg(feature = "measurement")] + pub use crate::measurement::{ + Area, AreaInput, AreaUnit, Energy, EnergyInput, EnergyUnit, Frequency, FrequencyInput, + FrequencyUnit, Length, LengthInput, LengthUnit, Power, PowerInput, PowerUnit, Pressure, + PressureInput, PressureUnit, Speed, SpeedInput, SpeedUnit, Temperature, TemperatureInput, + TemperatureUnit, Volume, VolumeInput, VolumeUnit, Weight, WeightInput, WeightUnit, + }; + + #[cfg(feature = "net")] + pub use crate::net::{ + ApiKey, ApiKeyInput, Domain, DomainInput, HttpStatusCode, HttpStatusCodeInput, IpAddress, + IpAddressInput, IpV4Address, IpV4AddressInput, IpV6Address, IpV6AddressInput, MacAddress, + MacAddressInput, MimeType, MimeTypeInput, Port, PortInput, Url, UrlInput, + }; + + #[cfg(feature = "primitives")] + pub use crate::primitives::{ + Base64String, Base64StringInput, BoundedString, HexColor, HexColorInput, Locale, + LocaleInput, NonEmptyString, NonEmptyStringInput, NonNegativeDecimal, + NonNegativeDecimalInput, NonNegativeInt, NonNegativeIntInput, PositiveDecimal, + PositiveDecimalInput, PositiveInt, PositiveIntInput, Probability, ProbabilityInput, + }; + + #[cfg(feature = "temporal")] + pub use crate::temporal::{ + BirthDate, BirthDateInput, BusinessHours, BusinessHoursInput, ExpiryDate, ExpiryDateInput, + TimeRange, TimeRangeInput, UnixTimestamp, UnixTimestampInput, + }; } diff --git a/src/net/api_key.rs b/src/net/api_key.rs index 3aca626..74e711a 100644 --- a/src/net/api_key.rs +++ b/src/net/api_key.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`ApiKey`]. pub type ApiKeyInput = String; -/// Output type for [`ApiKey`]. - /// A validated API key — non-empty, trimmed. /// /// `Display` shows a masked version with only the last 4 characters visible diff --git a/src/net/domain.rs b/src/net/domain.rs index 25ba61f..2d3172e 100644 --- a/src/net/domain.rs +++ b/src/net/domain.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Domain`]. pub type DomainInput = String; -/// Output type for [`Domain`]. - /// A validated domain name without a scheme (e.g. `"example.com"`). /// /// **Normalisation:** trimmed, lowercased. diff --git a/src/net/http_status_code.rs b/src/net/http_status_code.rs index f33f821..e6428c8 100644 --- a/src/net/http_status_code.rs +++ b/src/net/http_status_code.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`HttpStatusCode`]. pub type HttpStatusCodeInput = u16; -/// Output type for [`HttpStatusCode`]. - /// A validated HTTP status code in the range `100..=599`. /// /// # Example @@ -94,7 +92,10 @@ impl TryFrom<&str> for HttpStatusCode { type Error = ValidationError; fn try_from(value: &str) -> Result { - let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("HttpStatusCode", value))?; + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("HttpStatusCode", value))?; Self::new(parsed) } } diff --git a/src/net/ip_address.rs b/src/net/ip_address.rs index 074a3b7..543bf00 100644 --- a/src/net/ip_address.rs +++ b/src/net/ip_address.rs @@ -6,8 +6,6 @@ use super::{IpV4Address, IpV6Address}; /// Input for [`IpAddress`] — either a v4 or v6 address string. pub type IpAddressInput = String; -/// Output type for [`IpAddress`]. - /// A validated IP address — either IPv4 or IPv6. /// /// Tries IPv4 first, then IPv6. The canonical string is stored normalised. diff --git a/src/net/ip_v4_address.rs b/src/net/ip_v4_address.rs index 2f39e2f..657df99 100644 --- a/src/net/ip_v4_address.rs +++ b/src/net/ip_v4_address.rs @@ -5,8 +5,6 @@ use std::net::Ipv4Addr; /// Input type for [`IpV4Address`]. pub type IpV4AddressInput = String; -/// Output type for [`IpV4Address`]. - /// A validated IPv4 address (e.g. `"192.168.1.1"`). /// /// **Normalisation:** trimmed. Leading zeros in octets are rejected @@ -66,12 +64,18 @@ impl PrimitiveValue for IpV4Address { impl IpV4Address { /// Returns `true` for loopback addresses (`127.0.0.0/8`). pub fn is_loopback(&self) -> bool { - self.0.parse::().map(|ip| ip.is_loopback()).unwrap_or(false) + self.0 + .parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) } /// Returns `true` for private addresses (10/8, 172.16/12, 192.168/16). pub fn is_private(&self) -> bool { - self.0.parse::().map(|ip| ip.is_private()).unwrap_or(false) + self.0 + .parse::() + .map(|ip| ip.is_private()) + .unwrap_or(false) } } @@ -155,7 +159,11 @@ mod tests { #[test] fn is_loopback() { assert!(IpV4Address::new("127.0.0.1".into()).unwrap().is_loopback()); - assert!(!IpV4Address::new("192.168.1.1".into()).unwrap().is_loopback()); + assert!( + !IpV4Address::new("192.168.1.1".into()) + .unwrap() + .is_loopback() + ); } #[test] diff --git a/src/net/ip_v6_address.rs b/src/net/ip_v6_address.rs index fc27335..65346aa 100644 --- a/src/net/ip_v6_address.rs +++ b/src/net/ip_v6_address.rs @@ -5,8 +5,6 @@ use std::net::Ipv6Addr; /// Input type for [`IpV6Address`]. pub type IpV6AddressInput = String; -/// Output type for [`IpV6Address`]. - /// A validated IPv6 address (e.g. `"2001:db8::1"`). /// /// **Normalisation:** trimmed; the address is stored in the canonical diff --git a/src/net/mac_address.rs b/src/net/mac_address.rs index 35aefce..c8bbb26 100644 --- a/src/net/mac_address.rs +++ b/src/net/mac_address.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`MacAddress`]. pub type MacAddressInput = String; -/// Output type for [`MacAddress`]. - /// A validated MAC address, normalised to lowercase colon-separated hex. /// /// **Normalisation:** accepts colon-separated (`AA:BB:CC:DD:EE:FF`), diff --git a/src/net/mime_type.rs b/src/net/mime_type.rs index 84c5851..c3bf7db 100644 --- a/src/net/mime_type.rs +++ b/src/net/mime_type.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`MimeType`]. pub type MimeTypeInput = String; -/// Output type for [`MimeType`]. - /// A validated MIME type (e.g. `"image/png"`, `"application/json"`). /// /// **Normalisation:** trimmed, lowercased. diff --git a/src/net/mod.rs b/src/net/mod.rs index 4813db8..8e42aad 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -18,4 +18,4 @@ pub use ip_v6_address::{IpV6Address, IpV6AddressInput}; pub use mac_address::{MacAddress, MacAddressInput}; pub use mime_type::{MimeType, MimeTypeInput}; pub use port::{Port, PortInput}; -pub use url::{Url, UrlInput}; \ No newline at end of file +pub use url::{Url, UrlInput}; diff --git a/src/net/port.rs b/src/net/port.rs index 92926da..260d648 100644 --- a/src/net/port.rs +++ b/src/net/port.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Port`]. pub type PortInput = u16; -/// Output type for [`Port`]. - /// A validated network port number in the range `1..=65535`. /// /// Port 0 is reserved and rejected. @@ -82,7 +80,10 @@ impl TryFrom<&str> for Port { type Error = ValidationError; fn try_from(value: &str) -> Result { - let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("Port", value))?; + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("Port", value))?; Self::new(parsed) } } diff --git a/src/net/url.rs b/src/net/url.rs index bf6ab58..f00569a 100644 --- a/src/net/url.rs +++ b/src/net/url.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Url`]. pub type UrlInput = String; -/// Output type for [`Url`]. - /// A validated URL. Accepts `http`, `https`, `ftp`, `ftps`, `ws`, and `wss` schemes. /// Scheme and host are normalised to lowercase on construction. /// diff --git a/src/primitives/base64_string.rs b/src/primitives/base64_string.rs index 55f1bb3..99653d7 100644 --- a/src/primitives/base64_string.rs +++ b/src/primitives/base64_string.rs @@ -6,8 +6,6 @@ use base64::engine::general_purpose::STANDARD; /// Input type for [`Base64String`]. pub type Base64StringInput = String; -/// Output type for [`Base64String`]. - /// A validated standard Base64-encoded string. /// /// Accepts the standard alphabet (`A–Z`, `a–z`, `0–9`, `+`, `/`) with `=` diff --git a/src/primitives/hex_color.rs b/src/primitives/hex_color.rs index f9d311f..21f1ac6 100644 --- a/src/primitives/hex_color.rs +++ b/src/primitives/hex_color.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`HexColor`]. pub type HexColorInput = String; -/// Output type for [`HexColor`] — always a 7-character `#RRGGBB` string. - /// A CSS hex color in canonical `#RRGGBB` form, normalised to uppercase. /// /// Accepts both 6-digit (`#FF0000`) and 3-digit shorthand (`#F00`) input. diff --git a/src/primitives/locale.rs b/src/primitives/locale.rs index 1ab36c7..1ab14a2 100644 --- a/src/primitives/locale.rs +++ b/src/primitives/locale.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Locale`]. pub type LocaleInput = String; -/// Output type for [`Locale`] — BCP 47 canonical form, e.g. `"en-US"`. - /// A BCP 47 language tag (e.g. `"en-US"`, `"cs-CZ"`, `"fr"`). /// /// Accepts both `-` and `_` as separators. On construction, the language diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index d962eb9..607e739 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -18,4 +18,4 @@ pub use non_negative_decimal::{NonNegativeDecimal, NonNegativeDecimalInput}; pub use non_negative_int::{NonNegativeInt, NonNegativeIntInput}; pub use positive_decimal::{PositiveDecimal, PositiveDecimalInput}; pub use positive_int::{PositiveInt, PositiveIntInput}; -pub use probability::{Probability, ProbabilityInput}; \ No newline at end of file +pub use probability::{Probability, ProbabilityInput}; diff --git a/src/primitives/non_empty_string.rs b/src/primitives/non_empty_string.rs index bb6a343..ba8e153 100644 --- a/src/primitives/non_empty_string.rs +++ b/src/primitives/non_empty_string.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`NonEmptyString`]. pub type NonEmptyStringInput = String; -/// Output type for [`NonEmptyString`]. - /// A non-empty, trimmed string. /// /// Surrounding whitespace is stripped on construction. A string that consists diff --git a/src/primitives/non_negative_decimal.rs b/src/primitives/non_negative_decimal.rs index 0d45078..2cc9470 100644 --- a/src/primitives/non_negative_decimal.rs +++ b/src/primitives/non_negative_decimal.rs @@ -5,8 +5,6 @@ use rust_decimal::Decimal; /// Input type for [`NonNegativeDecimal`]. pub type NonNegativeDecimalInput = Decimal; -/// Output type for [`NonNegativeDecimal`]. - /// A non-negative decimal number (`Decimal >= 0`). /// /// Negative values are rejected on construction. Zero is allowed. @@ -72,7 +70,10 @@ impl TryFrom<&str> for NonNegativeDecimal { type Error = ValidationError; fn try_from(value: &str) -> Result { - let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("NonNegativeDecimal", value))?; + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("NonNegativeDecimal", value))?; Self::new(parsed) } } diff --git a/src/primitives/non_negative_int.rs b/src/primitives/non_negative_int.rs index f43b9a8..0be4bf8 100644 --- a/src/primitives/non_negative_int.rs +++ b/src/primitives/non_negative_int.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`NonNegativeInt`]. pub type NonNegativeIntInput = i64; -/// Output type for [`NonNegativeInt`]. - /// A non-negative integer (`i64 >= 0`). /// /// Negative values are rejected on construction. Zero is allowed. @@ -70,7 +68,10 @@ impl TryFrom<&str> for NonNegativeInt { type Error = ValidationError; fn try_from(value: &str) -> Result { - let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("NonNegativeInt", value))?; + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("NonNegativeInt", value))?; Self::new(parsed) } } diff --git a/src/primitives/positive_decimal.rs b/src/primitives/positive_decimal.rs index 9e0b3f3..f7a28a2 100644 --- a/src/primitives/positive_decimal.rs +++ b/src/primitives/positive_decimal.rs @@ -5,8 +5,6 @@ use rust_decimal::Decimal; /// Input type for [`PositiveDecimal`]. pub type PositiveDecimalInput = Decimal; -/// Output type for [`PositiveDecimal`]. - /// A strictly positive decimal number (`Decimal > 0`). /// /// Zero and negative values are rejected on construction. @@ -72,7 +70,10 @@ impl TryFrom<&str> for PositiveDecimal { type Error = ValidationError; fn try_from(value: &str) -> Result { - let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("PositiveDecimal", value))?; + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("PositiveDecimal", value))?; Self::new(parsed) } } diff --git a/src/primitives/positive_int.rs b/src/primitives/positive_int.rs index c9bade5..89886bb 100644 --- a/src/primitives/positive_int.rs +++ b/src/primitives/positive_int.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`PositiveInt`]. pub type PositiveIntInput = i64; -/// Output type for [`PositiveInt`]. - /// A strictly positive integer (`i64 > 0`). /// /// Zero and negative values are rejected on construction. @@ -71,7 +69,10 @@ impl TryFrom<&str> for PositiveInt { type Error = ValidationError; fn try_from(value: &str) -> Result { - let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("PositiveInt", value))?; + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("PositiveInt", value))?; Self::new(parsed) } } diff --git a/src/primitives/probability.rs b/src/primitives/probability.rs index 43b62a2..fb3a95f 100644 --- a/src/primitives/probability.rs +++ b/src/primitives/probability.rs @@ -4,8 +4,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Probability`]. pub type ProbabilityInput = f64; -/// Output type for [`Probability`]. - /// A probability value in the range `0.0..=1.0`. /// /// NaN, infinite values, and values outside `[0.0, 1.0]` are rejected. @@ -71,7 +69,10 @@ impl TryFrom<&str> for Probability { type Error = ValidationError; fn try_from(value: &str) -> Result { - let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("Probability", value))?; + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("Probability", value))?; Self::new(parsed) } } diff --git a/src/temporal/birth_date.rs b/src/temporal/birth_date.rs index 7b2ca2e..aea08ce 100644 --- a/src/temporal/birth_date.rs +++ b/src/temporal/birth_date.rs @@ -6,8 +6,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`BirthDate`]. pub type BirthDateInput = NaiveDate; -/// Output type for [`BirthDate`]. - /// A validated date of birth. /// /// The date must be strictly in the past and no more than 150 years before @@ -26,7 +24,10 @@ pub type BirthDateInput = NaiveDate; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(try_from = "chrono::NaiveDate", into = "chrono::NaiveDate"))] +#[cfg_attr( + feature = "serde", + serde(try_from = "chrono::NaiveDate", into = "chrono::NaiveDate") +)] pub struct BirthDate(NaiveDate); impl ValueObject for BirthDate { @@ -98,7 +99,8 @@ impl TryFrom<&str> for BirthDate { type Error = ValidationError; fn try_from(value: &str) -> Result { - let parsed = chrono::NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| ValidationError::invalid("BirthDate", value))?; + let parsed = chrono::NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d") + .map_err(|_| ValidationError::invalid("BirthDate", value))?; Self::new(parsed) } } diff --git a/src/temporal/business_hours.rs b/src/temporal/business_hours.rs index ae4b041..392d33e 100644 --- a/src/temporal/business_hours.rs +++ b/src/temporal/business_hours.rs @@ -14,8 +14,6 @@ pub struct BusinessHoursInput { pub close: NaiveTime, } -/// Output type for [`BusinessHours`] — canonical `" HH:MM–HH:MM"` string. - /// Validated business hours for a single weekday. /// /// `open` must be strictly before `close`. The canonical output is formatted @@ -138,9 +136,15 @@ impl TryFrom<&str> for BusinessHours { _ => return Err(err()), }; let (open_str, close_str) = times_str.split_once('\u{2013}').ok_or_else(err)?; - let open = chrono::NaiveTime::parse_from_str(open_str.trim(), "%H:%M").map_err(|_| err())?; - let close = chrono::NaiveTime::parse_from_str(close_str.trim(), "%H:%M").map_err(|_| err())?; - Self::new(BusinessHoursInput { weekday, open, close }) + let open = + chrono::NaiveTime::parse_from_str(open_str.trim(), "%H:%M").map_err(|_| err())?; + let close = + chrono::NaiveTime::parse_from_str(close_str.trim(), "%H:%M").map_err(|_| err())?; + Self::new(BusinessHoursInput { + weekday, + open, + close, + }) } } diff --git a/src/temporal/expiry_date.rs b/src/temporal/expiry_date.rs index 90943d4..d65d6d9 100644 --- a/src/temporal/expiry_date.rs +++ b/src/temporal/expiry_date.rs @@ -6,8 +6,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`ExpiryDate`]. pub type ExpiryDateInput = NaiveDate; -/// Output type for [`ExpiryDate`]. - /// A validated expiry date that is strictly in the future. /// /// The date must be after today at construction time. @@ -24,7 +22,10 @@ pub type ExpiryDateInput = NaiveDate; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(try_from = "chrono::NaiveDate", into = "chrono::NaiveDate"))] +#[cfg_attr( + feature = "serde", + serde(try_from = "chrono::NaiveDate", into = "chrono::NaiveDate") +)] pub struct ExpiryDate(NaiveDate); impl ValueObject for ExpiryDate { @@ -77,7 +78,8 @@ impl TryFrom<&str> for ExpiryDate { type Error = ValidationError; fn try_from(value: &str) -> Result { - let parsed = chrono::NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| ValidationError::invalid("ExpiryDate", value))?; + let parsed = chrono::NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d") + .map_err(|_| ValidationError::invalid("ExpiryDate", value))?; Self::new(parsed) } } diff --git a/src/temporal/mod.rs b/src/temporal/mod.rs index 00e022b..695a388 100644 --- a/src/temporal/mod.rs +++ b/src/temporal/mod.rs @@ -8,4 +8,4 @@ pub use birth_date::{BirthDate, BirthDateInput}; pub use business_hours::{BusinessHours, BusinessHoursInput}; pub use expiry_date::{ExpiryDate, ExpiryDateInput}; pub use time_range::{TimeRange, TimeRangeInput}; -pub use unix_timestamp::{UnixTimestamp, UnixTimestampInput}; \ No newline at end of file +pub use unix_timestamp::{UnixTimestamp, UnixTimestampInput}; diff --git a/src/temporal/time_range.rs b/src/temporal/time_range.rs index 5176e29..6c7b513 100644 --- a/src/temporal/time_range.rs +++ b/src/temporal/time_range.rs @@ -12,8 +12,6 @@ pub struct TimeRangeInput { pub end: DateTime, } -/// Output type for [`TimeRange`] — canonical `" / "` string. - /// A validated time range with a start strictly before its end. /// /// Both `start` and `end` are `chrono::DateTime`. The canonical output @@ -222,44 +220,76 @@ mod tests { #[test] fn contains_inside() { - let r = TimeRange::new(TimeRangeInput { start: start(), end: end() }).unwrap(); + let r = TimeRange::new(TimeRangeInput { + start: start(), + end: end(), + }) + .unwrap(); let mid = Utc.with_ymd_and_hms(2025, 1, 1, 11, 0, 0).unwrap(); assert!(r.contains(&mid)); } #[test] fn contains_at_start_inclusive() { - let r = TimeRange::new(TimeRangeInput { start: start(), end: end() }).unwrap(); + let r = TimeRange::new(TimeRangeInput { + start: start(), + end: end(), + }) + .unwrap(); assert!(r.contains(&start())); } #[test] fn contains_at_end_exclusive() { - let r = TimeRange::new(TimeRangeInput { start: start(), end: end() }).unwrap(); + let r = TimeRange::new(TimeRangeInput { + start: start(), + end: end(), + }) + .unwrap(); assert!(!r.contains(&end())); } #[test] fn contains_outside() { - let r = TimeRange::new(TimeRangeInput { start: start(), end: end() }).unwrap(); + let r = TimeRange::new(TimeRangeInput { + start: start(), + end: end(), + }) + .unwrap(); let before = Utc.with_ymd_and_hms(2025, 1, 1, 9, 0, 0).unwrap(); assert!(!r.contains(&before)); } #[test] fn overlaps_true() { - let r1 = TimeRange::new(TimeRangeInput { start: start(), end: end() }).unwrap(); + let r1 = TimeRange::new(TimeRangeInput { + start: start(), + end: end(), + }) + .unwrap(); let overlap_start = Utc.with_ymd_and_hms(2025, 1, 1, 11, 0, 0).unwrap(); let overlap_end = Utc.with_ymd_and_hms(2025, 1, 1, 13, 0, 0).unwrap(); - let r2 = TimeRange::new(TimeRangeInput { start: overlap_start, end: overlap_end }).unwrap(); + let r2 = TimeRange::new(TimeRangeInput { + start: overlap_start, + end: overlap_end, + }) + .unwrap(); assert!(r1.overlaps(&r2)); } #[test] fn overlaps_adjacent_no_overlap() { - let r1 = TimeRange::new(TimeRangeInput { start: start(), end: end() }).unwrap(); + let r1 = TimeRange::new(TimeRangeInput { + start: start(), + end: end(), + }) + .unwrap(); let after_end = Utc.with_ymd_and_hms(2025, 1, 1, 13, 0, 0).unwrap(); - let r2 = TimeRange::new(TimeRangeInput { start: end(), end: after_end }).unwrap(); + let r2 = TimeRange::new(TimeRangeInput { + start: end(), + end: after_end, + }) + .unwrap(); assert!(!r1.overlaps(&r2)); } diff --git a/src/temporal/unix_timestamp.rs b/src/temporal/unix_timestamp.rs index f4659d5..81f7392 100644 --- a/src/temporal/unix_timestamp.rs +++ b/src/temporal/unix_timestamp.rs @@ -6,8 +6,6 @@ use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`UnixTimestamp`]. pub type UnixTimestampInput = i64; -/// Output type for [`UnixTimestamp`]. - /// A validated Unix timestamp — non-negative seconds since the Unix epoch. /// /// Negative values (pre-1970) are rejected. @@ -56,7 +54,9 @@ impl PrimitiveValue for UnixTimestamp { impl UnixTimestamp { /// Converts to a `DateTime`. pub fn as_datetime(&self) -> DateTime { - Utc.timestamp_opt(self.0, 0).single().expect("valid timestamp") + Utc.timestamp_opt(self.0, 0) + .single() + .expect("valid timestamp") } } @@ -77,7 +77,10 @@ impl TryFrom<&str> for UnixTimestamp { type Error = ValidationError; fn try_from(value: &str) -> Result { - let parsed = value.trim().parse::().map_err(|_| ValidationError::invalid("UnixTimestamp", value))?; + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("UnixTimestamp", value))?; Self::new(parsed) } } From 6e2886252623f10cac085db23b2e0351fc093db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Thu, 23 Apr 2026 21:25:42 +0200 Subject: [PATCH 14/15] fix: remove stale sql CI job, replace once_cell with std::sync::LazyLock The sql feature was removed in the SQLx refactor but the test-sql CI job still referenced it. Drop the job and its Postgres service entirely. once_cell::sync::Lazy was introduced in v1.3.0 but Cargo.toml pinned version = "1", resolving to v1.0.1 under minimal-versions and breaking the check. Replace with std::sync::LazyLock (stable since Rust 1.80, within our 1.85 MSRV) and remove the once_cell dependency. --- .github/workflows/ci.yml | 36 ++---------------------------------- Cargo.lock | 1 - Cargo.toml | 3 +-- src/contact/email_address.rs | 6 +++--- src/contact/phone_number.rs | 4 ++-- 5 files changed, 8 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fb2b0c..62d0753 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: ci: name: CI if: ${{ always() }} - needs: [fmt, clippy, docs, msrv, test, test-sql] + needs: [fmt, clippy, docs, msrv, test] runs-on: ubuntu-latest steps: - name: Result @@ -63,7 +63,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: clippy (no default features) run: cargo clippy --no-default-features -- -Dclippy::all - - name: clippy (all features except sql) + - name: clippy (full + serde) run: cargo clippy --features full,serde -- -Dclippy::all - name: clippy (all features) run: cargo clippy --all-features -- -Dclippy::all @@ -129,38 +129,6 @@ jobs: - name: cargo test --doc run: cargo test --doc --features full,serde - # ── Tests (sql feature, requires Postgres) ──────────────────────────────── - test-sql: - name: test-sql (ubuntu-latest / stable) - needs: [fmt, clippy] - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: arvo - POSTGRES_PASSWORD: arvo - POSTGRES_DB: arvo_test - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - DATABASE_URL: postgres://arvo:arvo@localhost:5432/arvo_test - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: sql - - name: cargo test (sql) - run: cargo test --features sql - - name: cargo test (all features) - run: cargo test --all-features - # ── Minimal dependency versions ─────────────────────────────────────────── minimal-versions: name: minimal-versions diff --git a/Cargo.lock b/Cargo.lock index 4b11c3e..d2d15d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,7 +49,6 @@ version = "0.9.0" dependencies = [ "base64", "chrono", - "once_cell", "regex", "rust_decimal", "serde", diff --git a/Cargo.toml b/Cargo.toml index 0c47d8a..77c0958 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ rust-version = "1.85" default = [] # Domain modules — opt-in so you only pay for what you use -contact = ["dep:once_cell", "dep:regex", "dep:url"] +contact = ["dep:regex", "dep:url"] finance = ["dep:rust_decimal", "dep:chrono"] geo = [] measurement = [] @@ -44,7 +44,6 @@ full = [ [dependencies] thiserror = "2" -once_cell = { version = "1", optional = true } regex = { version = "1", optional = true } rust_decimal = { version = "1.26", optional = true } chrono = { version = "0.4.23", optional = true, features = ["serde"] } diff --git a/src/contact/email_address.rs b/src/contact/email_address.rs index d63c664..1c06bcb 100644 --- a/src/contact/email_address.rs +++ b/src/contact/email_address.rs @@ -1,7 +1,7 @@ use crate::errors::ValidationError; use crate::traits::{PrimitiveValue, ValueObject}; -use once_cell::sync::Lazy; use regex::Regex; +use std::sync::LazyLock; /// Input type for [`EmailAddress`] — a raw string before validation. pub type EmailAddressInput = String; @@ -10,8 +10,8 @@ pub type EmailAddressInput = String; /// /// Pattern checks for a local part, `@`, a domain, and a TLD of at least /// 2 characters. Full RFC 5322 compliance is intentionally out of scope. -static EMAIL_REGEX: Lazy = - Lazy::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]{2,}$").unwrap()); +static EMAIL_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]{2,}$").unwrap()); /// A validated, normalised email address. /// diff --git a/src/contact/phone_number.rs b/src/contact/phone_number.rs index 8543b27..da5a164 100644 --- a/src/contact/phone_number.rs +++ b/src/contact/phone_number.rs @@ -1,7 +1,7 @@ use crate::errors::ValidationError; use crate::traits::{PrimitiveValue, ValueObject}; -use once_cell::sync::Lazy; use regex::Regex; +use std::sync::LazyLock; use super::country_code::CountryCode; @@ -15,7 +15,7 @@ pub struct PhoneNumberInput { } /// Validates the local number part: digits only, 4–14 characters. -static NUMBER_REGEX: Lazy = Lazy::new(|| Regex::new(r"^\d{4,14}$").unwrap()); +static NUMBER_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^\d{4,14}$").unwrap()); /// A validated phone number stored in canonical E.164 format. /// From c520a3a56faad5fa9507c7a8790e5e8e101dde27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Hrach?= Date: Thu, 23 Apr 2026 21:38:50 +0200 Subject: [PATCH 15/15] fix: raise serde minimum to 1.0.116 for try_from attribute support serde(try_from = "...") was introduced in 1.0.116; the previous version = "1" resolved to v1.0.99 under minimal-versions, which panicked with "unknown serde container attribute try_from". --- Cargo.lock | 18 ++++++++++++------ Cargo.toml | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d2d15d5..44e8cd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -867,11 +867,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", ] [[package]] @@ -880,7 +880,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -1034,13 +1034,19 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 77c0958..9a46e4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ uuid = { version = "1", optional = true, features = ["v4"] } ulid = { version = "1", optional = true } url = { version = "~2.4", optional = true } base64 = { version = "0.22", optional = true } -serde = { version = "1", optional = true, features = ["derive"] } +serde = { version = "1.0.116", optional = true, features = ["derive"] } [dev-dependencies] serde_json = "1"