Skip to main content

nodedb_types/id/
collection_key.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Canonical collection identity for placement and surrogate identity.
4//!
5//! A collection's vShard and its surrogate bindings are keyed by the pair
6//! `(database_id, bare_name)`. The bare name is the name the catalog keys the
7//! collection by. The database-qualified form `"{database_id}/{name}"` names
8//! the same collection, but it folds the database into the string a second
9//! time. Hashing it gives a different vShard than hashing the bare name.
10//!
11//! [`CollectionKey`] is the only input the vShard hash and the surrogate
12//! allocator accept. It has no `From<&str>`. A caller builds it from a bare
13//! catalog name with [`CollectionKey::from_bare`], or from a qualified name
14//! with [`CollectionKey::from_qualified`], which strips the qualifier.
15
16use super::{DatabaseId, QualifiedCollection};
17
18/// Error returned when a qualified collection name does not carry the
19/// qualifier of the database it is resolved in.
20#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
21pub enum CollectionKeyError {
22    /// The name lacks the `"{database_id}/"` prefix that
23    /// [`QualifiedCollection::new`] writes for a non-default database.
24    #[error(
25        "collection name '{name}' is not qualified for database {database_id}; \
26         expected the prefix '{database_id}/'"
27    )]
28    NotQualified {
29        /// Raw id of the database the name was resolved in.
30        database_id: u64,
31        /// The rejected name.
32        name: String,
33    },
34}
35
36/// The canonical `(database_id, bare_name)` identity of a collection.
37///
38/// Borrowed: it never allocates. Build it with [`Self::from_bare`] from a
39/// catalog name, or with [`Self::from_qualified`] /
40/// [`Self::from_qualified_str`] from a database-qualified name.
41///
42/// No `From<&str>` impl exists, so a raw string never converts implicitly:
43///
44/// ```compile_fail
45/// use nodedb_types::CollectionKey;
46/// let key: CollectionKey<'_> = "1024/users".into();
47/// ```
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub struct CollectionKey<'a> {
50    database_id: DatabaseId,
51    name: &'a str,
52}
53
54impl<'a> CollectionKey<'a> {
55    /// Build a key from the bare name the catalog keys the collection by.
56    ///
57    /// `name` must be the catalog name, never a `"{database_id}/{name}"`
58    /// string. A qualified name goes through [`Self::from_qualified`].
59    pub fn from_bare(database_id: DatabaseId, name: &'a str) -> Self {
60        Self { database_id, name }
61    }
62
63    /// Build a key from a [`QualifiedCollection`], stripping its qualifier.
64    pub fn from_qualified(
65        database_id: DatabaseId,
66        qualified: &'a QualifiedCollection,
67    ) -> Result<Self, CollectionKeyError> {
68        Self::from_qualified_str(database_id, qualified.as_str())
69    }
70
71    /// Build a key from a database-qualified name carried as a string on a
72    /// plan, a WAL record, or the wire.
73    ///
74    /// The default database stores names unqualified, so the name is taken
75    /// as-is. The empty name marks a plan with no routing collection and is
76    /// the same in both forms, so it is also taken as-is. Any other name in
77    /// any other database requires the exact `"{database_id}/"` prefix
78    /// [`QualifiedCollection::new`] writes. Exactly one prefix is stripped,
79    /// so a bare name that itself contains `/` survives intact.
80    pub fn from_qualified_str(
81        database_id: DatabaseId,
82        qualified: &'a str,
83    ) -> Result<Self, CollectionKeyError> {
84        if database_id == DatabaseId::DEFAULT || qualified.is_empty() {
85            return Ok(Self::from_bare(database_id, qualified));
86        }
87        let not_qualified = || CollectionKeyError::NotQualified {
88            database_id: database_id.as_u64(),
89            name: qualified.to_owned(),
90        };
91        let (head, bare) = qualified.split_once('/').ok_or_else(not_qualified)?;
92        if head.is_empty() || !head.bytes().all(|b| b.is_ascii_digit()) {
93            return Err(not_qualified());
94        }
95        match head.parse::<u64>() {
96            Ok(raw) if raw == database_id.as_u64() && !head.starts_with('0') => {
97                Ok(Self::from_bare(database_id, bare))
98            }
99            _ => Err(not_qualified()),
100        }
101    }
102
103    /// The database the collection lives in.
104    pub fn database_id(&self) -> DatabaseId {
105        self.database_id
106    }
107
108    /// The bare catalog name.
109    pub fn name(&self) -> &'a str {
110        self.name
111    }
112
113    /// The database-qualified name storage engines key data by.
114    pub fn qualified(&self) -> QualifiedCollection {
115        QualifiedCollection::new(self.database_id, self.name)
116    }
117
118    /// The vShard this collection homes to.
119    pub fn vshard(&self) -> super::VShardId {
120        super::VShardId::from_collection(*self)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    const DB: DatabaseId = DatabaseId::new(1024);
129
130    #[test]
131    fn qualified_input_yields_the_bare_key() {
132        let qualified = QualifiedCollection::new(DB, "users");
133        let from_qualified = CollectionKey::from_qualified(DB, &qualified).expect("qualified");
134        assert_eq!(from_qualified.name(), "users");
135        assert_eq!(from_qualified, CollectionKey::from_bare(DB, "users"));
136        assert_eq!(
137            from_qualified.vshard(),
138            CollectionKey::from_bare(DB, "users").vshard()
139        );
140    }
141
142    #[test]
143    fn qualified_string_cannot_become_a_key_without_dequalifying() {
144        // The only string-accepting constructors are `from_bare`, which the
145        // caller names explicitly, and the qualified constructors, which strip
146        // the qualifier. The qualified path therefore always lands on the bare
147        // name, never on the qualified string.
148        let qualified = QualifiedCollection::new(DB, "users");
149        let key = CollectionKey::from_qualified_str(DB, qualified.as_str()).expect("qualified");
150        assert_ne!(key.name(), qualified.as_str());
151        assert_eq!(key.name(), "users");
152        assert_eq!(key.qualified(), qualified);
153    }
154
155    #[test]
156    fn default_database_names_are_already_bare() {
157        let key = CollectionKey::from_qualified_str(DatabaseId::DEFAULT, "users").expect("default");
158        assert_eq!(key, CollectionKey::from_bare(DatabaseId::DEFAULT, "users"));
159    }
160
161    #[test]
162    fn empty_name_is_the_no_collection_sentinel_in_every_database() {
163        let key = CollectionKey::from_qualified_str(DB, "").expect("empty");
164        assert_eq!(key, CollectionKey::from_bare(DB, ""));
165    }
166
167    #[test]
168    fn exactly_one_qualifier_is_stripped() {
169        let qualified = QualifiedCollection::new(DB, "1024/nested");
170        let key = CollectionKey::from_qualified(DB, &qualified).expect("qualified");
171        assert_eq!(key.name(), "1024/nested");
172    }
173
174    #[test]
175    fn unqualified_name_in_a_named_database_is_rejected() {
176        let err = CollectionKey::from_qualified_str(DB, "users").expect_err("unqualified");
177        assert_eq!(
178            err,
179            CollectionKeyError::NotQualified {
180                database_id: 1024,
181                name: "users".to_owned(),
182            }
183        );
184    }
185
186    #[test]
187    fn foreign_database_qualifier_is_rejected() {
188        assert!(CollectionKey::from_qualified_str(DB, "7/users").is_err());
189        assert!(CollectionKey::from_qualified_str(DB, "+1024/users").is_err());
190        assert!(CollectionKey::from_qualified_str(DB, "01024/users").is_err());
191        assert!(CollectionKey::from_qualified_str(DB, "/users").is_err());
192    }
193
194    #[test]
195    fn bare_and_qualified_hashes_differ_for_a_named_database() {
196        // The two strings name one collection. Only the key keeps them on one
197        // vShard, so hashing the qualified string directly is a routing error.
198        let mut differs = false;
199        for i in 0..64 {
200            let name = format!("coll_{i}");
201            let qualified = QualifiedCollection::new(DB, &name);
202            let key = CollectionKey::from_qualified(DB, &qualified).expect("qualified");
203            let raw_qualified = CollectionKey::from_bare(DB, qualified.as_str());
204            assert_eq!(key.vshard(), CollectionKey::from_bare(DB, &name).vshard());
205            differs |= key.vshard() != raw_qualified.vshard();
206        }
207        assert!(differs);
208    }
209}