use std::cell::RefCell; use std::path::Path; use diesel::connection::SimpleConnection; use diesel::prelude::*; use diesel::sql_types::Text; use crate::db::migrations; #[derive(Debug, thiserror::Error)] pub enum DatabaseError { #[error("{0}")] Connection(#[from] diesel::ConnectionError), #[error("{0}")] Query(#[from] diesel::result::Error), } /// Shared synchronous Diesel connection used by the engine query API. pub struct DbConnection(RefCell); #[derive(QueryableByName)] struct DatabasePathRow { #[diesel(sql_type = Text)] file: String, } impl DbConnection { pub fn with( &self, operation: impl FnOnce(&mut SqliteConnection) -> diesel::QueryResult, ) -> diesel::QueryResult { operation(&mut self.0.borrow_mut()) } pub(crate) fn with_migrations( &self, operation: impl FnOnce(&mut SqliteConnection) -> T, ) -> T { operation(&mut self.0.borrow_mut()) } pub(crate) fn begin_savepoint(&self) -> diesel::QueryResult<()> { self.0.borrow_mut().batch_execute("SAVEPOINT bds_operation") } pub(crate) fn release_savepoint(&self) -> diesel::QueryResult<()> { self.0.borrow_mut().batch_execute("RELEASE bds_operation") } pub(crate) fn rollback_savepoint(&self) -> diesel::QueryResult<()> { self.0 .borrow_mut() .batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation") } #[cfg(test)] pub(crate) fn reject_tag_template_updates_for_test(&self) -> diesel::QueryResult<()> { self.0.borrow_mut().batch_execute( "CREATE TRIGGER reject_template_tag_cascade \ BEFORE UPDATE OF post_template_slug ON tags \ BEGIN SELECT RAISE(ABORT, 'reject cascade'); END", ) } #[cfg(test)] pub(crate) fn insert_legacy_null_mcp_proposal_for_test(&self) -> diesel::QueryResult<()> { self.0.borrow_mut().batch_execute( "INSERT INTO mcp_proposals \ (id, project_id, kind, status, entity_id, data, created_at, expires_at) \ VALUES ('null-entity', 'p1', 'draft_post', 'pending', NULL, '{}', 3, 99)", ) } /// Filesystem database path for sibling surfaces that must open their own /// short-lived connection (gallery workers, preview servers, Lua hosts). pub fn database_path(&self) -> diesel::QueryResult { self.with(|conn| { diesel::sql_query("SELECT file FROM pragma_database_list WHERE name = 'main'") .get_result::(conn) .and_then(|row| { if row.file.is_empty() { Err(diesel::result::Error::NotFound) } else { Ok(row.file.into()) } }) }) } } /// Database wrapper managing a SQLite connection. pub struct Database { conn: DbConnection, } impl Database { /// Open an existing bDS project database. pub fn open(path: &Path) -> Result { Self::establish(path.to_string_lossy().as_ref(), true) } /// Open an in-memory database (for tests). pub fn open_in_memory() -> Result { Self::establish(":memory:", false) } fn establish(database_url: &str, wal: bool) -> Result { let mut conn = SqliteConnection::establish(database_url)?; // SQLite connection configuration is backend-specific and not expressible in Diesel's DSL. conn.batch_execute(if wal { "PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;" } else { "PRAGMA foreign_keys=ON;" })?; Ok(Self { conn: DbConnection(RefCell::new(conn)), }) } pub fn conn(&self) -> &DbConnection { &self.conn } /// Run all pending embedded Diesel migrations. pub fn migrate(&self) -> Result<(), Box> { migrations::run_migrations(&self.conn) } } #[cfg(test)] mod tests { use super::*; #[test] fn open_in_memory() { let db = Database::open_in_memory().expect("should open in-memory db"); let result = db .conn() .with(|conn| { diesel::select(1.into_sql::()).get_result::(conn) }) .unwrap(); assert_eq!(result, 1); } #[test] fn reports_the_disk_database_path() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("project.sqlite3"); let db = Database::open(&path).unwrap(); assert_eq!( db.conn().database_path().unwrap().canonicalize().unwrap(), path.canonicalize().unwrap() ); } }