use chrono::Utc; use sqlx::AnyPool; use crate::models::EmoteRow; /// Thin database abstraction that works with both SQLite and PostgreSQL /// through `sqlx::AnyPool`. #[derive(Clone)] pub struct Database { pub pool: AnyPool, } impl Database { pub async fn connect(url: &str) -> Result { let pool = AnyPool::connect(url).await?; Ok(Self { pool }) } /// Run pending migrations. pub async fn migrate(&self) -> Result<(), sqlx::migrate::MigrateError> { sqlx::migrate!("./migrations").run(&self.pool).await } pub async fn list_emotes(&self) -> Result, sqlx::Error> { let rows = sqlx::query_as::<_, EmoteRow>( "SELECT uuid, name, alias, image_key, created, modified FROM emotes ORDER BY created DESC", ) .fetch_all(&self.pool) .await?; Ok(rows) } pub async fn get_emote_by_id(&self, uuid: &str) -> Result, sqlx::Error> { let row = sqlx::query_as::<_, EmoteRow>( "SELECT uuid, name, alias, image_key, created, modified FROM emotes WHERE uuid = $1", ) .bind(uuid) .fetch_optional(&self.pool) .await?; Ok(row) } pub async fn create_emote( &self, uuid: &str, name: &str, alias: Option<&str>, image_key: &str, ) -> Result { let now = Utc::now().to_rfc3339(); sqlx::query( "INSERT INTO emotes (uuid, name, alias, image_key, created, modified) VALUES ($1, $2, $3, $4, $5, $6)", ) .bind(uuid) .bind(name) .bind(alias) .bind(image_key) .bind(&now) .bind(&now) .execute(&self.pool) .await?; Ok(EmoteRow { uuid: uuid.to_string(), name: name.to_string(), alias: alias.map(|s| s.to_string()), image_key: image_key.to_string(), modified: now.clone(), created: now, }) } pub async fn update_emote( &self, uuid: &str, name: Option<&str>, alias: Option>, image_key: Option<&str>, ) -> Result, sqlx::Error> { let now = Utc::now().to_rfc3339(); // alias has three states: None = keep, Some(None) = clear, Some(Some(v)) = set. // Pass a boolean flag so CASE WHEN can choose between the new value and the // existing column — all resolved atomically in a single statement. let alias_touch = alias.is_some(); let alias_value: Option<&str> = alias.flatten(); let row = sqlx::query_as::<_, EmoteRow>( "UPDATE emotes SET name = COALESCE($1, name), alias = CASE WHEN $2 THEN $3 ELSE alias END, image_key = COALESCE($4, image_key), modified = $5 WHERE uuid = $6 RETURNING uuid, name, alias, image_key, created, modified", ) .bind(name) .bind(alias_touch) .bind(alias_value) .bind(image_key) .bind(&now) .bind(uuid) .fetch_optional(&self.pool) .await?; Ok(row) } pub async fn delete_emote(&self, uuid: &str) -> Result { let result = sqlx::query("DELETE FROM emotes WHERE uuid = $1") .bind(uuid) .execute(&self.pool) .await?; Ok(result.rows_affected() > 0) } } #[cfg(test)] mod tests { use super::*; use crate::models::new_uuid; async fn test_db() -> Database { sqlx::any::install_default_drivers(); let pool = sqlx::pool::PoolOptions::::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); let db = Database { pool }; db.migrate().await.unwrap(); db } #[tokio::test] async fn create_and_get_by_id() { let db = test_db().await; let id = new_uuid(); let row = db.create_emote(&id, "cat", Some("kitty"), "emoji/cat.png").await.unwrap(); assert_eq!(row.uuid, id); assert_eq!(row.name, "cat"); assert_eq!(row.alias.as_deref(), Some("kitty")); assert_eq!(row.image_key, "emoji/cat.png"); let fetched = db.get_emote_by_id(&id).await.unwrap().unwrap(); assert_eq!(fetched.uuid, id); assert_eq!(fetched.name, "cat"); } #[tokio::test] async fn get_unknown_uuid_returns_none() { let db = test_db().await; let result = db.get_emote_by_id("does-not-exist").await.unwrap(); assert!(result.is_none()); } #[tokio::test] async fn list_returns_all_emotes_newest_first() { let db = test_db().await; let id1 = new_uuid(); let id2 = new_uuid(); db.create_emote(&id1, "alpha", None, "emoji/alpha.png").await.unwrap(); db.create_emote(&id2, "beta", None, "emoji/beta.png").await.unwrap(); let rows = db.list_emotes().await.unwrap(); assert_eq!(rows.len(), 2); // ORDER BY created DESC — beta was inserted last so it comes first assert_eq!(rows[0].name, "beta"); assert_eq!(rows[1].name, "alpha"); } #[tokio::test] async fn update_name() { let db = test_db().await; let id = new_uuid(); db.create_emote(&id, "old", None, "emoji/x.png").await.unwrap(); let updated = db.update_emote(&id, Some("new"), None, None).await.unwrap().unwrap(); assert_eq!(updated.name, "new"); assert_eq!(updated.image_key, "emoji/x.png"); // unchanged } #[tokio::test] async fn update_unspecified_fields_are_kept() { let db = test_db().await; let id = new_uuid(); db.create_emote(&id, "name", Some("alias"), "emoji/x.png").await.unwrap(); // Pass None for all optional fields — nothing should change except modified timestamp. let updated = db.update_emote(&id, None, None, None).await.unwrap().unwrap(); assert_eq!(updated.name, "name"); assert_eq!(updated.alias.as_deref(), Some("alias")); assert_eq!(updated.image_key, "emoji/x.png"); } #[tokio::test] async fn update_alias_set_and_clear() { let db = test_db().await; let id = new_uuid(); db.create_emote(&id, "cat", None, "emoji/cat.png").await.unwrap(); // Set alias let with_alias = db .update_emote(&id, None, Some(Some("kitty")), None) .await .unwrap() .unwrap(); assert_eq!(with_alias.alias.as_deref(), Some("kitty")); // Clear alias let cleared = db .update_emote(&id, None, Some(None), None) .await .unwrap() .unwrap(); assert!(cleared.alias.is_none()); } #[tokio::test] async fn update_unknown_uuid_returns_none() { let db = test_db().await; let result = db.update_emote("no-such-id", Some("x"), None, None).await.unwrap(); assert!(result.is_none()); } #[tokio::test] async fn delete_removes_emote() { let db = test_db().await; let id = new_uuid(); db.create_emote(&id, "bye", None, "emoji/bye.png").await.unwrap(); assert!(db.delete_emote(&id).await.unwrap()); assert!(db.get_emote_by_id(&id).await.unwrap().is_none()); } #[tokio::test] async fn delete_unknown_uuid_returns_false() { let db = test_db().await; assert!(!db.delete_emote("no-such-id").await.unwrap()); } }