Skip to main content

stateset_primitives/
id.rs

1//! Strongly-typed entity identifiers.
2//!
3//! Each ID type is a newtype around [`Uuid`] that prevents accidentally mixing up
4//! identifiers from different domains. All ID types are `Copy`, `Eq`, `Hash`, and
5//! support serialization via `serde`.
6
7use core::fmt;
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11/// Generate a strongly-typed ID newtype around `Uuid`.
12///
13/// The generated type implements:
14/// - `Debug`, `Clone`, `Copy`, `PartialEq`, `Eq`, `PartialOrd`, `Ord`, `Hash`
15/// - `Display` (delegates to `Uuid::to_string()`)
16/// - `FromStr` (parses via `Uuid::parse_str`)
17/// - `From<Uuid>`, `From<IdType> for Uuid`
18/// - `AsRef<Uuid>`
19/// - `Serialize`, `Deserialize` (transparent)
20/// - `new()` to generate a random v4 ID
21/// - `nil()` for a zero/nil ID
22macro_rules! define_id {
23    (
24        $(#[$meta:meta])*
25        $name:ident
26    ) => {
27        $(#[$meta])*
28        #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
29        #[serde(transparent)]
30        #[must_use]
31        pub struct $name(Uuid);
32
33        impl $name {
34            /// Create a new random ID (UUID v4).
35            #[inline]
36            #[cfg(feature = "std")]
37            pub fn new() -> Self {
38                Self(Uuid::new_v4())
39            }
40
41            /// Create a nil (all-zeros) ID.
42            #[inline]
43            pub const fn nil() -> Self {
44                Self(Uuid::nil())
45            }
46
47            /// Create from an existing [`Uuid`].
48            #[inline]
49            pub const fn from_uuid(id: Uuid) -> Self {
50                Self(id)
51            }
52
53            /// Get the inner [`Uuid`].
54            #[inline]
55            pub const fn as_uuid(&self) -> &Uuid {
56                &self.0
57            }
58
59            /// Consume and return the inner [`Uuid`].
60            #[inline]
61            pub const fn into_uuid(self) -> Uuid {
62                self.0
63            }
64
65            /// Returns `true` if this is a nil (all-zeros) ID.
66            #[inline]
67            pub const fn is_nil(&self) -> bool {
68                self.0.is_nil()
69            }
70        }
71
72        #[cfg(feature = "std")]
73        impl Default for $name {
74            #[inline]
75            fn default() -> Self {
76                Self::new()
77            }
78        }
79
80        impl fmt::Debug for $name {
81            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82                write!(f, "{}({})", stringify!($name), self.0)
83            }
84        }
85
86        impl fmt::Display for $name {
87            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88                self.0.fmt(f)
89            }
90        }
91
92        impl core::str::FromStr for $name {
93            type Err = uuid::Error;
94
95            #[inline]
96            fn from_str(s: &str) -> Result<Self, Self::Err> {
97                Uuid::parse_str(s).map(Self)
98            }
99        }
100
101        impl From<Uuid> for $name {
102            #[inline]
103            fn from(id: Uuid) -> Self {
104                Self(id)
105            }
106        }
107
108        impl From<$name> for Uuid {
109            #[inline]
110            fn from(id: $name) -> Self {
111                id.0
112            }
113        }
114
115        impl AsRef<Uuid> for $name {
116            #[inline]
117            fn as_ref(&self) -> &Uuid {
118                &self.0
119            }
120        }
121
122        #[cfg(feature = "sqlx-postgres")]
123        impl sqlx::Type<sqlx::Postgres> for $name {
124            fn type_info() -> sqlx::postgres::PgTypeInfo {
125                <Uuid as sqlx::Type<sqlx::Postgres>>::type_info()
126            }
127        }
128
129        #[cfg(feature = "sqlx-postgres")]
130        impl sqlx::postgres::PgHasArrayType for $name {
131            fn array_type_info() -> sqlx::postgres::PgTypeInfo {
132                <Uuid as sqlx::postgres::PgHasArrayType>::array_type_info()
133            }
134        }
135
136        #[cfg(feature = "sqlx-postgres")]
137        impl<'q> sqlx::Encode<'q, sqlx::Postgres> for $name {
138            fn encode_by_ref(
139                &self,
140                buf: &mut sqlx::postgres::PgArgumentBuffer,
141            ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
142                <Uuid as sqlx::Encode<sqlx::Postgres>>::encode_by_ref(&self.0, buf)
143            }
144        }
145
146        #[cfg(feature = "sqlx-postgres")]
147        impl<'r> sqlx::Decode<'r, sqlx::Postgres> for $name {
148            fn decode(value: sqlx::postgres::PgValueRef<'r>)
149            -> Result<Self, sqlx::error::BoxDynError> {
150                <Uuid as sqlx::Decode<'r, sqlx::Postgres>>::decode(value).map(Self)
151            }
152        }
153
154        #[cfg(any(test, feature = "arbitrary"))]
155        impl proptest::arbitrary::Arbitrary for $name {
156            type Parameters = ();
157            type Strategy = proptest::strategy::MapInto<
158                proptest::arbitrary::StrategyFor<[u8; 16]>,
159                Self,
160            >;
161
162            fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
163                use proptest::strategy::Strategy;
164                proptest::arbitrary::any::<[u8; 16]>().prop_map_into()
165            }
166        }
167
168        #[cfg(any(test, feature = "arbitrary"))]
169        impl From<[u8; 16]> for $name {
170            fn from(bytes: [u8; 16]) -> Self {
171                Self(Uuid::from_bytes(bytes))
172            }
173        }
174
175        #[cfg(feature = "rusqlite")]
176        impl rusqlite::types::ToSql for $name {
177            #[inline]
178            fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
179                // Write UUID to stack buffer (no heap allocation)
180                Ok(rusqlite::types::ToSqlOutput::Owned(
181                    rusqlite::types::Value::Text(self.0.to_string()),
182                ))
183            }
184        }
185
186        #[cfg(feature = "rusqlite")]
187        impl rusqlite::types::FromSql for $name {
188            #[inline]
189            fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
190                let text = value.as_str()?;
191                Uuid::parse_str(text)
192                    .map(Self)
193                    .map_err(|e| rusqlite::types::FromSqlError::Other(Box::new(e)))
194            }
195        }
196    };
197}
198
199// ---------------------------------------------------------------------------
200// Entity ID types
201// ---------------------------------------------------------------------------
202
203define_id! {
204    /// Strongly-typed order identifier.
205    OrderId
206}
207
208define_id! {
209    /// Strongly-typed customer identifier.
210    CustomerId
211}
212
213define_id! {
214    /// Strongly-typed product identifier.
215    ProductId
216}
217
218define_id! {
219    /// Strongly-typed invoice identifier.
220    InvoiceId
221}
222
223define_id! {
224    /// Strongly-typed shipment identifier.
225    ShipmentId
226}
227
228define_id! {
229    /// Strongly-typed return/RMA identifier.
230    ReturnId
231}
232
233define_id! {
234    /// Strongly-typed warehouse identifier.
235    WarehouseId
236}
237
238define_id! {
239    /// Strongly-typed payment identifier.
240    PaymentId
241}
242
243define_id! {
244    /// Strongly-typed inventory item identifier.
245    InventoryItemId
246}
247
248define_id! {
249    /// Strongly-typed subscription identifier.
250    SubscriptionId
251}
252
253define_id! {
254    /// Strongly-typed shopping cart identifier.
255    CartId
256}
257
258define_id! {
259    /// Strongly-typed fulfillment identifier.
260    FulfillmentId
261}
262
263define_id! {
264    /// Strongly-typed order line item identifier.
265    OrderItemId
266}
267
268define_id! {
269    /// Strongly-typed purchase order identifier.
270    PurchaseOrderId
271}
272
273define_id! {
274    /// Strongly-typed promotion identifier.
275    PromotionId
276}
277
278define_id! {
279    /// Strongly-typed warranty identifier.
280    WarrantyId
281}
282
283define_id! {
284    /// Strongly-typed credit memo identifier.
285    CreditId
286}
287
288define_id! {
289    /// Strongly-typed agent identifier (A2A commerce).
290    AgentId
291}
292
293define_id! {
294    /// Strongly-typed gift card identifier.
295    GiftCardId
296}
297
298define_id! {
299    /// Strongly-typed store credit identifier.
300    StoreCreditId
301}
302
303define_id! {
304    /// Strongly-typed customer segment identifier.
305    SegmentId
306}
307
308define_id! {
309    /// Strongly-typed shipping zone identifier.
310    ShippingZoneId
311}
312
313define_id! {
314    /// Strongly-typed shipping method identifier.
315    ShippingMethodId
316}
317
318define_id! {
319    /// Strongly-typed product review identifier.
320    ReviewId
321}
322
323define_id! {
324    /// Strongly-typed wishlist identifier.
325    WishlistId
326}
327
328define_id! {
329    /// Strongly-typed loyalty program identifier.
330    LoyaltyProgramId
331}
332
333define_id! {
334    /// Strongly-typed reward identifier.
335    RewardId
336}
337
338define_id! {
339    /// Strongly-typed gift card transaction identifier.
340    GiftCardTransactionId
341}
342
343define_id! {
344    /// Strongly-typed store credit transaction identifier.
345    StoreCreditTransactionId
346}
347
348define_id! {
349    /// Strongly-typed loyalty transaction identifier.
350    LoyaltyTransactionId
351}
352
353define_id! {
354    /// Strongly-typed fraud rule identifier.
355    FraudRuleId
356}
357
358define_id! {
359    /// Strongly-typed search configuration identifier.
360    SearchConfigId
361}
362
363define_id! {
364    /// Strongly-typed loyalty account identifier.
365    LoyaltyAccountId
366}
367
368define_id! {
369    /// Strongly-typed sales/fulfillment channel identifier.
370    ChannelId
371}
372
373define_id! {
374    /// Strongly-typed B2B company (account) identifier.
375    CompanyId
376}
377
378define_id! {
379    /// Strongly-typed contact identifier.
380    ContactId
381}
382
383define_id! {
384    /// Strongly-typed company shipping address identifier.
385    CompanyAddressId
386}
387
388define_id! {
389    /// Strongly-typed transfer order identifier.
390    TransferOrderId
391}
392
393define_id! {
394    /// Strongly-typed transfer order line item identifier.
395    TransferOrderItemId
396}
397
398define_id! {
399    /// Strongly-typed unit class identifier (e.g. Length, Weight, Volume).
400    UnitClassId
401}
402
403define_id! {
404    /// Strongly-typed unit of measure identifier.
405    UnitOfMeasureId
406}
407
408define_id! {
409    /// Strongly-typed unit conversion rule identifier.
410    UnitConversionRuleId
411}
412
413define_id! {
414    /// Strongly-typed production batch identifier.
415    ProductionBatchId
416}
417
418define_id! {
419    /// Strongly-typed supplier SKU identifier.
420    SupplierSkuId
421}
422
423define_id! {
424    /// Strongly-typed vendor return identifier.
425    VendorReturnId
426}
427
428define_id! {
429    /// Strongly-typed vendor return line item identifier.
430    VendorReturnItemId
431}
432
433define_id! {
434    /// Strongly-typed vendor credit identifier.
435    VendorCreditId
436}
437
438define_id! {
439    /// Strongly-typed vendor credit application identifier.
440    VendorCreditApplicationId
441}
442
443define_id! {
444    /// Strongly-typed payment obligation identifier.
445    PaymentObligationId
446}
447
448define_id! {
449    /// Strongly-typed price level identifier.
450    PriceLevelId
451}
452
453define_id! {
454    /// Strongly-typed prepayment identifier.
455    PrepaymentId
456}
457
458define_id! {
459    /// Strongly-typed prepayment application identifier.
460    PrepaymentApplicationId
461}
462
463define_id! {
464    /// Strongly-typed price schedule identifier.
465    PriceScheduleId
466}
467
468define_id! {
469    /// Strongly-typed activity log entry identifier.
470    ActivityLogId
471}
472
473define_id! {
474    /// Strongly-typed integration field-mapping identifier.
475    IntegrationMappingId
476}
477
478define_id! {
479    /// Strongly-typed inbound shipment identifier.
480    InboundShipmentId
481}
482
483define_id! {
484    /// Strongly-typed inbound shipment line item identifier.
485    InboundShipmentItemId
486}
487
488define_id! {
489    /// Strongly-typed purgatory (non-posted) order identifier.
490    PurgatoryOrderId
491}
492
493define_id! {
494    /// Strongly-typed purgatory order line item identifier.
495    PurgatoryLineItemId
496}
497
498define_id! {
499    /// Strongly-typed print station identifier.
500    PrintStationId
501}
502
503define_id! {
504    /// Strongly-typed print job identifier.
505    PrintJobId
506}
507
508define_id! {
509    /// Strongly-typed EDI document identifier.
510    EdiDocumentId
511}
512
513define_id! {
514    /// Strongly-typed integration field-path mapping identifier.
515    IntegrationFieldMappingId
516}
517
518define_id! {
519    /// Strongly-typed operational topology snapshot identifier.
520    TopologySnapshotId
521}
522
523define_id! {
524    /// Strongly-typed stock snapshot identifier.
525    StockSnapshotId
526}
527
528define_id! {
529    /// Strongly-typed stock snapshot line identifier.
530    StockSnapshotLineId
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn id_types_are_distinct() {
539        let order_id = OrderId::from_uuid(Uuid::from_bytes([1; 16]));
540        let customer_id = CustomerId::from_uuid(Uuid::from_bytes([2; 16]));
541
542        // Both are UUIDs internally, but they're different types
543        let _: Uuid = order_id.into();
544        let _: Uuid = customer_id.into();
545
546        // This would NOT compile (which is the point):
547        // let _: OrderId = customer_id;
548    }
549
550    #[test]
551    fn roundtrip_display_parse() {
552        let id = OrderId::from_uuid(Uuid::from_bytes([3; 16]));
553        let s = id.to_string();
554        let parsed: OrderId = s.parse().unwrap();
555        assert_eq!(id, parsed);
556    }
557
558    #[test]
559    fn serde_roundtrip() {
560        let id = ProductId::from_uuid(Uuid::from_bytes([4; 16]));
561        let json = serde_json::to_string(&id).unwrap();
562        let parsed: ProductId = serde_json::from_str(&json).unwrap();
563        assert_eq!(id, parsed);
564
565        // Serializes as a plain UUID string
566        let uuid_json = serde_json::to_string(id.as_uuid()).unwrap();
567        assert_eq!(json, uuid_json);
568    }
569
570    #[test]
571    fn nil_id() {
572        let id = OrderId::nil();
573        assert!(id.is_nil());
574        assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000");
575    }
576
577    #[test]
578    fn debug_includes_type_name() {
579        let id = CustomerId::nil();
580        let debug = format!("{:?}", id);
581        assert!(debug.starts_with("CustomerId("));
582    }
583
584    #[test]
585    fn from_uuid_roundtrip() {
586        let uuid = Uuid::from_bytes([5; 16]);
587        let order_id = OrderId::from(uuid);
588        assert_eq!(Uuid::from(order_id), uuid);
589    }
590
591    mod proptests {
592        use super::*;
593        use proptest::prelude::*;
594
595        proptest! {
596            #[test]
597            fn order_id_display_parse_roundtrip(id: OrderId) {
598                let s = id.to_string();
599                let parsed: OrderId = s.parse().unwrap();
600                prop_assert_eq!(id, parsed);
601            }
602
603            #[test]
604            fn customer_id_display_parse_roundtrip(id: CustomerId) {
605                let s = id.to_string();
606                let parsed: CustomerId = s.parse().unwrap();
607                prop_assert_eq!(id, parsed);
608            }
609
610            #[test]
611            fn product_id_serde_roundtrip(id: ProductId) {
612                let json = serde_json::to_string(&id).unwrap();
613                let parsed: ProductId = serde_json::from_str(&json).unwrap();
614                prop_assert_eq!(id, parsed);
615            }
616        }
617    }
618}