From fcbde5aecb3246d3e60f0556295966d5e24fe639 Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Sun, 12 Jan 2025 10:24:53 +0100 Subject: [PATCH] Add a SQLite database backend (#265) Part 1 of #248 Reviewed-on: https://git.thenineworlds.net/wojtek/musichoard/pulls/265 --- Cargo.lock | 101 ++++- Cargo.toml | 10 +- README.md | 13 + src/core/interface/database/mod.rs | 6 +- src/external/database/json/mod.rs | 2 +- src/external/database/mod.rs | 5 +- src/external/database/serde/common.rs | 16 +- src/external/database/serde/deserialize.rs | 46 ++- src/external/database/serde/mod.rs | 2 +- src/external/database/serde/serialize.rs | 32 +- src/external/database/sql/backend.rs | 265 ++++++++++++ src/external/database/sql/mod.rs | 448 +++++++++++++++++++++ src/external/database/sql/testmod.rs | 202 ++++++++++ tests/database/json.rs | 2 +- tests/database/mod.rs | 5 +- tests/database/sql.rs | 63 +++ tests/files/database/database.db | Bin 0 -> 28672 bytes 17 files changed, 1154 insertions(+), 64 deletions(-) create mode 100644 src/external/database/sql/backend.rs create mode 100644 src/external/database/sql/mod.rs create mode 100644 src/external/database/sql/testmod.rs create mode 100644 tests/database/sql.rs create mode 100644 tests/files/database/database.db diff --git a/Cargo.lock b/Cargo.lock index 8abaead..9e66236 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,18 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -298,6 +310,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -432,6 +456,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.2" @@ -443,6 +476,15 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.3.3" @@ -731,7 +773,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -796,6 +838,17 @@ version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -830,7 +883,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -868,14 +921,13 @@ dependencies = [ [[package]] name = "mockall" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" dependencies = [ "cfg-if", "downcast", "fragile", - "lazy_static", "mockall_derive", "predicates", "predicates-tree", @@ -883,9 +935,9 @@ dependencies = [ [[package]] name = "mockall_derive" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" dependencies = [ "cfg-if", "proc-macro2", @@ -905,6 +957,7 @@ dependencies = [ "paste", "ratatui", "reqwest", + "rusqlite", "serde", "serde_json", "structopt", @@ -1255,6 +1308,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.6.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -2209,6 +2276,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.95", +] + [[package]] name = "zerofrom" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index cbc46c2..a1df039 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ openssh = { version = "0.11.4", features = ["native-mux"], default-features = fa paste = { version = "1.0.15", optional = true } ratatui = { version = "0.29.0", optional = true} reqwest = { version = "0.12.12", features = ["blocking", "json"], optional = true } +rusqlite = { version = "0.32.1", optional = true } serde = { version = "1.0.217", features = ["derive"], optional = true } serde_json = { version = "1.0.134", optional = true} structopt = { version = "0.3.26", optional = true} @@ -23,16 +24,17 @@ url = { version = "2.5.4" } uuid = { version = "1.11.0" } [build-dependencies] -version_check = "0.9.4" +version_check = "0.9.5" [dev-dependencies] -mockall = "0.12.1" -once_cell = "1.19.0" -tempfile = "3.10.0" +mockall = "0.13.1" +tempfile = "3.15.0" [features] default = ["database-json", "library-beets"] bin = ["structopt"] +database-sqlite = ["rusqlite", "serde", "serde_json"] +database-sqlite-bundled = ["rusqlite/bundled"] database-json = ["serde", "serde_json"] library-beets = [] library-beets-ssh = ["openssh", "tokio"] diff --git a/README.md b/README.md index 7e1bc40..fc478b0 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,19 @@ ### Pre-requisites +#### database-sqlite + +This feature requires the `sqlite` library. + +Either install system libraries: with + +On Fedora: +``` sh +sudo dnf install sqlite-devel +``` + +Or use a bundled version by enabling the `database-sqlite-bundled` feature. + #### musicbrainz-api This feature requires the `openssl` system library. diff --git a/src/core/interface/database/mod.rs b/src/core/interface/database/mod.rs index b60575c..3f9ab5f 100644 --- a/src/core/interface/database/mod.rs +++ b/src/core/interface/database/mod.rs @@ -11,7 +11,7 @@ use crate::core::collection::{self, Collection}; #[cfg_attr(test, automock)] pub trait IDatabase { /// Load collection from the database. - fn load(&self) -> Result; + fn load(&mut self) -> Result; /// Save collection to the database. fn save(&mut self, collection: &Collection) -> Result<(), SaveError>; @@ -21,7 +21,7 @@ pub trait IDatabase { pub struct NullDatabase; impl IDatabase for NullDatabase { - fn load(&self) -> Result { + fn load(&mut self) -> Result { Ok(vec![]) } @@ -100,7 +100,7 @@ mod tests { #[test] fn null_database_load() { - let database = NullDatabase; + let mut database = NullDatabase; assert!(database.load().unwrap().is_empty()); } diff --git a/src/external/database/json/mod.rs b/src/external/database/json/mod.rs index 0b7edf9..3d4709b 100644 --- a/src/external/database/json/mod.rs +++ b/src/external/database/json/mod.rs @@ -49,7 +49,7 @@ impl JsonDatabase { } impl IDatabase for JsonDatabase { - fn load(&self) -> Result { + fn load(&mut self) -> Result { let serialized = self.backend.read()?; let database: DeserializeDatabase = serde_json::from_str(&serialized)?; Ok(database.into()) diff --git a/src/external/database/mod.rs b/src/external/database/mod.rs index 8bc349c..ceca477 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-json")] +#[cfg(feature = "database-sqlite")] +pub mod sql; + +#[cfg(any(feature = "database-json", feature = "database-sqlite"))] mod serde; diff --git a/src/external/database/serde/common.rs b/src/external/database/serde/common.rs index 1cc230e..000fc8d 100644 --- a/src/external/database/serde/common.rs +++ b/src/external/database/serde/common.rs @@ -13,8 +13,8 @@ pub enum AlbumLibIdDef { None, } -#[derive(Debug, Deserialize, Serialize)] -pub struct SerdeAlbumLibId(#[serde(with = "AlbumLibIdDef")] AlbumLibId); +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct SerdeAlbumLibId(#[serde(with = "AlbumLibIdDef")] pub AlbumLibId); impl From for AlbumLibId { fn from(value: SerdeAlbumLibId) -> Self { @@ -36,8 +36,8 @@ pub struct AlbumDateDef { day: Option, } -#[derive(Debug, Deserialize, Serialize)] -pub struct SerdeAlbumDate(#[serde(with = "AlbumDateDef")] AlbumDate); +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct SerdeAlbumDate(#[serde(with = "AlbumDateDef")] pub AlbumDate); impl From for AlbumDate { fn from(value: SerdeAlbumDate) -> Self { @@ -69,8 +69,8 @@ pub enum AlbumPrimaryTypeDef { Other, } -#[derive(Debug, Deserialize, Serialize)] -pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] AlbumPrimaryType); +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] pub AlbumPrimaryType); impl From for AlbumPrimaryType { fn from(value: SerdeAlbumPrimaryType) -> Self { @@ -101,8 +101,8 @@ pub enum AlbumSecondaryTypeDef { FieldRecording, } -#[derive(Debug, Deserialize, Serialize)] -pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] AlbumSecondaryType); +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] pub AlbumSecondaryType); impl From for AlbumSecondaryType { fn from(value: SerdeAlbumSecondaryType) -> Self { diff --git a/src/external/database/serde/deserialize.rs b/src/external/database/serde/deserialize.rs index 182c9c6..007099e 100644 --- a/src/external/database/serde/deserialize.rs +++ b/src/external/database/serde/deserialize.rs @@ -23,38 +23,42 @@ pub enum DeserializeDatabase { impl From for Collection { fn from(database: DeserializeDatabase) -> Self { match database { - DeserializeDatabase::V20250103(collection) => { - collection.into_iter().map(Into::into).collect() + DeserializeDatabase::V20250103(db) => { + let mut collection: Collection = db.into_iter().map(Into::into).collect(); + collection.sort_unstable(); + collection } } } } -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] pub struct DeserializeArtist { - name: String, - sort: Option, - musicbrainz: DeserializeMbRefOption, - properties: HashMap>, - albums: Vec, + pub name: String, + pub sort: Option, + pub musicbrainz: DeserializeMbRefOption, + pub properties: HashMap>, + pub albums: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] pub struct DeserializeAlbum { - title: String, - lib_id: SerdeAlbumLibId, - date: SerdeAlbumDate, - seq: u8, - musicbrainz: DeserializeMbRefOption, - primary_type: Option, - secondary_types: Vec, + pub title: String, + pub lib_id: SerdeAlbumLibId, + pub date: SerdeAlbumDate, + pub seq: u8, + pub musicbrainz: DeserializeMbRefOption, + pub primary_type: Option, + pub secondary_types: Vec, } -#[derive(Debug, Deserialize)] -pub struct DeserializeMbRefOption(#[serde(with = "MbRefOptionDef")] MbRefOption); +#[derive(Clone, Debug, Deserialize)] +pub struct DeserializeMbRefOption( + #[serde(with = "MbRefOptionDef")] pub MbRefOption, +); #[derive(Clone, Debug)] -pub struct DeserializeMbid(Mbid); +pub struct DeserializeMbid(pub Mbid); macro_rules! impl_from_for_mb_ref_option { ($ref:ty) => { @@ -110,6 +114,8 @@ impl<'de> Deserialize<'de> for DeserializeMbid { impl From for Artist { fn from(artist: DeserializeArtist) -> Self { + let mut albums: Vec = artist.albums.into_iter().map(Into::into).collect(); + albums.sort_unstable(); Artist { meta: ArtistMeta { id: ArtistId { @@ -121,7 +127,7 @@ impl From for Artist { properties: artist.properties, }, }, - albums: artist.albums.into_iter().map(Into::into).collect(), + albums, } } } diff --git a/src/external/database/serde/mod.rs b/src/external/database/serde/mod.rs index 6ccd8c4..9ca8585 100644 --- a/src/external/database/serde/mod.rs +++ b/src/external/database/serde/mod.rs @@ -1,5 +1,5 @@ //! Helper module for backends that can use serde for (de)serialisation. -mod common; +pub mod common; pub mod deserialize; pub mod serialize; diff --git a/src/external/database/serde/serialize.rs b/src/external/database/serde/serialize.rs index a88657f..197fc71 100644 --- a/src/external/database/serde/serialize.rs +++ b/src/external/database/serde/serialize.rs @@ -22,32 +22,32 @@ impl<'a> From<&'a Collection> for SerializeDatabase<'a> { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, PartialEq, Eq)] 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)] +#[derive(Debug, Serialize, PartialEq, Eq)] 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)] +#[derive(Debug, Serialize, PartialEq, Eq)] pub struct SerializeMbRefOption<'a>( #[serde(with = "MbRefOptionDef")] MbRefOption>, ); -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct SerializeMbid<'a>(&'a Mbid); impl<'a, T: IMusicBrainzRef> From<&'a MbRefOption> for SerializeMbRefOption<'a> { diff --git a/src/external/database/sql/backend.rs b/src/external/database/sql/backend.rs new file mode 100644 index 0000000..3bc9cb6 --- /dev/null +++ b/src/external/database/sql/backend.rs @@ -0,0 +1,265 @@ +//! Module for storing MusicHoard data in a SQLite database. + +use std::path::PathBuf; + +use rusqlite::{ + self, types::FromSql, CachedStatement, Connection, Params, Row, Rows, Statement, Transaction, +}; + +use crate::{ + collection::album::AlbumDate, + external::database::{ + serde::{ + common::SerdeAlbumDate, + deserialize::{DeserializeAlbum, DeserializeArtist}, + serialize::{SerializeAlbum, SerializeArtist}, + }, + sql::{Error, ISqlDatabaseBackend, ISqlTransactionBackend}, + }, +}; + +/// SQLite database backend that uses SQLite as the implementation. +pub struct SqlDatabaseSqliteBackend { + conn: Connection, +} + +pub struct SqlTransactionSqliteBackend<'conn> { + tx: Transaction<'conn>, +} + +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()))?, + }) + } +} + +impl SqlTransactionSqliteBackend<'_> { + // We only prepare strings known at compile time so errors in prep are bugs. + fn prepare(&self, sql: &'static str) -> Statement { + self.tx.prepare(sql).unwrap() + } + + // We only prepare strings known at compile time so errors in prep are bugs. + fn prepare_cached(&self, sql: &'static str) -> CachedStatement { + self.tx.prepare_cached(sql).unwrap() + } + + fn execute(stmt: &mut Statement, params: P) -> Result<(), Error> { + stmt.execute(params) + .map(|_| ()) + .map_err(|err| Error::ExecError(err.to_string())) + } + + fn query<'s, P: Params>(stmt: &'s mut Statement, params: P) -> Result, Error> { + stmt.query(params) + .map_err(|err| Error::ExecError(err.to_string())) + } + + fn next_row<'r, 's>(rows: &'r mut Rows<'s>) -> Result>, Error> { + rows.next() + .map_err(|err| Error::SerDeError(err.to_string())) + } + + fn get_value(row: &Row<'_>, idx: usize) -> Result { + row.get(idx) + .map_err(|err| Error::SerDeError(err.to_string())) + } +} + +impl<'conn> ISqlDatabaseBackend<'conn> for SqlDatabaseSqliteBackend { + type Tx = SqlTransactionSqliteBackend<'conn>; + + fn transaction(&'conn mut self) -> Result { + self.conn + .transaction() + .map(|tx| SqlTransactionSqliteBackend { tx }) + .map_err(|err| Error::OpenError(err.to_string())) + } +} + +impl ISqlTransactionBackend for SqlTransactionSqliteBackend<'_> { + fn commit(self) -> Result<(), Error> { + self.tx + .commit() + .map_err(|err| Error::ExecError(err.to_string())) + } + + 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) ON DELETE CASCADE ON UPDATE NO ACTION + )", + ); + 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(&self, version: &str) -> Result<(), Error> { + let mut stmt = self.prepare_cached( + "INSERT INTO database_metadata (name, value) + VALUES (?1, ?2)", + ); + Self::execute(&mut stmt, ("version", version)) + } + + fn select_database_version<'a>(&self) -> Result, Error> { + let mut stmt = + self.prepare_cached("SELECT value FROM database_metadata WHERE name = 'version'"); + let mut rows = Self::query(&mut stmt, ())?; + + Self::next_row(&mut rows)? + .map(|row| Self::get_value(row, 0)) + .transpose() + } + + fn insert_artist(&self, artist: &SerializeArtist<'_>) -> 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 select_all_artists(&self) -> Result, Error> { + let mut stmt = self.prepare_cached("SELECT name, sort, mbid, properties FROM artists"); + let mut rows = Self::query(&mut stmt, ())?; + + let mut artists = vec![]; + while let Some(row) = Self::next_row(&mut rows)? { + artists.push(row.try_into()?); + } + + Ok(artists) + } + + fn insert_album(&self, artist_name: &str, album: &SerializeAlbum<'_>) -> 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)?, + ), + ) + } + + fn select_artist_albums(&self, artist_name: &str) -> Result, Error> { + let mut stmt = self.prepare_cached( + "SELECT title, lib_id, year, month, day, seq, mbid, primary_type, secondary_types + FROM albums WHERE artist_name = ?1", + ); + let mut rows = Self::query(&mut stmt, [artist_name])?; + + let mut albums = vec![]; + while let Some(row) = Self::next_row(&mut rows)? { + albums.push(row.try_into()?); + } + + Ok(albums) + } +} + +impl TryFrom<&Row<'_>> for DeserializeArtist { + type Error = Error; + + fn try_from(row: &Row<'_>) -> Result { + type Backend<'a> = SqlTransactionSqliteBackend<'a>; + Ok(DeserializeArtist { + name: Backend::get_value(row, 0)?, + sort: Backend::get_value(row, 1)?, + musicbrainz: serde_json::from_str(&Backend::get_value::(row, 2)?)?, + properties: serde_json::from_str(&Backend::get_value::(row, 3)?)?, + albums: vec![], + }) + } +} + +impl TryFrom<&Row<'_>> for DeserializeAlbum { + type Error = Error; + + fn try_from(row: &Row<'_>) -> Result { + type Backend<'a> = SqlTransactionSqliteBackend<'a>; + Ok(DeserializeAlbum { + title: Backend::get_value(row, 0)?, + lib_id: serde_json::from_str(&Backend::get_value::(row, 1)?)?, + date: SerdeAlbumDate(AlbumDate::new( + Backend::get_value(row, 2)?, + Backend::get_value(row, 3)?, + Backend::get_value(row, 4)?, + )), + seq: Backend::get_value(row, 5)?, + musicbrainz: serde_json::from_str(&Backend::get_value::(row, 6)?)?, + primary_type: serde_json::from_str(&Backend::get_value::(row, 7)?)?, + secondary_types: serde_json::from_str(&Backend::get_value::(row, 8)?)?, + }) + } +} diff --git a/src/external/database/sql/mod.rs b/src/external/database/sql/mod.rs new file mode 100644 index 0000000..78a3305 --- /dev/null +++ b/src/external/database/sql/mod.rs @@ -0,0 +1,448 @@ +//! 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::{ + deserialize::{DeserializeAlbum, DeserializeArtist, DeserializeDatabase}, + serialize::{SerializeAlbum, SerializeArtist, SerializeDatabase}, + }, +}; + +const V20250103: &str = "V20250103"; + +/// Trait for the SQL database backend. +pub trait ISqlDatabaseBackend<'conn> { + type Tx: ISqlTransactionBackend + 'conn; + + /// Begin an SQL transaction. + fn transaction(&'conn mut self) -> Result; +} + +/// Trait for the SQL database backend. +#[cfg_attr(test, automock)] +pub trait ISqlTransactionBackend { + /// Commit transaction. + fn commit(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(&self, version: &str) -> Result<(), Error>; + + /// Get the database version. + fn select_database_version(&self) -> Result, Error>; + + /// Insert an artist into the artist table. + #[allow(clippy::needless_lifetimes)] // Conflicts with automock. + fn insert_artist<'a>(&self, artist: &SerializeArtist<'a>) -> Result<(), Error>; + + /// Get all artists from the artist table. + fn select_all_artists(&self) -> Result, Error>; + + /// Insert an artist into the artist table. + #[allow(clippy::needless_lifetimes)] // Conflicts with automock. + fn insert_album<'a>(&self, artist_name: &str, album: &SerializeAlbum<'a>) -> Result<(), Error>; + + /// Get all albums from the album table. + fn select_artist_albums(&self, artist_name: &str) -> 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 during serialisation. + SerDeError(String), + /// An error occurred during SQL execution. + ExecError(String), +} + +impl From for Error { + fn from(value: serde_json::Error) -> Self { + Error::SerDeError(value.to_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::SerDeError(ref s) => write!(f, "an error occurred during serialisation : {s}"), + Self::ExecError(ref s) => write!(f, "an error occurred during SQL execution: {s}"), + } + } +} + +impl From for SaveError { + fn from(value: Error) -> Self { + match value { + Error::SerDeError(s) => SaveError::SerDeError(s), + _ => SaveError::IoError(value.to_string()), + } + } +} + +impl From for LoadError { + fn from(value: Error) -> Self { + match value { + Error::SerDeError(s) => LoadError::SerDeError(s), + _ => LoadError::IoError(value.to_string()), + } + } +} + +/// SQL database. +pub struct SqlDatabase { + backend: SDB, +} + +impl ISqlDatabaseBackend<'conn>> SqlDatabase { + /// Create a new SQL database with the provided backend, e.g. + /// [`backend::SqlDatabaseSqliteBackend`]. + pub fn new(backend: SDB) -> Result { + let mut db = SqlDatabase { backend }; + let tx = db.backend.transaction()?; + Self::create_tables(&tx)?; + tx.commit()?; + Ok(db) + } + + fn create_tables(tx: &Tx) -> Result<(), Error> { + tx.create_database_metadata_table()?; + tx.create_artists_table()?; + tx.create_albums_table()?; + Ok(()) + } + + fn drop_tables(tx: &Tx) -> Result<(), Error> { + tx.drop_albums_table()?; + tx.drop_artists_table()?; + tx.drop_database_metadata_table()?; + Ok(()) + } +} + +impl ISqlDatabaseBackend<'conn>> IDatabase for SqlDatabase { + fn load(&mut self) -> Result { + let tx = self.backend.transaction()?; + + let version = tx + .select_database_version()? + .ok_or_else(|| Error::SerDeError(String::from("missing database version")))?; + + let database = match version.as_str() { + V20250103 => { + let mut coll = tx.select_all_artists()?; + for artist in coll.iter_mut() { + artist.albums.extend(tx.select_artist_albums(&artist.name)?); + } + Ok(DeserializeDatabase::V20250103(coll)) + } + s => Err(Error::SerDeError(format!("unknown database version: {s}"))), + }?; + + tx.commit()?; + Ok(database.into()) + } + + fn save(&mut self, collection: &Collection) -> Result<(), SaveError> { + let database: SerializeDatabase = collection.into(); + let tx = self.backend.transaction()?; + + Self::drop_tables(&tx)?; + Self::create_tables(&tx)?; + + match database { + SerializeDatabase::V20250103(artists) => { + tx.insert_database_version(V20250103)?; + for artist in artists.iter() { + tx.insert_artist(artist)?; + for album in artist.albums.iter() { + tx.insert_album(artist.name, album)?; + } + } + } + } + + tx.commit()?; + Ok(()) + } +} + +#[cfg(test)] +pub mod testmod; + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + + use mockall::{predicate, Sequence}; + + use crate::{ + core::{collection::Collection, testmod::FULL_COLLECTION}, + external::database::sql::testmod::{ + DATABASE_SQL_ALBUMS, DATABASE_SQL_ARTISTS, DATABASE_SQL_VERSION, + }, + }; + + use super::*; + + 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 + } + + struct SqlDatabaseTestBackend { + pub txs: VecDeque, + } + + impl SqlDatabaseTestBackend { + fn new(txs: VecDeque) -> Self { + SqlDatabaseTestBackend { txs } + } + } + + impl ISqlDatabaseBackend<'_> for SqlDatabaseTestBackend { + type Tx = MockISqlTransactionBackend; + + fn transaction(&mut self) -> Result { + self.txs + .pop_front() + .ok_or_else(|| Error::OpenError(String::from("lol"))) + } + } + + macro_rules! then { + ($tx:ident, $seq:ident, $expect:ident) => { + $tx.$expect().times(1).in_sequence(&mut $seq) + }; + } + + macro_rules! then0 { + ($tx:ident, $seq:ident, $expect:ident) => { + then!($tx, $seq, $expect).return_once(|| Ok(())) + }; + } + + macro_rules! then1 { + ($tx:ident, $seq:ident, $expect:ident) => { + then!($tx, $seq, $expect).return_once(|_| Ok(())) + }; + } + + macro_rules! then2 { + ($tx:ident, $seq:ident, $expect:ident) => { + then!($tx, $seq, $expect).return_once(|_, _| Ok(())) + }; + } + + macro_rules! expect_create { + ($tx:ident, $seq:ident) => { + let mut seq = Sequence::new(); + then0!($tx, seq, expect_create_database_metadata_table); + then0!($tx, seq, expect_create_artists_table); + then0!($tx, seq, expect_create_albums_table); + }; + } + + macro_rules! expect_drop { + ($tx:ident, $seq:ident) => { + let mut seq = Sequence::new(); + then0!($tx, seq, expect_drop_albums_table); + then0!($tx, seq, expect_drop_artists_table); + then0!($tx, seq, expect_drop_database_metadata_table); + }; + } + + fn database(txs: VecDeque) -> SqlDatabase { + let mut backend = SqlDatabaseTestBackend::new(txs); + let mut tx = MockISqlTransactionBackend::new(); + + let mut seq = Sequence::new(); + expect_create!(tx, seq); + then0!(tx, seq, expect_commit); + + backend.txs.push_front(tx); + SqlDatabase::new(backend).unwrap() + } + + #[test] + fn save() { + let write_data = FULL_COLLECTION.to_owned(); + + let mut tx = MockISqlTransactionBackend::new(); + let mut seq = Sequence::new(); + expect_drop!(tx, seq); + expect_create!(tx, seq); + then1!(tx, seq, expect_insert_database_version).with(predicate::eq(V20250103)); + for artist in write_data.iter() { + let ac = artist.clone(); + then1!(tx, seq, expect_insert_artist) + .withf(move |a| a == &Into::::into(&ac)); + for album in artist.albums.iter() { + let (nc, ac) = (artist.meta.id.name.clone(), album.clone()); + then2!(tx, seq, expect_insert_album) + .withf(move |n, a| n == nc && a == &Into::::into(&ac)); + } + } + then0!(tx, seq, expect_commit); + + assert!(database(VecDeque::from([tx])).save(&write_data).is_ok()); + } + + #[test] + fn load() { + let mut tx = MockISqlTransactionBackend::new(); + let mut seq = Sequence::new(); + + then!(tx, seq, expect_select_database_version) + .return_once(|| Ok(Some(DATABASE_SQL_VERSION.to_string()))); + + let de_artists = DATABASE_SQL_ARTISTS.to_owned(); + let artists: Collection = de_artists.iter().cloned().map(Into::into).collect(); + then!(tx, seq, expect_select_all_artists).return_once(|| Ok(de_artists)); + + for artist in artists.iter() { + let de_albums = DATABASE_SQL_ALBUMS.get(&artist.meta.id.name).unwrap(); + then!(tx, seq, expect_select_artist_albums) + .with(predicate::eq(artist.meta.id.name.clone())) + .return_once(|_| Ok(de_albums.to_owned())); + } + + then0!(tx, seq, expect_commit); + + let read_data = database(VecDeque::from([tx])).load().unwrap(); + + let expected = expected(); + assert_eq!(read_data, expected); + } + + #[test] + fn load_missing_database_version() { + let mut tx = MockISqlTransactionBackend::new(); + let mut seq = Sequence::new(); + then!(tx, seq, expect_select_database_version).return_once(|| Ok(None)); + let error = database(VecDeque::from([tx])).load().unwrap_err(); + assert!(matches!(error, LoadError::SerDeError(_))); + } + + #[test] + fn load_unknown_database_version() { + let mut tx = MockISqlTransactionBackend::new(); + let mut seq = Sequence::new(); + then!(tx, seq, expect_select_database_version) + .return_once(|| Ok(Some(String::from("no u")))); + let error = database(VecDeque::from([tx])).load().unwrap_err(); + assert!(matches!(error, LoadError::SerDeError(_))); + } + + #[test] + fn load_backend_open_error() { + let error = database(VecDeque::from([])).load().unwrap_err(); + assert!(matches!(error, LoadError::IoError(_))); + } + + #[test] + fn save_backend_open_error() { + let error = database(VecDeque::from([])).save(&vec![]).unwrap_err(); + assert!(matches!(error, SaveError::IoError(_))); + } + + #[test] + fn load_backend_exec_error() { + let mut tx = MockISqlTransactionBackend::new(); + let mut seq = Sequence::new(); + then!(tx, seq, expect_select_database_version) + .return_once(|| Err(Error::ExecError(String::from("serde")))); + let error = database(VecDeque::from([tx])).load().unwrap_err(); + assert!(matches!(error, LoadError::IoError(_))); + } + + #[test] + fn save_backend_exec_error() { + let mut tx = MockISqlTransactionBackend::new(); + let mut seq = Sequence::new(); + expect_drop!(tx, seq); + expect_create!(tx, seq); + then!(tx, seq, expect_insert_database_version) + .with(predicate::eq(V20250103)) + .return_once(|_| Err(Error::ExecError(String::from("exec")))); + + let error = database(VecDeque::from([tx])).save(&vec![]).unwrap_err(); + assert!(matches!(error, SaveError::IoError(_))); + } + + #[test] + fn load_backend_serde_error() { + let mut tx = MockISqlTransactionBackend::new(); + let mut seq = Sequence::new(); + + then!(tx, seq, expect_select_database_version) + .return_once(|| Ok(Some(DATABASE_SQL_VERSION.to_string()))); + then!(tx, seq, expect_select_all_artists) + .return_once(|| Err(Error::SerDeError(String::from("serde")))); + + let error = database(VecDeque::from([tx])).load().unwrap_err(); + assert!(matches!(error, LoadError::SerDeError(_))); + } + + #[test] + fn save_backend_serde_error() { + let write_data = FULL_COLLECTION.to_owned(); + + let mut tx = MockISqlTransactionBackend::new(); + let mut seq = Sequence::new(); + expect_drop!(tx, seq); + expect_create!(tx, seq); + then1!(tx, seq, expect_insert_database_version).with(predicate::eq(V20250103)); + then!(tx, seq, expect_insert_artist) + .return_once(|_| Err(Error::SerDeError(String::from("serde")))); + + let error = database(VecDeque::from([tx])) + .save(&write_data) + .unwrap_err(); + assert!(matches!(error, SaveError::SerDeError(_))); + } + + #[test] + fn serde_json_error() { + let error = serde_json::from_str::("").unwrap_err(); + let error: Error = error.into(); + assert!(matches!(error, Error::SerDeError(_))); + assert!(!error.to_string().is_empty()); + } +} diff --git a/src/external/database/sql/testmod.rs b/src/external/database/sql/testmod.rs new file mode 100644 index 0000000..c03a880 --- /dev/null +++ b/src/external/database/sql/testmod.rs @@ -0,0 +1,202 @@ +use std::collections::HashMap; + +use once_cell::sync::Lazy; + +use crate::{ + collection::{ + album::{AlbumDate, AlbumLibId, AlbumPrimaryType}, + musicbrainz::MbRefOption, + }, + external::database::serde::{ + common::{SerdeAlbumDate, SerdeAlbumLibId, SerdeAlbumPrimaryType}, + deserialize::{ + DeserializeAlbum, DeserializeArtist, DeserializeMbRefOption, DeserializeMbid, + }, + }, +}; + +pub static DATABASE_SQL_VERSION: &str = "V20250103"; + +pub static DATABASE_SQL_ARTISTS: Lazy> = Lazy::new(|| { + vec![ + DeserializeArtist { + name: String::from("Album_Artist ‘A’"), + sort: None, + musicbrainz: DeserializeMbRefOption(MbRefOption::Some(DeserializeMbid( + "00000000-0000-0000-0000-000000000000".try_into().unwrap(), + ))), + properties: HashMap::from([ + ( + String::from("MusicButler"), + vec![String::from( + "https://www.musicbutler.io/artist-page/000000000", + )], + ), + ( + String::from("Qobuz"), + vec![String::from( + "https://www.qobuz.com/nl-nl/interpreter/artist-a/download-streaming-albums", + )], + ), + ]), + albums: vec![], + }, + DeserializeArtist { + name: String::from("Album_Artist ‘B’"), + sort: None, + musicbrainz: DeserializeMbRefOption(MbRefOption::Some(DeserializeMbid( + "11111111-1111-1111-1111-111111111111".try_into().unwrap(), + ))), + properties: HashMap::from([ + (String::from("MusicButler"), vec![ + String::from("https://www.musicbutler.io/artist-page/111111111"), + String::from("https://www.musicbutler.io/artist-page/111111112"), + ]), + (String::from("Bandcamp"), vec![ + String::from("https://artist-b.bandcamp.com/") + ]), + (String::from("Qobuz"), vec![ + String::from( + "https://www.qobuz.com/nl-nl/interpreter/artist-b/download-streaming-albums", + ) + ]), + ]), + albums: vec![], + }, + DeserializeArtist { + name: String::from("The Album_Artist ‘C’"), + sort: Some(String::from("Album_Artist ‘C’, The")), + musicbrainz: DeserializeMbRefOption(MbRefOption::CannotHaveMbid), + properties: HashMap::new(), + albums: vec![], + }, + DeserializeArtist { + name: String::from("Album_Artist ‘D’"), + sort: None, + musicbrainz: DeserializeMbRefOption(MbRefOption::None), + properties: HashMap::new(), + albums: vec![], + }, + ] +}); + +pub static DATABASE_SQL_ALBUMS: Lazy>> = Lazy::new(|| { + HashMap::from([ + ( + String::from("Album_Artist ‘A’"), + vec![ + DeserializeAlbum { + title: String::from("album_title a.a"), + lib_id: SerdeAlbumLibId(AlbumLibId::Value(1)), + date: SerdeAlbumDate(AlbumDate::new(Some(1998), None, None)), + seq: 1, + musicbrainz: DeserializeMbRefOption(MbRefOption::Some(DeserializeMbid( + "00000000-0000-0000-0000-000000000000".try_into().unwrap(), + ))), + primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)), + secondary_types: vec![], + }, + DeserializeAlbum { + title: String::from("album_title a.b"), + lib_id: SerdeAlbumLibId(AlbumLibId::Value(2)), + date: SerdeAlbumDate(AlbumDate::new(Some(2015), Some(4), None)), + seq: 1, + musicbrainz: DeserializeMbRefOption(MbRefOption::None), + primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)), + secondary_types: vec![], + }, + ], + ), + ( + String::from("Album_Artist ‘B’"), + vec![ + DeserializeAlbum { + title: String::from("album_title b.a"), + lib_id: SerdeAlbumLibId(AlbumLibId::Value(3)), + date: SerdeAlbumDate(AlbumDate::new(Some(2003), Some(6), Some(6))), + seq: 1, + musicbrainz: DeserializeMbRefOption(MbRefOption::None), + primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)), + secondary_types: vec![], + }, + DeserializeAlbum { + title: String::from("album_title b.b"), + lib_id: SerdeAlbumLibId(AlbumLibId::Value(4)), + date: SerdeAlbumDate(AlbumDate::new(Some(2008), None, None)), + seq: 3, + musicbrainz: DeserializeMbRefOption(MbRefOption::Some(DeserializeMbid( + "11111111-1111-1111-1111-111111111111".try_into().unwrap(), + ))), + primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)), + secondary_types: vec![], + }, + DeserializeAlbum { + title: String::from("album_title b.c"), + lib_id: SerdeAlbumLibId(AlbumLibId::Value(5)), + date: SerdeAlbumDate(AlbumDate::new(Some(2009), None, None)), + seq: 2, + musicbrainz: DeserializeMbRefOption(MbRefOption::Some(DeserializeMbid( + "11111111-1111-1111-1111-111111111112".try_into().unwrap(), + ))), + primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)), + secondary_types: vec![], + }, + DeserializeAlbum { + title: String::from("album_title b.d"), + lib_id: SerdeAlbumLibId(AlbumLibId::Value(6)), + date: SerdeAlbumDate(AlbumDate::new(Some(2015), None, None)), + seq: 4, + musicbrainz: DeserializeMbRefOption(MbRefOption::None), + primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)), + secondary_types: vec![], + }, + ], + ), + ( + String::from("The Album_Artist ‘C’"), + vec![ + DeserializeAlbum { + title: String::from("album_title c.a"), + lib_id: SerdeAlbumLibId(AlbumLibId::Value(7)), + date: SerdeAlbumDate(AlbumDate::new(Some(1985), None, None)), + seq: 0, + musicbrainz: DeserializeMbRefOption(MbRefOption::None), + primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)), + secondary_types: vec![], + }, + DeserializeAlbum { + title: String::from("album_title c.b"), + lib_id: SerdeAlbumLibId(AlbumLibId::Value(8)), + date: SerdeAlbumDate(AlbumDate::new(Some(2018), None, None)), + seq: 0, + musicbrainz: DeserializeMbRefOption(MbRefOption::None), + primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)), + secondary_types: vec![], + }, + ], + ), + ( + String::from("Album_Artist ‘D’"), + vec![ + DeserializeAlbum { + title: String::from("album_title d.a"), + lib_id: SerdeAlbumLibId(AlbumLibId::Value(9)), + date: SerdeAlbumDate(AlbumDate::new(Some(1995), None, None)), + seq: 0, + musicbrainz: DeserializeMbRefOption(MbRefOption::None), + primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)), + secondary_types: vec![], + }, + DeserializeAlbum { + title: String::from("album_title d.b"), + lib_id: SerdeAlbumLibId(AlbumLibId::Value(10)), + date: SerdeAlbumDate(AlbumDate::new(Some(2028), None, None)), + seq: 0, + musicbrainz: DeserializeMbRefOption(MbRefOption::None), + primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)), + secondary_types: vec![], + }, + ], + ), + ]) +}); diff --git a/tests/database/json.rs b/tests/database/json.rs index da96d9e..9e13334 100644 --- a/tests/database/json.rs +++ b/tests/database/json.rs @@ -43,7 +43,7 @@ fn save() { #[test] fn load() { let backend = JsonDatabaseFileBackend::new(&*DATABASE_TEST_FILE); - let database = JsonDatabase::new(backend); + let mut database = JsonDatabase::new(backend); let read_data: Vec = database.load().unwrap(); diff --git a/tests/database/mod.rs b/tests/database/mod.rs index 4e33b53..c088ad8 100644 --- a/tests/database/mod.rs +++ b/tests/database/mod.rs @@ -1,3 +1,4 @@ -#![cfg(feature = "database-json")] - +#[cfg(feature = "database-json")] pub mod json; +#[cfg(feature = "database-sqlite")] +pub mod sql; diff --git a/tests/database/sql.rs b/tests/database/sql.rs new file mode 100644 index 0000000..ea7461a --- /dev/null +++ b/tests/database/sql.rs @@ -0,0 +1,63 @@ +use std::{fs, path::PathBuf}; + +use once_cell::sync::Lazy; + +use musichoard::{ + collection::{artist::Artist, Collection}, + external::database::sql::{backend::SqlDatabaseSqliteBackend, SqlDatabase}, + interface::database::IDatabase, +}; +use tempfile::NamedTempFile; + +use crate::testlib::COLLECTION; + +pub static DATABASE_TEST_FILE: Lazy = + Lazy::new(|| fs::canonicalize("./tests/files/database/database.db").unwrap()); + +fn expected() -> Collection { + let mut expected = COLLECTION.to_owned(); + for artist in expected.iter_mut() { + for album in artist.albums.iter_mut() { + album.tracks.clear(); + } + } + expected +} + +#[test] +fn save() { + let file = NamedTempFile::new().unwrap(); + + let backend = SqlDatabaseSqliteBackend::new(file.path()).unwrap(); + let mut database = SqlDatabase::new(backend).unwrap(); + + let write_data = COLLECTION.to_owned(); + database.save(&write_data).unwrap(); +} + +#[test] +fn load() { + let backend = SqlDatabaseSqliteBackend::new(&*DATABASE_TEST_FILE).unwrap(); + let mut database = SqlDatabase::new(backend).unwrap(); + + let read_data: Vec = database.load().unwrap(); + + let expected = expected(); + assert_eq!(read_data, expected); +} + +#[test] +fn reverse() { + let file = NamedTempFile::new().unwrap(); + + let backend = SqlDatabaseSqliteBackend::new(file.path()).unwrap(); + let mut database = SqlDatabase::new(backend).unwrap(); + + let write_data = COLLECTION.to_owned(); + database.save(&write_data).unwrap(); + let read_data: Vec = database.load().unwrap(); + + // Not all data is saved into database. + let expected = expected(); + assert_eq!(read_data, expected); +} diff --git a/tests/files/database/database.db b/tests/files/database/database.db new file mode 100644 index 0000000000000000000000000000000000000000..9fe47ada8d16334914faad64d9c5f70de30597aa GIT binary patch literal 28672 zcmeI5Uu@e%9LMdtO}d4~Y!ig2LbY6#ZDMZXI8N>I0BPsJSl5oG9gM1qF7~B0b?nS` zT1r*1bqI;Ky`eoI@x%iU6B6PD0Yc)>8(JrjKtkf7uY2JI!4u*fr(V~jMMNM0`kmtV zeCOZ!zMnf=`d#jv(hF0jkGZPj){xIh)WZ}_Q_pZ5MNtW|#mTlCo+gpr-2<|x_rwp1 zCaBTXw+7hXs90i@O8(A%H1JLGPGWT6-GdAu9S8scAOHk_01yBIKmZ8*dkDPY^u<%@ zH2s$9ql$%*RcX|`@H;*^s}<)oZmu{nrEy^zcj_p&Klr9^VQx-)X^xwoA=~`a6nAO% z{Kewz74Cv|*DAB4zmSXL)M z9@ZV(*tPZ!GGAEiWPEOBRy+UfbkJtpsXfq6bFkx`pH-<^>mu1OxwV(dRHyttd&v2cbdoL zu$z^|aNm3Vbtaw~9Hei|huyQ;lwPY6eGhifFt1Y&ZEyNN>7btD`mG*KdUJaHi& zU8jyoHSvKhv$1({A1e3?;>-!U^u)#fM;VcxH4V)9%b1%omzI6ow3p&6 zGfdCh6&ItGU>#BHVP=HBgve?K?vjO;8o2K%W`o|j_MVNr*D&`A_Iz%|{o+H5MAn=$ zT;1`%ytRZllhc>i&{tcCvk|vu;uVwF^d4o>bjd=tiI~S2nZEqR`!=`W;+<00KY&2mk>f00e*l5cmrOuD{e5 z8=_XWiWkW;V#_jhw85908s^9Of?Um4v$`zcs)~iYn$-n_u_UNj6^|iYsN_(E-`wCY zHat_GXpmP0xcvA6zwG;UZ#5yt!&LdhtVpwcdp&?xCydix`u7PLsFz$&*^zXE+D~>RaM9%Q4uOeu1drg@_C#S zRYljk*PT;EQC12{cgmNWQUp&3mVO694kz$3(`;ProJ-zr;1S<05-A}yD@w8?RCV1T zBCCdgjI1cAdRD6DWwAoqyL*vJt{^LllFfG2yw%;>{)xDrP@HBpRPj5mt?i-WwvN-Wk@U)q;q9Au zuWf(4{n_@X+aDC&7014Re^wAOa)q2B$TF@9`P>*16iFWws$;U0RSWrQPRs==Cy;IE zs8-*5hoNRsHOJGPwb4rJgy!8Ai0bZ+&M8tpugHqn6_>C^he8Q{|Lr%oJU?F84F519@*?AR_+s!k7W@J~Wv*g= zaUz}&cTJDA&s~p7lG{r9e7B@UEtVJ_PD{j61lm@gJjNCi@a2tzsTI zws(F!Um~+oEbKcrAIj-iPVm1E3yVv=iH%54mvic)dP}W(NA7j}ZdBU>lSoGw3Mb%4 zcDa^a