Skip to main content

rullst_orm/polyglot/
document.rs

1use async_trait::async_trait;
2
3use super::PolyglotError;
4
5const MAX_IDENTIFIER_BYTES: usize = 64;
6const MAX_DOCUMENT_ID_BYTES: usize = 128;
7const MAX_PAGE_SIZE: u32 = 500;
8
9/// A validated portable collection or table name.
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct CollectionName(String);
12
13impl CollectionName {
14    /// Validates an ASCII identifier before it can reach a driver.
15    pub fn new(value: impl Into<String>) -> Result<Self, PolyglotError> {
16        let value = value.into();
17        let mut bytes = value.bytes();
18        let starts_safely = bytes
19            .next()
20            .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
21        let rest_is_safe = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
22        if value.len() > MAX_IDENTIFIER_BYTES || !starts_safely || !rest_is_safe {
23            return Err(PolyglotError::InvalidIdentifier {
24                kind: "collection name",
25                reason: "use 1-64 ASCII letters, digits, or underscores and start with a letter or underscore",
26            });
27        }
28        Ok(Self(value))
29    }
30
31    /// Returns the validated identifier.
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35}
36
37/// A validated portable document identifier.
38#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub struct DocumentId(String);
40
41impl DocumentId {
42    /// Validates a bounded identifier accepted by every document adapter.
43    pub fn new(value: impl Into<String>) -> Result<Self, PolyglotError> {
44        let value = value.into();
45        let valid = !value.is_empty()
46            && value.len() <= MAX_DOCUMENT_ID_BYTES
47            && value
48                .bytes()
49                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'));
50        if !valid {
51            return Err(PolyglotError::InvalidIdentifier {
52                kind: "document id",
53                reason: "use 1-128 ASCII letters, digits, underscores, or hyphens",
54            });
55        }
56        Ok(Self(value))
57    }
58
59    /// Returns the validated identifier.
60    pub fn as_str(&self) -> &str {
61        &self.0
62    }
63}
64
65/// A bounded, deterministic document-list page.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct DocumentPage {
68    offset: u64,
69    limit: u32,
70}
71
72/// One portable document paired with the identifier owned by its repository.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct DocumentEntry<T> {
75    id: DocumentId,
76    entity: T,
77}
78
79impl<T> DocumentEntry<T> {
80    /// Creates an inventory entry from an already validated identifier.
81    pub fn new(id: DocumentId, entity: T) -> Self {
82        Self { id, entity }
83    }
84
85    /// Returns the portable identifier.
86    pub fn id(&self) -> &DocumentId {
87        &self.id
88    }
89
90    /// Returns the decoded entity.
91    pub fn entity(&self) -> &T {
92        &self.entity
93    }
94
95    /// Separates the identifier from the decoded entity.
96    pub fn into_parts(self) -> (DocumentId, T) {
97        (self.id, self.entity)
98    }
99}
100
101impl DocumentPage {
102    /// Creates a page with a maximum size of 500 documents.
103    pub fn new(offset: u64, limit: u32) -> Result<Self, PolyglotError> {
104        if limit == 0
105            || limit > MAX_PAGE_SIZE
106            || offset > i64::MAX as u64
107            || usize::try_from(offset).is_err()
108        {
109            return Err(PolyglotError::InvalidIdentifier {
110                kind: "document page",
111                reason: "offset must fit the target index and a signed 64-bit integer; limit must be between 1 and 500",
112            });
113        }
114        Ok(Self { offset, limit })
115    }
116
117    /// Returns the zero-based number of documents to skip.
118    pub const fn offset(self) -> u64 {
119        self.offset
120    }
121
122    /// Returns the bounded number of documents to read.
123    pub const fn limit(self) -> u32 {
124        self.limit
125    }
126}
127
128/// Portable document CRUD without pretending to expose relational semantics.
129#[async_trait]
130pub trait DocumentRepository<T>: Send + Sync {
131    /// Creates a document and fails on an existing identifier.
132    async fn create(
133        &self,
134        collection: &CollectionName,
135        id: &DocumentId,
136        entity: &T,
137    ) -> Result<(), PolyglotError>;
138
139    /// Finds one document by its portable identifier.
140    async fn find(
141        &self,
142        collection: &CollectionName,
143        id: &DocumentId,
144    ) -> Result<Option<T>, PolyglotError>;
145
146    /// Replaces an existing document and fails if it does not exist.
147    async fn replace(
148        &self,
149        collection: &CollectionName,
150        id: &DocumentId,
151        entity: &T,
152    ) -> Result<(), PolyglotError>;
153
154    /// Deletes a document, returning whether it existed.
155    async fn delete(
156        &self,
157        collection: &CollectionName,
158        id: &DocumentId,
159    ) -> Result<bool, PolyglotError>;
160
161    /// Lists a deterministic, bounded page ordered by document identifier.
162    async fn list(
163        &self,
164        collection: &CollectionName,
165        page: DocumentPage,
166    ) -> Result<Vec<T>, PolyglotError>;
167}
168
169/// Identifier-preserving inventory used by bounded export and recovery tools.
170///
171/// This is separate from [`DocumentRepository`] so adding recovery support does
172/// not break third-party CRUD implementations. Implementors must return rows in
173/// strictly increasing [`DocumentId`] order for stable offset pagination.
174#[async_trait]
175pub trait DocumentInventory<T>: DocumentRepository<T> {
176    /// Lists one bounded page with each portable identifier retained.
177    async fn list_entries(
178        &self,
179        collection: &CollectionName,
180        page: DocumentPage,
181    ) -> Result<Vec<DocumentEntry<T>>, PolyglotError>;
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn validates_portable_identifiers_and_pages() {
190        assert_eq!(
191            CollectionName::new("audit_events").unwrap().as_str(),
192            "audit_events"
193        );
194        assert!(CollectionName::new("events-v2").is_err());
195        assert_eq!(DocumentId::new("evt-42").unwrap().as_str(), "evt-42");
196        assert!(DocumentId::new("../secret").is_err());
197        assert_eq!(DocumentPage::new(10, 25).unwrap().limit(), 25);
198        assert!(DocumentPage::new(0, 0).is_err());
199        assert!(DocumentPage::new(0, 501).is_err());
200        assert!(DocumentPage::new(u64::MAX, 1).is_err());
201    }
202}