use crate::blobstore::BlobStore;
use adminx_core::attach::{Attachment, Attachments, UploadedFile};
use adminx_core::storage::{
storage, FilterClause, FilterOp, QueryOptions, StorageError,
};
use async_trait::async_trait;
use serde_json::{Map, Value};
pub const TABLE: &str = "adminx_attachments";
const LIST_CAP: u64 = 500;
pub struct AttachmentStore {
blobs: Box<dyn BlobStore>,
}
impl AttachmentStore {
pub fn new(blobs: Box<dyn BlobStore>) -> Self {
Self { blobs }
}
async fn rows(
&self,
owner_type: &str,
owner_id: &str,
field: Option<&str>,
) -> Result<Vec<Value>, StorageError> {
let mut filters = vec![
FilterClause {
field: "owner_type".into(),
op: FilterOp::Eq,
value: owner_type.to_string(),
},
FilterClause {
field: "owner_id".into(),
op: FilterOp::Eq,
value: owner_id.to_string(),
},
];
if let Some(f) = field {
filters.push(FilterClause {
field: "field".into(),
op: FilterOp::Eq,
value: f.to_string(),
});
}
let opts = QueryOptions {
page: 1,
per_page: LIST_CAP,
sort_by: Some("id".to_string()),
sort_desc: false,
filters,
};
Ok(storage().list(TABLE, &opts).await?.rows)
}
async fn remove_row(&self, row: &Value) -> Result<(), StorageError> {
if let Some(key) = row.get("storage_key").and_then(|v| v.as_str()) {
self.blobs.delete(key).await?;
}
if let Some(id) = row_id(row) {
storage().delete(TABLE, "id", &id, false).await?;
}
Ok(())
}
}
#[async_trait]
impl Attachments for AttachmentStore {
async fn put(
&self,
owner_type: &str,
owner_id: &str,
field: &str,
file: UploadedFile,
) -> Result<Attachment, StorageError> {
for row in self.rows(owner_type, owner_id, Some(field)).await? {
self.remove_row(&row).await?;
}
let storage_key = format!(
"{owner_type}/{owner_id}/{field}/{}",
nanoid::nanoid!()
);
self.blobs.put(&storage_key, &file.bytes).await?;
let byte_size = file.bytes.len() as u64;
let mut record = Map::new();
record.insert("owner_type".into(), Value::String(owner_type.into()));
record.insert("owner_id".into(), Value::String(owner_id.into()));
record.insert("field".into(), Value::String(field.into()));
record.insert("filename".into(), Value::String(file.filename.clone()));
record.insert("content_type".into(), Value::String(file.content_type.clone()));
record.insert("byte_size".into(), Value::Number(byte_size.into()));
record.insert("storage_key".into(), Value::String(storage_key.clone()));
record.insert(
"created_at".into(),
Value::String(chrono::Utc::now().to_rfc3339()),
);
if let Err(e) = storage().create(TABLE, record).await {
let _ = self.blobs.delete(&storage_key).await;
return Err(e);
}
Ok(Attachment {
field: field.into(),
filename: file.filename,
content_type: file.content_type,
byte_size,
storage_key,
})
}
async fn get(
&self,
owner_type: &str,
owner_id: &str,
field: &str,
) -> Result<Option<Attachment>, StorageError> {
Ok(self
.rows(owner_type, owner_id, Some(field))
.await?
.first()
.map(to_attachment))
}
async fn list(
&self,
owner_type: &str,
owner_id: &str,
) -> Result<Vec<Attachment>, StorageError> {
Ok(self
.rows(owner_type, owner_id, None)
.await?
.iter()
.map(to_attachment)
.collect())
}
async fn read(&self, storage_key: &str) -> Result<Vec<u8>, StorageError> {
self.blobs.get(storage_key).await
}
async fn delete(
&self,
owner_type: &str,
owner_id: &str,
field: &str,
) -> Result<(), StorageError> {
for row in self.rows(owner_type, owner_id, Some(field)).await? {
self.remove_row(&row).await?;
}
Ok(())
}
async fn delete_all(&self, owner_type: &str, owner_id: &str) -> Result<(), StorageError> {
for row in self.rows(owner_type, owner_id, None).await? {
self.remove_row(&row).await?;
}
Ok(())
}
}
fn to_attachment(row: &Value) -> Attachment {
let s = |k: &str| row.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
Attachment {
field: s("field"),
filename: s("filename"),
content_type: s("content_type"),
byte_size: row
.get("byte_size")
.and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
.unwrap_or(0),
storage_key: s("storage_key"),
}
}
fn row_id(row: &Value) -> Option<String> {
match row.get("id") {
Some(Value::Number(n)) => Some(n.to_string()),
Some(Value::String(s)) => Some(s.clone()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn row_maps_to_attachment_with_either_size_type() {
let typed = json!({
"field": "avatar", "filename": "a.png", "content_type": "image/png",
"byte_size": 1234, "storage_key": "posts/1/avatar/x"
});
let a = to_attachment(&typed);
assert_eq!(a.byte_size, 1234);
assert_eq!(a.filename, "a.png");
let stringy = json!({ "byte_size": "1234", "storage_key": "k",
"field": "f", "filename": "n", "content_type": "t" });
assert_eq!(to_attachment(&stringy).byte_size, 1234);
}
#[test]
fn row_id_survives_int_or_string_pk() {
assert_eq!(row_id(&json!({"id": 5})).as_deref(), Some("5"));
assert_eq!(row_id(&json!({"id": "abc"})).as_deref(), Some("abc"));
assert_eq!(row_id(&json!({})), None);
}
}