Skip to main content

domain_key/
composite_key.rs

1//! `CompositeKey<A, B, const SEP: char>` — a typed composite of two domain keys.
2//!
3//! A `CompositeKey` pairs a `Key<A>` and a `Key<B>` into a single composite identifier
4//! that serialises as `"{first}{SEP}{second}"` and round-trips through [`FromStr`].
5//!
6//! ## Default separator
7//!
8//! The separator defaults to `':'`, which is suitable for most use cases.  Use the
9//! third const parameter to choose a different separator:
10//!
11//! ```rust
12//! use domain_key::{CompositeKey, Domain, KeyDomain, Key};
13//!
14//! #[derive(Debug)] struct OrgDomain;
15//! impl Domain for OrgDomain { const DOMAIN_NAME: &'static str = "org"; }
16//! impl KeyDomain for OrgDomain {}
17//!
18//! #[derive(Debug)] struct RepoDomain;
19//! impl Domain for RepoDomain { const DOMAIN_NAME: &'static str = "repo"; }
20//! impl KeyDomain for RepoDomain {}
21//!
22//! // Default separator ':'
23//! type OrgRepoKey = CompositeKey<OrgDomain, RepoDomain>;
24//!
25//! // Custom separator '/'
26//! type OrgRepoPath = CompositeKey<OrgDomain, RepoDomain, '/'>;
27//!
28//! let org  = Key::<OrgDomain>::new("acme").unwrap();
29//! let repo = Key::<RepoDomain>::new("widget").unwrap();
30//!
31//! let ck: OrgRepoKey = CompositeKey::new(org.clone(), repo.clone());
32//! assert_eq!(ck.to_string(), "acme:widget");
33//!
34//! let path: OrgRepoPath = CompositeKey::new(org, repo);
35//! assert_eq!(path.to_string(), "acme/widget");
36//! ```
37//!
38//! ## Parsing
39//!
40//! Parsing splits on the **first** occurrence of the separator.  Extra occurrences
41//! of `SEP` in the second component are left intact and validated as part of
42//! `Key<B>`:
43//!
44//! ```rust
45//! # use domain_key::{CompositeKey, Domain, KeyDomain};
46//! # #[derive(Debug)] struct A; impl Domain for A { const DOMAIN_NAME: &'static str = "a"; } impl KeyDomain for A {}
47//! # #[derive(Debug)] struct B; impl Domain for B { const DOMAIN_NAME: &'static str = "b"; } impl KeyDomain for B {}
48//! let result = "x:y".parse::<CompositeKey<A, B>>();
49//! assert!(result.is_ok());
50//! ```
51//!
52//! ## Panics (debug only)
53//!
54//! [`CompositeKey::new`] contains a `debug_assert!` that fires if the first component's
55//! string representation contains `SEP`.  In **release** builds this check is elided.
56//!
57//! ## Caller responsibility (release)
58//!
59//! In release builds callers are responsible for ensuring that `first.as_str()` does not
60//! contain the separator character.  A first component containing `SEP` will produce a
61//! composite key that cannot be parsed back to the same components, causing a silent
62//! round-trip failure.  Use [`CompositeKey::from_str`] if you need hard validation on
63//! every call.
64
65use core::cmp::Ordering;
66use core::fmt;
67use core::hash::{Hash, Hasher};
68use core::str::FromStr;
69
70#[cfg(feature = "serde")]
71use serde::{Deserialize, Serialize};
72
73use crate::domain::KeyDomain;
74use crate::error::CompositeKeyParseError;
75use crate::key::Key;
76
77// ============================================================================
78// STRUCT
79// ============================================================================
80
81/// A typed composite of two domain keys, separated by `SEP` (default `':'`).
82///
83/// See the [module-level docs](self) for usage examples.
84pub struct CompositeKey<A: KeyDomain, B: KeyDomain, const SEP: char = ':'> {
85    first: Key<A>,
86    second: Key<B>,
87}
88
89impl<A: KeyDomain, B: KeyDomain, const SEP: char> fmt::Debug for CompositeKey<A, B, SEP> {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        f.debug_struct("CompositeKey")
92            .field("first", &self.first.as_str())
93            .field("second", &self.second.as_str())
94            .field("sep", &SEP)
95            .finish()
96    }
97}
98
99impl<A: KeyDomain, B: KeyDomain, const SEP: char> Clone for CompositeKey<A, B, SEP> {
100    fn clone(&self) -> Self {
101        Self {
102            first: self.first.clone(),
103            second: self.second.clone(),
104        }
105    }
106}
107
108// ============================================================================
109// CONSTRUCTOR & ACCESSORS
110// ============================================================================
111
112impl<A: KeyDomain, B: KeyDomain, const SEP: char> CompositeKey<A, B, SEP> {
113    /// Create a `CompositeKey` from two typed component keys.
114    ///
115    /// # Panics (debug only)
116    ///
117    /// Panics in debug builds if `first.as_str()` contains the separator `SEP`.
118    /// In release builds the check is elided; callers are responsible for ensuring
119    /// the first component does not contain the separator (see module docs).
120    #[must_use]
121    pub fn new(first: Key<A>, second: Key<B>) -> Self {
122        debug_assert!(
123            !first.as_str().contains(SEP),
124            "CompositeKey::new: first component {:?} contains separator {:?}",
125            first.as_str(),
126            SEP,
127        );
128        Self { first, second }
129    }
130
131    /// Returns a reference to the first component key.
132    #[must_use]
133    #[inline]
134    pub fn first(&self) -> &Key<A> {
135        &self.first
136    }
137
138    /// Returns a reference to the second component key.
139    #[must_use]
140    #[inline]
141    pub fn second(&self) -> &Key<B> {
142        &self.second
143    }
144}
145
146// ============================================================================
147// DISPLAY
148// ============================================================================
149
150impl<A: KeyDomain, B: KeyDomain, const SEP: char> fmt::Display for CompositeKey<A, B, SEP> {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        write!(f, "{}{}{}", self.first.as_str(), SEP, self.second.as_str())
153    }
154}
155
156// ============================================================================
157// FROM STR
158// ============================================================================
159
160impl<A: KeyDomain, B: KeyDomain, const SEP: char> FromStr for CompositeKey<A, B, SEP> {
161    type Err = CompositeKeyParseError;
162
163    /// Parse a composite key from a string by splitting on the **first** occurrence
164    /// of `SEP`.
165    ///
166    /// # Errors
167    ///
168    /// - [`CompositeKeyParseError::MissingSeparator`] if `SEP` is not found.
169    /// - [`CompositeKeyParseError::InvalidFirst`] if the first segment is not a valid `Key<A>`.
170    /// - [`CompositeKeyParseError::InvalidSecond`] if the second segment is not a valid `Key<B>`.
171    fn from_str(s: &str) -> Result<Self, Self::Err> {
172        let sep_pos = s
173            .find(SEP)
174            .ok_or(CompositeKeyParseError::MissingSeparator { separator: SEP })?;
175
176        let first_str = &s[..sep_pos];
177        // Skip the separator itself (SEP is a char; its UTF-8 length may be >1)
178        let second_str = &s[sep_pos + SEP.len_utf8()..];
179
180        let first = Key::<A>::new(first_str).map_err(CompositeKeyParseError::InvalidFirst)?;
181        let second =
182            Key::<B>::new(second_str).map_err(CompositeKeyParseError::InvalidSecond)?;
183
184        Ok(Self { first, second })
185    }
186}
187
188// ============================================================================
189// EQUALITY
190// ============================================================================
191
192impl<A: KeyDomain, B: KeyDomain, const SEP: char> PartialEq for CompositeKey<A, B, SEP> {
193    #[inline]
194    fn eq(&self, other: &Self) -> bool {
195        self.first == other.first && self.second == other.second
196    }
197}
198
199impl<A: KeyDomain, B: KeyDomain, const SEP: char> Eq for CompositeKey<A, B, SEP> {}
200
201// ============================================================================
202// ORDERING
203// ============================================================================
204
205impl<A: KeyDomain, B: KeyDomain, const SEP: char> PartialOrd for CompositeKey<A, B, SEP> {
206    #[inline]
207    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
208        Some(self.cmp(other))
209    }
210}
211
212impl<A: KeyDomain, B: KeyDomain, const SEP: char> Ord for CompositeKey<A, B, SEP> {
213    /// Lexicographic ordering: compare first components, then second.
214    ///
215    /// # Note on ordering consistency
216    ///
217    /// This ordering compares the two `Key` fields independently and may differ from
218    /// the lexicographic order of the `to_string()` representation.  For example,
219    /// when `SEP = ':'` (ASCII 58), a first component ending with a digit (ASCII 48–57)
220    /// sorts differently field-by-field than it does by the serialised string, because
221    /// `'0'`–`'9'` are strictly less than `':'` in ASCII.
222    ///
223    /// If you need ordering that is consistent with the string representation (e.g. to
224    /// match SQL `ORDER BY` on the stored column), sort by `ck.to_string()` instead.
225    #[inline]
226    fn cmp(&self, other: &Self) -> Ordering {
227        self.first
228            .cmp(&other.first)
229            .then_with(|| self.second.cmp(&other.second))
230    }
231}
232
233// ============================================================================
234// HASH
235// ============================================================================
236
237impl<A: KeyDomain, B: KeyDomain, const SEP: char> Hash for CompositeKey<A, B, SEP> {
238    /// Hash the composite key by hashing both component strings and the separator
239    /// sequentially (zero-allocation).
240    #[inline]
241    fn hash<H: Hasher>(&self, state: &mut H) {
242        self.first.as_str().hash(state);
243        SEP.hash(state);
244        self.second.as_str().hash(state);
245    }
246}
247
248// ============================================================================
249// SERDE
250// ============================================================================
251
252#[cfg(feature = "serde")]
253impl<A: KeyDomain, B: KeyDomain, const SEP: char> Serialize for CompositeKey<A, B, SEP> {
254    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
255    where
256        S: serde::Serializer,
257    {
258        self.to_string().serialize(serializer)
259    }
260}
261
262#[cfg(feature = "serde")]
263impl<'de, A: KeyDomain, B: KeyDomain, const SEP: char> Deserialize<'de>
264    for CompositeKey<A, B, SEP>
265{
266    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
267    where
268        D: serde::Deserializer<'de>,
269    {
270        // Use `String` for all deserializers.  `<&str>` would only work for
271        // deserializers that can provide a borrowed reference (e.g. `from_str`),
272        // but silently breaks `from_reader` and other non-borrowing contexts.
273        let s = String::deserialize(deserializer)?;
274        CompositeKey::from_str(&s).map_err(serde::de::Error::custom)
275    }
276}
277
278// ============================================================================
279// TESTS
280// ============================================================================
281
282#[cfg(test)]
283mod tests {
284    use std::collections::HashMap;
285    use std::hash::{Hash, Hasher};
286
287    use super::*;
288    use crate::error::KeyParseError;
289    use crate::{Domain, KeyDomain};
290
291    // ---- test domains -------------------------------------------------------
292
293    #[derive(Debug)]
294    struct UserDomain;
295    impl Domain for UserDomain {
296        const DOMAIN_NAME: &'static str = "user";
297    }
298    impl KeyDomain for UserDomain {}
299
300    #[derive(Debug)]
301    struct PostDomain;
302    impl Domain for PostDomain {
303        const DOMAIN_NAME: &'static str = "post";
304    }
305    impl KeyDomain for PostDomain {}
306
307    // Type alias to anchor the default SEP in tests
308    type TestKey = CompositeKey<UserDomain, PostDomain>;
309
310    fn user(s: &str) -> Key<UserDomain> {
311        Key::new(s).unwrap()
312    }
313    fn post(s: &str) -> Key<PostDomain> {
314        Key::new(s).unwrap()
315    }
316
317    /// A domain that allows any non-empty character, including the default separator.
318    /// Used to construct keys that violate the `CompositeKey::new` invariant in tests.
319    #[derive(Debug)]
320    struct PermissiveDomain;
321    impl Domain for PermissiveDomain {
322        const DOMAIN_NAME: &'static str = "permissive";
323    }
324    impl KeyDomain for PermissiveDomain {
325        fn allowed_characters(_c: char) -> bool {
326            true
327        }
328    }
329
330    // ---- AE1: round-trip with default separator -----------------------------
331
332    #[test]
333    fn ae1_roundtrip_default_separator() {
334        let ck: TestKey = CompositeKey::new(user("user123"), post("post456"));
335        assert_eq!(ck.to_string(), "user123:post456");
336
337        let parsed: TestKey = "user123:post456".parse().unwrap();
338        assert_eq!(parsed.first(), &user("user123"));
339        assert_eq!(parsed.second(), &post("post456"));
340    }
341
342    // ---- AE2: missing separator error ---------------------------------------
343
344    #[test]
345    fn ae2_missing_separator() {
346        let err = "user123".parse::<TestKey>().unwrap_err();
347        assert_eq!(
348            err,
349            CompositeKeyParseError::MissingSeparator { separator: ':' }
350        );
351    }
352
353    // ---- AE3: empty first segment -------------------------------------------
354
355    #[test]
356    fn ae3_empty_first_segment() {
357        let err = ":post456".parse::<TestKey>().unwrap_err();
358        assert!(
359            matches!(err, CompositeKeyParseError::InvalidFirst(KeyParseError::Empty)),
360            "expected InvalidFirst(Empty), got {:?}",
361            err
362        );
363    }
364
365    // ---- AE4: custom separator '/' ------------------------------------------
366
367    #[test]
368    fn ae4_custom_separator() {
369        let ck: CompositeKey<UserDomain, PostDomain, '/'> =
370            CompositeKey::new(user("user123"), post("post456"));
371        assert_eq!(ck.to_string(), "user123/post456");
372
373        let parsed: CompositeKey<UserDomain, PostDomain, '/'> =
374            "user123/post456".parse().unwrap();
375        assert_eq!(parsed.first(), &user("user123"));
376        assert_eq!(parsed.second(), &post("post456"));
377
378        // Colon is not the separator for '/' variant
379        let err = "user123:post456"
380            .parse::<CompositeKey<UserDomain, PostDomain, '/'>>()
381            .unwrap_err();
382        assert!(matches!(
383            err,
384            CompositeKeyParseError::MissingSeparator { separator: '/' }
385        ));
386    }
387
388    // ---- AE5: equality and hash consistency ---------------------------------
389
390    #[test]
391    fn ae5_equality_and_hash_consistency() {
392        let ck1: TestKey = CompositeKey::new(user("user123"), post("post456"));
393        let ck2: TestKey = CompositeKey::new(user("user123"), post("post456"));
394        assert_eq!(ck1, ck2);
395
396        fn compute_hash<T: Hash>(value: &T) -> u64 {
397            let mut h = std::collections::hash_map::DefaultHasher::new();
398            value.hash(&mut h);
399            h.finish()
400        }
401        assert_eq!(compute_hash(&ck1), compute_hash(&ck2));
402    }
403
404    // ---- edge cases ---------------------------------------------------------
405
406    #[test]
407    fn split_on_first_colon_second_may_contain_colon() {
408        let result = "user123:extra:part".parse::<TestKey>();
409        let _ = result;
410    }
411
412    #[test]
413    fn empty_input_missing_separator() {
414        let err = "".parse::<TestKey>().unwrap_err();
415        assert!(matches!(
416            err,
417            CompositeKeyParseError::MissingSeparator { separator: ':' }
418        ));
419    }
420
421    #[test]
422    fn only_separator_empty_first_and_second() {
423        let err = ":".parse::<TestKey>().unwrap_err();
424        assert!(
425            matches!(err, CompositeKeyParseError::InvalidFirst(KeyParseError::Empty)),
426            "expected InvalidFirst(Empty), got {:?}",
427            err
428        );
429    }
430
431    #[test]
432    #[should_panic]
433    #[cfg(debug_assertions)]
434    fn debug_assert_fires_when_first_contains_sep() {
435        // `PermissiveDomain` allows all characters, so we can construct a `Key`
436        // whose value contains the separator `':'`.  `CompositeKey::new` must
437        // fire the `debug_assert!` and panic.
438        let first_with_sep = Key::<PermissiveDomain>::new("user:id").unwrap();
439        let _ = CompositeKey::<PermissiveDomain, PermissiveDomain>::new(
440            first_with_sep,
441            Key::<PermissiveDomain>::new("other").unwrap(),
442        );
443    }
444
445    #[test]
446    fn ord_lexicographic() {
447        let a_b: TestKey = "aaa:bbb".parse().unwrap();
448        let a_c: TestKey = "aaa:ccc".parse().unwrap();
449        let b_a: TestKey = "bbb:aaa".parse().unwrap();
450
451        assert!(a_b < a_c);
452        assert!(a_b < b_a);
453        assert!(a_c < b_a);
454    }
455
456    #[test]
457    fn clone_is_independent() {
458        let ck: TestKey = CompositeKey::new(user("user123"), post("post456"));
459        let cloned = ck.clone();
460        assert_eq!(ck, cloned);
461    }
462
463    #[test]
464    fn can_use_as_hash_map_key() {
465        let mut map: HashMap<TestKey, &str> = HashMap::new();
466        let ck: TestKey = CompositeKey::new(user("u1"), post("p1"));
467        map.insert(ck.clone(), "value");
468        assert_eq!(map[&ck], "value");
469    }
470
471    #[cfg(feature = "serde")]
472    mod serde_tests {
473        use super::*;
474
475        #[test]
476        fn json_roundtrip() {
477            let ck: TestKey = CompositeKey::new(user("user123"), post("post456"));
478            let json = serde_json::to_string(&ck).unwrap();
479            assert_eq!(json, r#""user123:post456""#);
480
481            let parsed: TestKey = serde_json::from_str(&json).unwrap();
482            assert_eq!(parsed, ck);
483        }
484
485        #[test]
486        fn json_error_no_separator() {
487            let result = serde_json::from_str::<TestKey>(r#""user123""#);
488            assert!(result.is_err());
489        }
490
491        #[test]
492        fn custom_separator_json_roundtrip() {
493            let ck: CompositeKey<UserDomain, PostDomain, '/'> =
494                CompositeKey::new(user("user123"), post("post456"));
495            let json = serde_json::to_string(&ck).unwrap();
496            assert_eq!(json, r#""user123/post456""#);
497
498            let parsed: CompositeKey<UserDomain, PostDomain, '/'> =
499                serde_json::from_str(&json).unwrap();
500            assert_eq!(parsed, ck);
501        }
502    }
503}