fraiseql_storage/metadata/
mod.rs1#[cfg(test)]
7mod tests;
8
9use chrono::{DateTime, Utc};
10use fraiseql_error::{FileError, FraiseQLError};
11use sqlx::PgPool;
12
13use crate::backend::types::ObjectInfo;
14
15#[derive(Debug, Clone)]
17pub struct StorageMetadataRow {
18 pub pk_storage_object: i64,
20 pub bucket: String,
22 pub key: String,
24 pub content_type: String,
26 pub size_bytes: i64,
28 pub etag: Option<String>,
30 pub owner_id: Option<String>,
32 pub created_at: DateTime<Utc>,
34 pub updated_at: DateTime<Utc>,
36}
37
38#[derive(Debug, Clone)]
40pub struct NewStorageObject {
41 pub bucket: String,
43 pub key: String,
45 pub content_type: String,
47 pub size_bytes: i64,
49 pub etag: Option<String>,
51 pub owner_id: Option<String>,
53}
54
55pub struct StorageMetadataRepo {
57 pool: PgPool,
58}
59
60impl StorageMetadataRepo {
61 #[must_use]
63 pub const fn new(pool: PgPool) -> Self {
64 Self { pool }
65 }
66
67 pub async fn insert(&self, row: &NewStorageObject) -> Result<i64, FraiseQLError> {
74 let (pk,): (i64,) = sqlx::query_as(
75 "INSERT INTO _fraiseql_storage_objects \
76 (bucket, key, content_type, size_bytes, etag, owner_id) \
77 VALUES ($1, $2, $3, $4, $5, $6) \
78 RETURNING pk_storage_object",
79 )
80 .bind(&row.bucket)
81 .bind(&row.key)
82 .bind(&row.content_type)
83 .bind(row.size_bytes)
84 .bind(&row.etag)
85 .bind(&row.owner_id)
86 .fetch_one(&self.pool)
87 .await
88 .map_err(|e| {
89 FraiseQLError::File(FileError::Backend {
90 message: e.to_string(),
91 source: Some(Box::new(e)),
92 })
93 })?;
94
95 Ok(pk)
96 }
97
98 pub async fn get(
104 &self,
105 bucket: &str,
106 key: &str,
107 ) -> Result<Option<StorageMetadataRow>, FraiseQLError> {
108 let row = sqlx::query_as::<_, MetadataQueryRow>(
109 "SELECT pk_storage_object, bucket, key, content_type, \
110 size_bytes, etag, owner_id, created_at, updated_at \
111 FROM _fraiseql_storage_objects \
112 WHERE bucket = $1 AND key = $2",
113 )
114 .bind(bucket)
115 .bind(key)
116 .fetch_optional(&self.pool)
117 .await
118 .map_err(|e| {
119 FraiseQLError::File(FileError::Backend {
120 message: e.to_string(),
121 source: Some(Box::new(e)),
122 })
123 })?;
124
125 Ok(row.map(Into::into))
126 }
127
128 pub async fn delete(&self, bucket: &str, key: &str) -> Result<bool, FraiseQLError> {
136 let result =
137 sqlx::query("DELETE FROM _fraiseql_storage_objects WHERE bucket = $1 AND key = $2")
138 .bind(bucket)
139 .bind(key)
140 .execute(&self.pool)
141 .await
142 .map_err(|e| {
143 FraiseQLError::File(FileError::Backend {
144 message: e.to_string(),
145 source: Some(Box::new(e)),
146 })
147 })?;
148
149 Ok(result.rows_affected() > 0)
150 }
151
152 pub async fn list(
160 &self,
161 bucket: &str,
162 prefix: Option<&str>,
163 limit: u32,
164 offset: u32,
165 ) -> Result<Vec<StorageMetadataRow>, FraiseQLError> {
166 let rows = match prefix {
167 Some(pfx) => {
168 sqlx::query_as::<_, MetadataQueryRow>(
169 "SELECT pk_storage_object, bucket, key, content_type, \
170 size_bytes, etag, owner_id, created_at, updated_at \
171 FROM _fraiseql_storage_objects \
172 WHERE bucket = $1 AND key LIKE $2 \
173 ORDER BY key ASC \
174 LIMIT $3 OFFSET $4",
175 )
176 .bind(bucket)
177 .bind(format!("{pfx}%"))
178 .bind(i64::from(limit))
179 .bind(i64::from(offset))
180 .fetch_all(&self.pool)
181 .await
182 },
183 None => {
184 sqlx::query_as::<_, MetadataQueryRow>(
185 "SELECT pk_storage_object, bucket, key, content_type, \
186 size_bytes, etag, owner_id, created_at, updated_at \
187 FROM _fraiseql_storage_objects \
188 WHERE bucket = $1 \
189 ORDER BY key ASC \
190 LIMIT $2 OFFSET $3",
191 )
192 .bind(bucket)
193 .bind(i64::from(limit))
194 .bind(i64::from(offset))
195 .fetch_all(&self.pool)
196 .await
197 },
198 }
199 .map_err(|e| {
200 FraiseQLError::File(FileError::Backend {
201 message: e.to_string(),
202 source: Some(Box::new(e)),
203 })
204 })?;
205
206 Ok(rows.into_iter().map(Into::into).collect())
207 }
208
209 pub async fn upsert(&self, row: &NewStorageObject) -> Result<i64, FraiseQLError> {
217 let (pk,): (i64,) = sqlx::query_as(
218 "INSERT INTO _fraiseql_storage_objects \
219 (bucket, key, content_type, size_bytes, etag, owner_id) \
220 VALUES ($1, $2, $3, $4, $5, $6) \
221 ON CONFLICT (bucket, key) DO UPDATE SET \
222 content_type = EXCLUDED.content_type, \
223 size_bytes = EXCLUDED.size_bytes, \
224 etag = EXCLUDED.etag, \
225 updated_at = now() \
226 RETURNING pk_storage_object",
227 )
228 .bind(&row.bucket)
229 .bind(&row.key)
230 .bind(&row.content_type)
231 .bind(row.size_bytes)
232 .bind(&row.etag)
233 .bind(&row.owner_id)
234 .fetch_one(&self.pool)
235 .await
236 .map_err(|e| {
237 FraiseQLError::File(FileError::Backend {
238 message: e.to_string(),
239 source: Some(Box::new(e)),
240 })
241 })?;
242
243 Ok(pk)
244 }
245}
246
247#[derive(sqlx::FromRow)]
256struct MetadataQueryRow {
257 pk_storage_object: i64,
258 bucket: String,
259 key: String,
260 content_type: String,
261 size_bytes: i64,
262 etag: Option<String>,
263 owner_id: Option<String>,
264 created_at: DateTime<Utc>,
265 updated_at: DateTime<Utc>,
266}
267
268impl From<MetadataQueryRow> for StorageMetadataRow {
269 fn from(row: MetadataQueryRow) -> Self {
270 Self {
271 pk_storage_object: row.pk_storage_object,
272 bucket: row.bucket,
273 key: row.key,
274 content_type: row.content_type,
275 size_bytes: row.size_bytes,
276 etag: row.etag,
277 owner_id: row.owner_id,
278 created_at: row.created_at,
279 updated_at: row.updated_at,
280 }
281 }
282}
283
284impl From<&StorageMetadataRow> for ObjectInfo {
289 fn from(row: &StorageMetadataRow) -> Self {
290 #[allow(clippy::cast_sign_loss)]
292 let size = row.size_bytes.max(0) as u64;
293 Self {
294 key: row.key.clone(),
295 size,
296 content_type: row.content_type.clone(),
297 etag: row.etag.clone().unwrap_or_default(),
298 last_modified: row.updated_at.to_rfc3339(),
299 }
300 }
301}