#[cfg(test)]
mod tests;
use chrono::{DateTime, Utc};
use fraiseql_error::{FileError, FraiseQLError};
use sqlx::PgPool;
use crate::backend::types::ObjectInfo;
#[derive(Debug, Clone)]
pub struct StorageMetadataRow {
pub pk_storage_object: i64,
pub bucket: String,
pub key: String,
pub content_type: String,
pub size_bytes: i64,
pub etag: Option<String>,
pub owner_id: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct NewStorageObject {
pub bucket: String,
pub key: String,
pub content_type: String,
pub size_bytes: i64,
pub etag: Option<String>,
pub owner_id: Option<String>,
}
pub struct StorageMetadataRepo {
pool: PgPool,
}
impl StorageMetadataRepo {
#[must_use]
pub const fn new(pool: PgPool) -> Self {
Self { pool }
}
pub async fn insert(&self, row: &NewStorageObject) -> Result<i64, FraiseQLError> {
let (pk,): (i64,) = sqlx::query_as(
"INSERT INTO _fraiseql_storage_objects \
(bucket, key, content_type, size_bytes, etag, owner_id) \
VALUES ($1, $2, $3, $4, $5, $6) \
RETURNING pk_storage_object",
)
.bind(&row.bucket)
.bind(&row.key)
.bind(&row.content_type)
.bind(row.size_bytes)
.bind(&row.etag)
.bind(&row.owner_id)
.fetch_one(&self.pool)
.await
.map_err(|e| {
FraiseQLError::File(FileError::Backend {
message: e.to_string(),
source: Some(Box::new(e)),
})
})?;
Ok(pk)
}
pub async fn get(
&self,
bucket: &str,
key: &str,
) -> Result<Option<StorageMetadataRow>, FraiseQLError> {
let row = sqlx::query_as::<_, MetadataQueryRow>(
"SELECT pk_storage_object, bucket, key, content_type, \
size_bytes, etag, owner_id, created_at, updated_at \
FROM _fraiseql_storage_objects \
WHERE bucket = $1 AND key = $2",
)
.bind(bucket)
.bind(key)
.fetch_optional(&self.pool)
.await
.map_err(|e| {
FraiseQLError::File(FileError::Backend {
message: e.to_string(),
source: Some(Box::new(e)),
})
})?;
Ok(row.map(Into::into))
}
pub async fn delete(&self, bucket: &str, key: &str) -> Result<bool, FraiseQLError> {
let result =
sqlx::query("DELETE FROM _fraiseql_storage_objects WHERE bucket = $1 AND key = $2")
.bind(bucket)
.bind(key)
.execute(&self.pool)
.await
.map_err(|e| {
FraiseQLError::File(FileError::Backend {
message: e.to_string(),
source: Some(Box::new(e)),
})
})?;
Ok(result.rows_affected() > 0)
}
pub async fn list(
&self,
bucket: &str,
prefix: Option<&str>,
limit: u32,
offset: u32,
) -> Result<Vec<StorageMetadataRow>, FraiseQLError> {
let rows = match prefix {
Some(pfx) => {
sqlx::query_as::<_, MetadataQueryRow>(
"SELECT pk_storage_object, bucket, key, content_type, \
size_bytes, etag, owner_id, created_at, updated_at \
FROM _fraiseql_storage_objects \
WHERE bucket = $1 AND key LIKE $2 \
ORDER BY key ASC \
LIMIT $3 OFFSET $4",
)
.bind(bucket)
.bind(format!("{pfx}%"))
.bind(i64::from(limit))
.bind(i64::from(offset))
.fetch_all(&self.pool)
.await
},
None => {
sqlx::query_as::<_, MetadataQueryRow>(
"SELECT pk_storage_object, bucket, key, content_type, \
size_bytes, etag, owner_id, created_at, updated_at \
FROM _fraiseql_storage_objects \
WHERE bucket = $1 \
ORDER BY key ASC \
LIMIT $2 OFFSET $3",
)
.bind(bucket)
.bind(i64::from(limit))
.bind(i64::from(offset))
.fetch_all(&self.pool)
.await
},
}
.map_err(|e| {
FraiseQLError::File(FileError::Backend {
message: e.to_string(),
source: Some(Box::new(e)),
})
})?;
Ok(rows.into_iter().map(Into::into).collect())
}
pub async fn upsert(&self, row: &NewStorageObject) -> Result<i64, FraiseQLError> {
let (pk,): (i64,) = sqlx::query_as(
"INSERT INTO _fraiseql_storage_objects \
(bucket, key, content_type, size_bytes, etag, owner_id) \
VALUES ($1, $2, $3, $4, $5, $6) \
ON CONFLICT (bucket, key) DO UPDATE SET \
content_type = EXCLUDED.content_type, \
size_bytes = EXCLUDED.size_bytes, \
etag = EXCLUDED.etag, \
updated_at = now() \
RETURNING pk_storage_object",
)
.bind(&row.bucket)
.bind(&row.key)
.bind(&row.content_type)
.bind(row.size_bytes)
.bind(&row.etag)
.bind(&row.owner_id)
.fetch_one(&self.pool)
.await
.map_err(|e| {
FraiseQLError::File(FileError::Backend {
message: e.to_string(),
source: Some(Box::new(e)),
})
})?;
Ok(pk)
}
}
#[derive(sqlx::FromRow)]
struct MetadataQueryRow {
pk_storage_object: i64,
bucket: String,
key: String,
content_type: String,
size_bytes: i64,
etag: Option<String>,
owner_id: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl From<MetadataQueryRow> for StorageMetadataRow {
fn from(row: MetadataQueryRow) -> Self {
Self {
pk_storage_object: row.pk_storage_object,
bucket: row.bucket,
key: row.key,
content_type: row.content_type,
size_bytes: row.size_bytes,
etag: row.etag,
owner_id: row.owner_id,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
}
impl From<&StorageMetadataRow> for ObjectInfo {
fn from(row: &StorageMetadataRow) -> Self {
#[allow(clippy::cast_sign_loss)]
let size = row.size_bytes.max(0) as u64;
Self {
key: row.key.clone(),
size,
content_type: row.content_type.clone(),
etag: row.etag.clone().unwrap_or_default(),
last_modified: row.updated_at.to_rfc3339(),
}
}
}