Skip to main content

domain_key/
id.rs

1//! Typed numeric identifier for domain-key
2//!
3//! This module provides `Id<D>`, a lightweight, type-safe numeric identifier
4//! parameterized by a domain marker. It wraps a [`NonZeroU64`] value, enforcing
5//! the invariant that identifiers are always non-zero (as is standard for
6//! database primary keys, entity IDs, etc.).
7//!
8//! # Niche Optimization
9//!
10//! Because the inner value is `NonZeroU64`, `Option<Id<D>>` has the same size
11//! as `Id<D>` itself (8 bytes) — zero-cost optionality.
12//!
13//! # Examples
14//!
15//! ```rust
16//! use domain_key::{Domain, IdDomain, Id};
17//!
18//! #[derive(Debug)]
19//! struct UserDomain;
20//!
21//! impl Domain for UserDomain {
22//!     const DOMAIN_NAME: &'static str = "user";
23//! }
24//! impl IdDomain for UserDomain {}
25//!
26//! type UserId = Id<UserDomain>;
27//!
28//! let id = UserId::new(42).unwrap();
29//! assert_eq!(id.get(), 42);
30//! assert_eq!(id.to_string(), "42");
31//!
32//! // Zero is rejected:
33//! assert!(UserId::new(0).is_none());
34//!
35//! // Option<Id> has the same size as Id:
36//! assert_eq!(std::mem::size_of::<Option<UserId>>(), std::mem::size_of::<UserId>());
37//! ```
38
39use core::fmt;
40use core::marker::PhantomData;
41use core::num::NonZeroU64;
42use core::str::FromStr;
43
44#[cfg(not(feature = "std"))]
45use alloc::string::String;
46
47#[cfg(feature = "serde")]
48use serde::{Deserialize, Serialize};
49
50use crate::domain::IdDomain;
51use crate::error::IdParseError;
52
53// ============================================================================
54// CORE ID IMPLEMENTATION
55// ============================================================================
56
57/// Lightweight, type-safe numeric identifier
58///
59/// `Id<D>` wraps a [`NonZeroU64`] with a phantom domain marker, providing
60/// compile-time type safety for numeric identifiers. It is `Copy`, making it
61/// ideal for use as database primary keys, entity IDs, and similar use cases.
62///
63/// The non-zero invariant means `Option<Id<D>>` is the same size as `Id<D>`
64/// (niche optimization), and zero can never accidentally represent a valid ID.
65///
66/// # Domain Trait
67///
68/// `Id<D>` uses [`IdDomain`] — a lightweight marker trait with only a domain name.
69/// No string validation or normalization needed, unlike [`KeyDomain`](crate::KeyDomain).
70///
71/// # Memory Layout
72///
73/// ```text
74/// Id<D> struct (8 bytes, niche-optimized):
75/// ┌──────────────────┬─────────────┐
76/// │ NonZeroU64 (8B)  │ marker (0B) │
77/// └──────────────────┴─────────────┘
78///
79/// Option<Id<D>>: also 8 bytes (None = 0)
80/// ```
81///
82/// # Type Safety
83///
84/// Different domains produce incompatible ID types at compile time:
85///
86/// ```rust,compile_fail
87/// use domain_key::{Id, Domain, IdDomain};
88///
89/// #[derive(Debug)]
90/// struct UserDomain;
91/// impl Domain for UserDomain { const DOMAIN_NAME: &'static str = "user"; }
92/// impl IdDomain for UserDomain {}
93///
94/// #[derive(Debug)]
95/// struct OrderDomain;
96/// impl Domain for OrderDomain { const DOMAIN_NAME: &'static str = "order"; }
97/// impl IdDomain for OrderDomain {}
98///
99/// type UserId = Id<UserDomain>;
100/// type OrderId = Id<OrderDomain>;
101///
102/// let user_id = UserId::new(1).unwrap();
103/// let order_id: OrderId = user_id; // Compile error!
104/// ```
105pub struct Id<D: IdDomain> {
106    value: NonZeroU64,
107    _marker: PhantomData<D>,
108}
109
110// Manual trait impls to avoid requiring D: Copy/Clone/PartialEq/Hash/etc.
111// The phantom marker D is a ZST and never contributes to these operations.
112
113impl<D: IdDomain> fmt::Debug for Id<D> {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        write!(f, "{}({})", D::DOMAIN_NAME, self.value)
116    }
117}
118
119impl<D: IdDomain> Copy for Id<D> {}
120
121impl<D: IdDomain> Clone for Id<D> {
122    fn clone(&self) -> Self {
123        *self
124    }
125}
126
127impl<D: IdDomain> PartialEq for Id<D> {
128    fn eq(&self, other: &Self) -> bool {
129        self.value == other.value
130    }
131}
132
133impl<D: IdDomain> Eq for Id<D> {}
134
135impl<D: IdDomain> PartialOrd for Id<D> {
136    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
137        Some(self.cmp(other))
138    }
139}
140
141impl<D: IdDomain> Ord for Id<D> {
142    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
143        self.value.cmp(&other.value)
144    }
145}
146
147impl<D: IdDomain> core::hash::Hash for Id<D> {
148    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
149        self.value.hash(state);
150    }
151}
152
153impl<D: IdDomain> Id<D> {
154    /// Creates a new typed identifier from a `u64` value.
155    ///
156    /// Returns `None` if `value` is zero.
157    ///
158    /// # Examples
159    ///
160    /// ```rust
161    /// use domain_key::{Id, Domain, IdDomain};
162    ///
163    /// #[derive(Debug)]
164    /// struct UserDomain;
165    /// impl Domain for UserDomain { const DOMAIN_NAME: &'static str = "user"; }
166    /// impl IdDomain for UserDomain {}
167    ///
168    /// type UserId = Id<UserDomain>;
169    ///
170    /// assert!(UserId::new(1).is_some());
171    /// assert!(UserId::new(0).is_none());
172    /// ```
173    #[inline]
174    #[must_use]
175    pub const fn new(value: u64) -> Option<Self> {
176        match NonZeroU64::new(value) {
177            Some(nz) => Some(Self {
178                value: nz,
179                _marker: PhantomData,
180            }),
181            None => None,
182        }
183    }
184
185    /// Creates a new typed identifier from a [`NonZeroU64`] value.
186    #[inline]
187    #[must_use]
188    pub const fn from_non_zero(value: NonZeroU64) -> Self {
189        Self {
190            value,
191            _marker: PhantomData,
192        }
193    }
194
195    /// Returns the underlying value as `u64`.
196    #[inline]
197    #[must_use]
198    pub const fn get(&self) -> u64 {
199        self.value.get()
200    }
201
202    /// Returns the underlying [`NonZeroU64`] value.
203    #[inline]
204    #[must_use]
205    pub const fn non_zero(&self) -> NonZeroU64 {
206        self.value
207    }
208
209    /// Returns the domain name for this identifier type.
210    #[inline]
211    #[must_use]
212    pub fn domain(&self) -> &'static str {
213        D::DOMAIN_NAME
214    }
215}
216
217// ============================================================================
218// TRAIT IMPLEMENTATIONS
219// ============================================================================
220
221// Note: Id<D> intentionally does not implement Default
222// because there is no meaningful default for a non-zero identifier.
223
224impl<D: IdDomain> fmt::Display for Id<D> {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        write!(f, "{}", self.value)
227    }
228}
229
230impl<D: IdDomain> FromStr for Id<D> {
231    type Err = IdParseError;
232
233    fn from_str(s: &str) -> Result<Self, Self::Err> {
234        let value = s.parse::<NonZeroU64>()?;
235        Ok(Self::from_non_zero(value))
236    }
237}
238
239impl<D: IdDomain> From<NonZeroU64> for Id<D> {
240    #[inline]
241    fn from(value: NonZeroU64) -> Self {
242        Self::from_non_zero(value)
243    }
244}
245
246impl<D: IdDomain> From<Id<D>> for NonZeroU64 {
247    #[inline]
248    fn from(id: Id<D>) -> Self {
249        id.value
250    }
251}
252
253impl<D: IdDomain> From<Id<D>> for u64 {
254    #[inline]
255    fn from(id: Id<D>) -> Self {
256        id.value.get()
257    }
258}
259
260impl<D: IdDomain> TryFrom<u64> for Id<D> {
261    type Error = IdParseError;
262
263    fn try_from(value: u64) -> Result<Self, Self::Error> {
264        Self::new(value).ok_or(IdParseError::Zero)
265    }
266}
267
268impl<D: IdDomain> TryFrom<&str> for Id<D> {
269    type Error = IdParseError;
270
271    fn try_from(s: &str) -> Result<Self, Self::Error> {
272        s.parse()
273    }
274}
275
276impl<D: IdDomain> TryFrom<String> for Id<D> {
277    type Error = IdParseError;
278
279    fn try_from(s: String) -> Result<Self, Self::Error> {
280        s.parse()
281    }
282}
283
284// ============================================================================
285// SERDE SUPPORT
286// ============================================================================
287
288#[cfg(feature = "serde")]
289impl<D: IdDomain> Serialize for Id<D> {
290    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
291        self.value.serialize(serializer)
292    }
293}
294
295#[cfg(feature = "serde")]
296impl<'de, D: IdDomain> Deserialize<'de> for Id<D> {
297    fn deserialize<De: serde::Deserializer<'de>>(deserializer: De) -> Result<Self, De::Error> {
298        let value = NonZeroU64::deserialize(deserializer)?;
299        Ok(Self::from_non_zero(value))
300    }
301}
302
303// ============================================================================
304// TESTS
305// ============================================================================
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[cfg(not(feature = "std"))]
312    use alloc::{format, string::ToString};
313
314    #[derive(Debug)]
315    struct TestDomain;
316    impl crate::Domain for TestDomain {
317        const DOMAIN_NAME: &'static str = "test";
318    }
319    impl IdDomain for TestDomain {}
320
321    type TestId = Id<TestDomain>;
322
323    #[test]
324    fn new_stores_nonzero_value() {
325        let id = TestId::new(42).unwrap();
326        assert_eq!(id.get(), 42);
327    }
328
329    #[test]
330    fn zero_returns_none() {
331        assert!(TestId::new(0).is_none());
332    }
333
334    #[test]
335    fn try_from_u64_zero_is_error() {
336        let result = TestId::try_from(0u64);
337        assert!(result.is_err());
338        assert!(matches!(result.unwrap_err(), IdParseError::Zero));
339    }
340
341    #[test]
342    fn try_from_u64_nonzero_succeeds() {
343        let id = TestId::try_from(42u64).unwrap();
344        assert_eq!(id.get(), 42);
345    }
346
347    #[test]
348    fn from_non_zero_roundtrips() {
349        let nz = NonZeroU64::new(7).unwrap();
350        let id = TestId::from_non_zero(nz);
351        assert_eq!(id.get(), 7);
352        assert_eq!(id.non_zero(), nz);
353    }
354
355    #[test]
356    fn debug_shows_domain_and_value() {
357        let id = TestId::new(42).unwrap();
358        assert_eq!(format!("{id:?}"), "test(42)");
359    }
360
361    #[test]
362    fn domain_returns_name() {
363        let id = TestId::new(1).unwrap();
364        assert_eq!(id.domain(), "test");
365    }
366
367    #[test]
368    fn display_shows_numeric_value() {
369        let id = TestId::new(12345).unwrap();
370        assert_eq!(id.to_string(), "12345");
371    }
372
373    #[test]
374    fn parse_valid_string() {
375        let id: TestId = "42".parse().unwrap();
376        assert_eq!(id.get(), 42);
377    }
378
379    #[test]
380    fn parse_zero_string_is_error() {
381        let result: Result<TestId, _> = "0".parse();
382        assert!(result.is_err());
383    }
384
385    #[test]
386    fn parse_non_numeric_string_is_error() {
387        let result: Result<TestId, _> = "not_a_number".parse();
388        assert!(result.is_err());
389    }
390
391    #[test]
392    fn into_from_non_zero_u64_roundtrips() {
393        let nz = NonZeroU64::new(100).unwrap();
394        let id: TestId = nz.into();
395        assert_eq!(id.get(), 100);
396    }
397
398    #[test]
399    fn into_non_zero_u64_preserves_value() {
400        let id = TestId::new(99).unwrap();
401        let nz: NonZeroU64 = id.into();
402        assert_eq!(nz.get(), 99);
403    }
404
405    #[test]
406    fn into_u64_preserves_value() {
407        let id = TestId::new(99).unwrap();
408        let value: u64 = id.into();
409        assert_eq!(value, 99);
410    }
411
412    #[test]
413    fn try_from_str_succeeds() {
414        let id = TestId::try_from("7").unwrap();
415        assert_eq!(id.get(), 7);
416    }
417
418    #[test]
419    fn try_from_string_succeeds() {
420        let id = TestId::try_from(String::from("123")).unwrap();
421        assert_eq!(id.get(), 123);
422    }
423
424    #[test]
425    fn id_is_copy() {
426        let id1 = TestId::new(5).unwrap();
427        let id2 = id1; // Copy
428        assert_eq!(id1, id2); // id1 still valid
429    }
430
431    #[test]
432    fn ordering_follows_numeric_value() {
433        let a = TestId::new(1).unwrap();
434        let b = TestId::new(2).unwrap();
435        assert!(a < b);
436    }
437
438    #[cfg(feature = "std")]
439    #[test]
440    fn equal_ids_produce_same_hash() {
441        use core::hash::{Hash, Hasher};
442        let id1 = TestId::new(42).unwrap();
443        let id2 = TestId::new(42).unwrap();
444
445        let hash = |id: &TestId| {
446            let mut hasher = std::collections::hash_map::DefaultHasher::new();
447            id.hash(&mut hasher);
448            hasher.finish()
449        };
450
451        assert_eq!(hash(&id1), hash(&id2));
452    }
453
454    #[test]
455    fn max_u64_is_valid_id() {
456        let id = TestId::new(u64::MAX).unwrap();
457        assert_eq!(id.get(), u64::MAX);
458    }
459
460    #[test]
461    fn option_id_has_no_size_overhead() {
462        assert_eq!(
463            core::mem::size_of::<Option<TestId>>(),
464            core::mem::size_of::<TestId>()
465        );
466    }
467
468    #[cfg(feature = "serde")]
469    #[test]
470    fn serde_roundtrip_preserves_id() {
471        let id = TestId::new(42).unwrap();
472        let json = serde_json::to_string(&id).unwrap();
473        assert_eq!(json, "42");
474        let deserialized: TestId = serde_json::from_str(&json).unwrap();
475        assert_eq!(id, deserialized);
476    }
477
478    #[cfg(feature = "serde")]
479    #[test]
480    fn serde_rejects_zero() {
481        let result: Result<TestId, _> = serde_json::from_str("0");
482        assert!(result.is_err());
483    }
484
485    #[test]
486    fn different_domains_are_distinct_types() {
487        #[derive(Debug)]
488        struct DomainA;
489        impl crate::Domain for DomainA {
490            const DOMAIN_NAME: &'static str = "a";
491        }
492        impl IdDomain for DomainA {}
493
494        #[derive(Debug)]
495        struct DomainB;
496        impl crate::Domain for DomainB {
497            const DOMAIN_NAME: &'static str = "b";
498        }
499        impl IdDomain for DomainB {}
500
501        let _a: Id<DomainA> = Id::new(1).unwrap();
502        let _b: Id<DomainB> = Id::new(1).unwrap();
503
504        // These are different types — cannot be compared or assigned
505        // _a == _b would not compile
506    }
507}