Add trait to decouple file from json

This commit is contained in:
Wojciech Kozlowski 2023-03-31 23:10:51 +09:00
parent 7400c5ae10
commit ac8984a9a8
4 changed files with 138 additions and 80 deletions

View File

@ -1,46 +1,39 @@
//! Module for storing MusicHoard data in a JSON file database. //! Module for storing MusicHoard data in a JSON file database.
use std::fs::File; use std::fs::{read_to_string, write};
use std::io::{Read, Write}; use std::path::PathBuf;
use std::path::Path;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::Serialize; use serde::Serialize;
use super::{DatabaseRead, DatabaseWrite}; use super::{DatabaseRead, DatabaseWrite};
/// Trait for the JSON database backend.
pub trait DatabaseJsonBackend {
/// Read the JSON string from the backend.
fn read(&self) -> Result<String, std::io::Error>;
/// Write the JSON string to the backend.
fn write(&mut self, json: &str) -> Result<(), std::io::Error>;
}
/// A JSON file database. /// A JSON file database.
pub struct DatabaseJson { pub struct DatabaseJson {
database_file: File, backend: Box<dyn DatabaseJsonBackend>,
} }
impl DatabaseJson { impl DatabaseJson {
/// Create a read-only database instance. If the JSON file does not exist, an error is returned. pub fn new(backend: Box<dyn DatabaseJsonBackend>) -> Self {
pub fn reader(path: &Path) -> Result<Self, std::io::Error> { DatabaseJson { backend }
Ok(Self {
database_file: File::open(path)?,
})
}
/// Create a write-only database instance. If the file does not exist, it is created, if it does
/// exist, it is truncated.
pub fn writer(path: &Path) -> Result<Self, std::io::Error> {
Ok(Self {
database_file: File::create(path)?,
})
} }
} }
impl DatabaseRead for DatabaseJson { impl DatabaseRead for DatabaseJson {
fn read<D>(&mut self, collection: &mut D) -> Result<(), std::io::Error> fn read<D>(&self, collection: &mut D) -> Result<(), std::io::Error>
where where
D: DeserializeOwned, D: DeserializeOwned,
{ {
// Read entire file to memory as for now this is faster than a buffered read from disk: let serialized = self.backend.read()?;
// https://github.com/serde-rs/json/issues/160
let mut serialized = String::new();
self.database_file.read_to_string(&mut serialized)?;
*collection = serde_json::from_str(&serialized)?; *collection = serde_json::from_str(&serialized)?;
Ok(()) Ok(())
} }
@ -52,21 +45,54 @@ impl DatabaseWrite for DatabaseJson {
S: Serialize, S: Serialize,
{ {
let serialized = serde_json::to_string(&collection)?; let serialized = serde_json::to_string(&collection)?;
self.database_file.write_all(serialized.as_bytes())?; self.backend.write(&serialized)?;
Ok(()) Ok(())
} }
} }
pub struct DatabaseJsonFile {
path: PathBuf,
}
impl DatabaseJsonFile {
/// Create a database instance that will read/write to the provided path.
pub fn new(path: PathBuf) -> Self {
DatabaseJsonFile { path }
}
}
impl DatabaseJsonBackend for DatabaseJsonFile {
fn read(&self) -> Result<String, std::io::Error> {
// Read entire file to memory as for now this is faster than a buffered read from disk:
// https://github.com/serde-rs/json/issues/160
read_to_string(&self.path)
}
fn write(&mut self, json: &str) -> Result<(), std::io::Error> {
write(&self.path, json)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::path::Path;
use tempfile::NamedTempFile;
use super::*; use super::*;
use crate::{Album, AlbumId, Track}; use crate::{Album, AlbumId, Track};
const TEST_FILENAME: &str = "tests/files/database_json_test.json"; struct DatabaseJsonTest {
json: String,
}
impl DatabaseJsonBackend for DatabaseJsonTest {
fn read(&self) -> Result<String, std::io::Error> {
Ok(self.json.clone())
}
fn write(&mut self, json: &str) -> Result<(), std::io::Error> {
assert_eq!(self.json, json);
Ok(())
}
}
fn test_data() -> Vec<Album> { fn test_data() -> Vec<Album> {
vec![ vec![
@ -109,55 +135,91 @@ mod tests {
] ]
} }
fn album_to_json(album: &Album) -> String {
let album_id_artist = &album.id.artist;
let album_id_year = album.id.year;
let album_id_title = &album.id.title;
let mut album_tracks: Vec<String> = vec![];
for track in album.tracks.iter() {
let track_number = track.number;
let track_title = &track.title;
let mut track_artist: Vec<String> = vec![];
for artist in track.artist.iter() {
track_artist.push(format!("\"{artist}\""))
}
let track_artist = track_artist.join(",");
album_tracks.push(format!(
"{{\
\"number\":{track_number},\
\"title\":\"{track_title}\",\
\"artist\":[{track_artist}]\
}}"
));
}
let album_tracks = album_tracks.join(",");
format!(
"{{\
\"id\":{{\
\"artist\":\"{album_id_artist}\",\
\"year\":{album_id_year},\
\"title\":\"{album_id_title}\"\
}},\"tracks\":[{album_tracks}]\
}}"
)
}
fn albums_to_json(albums: &Vec<Album>) -> String {
let mut albums_strings: Vec<String> = vec![];
for album in albums.iter() {
albums_strings.push(album_to_json(album));
}
let albums_json = albums_strings.join(",");
format!("[{albums_json}]")
}
#[test] #[test]
fn write() { fn write() {
let write_data = test_data(); let write_data = test_data();
let temp_file = NamedTempFile::new().unwrap(); let backend = DatabaseJsonTest {
DatabaseJson::writer(temp_file.path()) json: albums_to_json(&write_data),
.unwrap() };
DatabaseJson::new(Box::new(backend))
.write(&write_data) .write(&write_data)
.unwrap(); .unwrap();
let mut write_data_str = String::new();
File::open(temp_file.path())
.unwrap()
.read_to_string(&mut write_data_str)
.unwrap();
let mut test_data_str = String::new();
File::open(TEST_FILENAME)
.unwrap()
.read_to_string(&mut test_data_str)
.unwrap();
assert_eq!(write_data_str, test_data_str);
} }
#[test] #[test]
fn read() { fn read() {
let expected = test_data();
let backend = DatabaseJsonTest {
json: albums_to_json(&expected),
};
let mut read_data: Vec<Album> = vec![]; let mut read_data: Vec<Album> = vec![];
DatabaseJson::reader(Path::new(TEST_FILENAME)) DatabaseJson::new(Box::new(backend))
.unwrap()
.read(&mut read_data) .read(&mut read_data)
.unwrap(); .unwrap();
assert_eq!(read_data, test_data());
assert_eq!(read_data, expected);
} }
#[test] #[test]
fn reverse() { fn reverse() {
let expected = test_data();
let backend = DatabaseJsonTest {
json: albums_to_json(&expected),
};
let mut database = DatabaseJson::new(Box::new(backend));
let write_data = test_data(); let write_data = test_data();
let mut read_data: Vec<Album> = vec![]; let mut read_data: Vec<Album> = vec![];
database.write(&write_data).unwrap();
let temp_file = NamedTempFile::new().unwrap(); database.read(&mut read_data).unwrap();
DatabaseJson::writer(temp_file.path())
.unwrap()
.write(&write_data)
.unwrap();
DatabaseJson::reader(temp_file.path())
.unwrap()
.read(&mut read_data)
.unwrap();
assert_eq!(write_data, read_data); assert_eq!(write_data, read_data);
} }

View File

@ -8,7 +8,7 @@ pub mod json;
/// Trait for database reads. /// Trait for database reads.
pub trait DatabaseRead { pub trait DatabaseRead {
/// Read collection from the database. /// Read collection from the database.
fn read<D>(&mut self, collection: &mut D) -> Result<(), std::io::Error> fn read<D>(&self, collection: &mut D) -> Result<(), std::io::Error>
where where
D: DeserializeOwned; D: DeserializeOwned;
} }

View File

@ -80,7 +80,7 @@ impl QueryArgsBeets for Query {
/// Trait for invoking beets commands. /// Trait for invoking beets commands.
pub trait BeetsExecutor { pub trait BeetsExecutor {
/// Invoke beets with the provided arguments. /// Invoke beets with the provided arguments.
fn exec(&mut self, arguments: Vec<String>) -> Result<Vec<String>, Error>; fn exec(&mut self, arguments: &[String]) -> Result<Vec<String>, Error>;
} }
/// Struct for interacting with the music library via beets. /// Struct for interacting with the music library via beets.
@ -106,7 +106,7 @@ impl Beets {
impl Library for Beets { impl Library for Beets {
fn list(&mut self, query: &Query) -> Result<Vec<Album>, Error> { fn list(&mut self, query: &Query) -> Result<Vec<Album>, Error> {
let cmd = Self::list_cmd_and_args(query); let cmd = Self::list_cmd_and_args(query);
let output = self.executor.exec(cmd)?; let output = self.executor.exec(&cmd)?;
Self::list_to_albums(output) Self::list_to_albums(output)
} }
} }
@ -209,7 +209,7 @@ impl Default for SystemExecutor {
} }
impl BeetsExecutor for SystemExecutor { impl BeetsExecutor for SystemExecutor {
fn exec(&mut self, arguments: Vec<String>) -> Result<Vec<String>, Error> { fn exec(&mut self, arguments: &[String]) -> Result<Vec<String>, Error> {
let output = Command::new(&self.bin).args(arguments).output()?; let output = Command::new(&self.bin).args(arguments).output()?;
let output = std::str::from_utf8(&output.stdout)?; let output = std::str::from_utf8(&output.stdout)?;
Ok(output.split('\n').map(|s| s.to_string()).collect()) Ok(output.split('\n').map(|s| s.to_string()).collect())
@ -226,10 +226,8 @@ mod tests {
} }
impl BeetsExecutor for TestExecutor { impl BeetsExecutor for TestExecutor {
fn exec(&mut self, arguments: Vec<String>) -> Result<Vec<String>, Error> { fn exec(&mut self, arguments: &[String]) -> Result<Vec<String>, Error> {
if self.arguments.is_some() { assert_eq!(&self.arguments.take().unwrap(), arguments);
assert_eq!(self.arguments.take().unwrap(), arguments);
}
self.output.take().unwrap() self.output.take().unwrap()
} }
} }
@ -303,6 +301,14 @@ mod tests {
strings strings
} }
fn albums_to_beets_string(albums: &Vec<Album>) -> Vec<String> {
let mut strings = vec![];
for album in albums.iter() {
strings.append(&mut album_to_beets_string(album));
}
strings
}
#[test] #[test]
fn test_query() { fn test_query() {
let query = Query::new() let query = Query::new()
@ -341,10 +347,7 @@ mod tests {
#[test] #[test]
fn test_list_ordered() { fn test_list_ordered() {
let expected = test_data(); let expected = test_data();
let mut output = vec![]; let output = albums_to_beets_string(&expected);
for album in expected.iter() {
output.append(&mut album_to_beets_string(album));
}
let executor = TestExecutor { let executor = TestExecutor {
arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT_ARG.to_string()]), arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT_ARG.to_string()]),
@ -359,10 +362,7 @@ mod tests {
#[test] #[test]
fn test_list_unordered() { fn test_list_unordered() {
let mut expected = test_data(); let mut expected = test_data();
let mut output = vec![]; let mut output = albums_to_beets_string(&expected);
for album in expected.iter() {
output.append(&mut album_to_beets_string(album));
}
let last = output.len() - 1; let last = output.len() - 1;
output.swap(0, last); output.swap(0, last);
@ -391,10 +391,7 @@ mod tests {
expected[1].id.year = expected[0].id.year; expected[1].id.year = expected[0].id.year;
expected[1].id.title = expected[0].id.title.clone(); expected[1].id.title = expected[0].id.title.clone();
let mut output = vec![]; let output = albums_to_beets_string(&expected);
for album in expected.iter() {
output.append(&mut album_to_beets_string(album));
}
let executor = TestExecutor { let executor = TestExecutor {
arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT_ARG.to_string()]), arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT_ARG.to_string()]),

View File

@ -1 +0,0 @@
[{"id":{"artist":"Artist A","year":1998,"title":"Release group A"},"tracks":[{"number":1,"title":"Track A.1","artist":["Artist A.A"]},{"number":2,"title":"Track A.2","artist":["Artist A.A"]},{"number":3,"title":"Track A.3","artist":["Artist A.A","Artist A.B"]}]},{"id":{"artist":"Artist B","year":2008,"title":"Release group B"},"tracks":[{"number":1,"title":"Track B.1","artist":["Artist B.A"]}]}]