use chrono::{DateTime, Utc};
use rustrails_support::runtime;
use thiserror::Error;
use uuid::Uuid;
use crate::{
blob::Blob,
service::{StorageError, StorageService},
};
#[derive(Debug, Error)]
pub enum AttachmentError {
#[error("attachment name must not be empty")]
EmptyName,
#[error(transparent)]
Storage(#[from] StorageError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attachment {
id: Uuid,
record_type: String,
record_id: String,
name: String,
blob: Blob,
created_at: DateTime<Utc>,
}
impl Attachment {
pub fn new(
record_type: impl Into<String>,
record_id: impl Into<String>,
name: impl Into<String>,
blob: Blob,
) -> Result<Self, AttachmentError> {
let name = name.into();
if name.trim().is_empty() {
return Err(AttachmentError::EmptyName);
}
Ok(Self {
id: Uuid::now_v7(),
record_type: record_type.into(),
record_id: record_id.into(),
name,
blob,
created_at: Utc::now(),
})
}
#[must_use]
pub fn id(&self) -> Uuid {
self.id
}
#[must_use]
pub fn record_type(&self) -> &str {
&self.record_type
}
#[must_use]
pub fn record_id(&self) -> &str {
&self.record_id
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn blob(&self) -> &Blob {
&self.blob
}
#[must_use]
pub fn blob_id(&self) -> Uuid {
self.blob.id()
}
#[must_use]
pub fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
pub async fn purge<S: StorageService + ?Sized>(
&self,
service: &S,
) -> Result<(), AttachmentError> {
service.delete(self.blob.key()).await?;
Ok(())
}
pub fn purge_sync<S: StorageService + ?Sized>(
&self,
service: &S,
) -> Result<(), AttachmentError> {
runtime::block_on(self.purge(service))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HasOneAttached {
pub name: String,
}
impl HasOneAttached {
pub fn new(name: impl Into<String>) -> Result<Self, AttachmentError> {
Ok(Self {
name: validated_name(name.into())?,
})
}
#[must_use]
pub fn bind(
&self,
record_type: impl Into<String>,
record_id: impl Into<String>,
) -> OneAttachment {
OneAttachment::new(record_type, record_id, self.name.clone())
}
}
pub fn has_one_attached(name: impl Into<String>) -> Result<HasOneAttached, AttachmentError> {
HasOneAttached::new(name)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HasManyAttached {
pub name: String,
}
impl HasManyAttached {
pub fn new(name: impl Into<String>) -> Result<Self, AttachmentError> {
Ok(Self {
name: validated_name(name.into())?,
})
}
#[must_use]
pub fn bind(
&self,
record_type: impl Into<String>,
record_id: impl Into<String>,
) -> ManyAttachments {
ManyAttachments::new(record_type, record_id, self.name.clone())
}
}
pub fn has_many_attached(name: impl Into<String>) -> Result<HasManyAttached, AttachmentError> {
HasManyAttached::new(name)
}
#[derive(Debug, Clone)]
pub struct OneAttachment {
record_type: String,
record_id: String,
name: String,
attachment: Option<Attachment>,
}
impl OneAttachment {
#[must_use]
pub fn new(
record_type: impl Into<String>,
record_id: impl Into<String>,
name: impl Into<String>,
) -> Self {
Self {
record_type: record_type.into(),
record_id: record_id.into(),
name: name.into(),
attachment: None,
}
}
pub fn attach(&mut self, blob: Blob) -> Result<&Attachment, AttachmentError> {
if self
.attachment
.as_ref()
.is_some_and(|current| current.blob_id() == blob.id())
{
return self.attachment.as_ref().ok_or(AttachmentError::EmptyName);
}
self.attachment = Some(Attachment::new(
self.record_type.clone(),
self.record_id.clone(),
self.name.clone(),
blob,
)?);
self.attachment.as_ref().ok_or(AttachmentError::EmptyName)
}
#[must_use]
pub fn is_attached(&self) -> bool {
self.attachment.is_some()
}
#[must_use]
pub fn attachment(&self) -> Option<&Attachment> {
self.attachment.as_ref()
}
pub fn detach(&mut self) -> Option<Attachment> {
self.attachment.take()
}
pub async fn purge<S: StorageService + ?Sized>(
&mut self,
service: &S,
) -> Result<Option<Attachment>, AttachmentError> {
if let Some(attachment) = &self.attachment {
attachment.purge(service).await?;
}
Ok(self.detach())
}
pub fn purge_sync<S: StorageService + ?Sized>(
&mut self,
service: &S,
) -> Result<Option<Attachment>, AttachmentError> {
runtime::block_on(self.purge(service))
}
}
#[derive(Debug, Clone)]
pub struct ManyAttachments {
record_type: String,
record_id: String,
name: String,
attachments: Vec<Attachment>,
}
impl ManyAttachments {
#[must_use]
pub fn new(
record_type: impl Into<String>,
record_id: impl Into<String>,
name: impl Into<String>,
) -> Self {
Self {
record_type: record_type.into(),
record_id: record_id.into(),
name: name.into(),
attachments: Vec::new(),
}
}
pub fn attach(&mut self, blob: Blob) -> Result<&Attachment, AttachmentError> {
let attachment = Attachment::new(
self.record_type.clone(),
self.record_id.clone(),
self.name.clone(),
blob,
)?;
self.attachments.push(attachment);
self.attachments.last().ok_or(AttachmentError::EmptyName)
}
pub fn attach_many<I>(&mut self, blobs: I) -> Result<(), AttachmentError>
where
I: IntoIterator<Item = Blob>,
{
for blob in blobs {
let _ = self.attach(blob)?;
}
Ok(())
}
#[must_use]
pub fn is_attached(&self) -> bool {
!self.attachments.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.attachments.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.attachments.is_empty()
}
#[must_use]
pub fn attachments(&self) -> &[Attachment] {
&self.attachments
}
pub fn blobs(&self) -> impl Iterator<Item = &Blob> {
self.attachments.iter().map(Attachment::blob)
}
pub fn detach_blob(&mut self, blob_id: Uuid) -> Option<Attachment> {
self.attachments
.iter()
.position(|attachment| attachment.blob_id() == blob_id)
.map(|index| self.attachments.remove(index))
}
pub async fn purge_all<S: StorageService + ?Sized>(
&mut self,
service: &S,
) -> Result<Vec<Attachment>, AttachmentError> {
for attachment in &self.attachments {
attachment.purge(service).await?;
}
Ok(std::mem::take(&mut self.attachments))
}
pub fn purge_all_sync<S: StorageService + ?Sized>(
&mut self,
service: &S,
) -> Result<Vec<Attachment>, AttachmentError> {
runtime::block_on(self.purge_all(service))
}
}
fn validated_name(name: String) -> Result<String, AttachmentError> {
if name.trim().is_empty() {
Err(AttachmentError::EmptyName)
} else {
Ok(name)
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use bytes::Bytes;
use rustrails_support::runtime;
use super::*;
use crate::{blob::Blob, service::memory::MemoryService, test_support::run_sync_test};
fn blob(filename: &str) -> Blob {
Blob::create(
Bytes::from(filename.as_bytes().to_vec()),
filename.to_owned(),
None,
BTreeMap::new(),
"memory",
)
.expect("blob should build")
}
#[test]
fn test_attachment_new_captures_record_information() {
let attachment = Attachment::new("User", "1", "avatar", blob("avatar.png"))
.expect("attachment should build");
assert_eq!(attachment.record_type(), "User");
assert_eq!(attachment.record_id(), "1");
assert_eq!(attachment.name(), "avatar");
}
#[test]
fn test_attachment_rejects_empty_name() {
let error = Attachment::new("User", "1", "", blob("avatar.png"))
.expect_err("attachment should fail");
assert!(matches!(error, AttachmentError::EmptyName));
}
#[test]
fn test_one_attachment_attach_sets_current_attachment() {
let mut one = OneAttachment::new("User", "1", "avatar");
let attachment = one
.attach(blob("avatar.png"))
.expect("attach should succeed");
assert_eq!(attachment.name(), "avatar");
assert!(one.is_attached());
}
#[test]
fn test_one_attachment_replace_swaps_blob() {
let mut one = OneAttachment::new("User", "1", "avatar");
let _ = one.attach(blob("old.png")).expect("attach should succeed");
let attachment = one.attach(blob("new.png")).expect("attach should succeed");
assert_eq!(attachment.blob().filename(), "new.png");
}
#[test]
fn test_one_attachment_attaching_same_blob_keeps_attachment() {
let mut one = OneAttachment::new("User", "1", "avatar");
let avatar = blob("avatar.png");
let first_id = one
.attach(avatar.clone())
.expect("attach should succeed")
.id();
let second_id = one.attach(avatar).expect("attach should succeed").id();
assert_eq!(first_id, second_id);
}
#[test]
fn test_one_attachment_detach_returns_previous_attachment() {
let mut one = OneAttachment::new("User", "1", "avatar");
let _ = one
.attach(blob("avatar.png"))
.expect("attach should succeed");
let detached = one.detach().expect("attachment should exist");
assert_eq!(detached.blob().filename(), "avatar.png");
assert!(!one.is_attached());
}
#[tokio::test]
async fn test_one_attachment_purge_deletes_backing_blob() {
let service = MemoryService::new("memory").expect("service should build");
let blob = blob("avatar.png");
service
.upload(blob.key(), Bytes::from_static(b"avatar"))
.await
.expect("upload should succeed");
let mut one = OneAttachment::new("User", "1", "avatar");
let _ = one.attach(blob.clone()).expect("attach should succeed");
let purged = one.purge(&service).await.expect("purge should succeed");
assert_eq!(
purged.expect("attachment should be returned").blob_id(),
blob.id()
);
assert!(
!service
.exists(blob.key())
.await
.expect("exists should succeed")
);
}
#[test]
fn test_attachment_purge_sync_deletes_backing_blob() {
run_sync_test(|| {
let service = MemoryService::new("memory").expect("service should build");
let blob = blob("avatar.png");
runtime::block_on(service.upload(blob.key(), Bytes::from_static(b"avatar")))
.expect("upload should succeed");
let attachment = Attachment::new("User", "1", "avatar", blob.clone())
.expect("attachment should build");
attachment
.purge_sync(&service)
.expect("purge_sync should succeed");
assert!(!runtime::block_on(service.exists(blob.key())).expect("exists should succeed"));
});
}
#[test]
fn test_one_attachment_purge_sync_deletes_backing_blob() {
run_sync_test(|| {
let service = MemoryService::new("memory").expect("service should build");
let blob = blob("avatar.png");
runtime::block_on(service.upload(blob.key(), Bytes::from_static(b"avatar")))
.expect("upload should succeed");
let mut one = OneAttachment::new("User", "1", "avatar");
let _ = one.attach(blob.clone()).expect("attach should succeed");
let purged = one.purge_sync(&service).expect("purge_sync should succeed");
assert_eq!(
purged.expect("attachment should be returned").blob_id(),
blob.id()
);
assert!(!runtime::block_on(service.exists(blob.key())).expect("exists should succeed"));
});
}
#[test]
fn test_many_attachments_attach_preserves_order() {
let mut many = ManyAttachments::new("User", "1", "photos");
let _ = many.attach(blob("one.png")).expect("attach should succeed");
let _ = many.attach(blob("two.png")).expect("attach should succeed");
assert_eq!(many.attachments()[0].blob().filename(), "one.png");
assert_eq!(many.attachments()[1].blob().filename(), "two.png");
}
#[test]
fn test_many_attachments_attach_many_adds_all_blobs() {
let mut many = ManyAttachments::new("User", "1", "photos");
many.attach_many([blob("one.png"), blob("two.png")])
.expect("attach should succeed");
assert_eq!(many.len(), 2);
}
#[test]
fn test_many_attachments_reports_empty_state() {
let many = ManyAttachments::new("User", "1", "photos");
assert!(many.is_empty());
assert!(!many.is_attached());
}
#[test]
fn test_many_attachments_blobs_iterator_exposes_blob_names() {
let mut many = ManyAttachments::new("User", "1", "photos");
many.attach_many([blob("one.png"), blob("two.png")])
.expect("attach should succeed");
let names: Vec<_> = many
.blobs()
.map(|blob| blob.filename().to_owned())
.collect();
assert_eq!(names, ["one.png", "two.png"]);
}
#[test]
fn test_many_attachments_detach_blob_removes_only_matching_blob() {
let mut many = ManyAttachments::new("User", "1", "photos");
let first = blob("one.png");
let first_id = first.id();
many.attach_many([first, blob("two.png")])
.expect("attach should succeed");
let detached = many.detach_blob(first_id).expect("attachment should exist");
assert_eq!(detached.blob().filename(), "one.png");
assert_eq!(many.len(), 1);
assert_eq!(many.attachments()[0].blob().filename(), "two.png");
}
#[test]
fn test_many_attachments_detach_blob_returns_none_for_missing_blob() {
let mut many = ManyAttachments::new("User", "1", "photos");
many.attach(blob("one.png")).expect("attach should succeed");
assert!(many.detach_blob(Uuid::now_v7()).is_none());
}
#[tokio::test]
async fn test_many_attachments_purge_all_deletes_backing_blobs() {
let service = MemoryService::new("memory").expect("service should build");
let first = blob("one.png");
let second = blob("two.png");
service
.upload(first.key(), Bytes::from_static(b"one"))
.await
.expect("upload should succeed");
service
.upload(second.key(), Bytes::from_static(b"two"))
.await
.expect("upload should succeed");
let mut many = ManyAttachments::new("User", "1", "photos");
many.attach_many([first.clone(), second.clone()])
.expect("attach should succeed");
let purged = many
.purge_all(&service)
.await
.expect("purge should succeed");
assert_eq!(purged.len(), 2);
assert!(
!service
.exists(first.key())
.await
.expect("exists should succeed")
);
assert!(
!service
.exists(second.key())
.await
.expect("exists should succeed")
);
assert!(many.is_empty());
}
#[test]
fn test_many_attachments_purge_all_sync_deletes_backing_blobs() {
run_sync_test(|| {
let service = MemoryService::new("memory").expect("service should build");
let first = blob("one.png");
let second = blob("two.png");
runtime::block_on(service.upload(first.key(), Bytes::from_static(b"one")))
.expect("upload should succeed");
runtime::block_on(service.upload(second.key(), Bytes::from_static(b"two")))
.expect("upload should succeed");
let mut many = ManyAttachments::new("User", "1", "photos");
many.attach_many([first.clone(), second.clone()])
.expect("attach should succeed");
let purged = many
.purge_all_sync(&service)
.expect("purge_all_sync should succeed");
assert_eq!(purged.len(), 2);
assert!(
!runtime::block_on(service.exists(first.key())).expect("exists should succeed")
);
assert!(
!runtime::block_on(service.exists(second.key())).expect("exists should succeed")
);
assert!(many.is_empty());
});
}
#[test]
fn test_many_attachments_can_attach_duplicate_filenames() {
let mut many = ManyAttachments::new("User", "1", "photos");
many.attach_many([blob("same.png"), blob("same.png")])
.expect("attach should succeed");
assert_eq!(many.len(), 2);
assert_ne!(
many.attachments()[0].blob_id(),
many.attachments()[1].blob_id()
);
}
#[test]
fn test_attachment_created_at_is_recent() {
let attachment = Attachment::new("User", "1", "avatar", blob("avatar.png"))
.expect("attachment should build");
assert!(attachment.created_at() <= Utc::now());
}
#[test]
fn test_one_attachment_empty_relation_has_no_attachment() {
let one = OneAttachment::new("User", "1", "avatar");
assert!(one.attachment().is_none());
}
#[test]
fn test_many_attachments_share_name_for_each_attachment() {
let mut many = ManyAttachments::new("User", "1", "photos");
many.attach_many([blob("one.png"), blob("two.png")])
.expect("attach should succeed");
assert!(
many.attachments()
.iter()
.all(|attachment| attachment.name() == "photos")
);
}
#[test]
fn test_has_one_attached_metadata_binds_to_record() {
let metadata = has_one_attached("avatar").expect("metadata should build");
let relation = metadata.bind("User", "1");
assert!(!relation.is_attached());
assert_eq!(metadata.name, "avatar");
}
#[test]
fn test_has_many_attached_metadata_binds_to_record() {
let metadata = has_many_attached("photos").expect("metadata should build");
let relation = metadata.bind("User", "1");
assert!(relation.is_empty());
assert_eq!(metadata.name, "photos");
}
#[test]
fn test_attachment_metadata_rejects_blank_name() {
assert!(matches!(
has_one_attached(" "),
Err(AttachmentError::EmptyName)
));
assert!(matches!(
has_many_attached(""),
Err(AttachmentError::EmptyName)
));
}
}