typed_oid/
oid.rs

1use std::{
2    fmt,
3    hash::{Hash, Hasher},
4    marker::PhantomData,
5    str::FromStr,
6};
7
8use data_encoding::BASE32HEX_NOPAD;
9#[cfg(feature = "uuid_v7")]
10use uuid::timestamp::{context::NoContext, Timestamp};
11use uuid::Uuid;
12
13use crate::{
14    error::{Error, Result},
15    prefix::Prefix,
16    uuid::uuid_from_str_b32h,
17    OidPrefix,
18};
19
20/// A Typed Object ID where the Prefix is part of the type
21///
22/// # Examples
23///
24/// A nice property of this two different prefix are two different types, and
25/// thus the following fails to compile:
26///
27/// ```compile_fail
28/// # use typed_oid::{Oid, OidPrefix};
29/// struct A;
30/// impl OidPrefix for A {}
31///
32/// struct B;
33/// impl OidPrefix for B {}
34///
35/// // The same UUID for both
36/// let oid_a: Oid<A> = Oid::try_with_uuid("b3cfdafa-3fec-41e2-82bf-ff881131abf1").unwrap();
37/// let oid_b: Oid<B> = Oid::try_with_uuid("b3cfdafa-3fec-41e2-82bf-ff881131abf1").unwrap();
38///
39/// // This fails to compile because `Oid<A>` is a different type than `Oid<B>` and no
40/// // PartialEq or Eq is implemented between these two types.
41/// oid_a == oid_b
42/// ```
43#[derive(PartialEq, Eq)]
44pub struct Oid<P> {
45    uuid: Uuid,
46    // Using fn for variance (invariant with respect to P) whereas using *mut would also be
47    // invariant with respect for P, but would then now allow the Auto-traits Send+Sync.
48    _prefix: PhantomData<fn(P) -> P>,
49}
50
51impl<P> fmt::Debug for Oid<P> {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        f.debug_struct(&format!("Oid<{}>", std::any::type_name::<P>()))
54            .field("uuid", &self.uuid)
55            .finish()
56    }
57}
58
59// Must manaully implement Copy and Clone because of the PhantomData see:
60// https://github.com/rust-lang/rust/issues/26925
61impl<P> Copy for Oid<P> {}
62
63impl<P> Clone for Oid<P> {
64    fn clone(&self) -> Self { *self }
65}
66
67impl<P: OidPrefix> Oid<P> {
68    /// Create a new `Oid` with a UUIDv4 (random)
69    #[cfg(feature = "uuid_v4")]
70    #[cfg_attr(docsrs, doc(cfg(feature = "uuid_v4")))]
71    pub fn new_v4() -> Self { Self::with_uuid(Uuid::new_v4()) }
72
73    /// Create a new `Oid` with a UUIDv7 (UNIX Epoch based for current system
74    /// clock)
75    #[cfg(feature = "uuid_v7")]
76    #[cfg_attr(docsrs, doc(cfg(feature = "uuid_v7")))]
77    pub fn new_v7_now() -> Self { Self::with_uuid(Uuid::new_v7(Timestamp::now(NoContext))) }
78
79    /// Create a new `Oid` with a UUIDv7 (UNIX Epoch based)
80    #[cfg(feature = "uuid_v7")]
81    #[cfg_attr(docsrs, doc(cfg(feature = "uuid_v7")))]
82    pub fn new_v7(ts: Timestamp) -> Self { Self::with_uuid(Uuid::new_v7(ts)) }
83
84    /// Create a new Oid with a given UUID
85    pub fn with_uuid(uuid: Uuid) -> Self {
86        Self {
87            uuid,
88            _prefix: PhantomData,
89        }
90    }
91
92    /// Attempts to create a new Oid with a given string-ish UUID
93    pub fn try_with_uuid<S: AsRef<str>>(uuid: S) -> Result<Self> {
94        Ok(Self::with_uuid(uuid.as_ref().try_into()?))
95    }
96
97    /// Attemp to create an Oid from a base32hex encoded UUID string-ish value
98    pub fn try_with_uuid_base32<S: AsRef<str>>(base32_uuid: S) -> Result<Self> {
99        Ok(Self::with_uuid(uuid_from_str_b32h(base32_uuid.as_ref())?))
100    }
101
102    /// Get the [`Prefix`] of the TOID
103    ///
104    /// # Panics
105    ///
106    /// If the Type `P` translates to an invalid prefix
107    pub fn prefix(&self) -> Prefix { Prefix::from_str(P::prefix()).expect("Invalid Prefix") }
108
109    /// Get the value portion of the  of the TOID, which is the base32 encoded
110    /// string following the `-` separator
111    pub fn value(&self) -> String { BASE32HEX_NOPAD.encode(self.uuid.as_bytes()) }
112
113    /// Get the UUID of the TOID
114    pub fn uuid(&self) -> &Uuid { &self.uuid }
115}
116
117impl<P: OidPrefix> fmt::Display for Oid<P> {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        write!(f, "{}-{}", P::prefix(), self.value())
120    }
121}
122
123impl<P: OidPrefix> FromStr for Oid<P> {
124    type Err = Error;
125
126    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
127        if let Some((pfx, val)) = s.split_once('-') {
128            if pfx.is_empty() {
129                return Err(Error::MissingPrefix);
130            }
131            if pfx != P::prefix() && !P::str_partial_eq(pfx) {
132                return Err(Error::InvalidPrefix {
133                    valid_until: pfx
134                        .chars()
135                        .zip(P::prefix().chars())
136                        .enumerate()
137                        .find(|(_i, (c1, c2))| c1 != c2)
138                        .map(|(i, _)| i)
139                        .unwrap(),
140                });
141            }
142
143            return Ok(Self {
144                uuid: uuid_from_str_b32h(val)?,
145                _prefix: PhantomData,
146            });
147        }
148
149        Err(Error::MissingSeparator)
150    }
151}
152
153impl<P> Hash for Oid<P>
154where
155    P: OidPrefix,
156{
157    fn hash<H: Hasher>(&self, state: &mut H) {
158        P::prefix().hash(state);
159        self.uuid.hash(state);
160    }
161}
162
163#[cfg(feature = "serde")]
164#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
165impl<P: OidPrefix> ::serde::Serialize for Oid<P> {
166    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
167    where
168        S: ::serde::ser::Serializer,
169    {
170        serializer.collect_str(self)
171    }
172}
173
174#[cfg(feature = "serde")]
175#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
176impl<'de, P: OidPrefix> ::serde::Deserialize<'de> for Oid<P> {
177    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
178    where
179        D: ::serde::de::Deserializer<'de>,
180    {
181        String::deserialize(deserializer)?
182            .parse()
183            .map_err(::serde::de::Error::custom)
184    }
185}
186
187#[cfg(feature = "surrealdb")]
188#[cfg_attr(docsrs, doc(cfg(feature = "surrealdb")))]
189use surrealdb::sql::Thing;
190
191#[cfg(feature = "surrealdb")]
192#[cfg_attr(docsrs, doc(cfg(feature = "surrealdb")))]
193impl<P: OidPrefix> TryFrom<Thing> for Oid<P> {
194    type Error = crate::Error;
195
196    fn try_from(thing: Thing) -> Result<Self> {
197        if !P::str_partial_eq(&thing.tb) {
198            return Err(Error::InvalidPrefix {
199                valid_until: thing
200                    .tb
201                    .chars()
202                    .zip(P::prefix().chars())
203                    .enumerate()
204                    .find(|(_i, (c1, c2))| c1 != c2)
205                    .map(|(i, _)| i)
206                    .unwrap(),
207            });
208        }
209
210        let val = thing.id.to_raw();
211        Self::try_with_uuid(&val).or_else(|_| Self::try_with_uuid_base32(val))
212    }
213}
214
215#[cfg(test)]
216mod oid_tests {
217    #[cfg(any(feature = "uuid_v4", feature = "uuid_v7"))]
218    use wildmatch::WildMatch;
219
220    #[cfg(any(feature = "uuid_v4", feature = "uuid_v7"))]
221    use super::*;
222
223    #[test]
224    #[cfg(any(feature = "uuid_v4", feature = "uuid_v7"))]
225    fn typed_oid() {
226        #[derive(Debug)]
227        struct Tst;
228        impl OidPrefix for Tst {}
229
230        #[cfg_attr(all(feature = "uuid_v4", feature = "uuid_v7"), allow(unused_variables))]
231        #[cfg(feature = "uuid_v4")]
232        let oid: Oid<Tst> = Oid::new_v4();
233        #[cfg(feature = "uuid_v7")]
234        let oid: Oid<Tst> = Oid::new_v7_now();
235        assert!(
236            WildMatch::new("Tst-??????????????????????????").matches(&oid.to_string()),
237            "{oid}"
238        );
239
240        let res = "Tst-0OUS781P4LU7V000PA2A2BN1GC".parse::<Oid<Tst>>();
241        assert!(res.is_ok());
242        let oid: Oid<Tst> = res.unwrap();
243        assert_eq!(
244            oid.uuid(),
245            &"063dc3a0-3925-7c7f-8000-ca84a12ee183"
246                .parse::<Uuid>()
247                .unwrap()
248        );
249
250        let res = "Frm-0OUS781P4LU7V000PA2A2BN1GC".parse::<Oid<Tst>>();
251        assert!(res.is_err());
252        assert_eq!(res.unwrap_err(), Error::InvalidPrefix { valid_until: 0 });
253    }
254
255    #[test]
256    #[cfg(any(feature = "uuid_v4", feature = "uuid_v7"))]
257    fn from_uuid_str() {
258        #[derive(Debug)]
259        struct Tst;
260        impl OidPrefix for Tst {}
261
262        let oid: Oid<Tst> = Oid::try_with_uuid("063dc3a0-3925-7c7f-8000-ca84a12ee183").unwrap();
263        assert!(
264            WildMatch::new("Tst-??????????????????????????").matches(&oid.to_string()),
265            "{oid}"
266        );
267    }
268
269    #[test]
270    #[cfg(any(feature = "uuid_v4", feature = "uuid_v7"))]
271    fn from_uuid_str_b32h() {
272        #[derive(Debug)]
273        struct Tst;
274        impl OidPrefix for Tst {}
275
276        let oid: Oid<Tst> = Oid::try_with_uuid_base32("0OUS781P4LU7V000PA2A2BN1GC").unwrap();
277        assert_eq!("Tst-0OUS781P4LU7V000PA2A2BN1GC", &oid.to_string());
278    }
279
280    #[test]
281    #[cfg(any(feature = "uuid_v4", feature = "uuid_v7"))]
282    fn hash() {
283        use std::collections::HashMap;
284        #[derive(Debug, PartialEq, Eq)]
285        struct Tst;
286        impl OidPrefix for Tst {}
287
288        let oid: Oid<Tst> = Oid::try_with_uuid("063dc3a0-3925-7c7f-8000-ca84a12ee183").unwrap();
289
290        let mut map = HashMap::new();
291        map.insert(oid, "test");
292    }
293
294    #[test]
295    #[cfg(any(feature = "uuid_v4", feature = "uuid_v7"))]
296    fn long_typed_oid() {
297        #[derive(Debug)]
298        struct TestingTesting;
299        impl OidPrefix for TestingTesting {}
300
301        #[cfg_attr(all(feature = "uuid_v4", feature = "uuid_v7"), allow(unused_variables))]
302        #[cfg(feature = "uuid_v4")]
303        let oid: Oid<TestingTesting> = Oid::new_v4();
304        #[cfg(feature = "uuid_v7")]
305        let oid: Oid<TestingTesting> = Oid::new_v7_now();
306        assert!(
307            WildMatch::new("TestingTesting-??????????????????????????").matches(&oid.to_string()),
308            "{oid}"
309        );
310
311        let res = "TestingTesting-0OUS781P4LU7V000PA2A2BN1GC".parse::<Oid<TestingTesting>>();
312        assert!(res.is_ok());
313        let oid: Oid<TestingTesting> = res.unwrap();
314        assert_eq!(
315            oid.uuid(),
316            &"063dc3a0-3925-7c7f-8000-ca84a12ee183"
317                .parse::<Uuid>()
318                .unwrap()
319        );
320
321        let res = "Frm-0OUS781P4LU7V000PA2A2BN1GC".parse::<Oid<TestingTesting>>();
322        assert!(res.is_err());
323        assert_eq!(res.unwrap_err(), Error::InvalidPrefix { valid_until: 0 });
324    }
325}
326
327#[cfg(test)]
328#[cfg(feature = "surrealdb")]
329mod surreal_thing_oid_tests {
330    use surrealdb::sql::Id;
331
332    use super::*;
333
334    #[test]
335    fn uuid() {
336        #[derive(Debug)]
337        struct Tst;
338        impl OidPrefix for Tst {
339            fn str_partial_eq(s: &str) -> bool { "test" == s }
340        }
341
342        let thing = Thing {
343            tb: "test".to_string(),
344            id: Id::String("063dc3a0-3925-7c7f-8000-ca84a12ee183".to_string()),
345        };
346
347        let toid: Result<Oid<Tst>> = thing.try_into();
348        assert!(toid.is_ok());
349    }
350
351    #[test]
352    fn uuid_base32() {
353        #[derive(Debug)]
354        struct Tst;
355        impl OidPrefix for Tst {
356            fn str_partial_eq(s: &str) -> bool { "test" == s }
357        }
358
359        let thing = Thing {
360            tb: "test".to_string(),
361            id: Id::String("0OUS781P4LU7V000PA2A2BN1GC".to_string()),
362        };
363
364        let toid: Result<Oid<Tst>> = thing.try_into();
365        assert!(toid.is_ok());
366    }
367}