rullst_orm/polyglot/
document.rs1use 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct CollectionName(String);
12
13impl CollectionName {
14 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 pub fn as_str(&self) -> &str {
33 &self.0
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub struct DocumentId(String);
40
41impl DocumentId {
42 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 pub fn as_str(&self) -> &str {
61 &self.0
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct DocumentPage {
68 offset: u64,
69 limit: u32,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct DocumentEntry<T> {
75 id: DocumentId,
76 entity: T,
77}
78
79impl<T> DocumentEntry<T> {
80 pub fn new(id: DocumentId, entity: T) -> Self {
82 Self { id, entity }
83 }
84
85 pub fn id(&self) -> &DocumentId {
87 &self.id
88 }
89
90 pub fn entity(&self) -> &T {
92 &self.entity
93 }
94
95 pub fn into_parts(self) -> (DocumentId, T) {
97 (self.id, self.entity)
98 }
99}
100
101impl DocumentPage {
102 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 pub const fn offset(self) -> u64 {
119 self.offset
120 }
121
122 pub const fn limit(self) -> u32 {
124 self.limit
125 }
126}
127
128#[async_trait]
130pub trait DocumentRepository<T>: Send + Sync {
131 async fn create(
133 &self,
134 collection: &CollectionName,
135 id: &DocumentId,
136 entity: &T,
137 ) -> Result<(), PolyglotError>;
138
139 async fn find(
141 &self,
142 collection: &CollectionName,
143 id: &DocumentId,
144 ) -> Result<Option<T>, PolyglotError>;
145
146 async fn replace(
148 &self,
149 collection: &CollectionName,
150 id: &DocumentId,
151 entity: &T,
152 ) -> Result<(), PolyglotError>;
153
154 async fn delete(
156 &self,
157 collection: &CollectionName,
158 id: &DocumentId,
159 ) -> Result<bool, PolyglotError>;
160
161 async fn list(
163 &self,
164 collection: &CollectionName,
165 page: DocumentPage,
166 ) -> Result<Vec<T>, PolyglotError>;
167}
168
169#[async_trait]
175pub trait DocumentInventory<T>: DocumentRepository<T> {
176 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}