Skip to main content

nodedb_types/id/
qualified_collection.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Database-qualified collection name.
4//!
5//! Storage engines key data on `(tenant_id, collection, document_id)`.
6//! Embedding the database ID into the collection token makes isolation
7//! between databases automatic, but only if every reader and writer
8//! qualifies the same way. This type is the single place that rule lives.
9
10use std::fmt;
11
12use serde::{Deserialize, Serialize};
13
14use super::DatabaseId;
15
16/// A collection name qualified by its database.
17///
18/// The default database yields the bare collection name; any other
19/// database prefixes it with `{database_id}/`. Security state (RLS
20/// policies, redaction policies, CRDT write gates) must be stored and
21/// looked up under the same `QualifiedCollection`, never a bare name,
22/// or the policy silently misses outside the default database.
23#[derive(
24    Debug,
25    Clone,
26    PartialEq,
27    Eq,
28    PartialOrd,
29    Ord,
30    Hash,
31    Serialize,
32    Deserialize,
33    zerompk::ToMessagePack,
34    zerompk::FromMessagePack,
35)]
36pub struct QualifiedCollection(String);
37
38impl QualifiedCollection {
39    /// Qualify `collection` for `database_id`.
40    ///
41    /// `DatabaseId::DEFAULT` yields the bare name; any other database
42    /// yields `{database_id}/{collection}`.
43    pub fn new(database_id: DatabaseId, collection: &str) -> Self {
44        if database_id == DatabaseId::DEFAULT {
45            Self(collection.to_string())
46        } else {
47            Self(format!("{}/{}", database_id.as_u64(), collection))
48        }
49    }
50
51    /// Rebuild a `QualifiedCollection` from a string already qualified on
52    /// disk or on the wire. The only way to construct one without going
53    /// through `new`; never call this with an unqualified name.
54    pub fn from_stored(qualified: String) -> Self {
55        Self(qualified)
56    }
57
58    pub fn as_str(&self) -> &str {
59        &self.0
60    }
61
62    /// The collection name [`Self::new`] qualified for `database_id`: the
63    /// catalog key a collection lookup in that database takes.
64    pub fn collection_name(&self, database_id: DatabaseId) -> &str {
65        if database_id == DatabaseId::DEFAULT {
66            return &self.0;
67        }
68        let prefix = format!("{}/", database_id.as_u64());
69        self.0.strip_prefix(prefix.as_str()).unwrap_or(&self.0)
70    }
71}
72
73impl fmt::Display for QualifiedCollection {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        f.write_str(&self.0)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn default_database_yields_bare_name() {
85        let q = QualifiedCollection::new(DatabaseId::DEFAULT, "users");
86        assert_eq!(q.as_str(), "users");
87        assert_eq!(q.to_string(), "users");
88    }
89
90    #[test]
91    fn non_default_database_yields_prefixed_name() {
92        let q = QualifiedCollection::new(DatabaseId::new(7), "users");
93        assert_eq!(q.as_str(), "7/users");
94    }
95
96    #[test]
97    fn collection_name_inverts_new() {
98        let q = QualifiedCollection::new(DatabaseId::new(7), "users");
99        assert_eq!(q.collection_name(DatabaseId::new(7)), "users");
100        let bare = QualifiedCollection::new(DatabaseId::DEFAULT, "users");
101        assert_eq!(bare.collection_name(DatabaseId::DEFAULT), "users");
102    }
103
104    #[test]
105    fn serde_roundtrips_as_plain_string() {
106        let q = QualifiedCollection::new(DatabaseId::new(7), "users");
107        let json = sonic_rs::to_string(&q).expect("serialize");
108        assert_eq!(json, "\"7/users\"");
109        let decoded: QualifiedCollection = sonic_rs::from_str(&json).expect("deserialize");
110        assert_eq!(q, decoded);
111    }
112
113    #[test]
114    fn zerompk_roundtrips() {
115        let q = QualifiedCollection::new(DatabaseId::new(7), "users");
116        let bytes = zerompk::to_msgpack_vec(&q).expect("msgpack serialization must succeed");
117        let decoded: QualifiedCollection =
118            zerompk::from_msgpack(&bytes).expect("msgpack deserialization must succeed");
119        assert_eq!(q, decoded);
120    }
121
122    #[test]
123    fn equal_inputs_produce_equal_and_same_hash_values() {
124        use std::collections::hash_map::DefaultHasher;
125        use std::hash::{Hash, Hasher};
126
127        let a = QualifiedCollection::new(DatabaseId::new(7), "users");
128        let b = QualifiedCollection::new(DatabaseId::new(7), "users");
129        assert_eq!(a, b);
130
131        let mut ha = DefaultHasher::new();
132        a.hash(&mut ha);
133        let mut hb = DefaultHasher::new();
134        b.hash(&mut hb);
135        assert_eq!(ha.finish(), hb.finish());
136    }
137
138    #[test]
139    fn from_stored_does_not_qualify() {
140        let q = QualifiedCollection::from_stored("7/users".to_string());
141        assert_eq!(q.as_str(), "7/users");
142    }
143}