use async_trait::async_trait;
use super::PolyglotError;
const MAX_IDENTIFIER_BYTES: usize = 64;
const MAX_DOCUMENT_ID_BYTES: usize = 128;
const MAX_PAGE_SIZE: u32 = 500;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CollectionName(String);
impl CollectionName {
pub fn new(value: impl Into<String>) -> Result<Self, PolyglotError> {
let value = value.into();
let mut bytes = value.bytes();
let starts_safely = bytes
.next()
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
let rest_is_safe = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
if value.len() > MAX_IDENTIFIER_BYTES || !starts_safely || !rest_is_safe {
return Err(PolyglotError::InvalidIdentifier {
kind: "collection name",
reason: "use 1-64 ASCII letters, digits, or underscores and start with a letter or underscore",
});
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DocumentId(String);
impl DocumentId {
pub fn new(value: impl Into<String>) -> Result<Self, PolyglotError> {
let value = value.into();
let valid = !value.is_empty()
&& value.len() <= MAX_DOCUMENT_ID_BYTES
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'));
if !valid {
return Err(PolyglotError::InvalidIdentifier {
kind: "document id",
reason: "use 1-128 ASCII letters, digits, underscores, or hyphens",
});
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DocumentPage {
offset: u64,
limit: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentEntry<T> {
id: DocumentId,
entity: T,
}
impl<T> DocumentEntry<T> {
pub fn new(id: DocumentId, entity: T) -> Self {
Self { id, entity }
}
pub fn id(&self) -> &DocumentId {
&self.id
}
pub fn entity(&self) -> &T {
&self.entity
}
pub fn into_parts(self) -> (DocumentId, T) {
(self.id, self.entity)
}
}
impl DocumentPage {
pub fn new(offset: u64, limit: u32) -> Result<Self, PolyglotError> {
if limit == 0
|| limit > MAX_PAGE_SIZE
|| offset > i64::MAX as u64
|| usize::try_from(offset).is_err()
{
return Err(PolyglotError::InvalidIdentifier {
kind: "document page",
reason: "offset must fit the target index and a signed 64-bit integer; limit must be between 1 and 500",
});
}
Ok(Self { offset, limit })
}
pub const fn offset(self) -> u64 {
self.offset
}
pub const fn limit(self) -> u32 {
self.limit
}
}
#[async_trait]
pub trait DocumentRepository<T>: Send + Sync {
async fn create(
&self,
collection: &CollectionName,
id: &DocumentId,
entity: &T,
) -> Result<(), PolyglotError>;
async fn find(
&self,
collection: &CollectionName,
id: &DocumentId,
) -> Result<Option<T>, PolyglotError>;
async fn replace(
&self,
collection: &CollectionName,
id: &DocumentId,
entity: &T,
) -> Result<(), PolyglotError>;
async fn delete(
&self,
collection: &CollectionName,
id: &DocumentId,
) -> Result<bool, PolyglotError>;
async fn list(
&self,
collection: &CollectionName,
page: DocumentPage,
) -> Result<Vec<T>, PolyglotError>;
}
#[async_trait]
pub trait DocumentInventory<T>: DocumentRepository<T> {
async fn list_entries(
&self,
collection: &CollectionName,
page: DocumentPage,
) -> Result<Vec<DocumentEntry<T>>, PolyglotError>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_portable_identifiers_and_pages() {
assert_eq!(
CollectionName::new("audit_events").unwrap().as_str(),
"audit_events"
);
assert!(CollectionName::new("events-v2").is_err());
assert_eq!(DocumentId::new("evt-42").unwrap().as_str(), "evt-42");
assert!(DocumentId::new("../secret").is_err());
assert_eq!(DocumentPage::new(10, 25).unwrap().limit(), 25);
assert!(DocumentPage::new(0, 0).is_err());
assert!(DocumentPage::new(0, 501).is_err());
assert!(DocumentPage::new(u64::MAX, 1).is_err());
}
}