Skip to main content

rustrails_storage/
attachment.rs

1//! Attachment associations between records and blobs.
2
3use chrono::{DateTime, Utc};
4use rustrails_support::runtime;
5use thiserror::Error;
6use uuid::Uuid;
7
8use crate::{
9    blob::Blob,
10    service::{StorageError, StorageService},
11};
12
13/// Errors returned by attachment operations.
14#[derive(Debug, Error)]
15pub enum AttachmentError {
16    /// The attachment name was empty.
17    #[error("attachment name must not be empty")]
18    EmptyName,
19    /// A storage backend failed while purging the blob.
20    #[error(transparent)]
21    Storage(#[from] StorageError),
22}
23
24/// Links a blob to a record instance.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Attachment {
27    id: Uuid,
28    record_type: String,
29    record_id: String,
30    name: String,
31    blob: Blob,
32    created_at: DateTime<Utc>,
33}
34
35impl Attachment {
36    /// Creates a new attachment.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error when the attachment name is empty.
41    pub fn new(
42        record_type: impl Into<String>,
43        record_id: impl Into<String>,
44        name: impl Into<String>,
45        blob: Blob,
46    ) -> Result<Self, AttachmentError> {
47        let name = name.into();
48        if name.trim().is_empty() {
49            return Err(AttachmentError::EmptyName);
50        }
51        Ok(Self {
52            id: Uuid::now_v7(),
53            record_type: record_type.into(),
54            record_id: record_id.into(),
55            name,
56            blob,
57            created_at: Utc::now(),
58        })
59    }
60
61    /// Returns the attachment identifier.
62    #[must_use]
63    pub fn id(&self) -> Uuid {
64        self.id
65    }
66
67    /// Returns the owning record type.
68    #[must_use]
69    pub fn record_type(&self) -> &str {
70        &self.record_type
71    }
72
73    /// Returns the owning record identifier.
74    #[must_use]
75    pub fn record_id(&self) -> &str {
76        &self.record_id
77    }
78
79    /// Returns the attachment name.
80    #[must_use]
81    pub fn name(&self) -> &str {
82        &self.name
83    }
84
85    /// Returns the referenced blob.
86    #[must_use]
87    pub fn blob(&self) -> &Blob {
88        &self.blob
89    }
90
91    /// Returns the blob identifier.
92    #[must_use]
93    pub fn blob_id(&self) -> Uuid {
94        self.blob.id()
95    }
96
97    /// Returns the creation timestamp.
98    #[must_use]
99    pub fn created_at(&self) -> DateTime<Utc> {
100        self.created_at
101    }
102
103    /// Deletes the attached blob from storage.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error when the storage service delete fails.
108    pub async fn purge<S: StorageService + ?Sized>(
109        &self,
110        service: &S,
111    ) -> Result<(), AttachmentError> {
112        service.delete(self.blob.key()).await?;
113        Ok(())
114    }
115
116    /// Deletes the attached blob from storage using the thread-local runtime.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error when the storage service delete fails.
121    pub fn purge_sync<S: StorageService + ?Sized>(
122        &self,
123        service: &S,
124    ) -> Result<(), AttachmentError> {
125        runtime::block_on(self.purge(service))
126    }
127}
128
129/// Metadata describing a `has_one_attached` declaration.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct HasOneAttached {
132    /// Attachment name on the owning record.
133    pub name: String,
134}
135
136impl HasOneAttached {
137    /// Creates `has_one_attached` metadata.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error when the attachment name is empty.
142    pub fn new(name: impl Into<String>) -> Result<Self, AttachmentError> {
143        Ok(Self {
144            name: validated_name(name.into())?,
145        })
146    }
147
148    /// Binds this metadata to a specific record instance.
149    #[must_use]
150    pub fn bind(
151        &self,
152        record_type: impl Into<String>,
153        record_id: impl Into<String>,
154    ) -> OneAttachment {
155        OneAttachment::new(record_type, record_id, self.name.clone())
156    }
157}
158
159/// Creates metadata for a `has_one_attached` declaration.
160///
161/// # Errors
162///
163/// Returns an error when the attachment name is empty.
164pub fn has_one_attached(name: impl Into<String>) -> Result<HasOneAttached, AttachmentError> {
165    HasOneAttached::new(name)
166}
167
168/// Metadata describing a `has_many_attached` declaration.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct HasManyAttached {
171    /// Attachment collection name on the owning record.
172    pub name: String,
173}
174
175impl HasManyAttached {
176    /// Creates `has_many_attached` metadata.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error when the attachment name is empty.
181    pub fn new(name: impl Into<String>) -> Result<Self, AttachmentError> {
182        Ok(Self {
183            name: validated_name(name.into())?,
184        })
185    }
186
187    /// Binds this metadata to a specific record instance.
188    #[must_use]
189    pub fn bind(
190        &self,
191        record_type: impl Into<String>,
192        record_id: impl Into<String>,
193    ) -> ManyAttachments {
194        ManyAttachments::new(record_type, record_id, self.name.clone())
195    }
196}
197
198/// Creates metadata for a `has_many_attached` declaration.
199///
200/// # Errors
201///
202/// Returns an error when the attachment name is empty.
203pub fn has_many_attached(name: impl Into<String>) -> Result<HasManyAttached, AttachmentError> {
204    HasManyAttached::new(name)
205}
206
207/// Represents a `has_one_attached` relation.
208#[derive(Debug, Clone)]
209pub struct OneAttachment {
210    record_type: String,
211    record_id: String,
212    name: String,
213    attachment: Option<Attachment>,
214}
215
216impl OneAttachment {
217    /// Creates an empty one-to-one attachment relation.
218    #[must_use]
219    pub fn new(
220        record_type: impl Into<String>,
221        record_id: impl Into<String>,
222        name: impl Into<String>,
223    ) -> Self {
224        Self {
225            record_type: record_type.into(),
226            record_id: record_id.into(),
227            name: name.into(),
228            attachment: None,
229        }
230    }
231
232    /// Attaches a blob, replacing any existing attachment.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error when the attachment cannot be built.
237    pub fn attach(&mut self, blob: Blob) -> Result<&Attachment, AttachmentError> {
238        if self
239            .attachment
240            .as_ref()
241            .is_some_and(|current| current.blob_id() == blob.id())
242        {
243            return self.attachment.as_ref().ok_or(AttachmentError::EmptyName);
244        }
245        self.attachment = Some(Attachment::new(
246            self.record_type.clone(),
247            self.record_id.clone(),
248            self.name.clone(),
249            blob,
250        )?);
251        self.attachment.as_ref().ok_or(AttachmentError::EmptyName)
252    }
253
254    /// Returns whether a blob is currently attached.
255    #[must_use]
256    pub fn is_attached(&self) -> bool {
257        self.attachment.is_some()
258    }
259
260    /// Returns the current attachment, if any.
261    #[must_use]
262    pub fn attachment(&self) -> Option<&Attachment> {
263        self.attachment.as_ref()
264    }
265
266    /// Removes the current attachment and returns it.
267    pub fn detach(&mut self) -> Option<Attachment> {
268        self.attachment.take()
269    }
270
271    /// Purges the current attachment from storage and detaches it.
272    ///
273    /// # Errors
274    ///
275    /// Returns an error when the storage service delete fails.
276    pub async fn purge<S: StorageService + ?Sized>(
277        &mut self,
278        service: &S,
279    ) -> Result<Option<Attachment>, AttachmentError> {
280        if let Some(attachment) = &self.attachment {
281            attachment.purge(service).await?;
282        }
283        Ok(self.detach())
284    }
285
286    /// Purges the current attachment from storage and detaches it using the thread-local runtime.
287    ///
288    /// # Errors
289    ///
290    /// Returns an error when the storage service delete fails.
291    pub fn purge_sync<S: StorageService + ?Sized>(
292        &mut self,
293        service: &S,
294    ) -> Result<Option<Attachment>, AttachmentError> {
295        runtime::block_on(self.purge(service))
296    }
297}
298
299/// Represents a `has_many_attached` relation.
300#[derive(Debug, Clone)]
301pub struct ManyAttachments {
302    record_type: String,
303    record_id: String,
304    name: String,
305    attachments: Vec<Attachment>,
306}
307
308impl ManyAttachments {
309    /// Creates an empty collection attachment relation.
310    #[must_use]
311    pub fn new(
312        record_type: impl Into<String>,
313        record_id: impl Into<String>,
314        name: impl Into<String>,
315    ) -> Self {
316        Self {
317            record_type: record_type.into(),
318            record_id: record_id.into(),
319            name: name.into(),
320            attachments: Vec::new(),
321        }
322    }
323
324    /// Appends a blob to the collection.
325    ///
326    /// # Errors
327    ///
328    /// Returns an error when the attachment cannot be built.
329    pub fn attach(&mut self, blob: Blob) -> Result<&Attachment, AttachmentError> {
330        let attachment = Attachment::new(
331            self.record_type.clone(),
332            self.record_id.clone(),
333            self.name.clone(),
334            blob,
335        )?;
336        self.attachments.push(attachment);
337        self.attachments.last().ok_or(AttachmentError::EmptyName)
338    }
339
340    /// Appends several blobs to the collection.
341    ///
342    /// # Errors
343    ///
344    /// Returns an error when any attachment cannot be built.
345    pub fn attach_many<I>(&mut self, blobs: I) -> Result<(), AttachmentError>
346    where
347        I: IntoIterator<Item = Blob>,
348    {
349        for blob in blobs {
350            let _ = self.attach(blob)?;
351        }
352        Ok(())
353    }
354
355    /// Returns whether at least one blob is attached.
356    #[must_use]
357    pub fn is_attached(&self) -> bool {
358        !self.attachments.is_empty()
359    }
360
361    /// Returns the attachment count.
362    #[must_use]
363    pub fn len(&self) -> usize {
364        self.attachments.len()
365    }
366
367    /// Returns whether the collection is empty.
368    #[must_use]
369    pub fn is_empty(&self) -> bool {
370        self.attachments.is_empty()
371    }
372
373    /// Returns all attachments in insertion order.
374    #[must_use]
375    pub fn attachments(&self) -> &[Attachment] {
376        &self.attachments
377    }
378
379    /// Returns an iterator over the attached blobs.
380    pub fn blobs(&self) -> impl Iterator<Item = &Blob> {
381        self.attachments.iter().map(Attachment::blob)
382    }
383
384    /// Removes an attachment by blob id.
385    pub fn detach_blob(&mut self, blob_id: Uuid) -> Option<Attachment> {
386        self.attachments
387            .iter()
388            .position(|attachment| attachment.blob_id() == blob_id)
389            .map(|index| self.attachments.remove(index))
390    }
391
392    /// Purges all attached blobs from storage and clears the collection.
393    ///
394    /// # Errors
395    ///
396    /// Returns an error when any storage delete fails.
397    pub async fn purge_all<S: StorageService + ?Sized>(
398        &mut self,
399        service: &S,
400    ) -> Result<Vec<Attachment>, AttachmentError> {
401        for attachment in &self.attachments {
402            attachment.purge(service).await?;
403        }
404        Ok(std::mem::take(&mut self.attachments))
405    }
406
407    /// Purges all attached blobs from storage and clears the collection using the thread-local runtime.
408    ///
409    /// # Errors
410    ///
411    /// Returns an error when any storage delete fails.
412    pub fn purge_all_sync<S: StorageService + ?Sized>(
413        &mut self,
414        service: &S,
415    ) -> Result<Vec<Attachment>, AttachmentError> {
416        runtime::block_on(self.purge_all(service))
417    }
418}
419
420fn validated_name(name: String) -> Result<String, AttachmentError> {
421    if name.trim().is_empty() {
422        Err(AttachmentError::EmptyName)
423    } else {
424        Ok(name)
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use std::collections::BTreeMap;
431
432    use bytes::Bytes;
433    use rustrails_support::runtime;
434
435    use super::*;
436    use crate::{blob::Blob, service::memory::MemoryService, test_support::run_sync_test};
437
438    fn blob(filename: &str) -> Blob {
439        Blob::create(
440            Bytes::from(filename.as_bytes().to_vec()),
441            filename.to_owned(),
442            None,
443            BTreeMap::new(),
444            "memory",
445        )
446        .expect("blob should build")
447    }
448
449    #[test]
450    fn test_attachment_new_captures_record_information() {
451        let attachment = Attachment::new("User", "1", "avatar", blob("avatar.png"))
452            .expect("attachment should build");
453        assert_eq!(attachment.record_type(), "User");
454        assert_eq!(attachment.record_id(), "1");
455        assert_eq!(attachment.name(), "avatar");
456    }
457
458    #[test]
459    fn test_attachment_rejects_empty_name() {
460        let error = Attachment::new("User", "1", "", blob("avatar.png"))
461            .expect_err("attachment should fail");
462        assert!(matches!(error, AttachmentError::EmptyName));
463    }
464
465    #[test]
466    fn test_one_attachment_attach_sets_current_attachment() {
467        let mut one = OneAttachment::new("User", "1", "avatar");
468        let attachment = one
469            .attach(blob("avatar.png"))
470            .expect("attach should succeed");
471        assert_eq!(attachment.name(), "avatar");
472        assert!(one.is_attached());
473    }
474
475    #[test]
476    fn test_one_attachment_replace_swaps_blob() {
477        let mut one = OneAttachment::new("User", "1", "avatar");
478        let _ = one.attach(blob("old.png")).expect("attach should succeed");
479        let attachment = one.attach(blob("new.png")).expect("attach should succeed");
480        assert_eq!(attachment.blob().filename(), "new.png");
481    }
482
483    #[test]
484    fn test_one_attachment_attaching_same_blob_keeps_attachment() {
485        let mut one = OneAttachment::new("User", "1", "avatar");
486        let avatar = blob("avatar.png");
487        let first_id = one
488            .attach(avatar.clone())
489            .expect("attach should succeed")
490            .id();
491        let second_id = one.attach(avatar).expect("attach should succeed").id();
492        assert_eq!(first_id, second_id);
493    }
494
495    #[test]
496    fn test_one_attachment_detach_returns_previous_attachment() {
497        let mut one = OneAttachment::new("User", "1", "avatar");
498        let _ = one
499            .attach(blob("avatar.png"))
500            .expect("attach should succeed");
501        let detached = one.detach().expect("attachment should exist");
502        assert_eq!(detached.blob().filename(), "avatar.png");
503        assert!(!one.is_attached());
504    }
505
506    #[tokio::test]
507    async fn test_one_attachment_purge_deletes_backing_blob() {
508        let service = MemoryService::new("memory").expect("service should build");
509        let blob = blob("avatar.png");
510        service
511            .upload(blob.key(), Bytes::from_static(b"avatar"))
512            .await
513            .expect("upload should succeed");
514        let mut one = OneAttachment::new("User", "1", "avatar");
515        let _ = one.attach(blob.clone()).expect("attach should succeed");
516        let purged = one.purge(&service).await.expect("purge should succeed");
517        assert_eq!(
518            purged.expect("attachment should be returned").blob_id(),
519            blob.id()
520        );
521        assert!(
522            !service
523                .exists(blob.key())
524                .await
525                .expect("exists should succeed")
526        );
527    }
528
529    #[test]
530    fn test_attachment_purge_sync_deletes_backing_blob() {
531        run_sync_test(|| {
532            let service = MemoryService::new("memory").expect("service should build");
533            let blob = blob("avatar.png");
534            runtime::block_on(service.upload(blob.key(), Bytes::from_static(b"avatar")))
535                .expect("upload should succeed");
536            let attachment = Attachment::new("User", "1", "avatar", blob.clone())
537                .expect("attachment should build");
538
539            attachment
540                .purge_sync(&service)
541                .expect("purge_sync should succeed");
542
543            assert!(!runtime::block_on(service.exists(blob.key())).expect("exists should succeed"));
544        });
545    }
546
547    #[test]
548    fn test_one_attachment_purge_sync_deletes_backing_blob() {
549        run_sync_test(|| {
550            let service = MemoryService::new("memory").expect("service should build");
551            let blob = blob("avatar.png");
552            runtime::block_on(service.upload(blob.key(), Bytes::from_static(b"avatar")))
553                .expect("upload should succeed");
554            let mut one = OneAttachment::new("User", "1", "avatar");
555            let _ = one.attach(blob.clone()).expect("attach should succeed");
556
557            let purged = one.purge_sync(&service).expect("purge_sync should succeed");
558
559            assert_eq!(
560                purged.expect("attachment should be returned").blob_id(),
561                blob.id()
562            );
563            assert!(!runtime::block_on(service.exists(blob.key())).expect("exists should succeed"));
564        });
565    }
566
567    #[test]
568    fn test_many_attachments_attach_preserves_order() {
569        let mut many = ManyAttachments::new("User", "1", "photos");
570        let _ = many.attach(blob("one.png")).expect("attach should succeed");
571        let _ = many.attach(blob("two.png")).expect("attach should succeed");
572        assert_eq!(many.attachments()[0].blob().filename(), "one.png");
573        assert_eq!(many.attachments()[1].blob().filename(), "two.png");
574    }
575
576    #[test]
577    fn test_many_attachments_attach_many_adds_all_blobs() {
578        let mut many = ManyAttachments::new("User", "1", "photos");
579        many.attach_many([blob("one.png"), blob("two.png")])
580            .expect("attach should succeed");
581        assert_eq!(many.len(), 2);
582    }
583
584    #[test]
585    fn test_many_attachments_reports_empty_state() {
586        let many = ManyAttachments::new("User", "1", "photos");
587        assert!(many.is_empty());
588        assert!(!many.is_attached());
589    }
590
591    #[test]
592    fn test_many_attachments_blobs_iterator_exposes_blob_names() {
593        let mut many = ManyAttachments::new("User", "1", "photos");
594        many.attach_many([blob("one.png"), blob("two.png")])
595            .expect("attach should succeed");
596        let names: Vec<_> = many
597            .blobs()
598            .map(|blob| blob.filename().to_owned())
599            .collect();
600        assert_eq!(names, ["one.png", "two.png"]);
601    }
602
603    #[test]
604    fn test_many_attachments_detach_blob_removes_only_matching_blob() {
605        let mut many = ManyAttachments::new("User", "1", "photos");
606        let first = blob("one.png");
607        let first_id = first.id();
608        many.attach_many([first, blob("two.png")])
609            .expect("attach should succeed");
610        let detached = many.detach_blob(first_id).expect("attachment should exist");
611        assert_eq!(detached.blob().filename(), "one.png");
612        assert_eq!(many.len(), 1);
613        assert_eq!(many.attachments()[0].blob().filename(), "two.png");
614    }
615
616    #[test]
617    fn test_many_attachments_detach_blob_returns_none_for_missing_blob() {
618        let mut many = ManyAttachments::new("User", "1", "photos");
619        many.attach(blob("one.png")).expect("attach should succeed");
620        assert!(many.detach_blob(Uuid::now_v7()).is_none());
621    }
622
623    #[tokio::test]
624    async fn test_many_attachments_purge_all_deletes_backing_blobs() {
625        let service = MemoryService::new("memory").expect("service should build");
626        let first = blob("one.png");
627        let second = blob("two.png");
628        service
629            .upload(first.key(), Bytes::from_static(b"one"))
630            .await
631            .expect("upload should succeed");
632        service
633            .upload(second.key(), Bytes::from_static(b"two"))
634            .await
635            .expect("upload should succeed");
636        let mut many = ManyAttachments::new("User", "1", "photos");
637        many.attach_many([first.clone(), second.clone()])
638            .expect("attach should succeed");
639        let purged = many
640            .purge_all(&service)
641            .await
642            .expect("purge should succeed");
643        assert_eq!(purged.len(), 2);
644        assert!(
645            !service
646                .exists(first.key())
647                .await
648                .expect("exists should succeed")
649        );
650        assert!(
651            !service
652                .exists(second.key())
653                .await
654                .expect("exists should succeed")
655        );
656        assert!(many.is_empty());
657    }
658
659    #[test]
660    fn test_many_attachments_purge_all_sync_deletes_backing_blobs() {
661        run_sync_test(|| {
662            let service = MemoryService::new("memory").expect("service should build");
663            let first = blob("one.png");
664            let second = blob("two.png");
665            runtime::block_on(service.upload(first.key(), Bytes::from_static(b"one")))
666                .expect("upload should succeed");
667            runtime::block_on(service.upload(second.key(), Bytes::from_static(b"two")))
668                .expect("upload should succeed");
669            let mut many = ManyAttachments::new("User", "1", "photos");
670            many.attach_many([first.clone(), second.clone()])
671                .expect("attach should succeed");
672
673            let purged = many
674                .purge_all_sync(&service)
675                .expect("purge_all_sync should succeed");
676
677            assert_eq!(purged.len(), 2);
678            assert!(
679                !runtime::block_on(service.exists(first.key())).expect("exists should succeed")
680            );
681            assert!(
682                !runtime::block_on(service.exists(second.key())).expect("exists should succeed")
683            );
684            assert!(many.is_empty());
685        });
686    }
687
688    #[test]
689    fn test_many_attachments_can_attach_duplicate_filenames() {
690        let mut many = ManyAttachments::new("User", "1", "photos");
691        many.attach_many([blob("same.png"), blob("same.png")])
692            .expect("attach should succeed");
693        assert_eq!(many.len(), 2);
694        assert_ne!(
695            many.attachments()[0].blob_id(),
696            many.attachments()[1].blob_id()
697        );
698    }
699
700    #[test]
701    fn test_attachment_created_at_is_recent() {
702        let attachment = Attachment::new("User", "1", "avatar", blob("avatar.png"))
703            .expect("attachment should build");
704        assert!(attachment.created_at() <= Utc::now());
705    }
706
707    #[test]
708    fn test_one_attachment_empty_relation_has_no_attachment() {
709        let one = OneAttachment::new("User", "1", "avatar");
710        assert!(one.attachment().is_none());
711    }
712
713    #[test]
714    fn test_many_attachments_share_name_for_each_attachment() {
715        let mut many = ManyAttachments::new("User", "1", "photos");
716        many.attach_many([blob("one.png"), blob("two.png")])
717            .expect("attach should succeed");
718        assert!(
719            many.attachments()
720                .iter()
721                .all(|attachment| attachment.name() == "photos")
722        );
723    }
724
725    #[test]
726    fn test_has_one_attached_metadata_binds_to_record() {
727        let metadata = has_one_attached("avatar").expect("metadata should build");
728        let relation = metadata.bind("User", "1");
729        assert!(!relation.is_attached());
730        assert_eq!(metadata.name, "avatar");
731    }
732
733    #[test]
734    fn test_has_many_attached_metadata_binds_to_record() {
735        let metadata = has_many_attached("photos").expect("metadata should build");
736        let relation = metadata.bind("User", "1");
737        assert!(relation.is_empty());
738        assert_eq!(metadata.name, "photos");
739    }
740
741    #[test]
742    fn test_attachment_metadata_rejects_blank_name() {
743        assert!(matches!(
744            has_one_attached("   "),
745            Err(AttachmentError::EmptyName)
746        ));
747        assert!(matches!(
748            has_many_attached(""),
749            Err(AttachmentError::EmptyName)
750        ));
751    }
752}