use chrono::{SecondsFormat, Utc};
use laterite_core::strata::*;
use laterite_core::Db;
#[derive(Iden)]
pub enum Media {
Table,
Id,
Disk,
Hash,
Size,
ContentType,
OriginalName,
Title,
Alt,
InLibrary,
CreatedBy,
CreatedAt,
UpdatedAt,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MediaRecord {
pub id: i64,
pub disk: String,
pub hash: String,
pub size: i64,
pub content_type: String,
pub original_name: String,
pub title: Option<String>,
pub alt: Option<String>,
pub in_library: bool,
pub created_by: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct NewMedia {
pub disk: String,
pub hash: String,
pub size: i64,
pub content_type: String,
pub original_name: String,
pub in_library: bool,
pub created_by: Option<i64>,
}
#[derive(Debug, thiserror::Error)]
pub enum RecordError {
#[error("database error")]
Database(#[from] sqlx::Error),
#[error(transparent)]
Core(#[from] CoreError),
}
pub async fn create(db: &Db, new: NewMedia) -> Result<i64, RecordError> {
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true);
let stmt = Query::insert()
.into_table(Media::Table)
.columns([
Media::Disk,
Media::Hash,
Media::Size,
Media::ContentType,
Media::OriginalName,
Media::InLibrary,
Media::CreatedBy,
Media::CreatedAt,
Media::UpdatedAt,
])
.values_panic([
new.disk.into(),
new.hash.into(),
new.size.into(),
new.content_type.into(),
new.original_name.into(),
new.in_library.into(),
new.created_by.into(),
now.clone().into(),
now.into(),
])
.to_owned();
Ok(insert_returning_id(db, stmt, Media::Id).await?)
}
pub async fn find(db: &Db, id: i64) -> Result<Option<MediaRecord>, RecordError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns([
Media::Id,
Media::Disk,
Media::Hash,
Media::Size,
Media::ContentType,
Media::OriginalName,
Media::Title,
Media::Alt,
Media::InLibrary,
Media::CreatedBy,
])
.from(Media::Table)
.and_where(Expr::col(Media::Id).eq(id))
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
row.map(|r| {
Ok(MediaRecord {
id: r.get_int("id")?,
disk: r.get_text("disk")?,
hash: r.get_text("hash")?,
size: r.get_int("size")?,
content_type: r.get_text("content_type")?,
original_name: r.get_text("original_name")?,
title: r.get_text_opt("title")?,
alt: r.get_text_opt("alt")?,
in_library: r.get_bool("in_library")?,
created_by: r.get_int_opt("created_by")?,
})
})
.transpose()
}
pub async fn delete(db: &Db, id: i64) -> Result<bool, RecordError> {
let Some(record) = find(db, id).await? else {
return Ok(false);
};
let (sql, values) = build(
db.backend,
Query::delete()
.from_table(Media::Table)
.and_where(Expr::col(Media::Id).eq(id))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
let (sql, values) = build(
db.backend,
Query::select()
.expr(Expr::col(Media::Id).count())
.from(Media::Table)
.and_where(Expr::col(Media::Hash).eq(record.hash))
.and_where(Expr::col(Media::Disk).eq(record.disk))
.to_owned(),
);
let remaining: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
.fetch_one(&db.pool)
.await?
.0;
Ok(remaining == 0)
}
#[cfg(test)]
mod tests {
use super::*;
use laterite_core::testing::connect_test;
async fn db() -> (Db, laterite_core::testing::TestGuard) {
connect_test(&[crate::migrations::migrations()]).await
}
fn new(hash: &str, name: &str) -> NewMedia {
NewMedia {
disk: "private".to_string(),
hash: hash.to_string(),
size: 12,
content_type: "image/png".to_string(),
original_name: name.to_string(),
in_library: false,
created_by: None,
}
}
#[tokio::test]
async fn a_record_keeps_what_the_blob_cannot() {
let (db, _guard) = db().await;
let id = create(&db, new(&"a".repeat(64), "logo.png")).await.unwrap();
let found = find(&db, id).await.unwrap().unwrap();
assert_eq!(found.hash, "a".repeat(64));
assert_eq!(found.disk, "private");
assert_eq!(found.original_name, "logo.png");
assert!(!found.in_library);
}
#[tokio::test]
async fn one_blob_can_back_several_records() {
let (db, _guard) = db().await;
let hash = "b".repeat(64);
let first = create(&db, new(&hash, "invoice.pdf")).await.unwrap();
let second = create(&db, new(&hash, "receipt.pdf")).await.unwrap();
assert_ne!(first, second);
assert_eq!(find(&db, first).await.unwrap().unwrap().hash, hash);
assert_eq!(
find(&db, second).await.unwrap().unwrap().original_name,
"receipt.pdf"
);
}
#[tokio::test]
async fn only_the_last_record_releases_the_blob() {
let (db, _guard) = db().await;
let hash = "c".repeat(64);
let first = create(&db, new(&hash, "one")).await.unwrap();
let second = create(&db, new(&hash, "two")).await.unwrap();
assert!(
!delete(&db, first).await.unwrap(),
"another record still holds it"
);
assert!(
delete(&db, second).await.unwrap(),
"the last one releases it"
);
}
#[tokio::test]
async fn a_blob_on_another_disk_is_a_different_blob() {
let (db, _guard) = db().await;
let hash = "d".repeat(64);
let private = create(&db, new(&hash, "here")).await.unwrap();
let public = create(
&db,
NewMedia {
disk: "public".to_string(),
..new(&hash, "there")
},
)
.await
.unwrap();
assert!(
delete(&db, private).await.unwrap(),
"its own disk had no other holder"
);
assert!(find(&db, public).await.unwrap().is_some());
}
#[tokio::test]
async fn deleting_a_record_that_is_gone_says_so() {
let (db, _guard) = db().await;
assert!(!delete(&db, 4242).await.unwrap());
assert!(find(&db, 4242).await.unwrap().is_none());
}
}