From 02fdef444f912d9626205c93e34a4001a01933e0 Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Sat, 11 Jan 2025 11:36:54 +0100 Subject: [PATCH] Draft code to write database --- src/external/database/mod.rs | 3 + src/external/database/serde/common.rs | 2 +- src/external/database/serde/serialize.rs | 24 +-- src/external/database/sql/backend.rs | 166 ++++++++++++++ src/external/database/sql/mod.rs | 263 +++++++++++++++++++++++ 5 files changed, 445 insertions(+), 13 deletions(-) create mode 100644 src/external/database/sql/backend.rs create mode 100644 src/external/database/sql/mod.rs diff --git a/src/external/database/mod.rs b/src/external/database/mod.rs index 8bc349c..72a8931 100644 --- a/src/external/database/mod.rs +++ b/src/external/database/mod.rs @@ -1,4 +1,7 @@ #[cfg(feature = "database-json")] pub mod json; +#[cfg(feature = "database-sqlite")] +pub mod sql; + #[cfg(feature = "database-json")] mod serde; diff --git a/src/external/database/serde/common.rs b/src/external/database/serde/common.rs index 1cc230e..e46b3de 100644 --- a/src/external/database/serde/common.rs +++ b/src/external/database/serde/common.rs @@ -37,7 +37,7 @@ pub struct AlbumDateDef { } #[derive(Debug, Deserialize, Serialize)] -pub struct SerdeAlbumDate(#[serde(with = "AlbumDateDef")] AlbumDate); +pub struct SerdeAlbumDate(#[serde(with = "AlbumDateDef")] pub AlbumDate); impl From for AlbumDate { fn from(value: SerdeAlbumDate) -> Self { diff --git a/src/external/database/serde/serialize.rs b/src/external/database/serde/serialize.rs index a88657f..aa9b336 100644 --- a/src/external/database/serde/serialize.rs +++ b/src/external/database/serde/serialize.rs @@ -24,22 +24,22 @@ impl<'a> From<&'a Collection> for SerializeDatabase<'a> { #[derive(Debug, Serialize)] pub struct SerializeArtist<'a> { - name: &'a str, - sort: Option<&'a str>, - musicbrainz: SerializeMbRefOption<'a>, - properties: BTreeMap<&'a str, &'a Vec>, - albums: Vec>, + pub name: &'a str, + pub sort: Option<&'a str>, + pub musicbrainz: SerializeMbRefOption<'a>, + pub properties: BTreeMap<&'a str, &'a Vec>, + pub albums: Vec>, } #[derive(Debug, Serialize)] pub struct SerializeAlbum<'a> { - title: &'a str, - lib_id: SerdeAlbumLibId, - date: SerdeAlbumDate, - seq: u8, - musicbrainz: SerializeMbRefOption<'a>, - primary_type: Option, - secondary_types: Vec, + pub title: &'a str, + pub lib_id: SerdeAlbumLibId, + pub date: SerdeAlbumDate, + pub seq: u8, + pub musicbrainz: SerializeMbRefOption<'a>, + pub primary_type: Option, + pub secondary_types: Vec, } #[derive(Debug, Serialize)] diff --git a/src/external/database/sql/backend.rs b/src/external/database/sql/backend.rs new file mode 100644 index 0000000..763d267 --- /dev/null +++ b/src/external/database/sql/backend.rs @@ -0,0 +1,166 @@ +//! Module for storing MusicHoard data in a JSON file database. + +use std::path::PathBuf; + +use rusqlite::{self, CachedStatement, Connection, Params, Statement}; + +use crate::external::database::{ + serde::serialize::{SerializeAlbum, SerializeArtist, SerializeDatabase}, + sql::{Error, ISqlDatabaseBackend}, +}; + +/// SQLite database backend that uses SQLite as the implementation. +pub struct SqlDatabaseSqliteBackend { + conn: Connection, +} + +impl SqlDatabaseSqliteBackend { + /// Create a [`SqlDatabaseSqliteBackend`] that will read/write to the provided database. + pub fn new>(path: P) -> Result { + Ok(SqlDatabaseSqliteBackend { + conn: Connection::open(path.into()).map_err(|err| Error::OpenError(err.to_string()))?, + }) + } + + fn prepare(&self, sql: &str) -> Result { + self.conn + .prepare(sql) + .map_err(|err| Error::StmtError(err.to_string())) + } + + fn prepare_cached(&self, sql: &str) -> Result { + self.conn + .prepare_cached(sql) + .map_err(|err| Error::StmtError(err.to_string())) + } + + fn execute(stmt: &mut Statement, params: P) -> Result<(), Error> { + stmt.execute(params) + .map(|_| ()) + .map_err(|err| Error::ExecError(err.to_string())) + } +} + +impl From for Error { + fn from(value: serde_json::Error) -> Self { + Error::SerDeError(value.to_string()) + } +} + +impl ISqlDatabaseBackend for SqlDatabaseSqliteBackend { + fn begin_transaction(&self) -> Result<(), Error> { + let mut stmt = self.prepare_cached("BEGIN TRANSACTION;")?; + Self::execute(&mut stmt, ()) + } + + fn commit_transaction(&self) -> Result<(), Error> { + let mut stmt = self.prepare_cached("COMMIT;")?; + Self::execute(&mut stmt, ()) + } + + fn create_database_metadata_table(&self) -> Result<(), Error> { + let mut stmt = self.prepare( + "CREATE TABLE IF NOT EXISTS database_metadata ( + name TEXT NOT NULL PRIMARY KEY, + value TEXT NOT NULL + )", + )?; + Self::execute(&mut stmt, ()) + } + + fn drop_database_metadata_table(&self) -> Result<(), Error> { + let mut stmt = self.prepare_cached("DROP TABLE database_metadata")?; + Self::execute(&mut stmt, ()) + } + + fn create_artists_table(&self) -> Result<(), Error> { + let mut stmt = self.prepare( + "CREATE TABLE IF NOT EXISTS artists ( + name TEXT NOT NULL PRIMARY KEY, + sort TEXT NULL, + mbid JSON NOT NULL DEFAULT '\"None\"', + properties JSON NOT NULL DEFAULT '{}' + )", + )?; + Self::execute(&mut stmt, ()) + } + + fn drop_artists_table(&self) -> Result<(),Error> { + let mut stmt = self.prepare_cached("DROP TABLE artists")?; + Self::execute(&mut stmt, ()) + } + + fn create_albums_table(&self) -> Result<(), Error> { + let mut stmt = self.prepare( + "CREATE TABLE IF NOT EXISTS albums ( + title TEXT NOT NULL PRIMARY KEY, + lib_id JSON NOT NULL DEFAULT '\"None\"', + mbid JSON NOT NULL DEFAULT '\"None\"', + artist_name TEXT NOT NULL, + year INT NULL, + month INT NULL, + day INT NULL, + seq INT NOT NULL, + primary_type JSON NOT NULL DEFAULT 'null', + secondary_types JSON NOT NULL DEFAULT '[]', + FOREIGN KEY (artist_name) REFERENCES artists(name) + )", + )?; + Self::execute(&mut stmt, ()) + } + + fn drop_albums_table(&self) -> Result<(),Error> { + let mut stmt = self.prepare_cached("DROP TABLE albums")?; + Self::execute(&mut stmt, ()) + } + + fn insert_database_version<'a>(&self, version: &SerializeDatabase<'a>) -> Result<(), Error> { + let mut stmt = self.prepare_cached( + "INSERT INTO database_metadata (name, value) + VALUES (?1, ?2)", + )?; + let version = match version { + SerializeDatabase::V20250103(_) => "V20250103", + }; + Self::execute(&mut stmt, ("version", version)) + } + + fn insert_artist<'a>(&self, artist: &SerializeArtist<'a>) -> Result<(), Error> { + let mut stmt = self.prepare_cached( + "INSERT INTO artists (name, sort, mbid, properties) + VALUES (?1, ?2, ?3, ?4)", + )?; + Self::execute( + &mut stmt, + ( + artist.name, + artist.sort, + serde_json::to_string(&artist.musicbrainz)?, + serde_json::to_string(&artist.properties)?, + ), + ) + } + + fn insert_album<'a>(&self, artist_name: &str, album: &SerializeAlbum<'a>) -> Result<(), Error> { + let mut stmt = self.prepare_cached( + "INSERT INTO albums (title, lib_id, mbid, artist_name, + year, month, day, seq, primary_type, secondary_types) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + )?; + Self::execute( + &mut stmt, + ( + album.title, + serde_json::to_string(&album.lib_id)?, + serde_json::to_string(&album.musicbrainz)?, + artist_name, + album.date.0.year, + album.date.0.month, + album.date.0.day, + album.seq, + serde_json::to_string(&album.primary_type)?, + serde_json::to_string(&album.secondary_types)?, + ), + ) + } +} diff --git a/src/external/database/sql/mod.rs b/src/external/database/sql/mod.rs new file mode 100644 index 0000000..932c180 --- /dev/null +++ b/src/external/database/sql/mod.rs @@ -0,0 +1,263 @@ +//! Module for storing MusicHoard data in a SQLdatabase. + +pub mod backend; + +use std::fmt; + +#[cfg(test)] +use mockall::automock; + +use crate::{ + core::{ + collection::Collection, + interface::database::{IDatabase, LoadError, SaveError}, + }, + external::database::serde::serialize::{SerializeAlbum, SerializeArtist, SerializeDatabase}, +}; + +/// Trait for the SQL database backend. +#[cfg_attr(test, automock)] +pub trait ISqlDatabaseBackend { + /// Begin an SQL transaction. + fn begin_transaction(&self) -> Result<(), Error>; + + /// Commit ongoing transaction. + fn commit_transaction(&self) -> Result<(), Error>; + + /// Create the database metadata table (if needed). + fn create_database_metadata_table(&self) -> Result<(), Error>; + + /// Drop the database metadata table. + fn drop_database_metadata_table(&self) -> Result<(), Error>; + + /// Create the artists table (if needed). + fn create_artists_table(&self) -> Result<(), Error>; + + /// Drop the artists table. + fn drop_artists_table(&self) -> Result<(), Error>; + + /// Create the albums table (if needed). + fn create_albums_table(&self) -> Result<(), Error>; + + /// Drop the albums table. + fn drop_albums_table(&self) -> Result<(), Error>; + + /// Set the database version. + fn insert_database_version<'a>(&self, version: &SerializeDatabase<'a>) -> Result<(), Error>; + + /// Insert an artist into the artist table. + fn insert_artist<'a>(&self, artist: &SerializeArtist<'a>) -> Result<(), Error>; + + /// Insert an artist into the artist table. + fn insert_album<'a>(&self, artist_name: &str, album: &SerializeAlbum<'a>) -> Result<(), Error>; +} + +/// Errors for SQL database backend. +#[derive(Debug)] +pub enum Error { + /// An error occurred when connecting to the database. + OpenError(String), + /// An error occurred when preparing a statement for execution. + StmtError(String), + /// An error occurred during serialisation. + SerDeError(String), + /// An error occurred during execution. + ExecError(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Self::OpenError(ref s) => { + write!(f, "an error occurred when connecting to the database: {s}") + } + Self::StmtError(ref s) => write!( + f, + "an error occurred when preparing a statement for execution: {s}" + ), + Self::SerDeError(ref s) => write!(f, "an error occurred during serialisation : {s}"), + Self::ExecError(ref s) => write!(f, "an error occurred during execution: {s}"), + } + } +} + +impl From for SaveError { + fn from(value: Error) -> Self { + match value { + Error::SerDeError(s) => SaveError::SerDeError(s), + _ => SaveError::IoError(value.to_string()), + } + } +} + +/// SQL database. +pub struct SqlDatabase { + backend: SDB, +} + +impl SqlDatabase { + /// Create a new SQL database with the provided backend, e.g. + /// [`backend::SqlDatabaseSqliteBackend`]. + pub fn new(backend: SDB) -> Result { + let db = SqlDatabase { backend }; + db.begin_transaction()?; + db.create_tables()?; + db.commit_transaction()?; + Ok(db) + } + + fn begin_transaction(&self) -> Result<(), Error> { + self.backend.begin_transaction() + } + + fn commit_transaction(&self) -> Result<(), Error> { + self.backend.commit_transaction() + } + + fn create_tables(&self) -> Result<(), Error> { + self.backend.create_database_metadata_table()?; + self.backend.create_artists_table()?; + self.backend.create_albums_table()?; + Ok(()) + } + + fn drop_tables(&self) -> Result<(), Error> { + self.backend.drop_database_metadata_table()?; + self.backend.drop_artists_table()?; + self.backend.drop_albums_table()?; + Ok(()) + } +} + +impl IDatabase for SqlDatabase { + fn load(&self) -> Result { + Ok(vec![]) + } + + fn save(&mut self, collection: &Collection) -> Result<(), SaveError> { + let database: SerializeDatabase = collection.into(); + self.begin_transaction()?; + + self.drop_tables()?; + self.create_tables()?; + + self.backend.insert_database_version(&database)?; + match database { + SerializeDatabase::V20250103(artists) => { + for artist in artists.iter() { + self.backend.insert_artist(artist)?; + for album in artist.albums.iter() { + self.backend.insert_album(artist.name, album)?; + } + } + } + } + + self.commit_transaction()?; + Ok(()) + } +} + +// #[cfg(test)] +// pub mod testmod; + +// #[cfg(test)] +// mod tests { +// use std::collections::HashMap; + +// use mockall::predicate; + +// use crate::core::{ +// collection::{artist::Artist, Collection}, +// testmod::FULL_COLLECTION, +// }; + +// use super::*; +// use testmod::DATABASE_JSON; + +// fn expected() -> Collection { +// let mut expected = FULL_COLLECTION.to_owned(); +// for artist in expected.iter_mut() { +// for album in artist.albums.iter_mut() { +// album.tracks.clear(); +// } +// } +// expected +// } + +// #[test] +// fn save() { +// let write_data = FULL_COLLECTION.to_owned(); +// let input = DATABASE_JSON.to_owned(); + +// let mut backend = MockISqlDatabaseBackend::new(); +// backend +// .expect_write() +// .with(predicate::eq(input)) +// .times(1) +// .return_once(|_| Ok(())); + +// SqlDatabase::new(backend).save(&write_data).unwrap(); +// } + +// #[test] +// fn load() { +// let expected = expected(); +// let result = Ok(DATABASE_JSON.to_owned()); +// eprintln!("{DATABASE_JSON}"); + +// let mut backend = MockISqlDatabaseBackend::new(); +// backend.expect_read().times(1).return_once(|| result); + +// let read_data: Vec = SqlDatabase::new(backend).load().unwrap(); + +// assert_eq!(read_data, expected); +// } + +// #[test] +// fn reverse() { +// let input = DATABASE_JSON.to_owned(); +// let result = Ok(input.clone()); + +// let mut backend = MockISqlDatabaseBackend::new(); +// backend +// .expect_write() +// .with(predicate::eq(input)) +// .times(1) +// .return_once(|_| Ok(())); +// backend.expect_read().times(1).return_once(|| result); +// let mut database = SqlDatabase::new(backend); + +// let write_data = FULL_COLLECTION.to_owned(); +// database.save(&write_data).unwrap(); +// let read_data: Vec = database.load().unwrap(); + +// // Album information is not saved to disk. +// let expected = expected(); +// assert_eq!(read_data, expected); +// } + +// #[test] +// fn load_errors() { +// let json = String::from(""); +// let serde_err = serde_json::from_str::(&json); +// assert!(serde_err.is_err()); + +// let serde_err: LoadError = serde_err.unwrap_err().into(); +// assert!(!serde_err.to_string().is_empty()); +// assert!(!format!("{:?}", serde_err).is_empty()); +// } + +// #[test] +// fn save_errors() { +// // serde_json will raise an error as it has certain requirements on keys. +// let mut object = HashMap::, String>::new(); +// object.insert(Ok(()), String::from("string")); +// let serde_err = serde_json::to_string(&object); +// assert!(serde_err.is_err()); + +// let serde_err: SaveError = serde_err.unwrap_err().into(); +// assert!(!serde_err.to_string().is_empty()); +// assert!(!format!("{:?}", serde_err).is_empty()); +// } +// }