From a4d566360c9216cb34ed062166d55c40e226e8bb Mon Sep 17 00:00:00 2001 From: noctuum <25441068+noctuum@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:50:37 +0500 Subject: [PATCH] feat(store): keep each list as it was last read A show keeps its link to an entry after the entry leaves the list. --- crates/core/src/lib.rs | 1 + crates/core/src/list.rs | 163 ++++++++ crates/daemon/src/detection.rs | 2 + crates/store/src/lib.rs | 62 ++- crates/store/src/list.rs | 683 +++++++++++++++++++++++++++++++++ crates/store/src/migrate.rs | 145 ++++++- crates/store/src/queue.rs | 73 +--- 7 files changed, 1053 insertions(+), 76 deletions(-) create mode 100644 crates/core/src/list.rs create mode 100644 crates/store/src/list.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 7716cd3..9ecaa66 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -7,6 +7,7 @@ pub mod clock; pub mod encoding; +pub mod list; pub mod path; pub mod policy; pub mod recognise; diff --git a/crates/core/src/list.rs b/crates/core/src/list.rs new file mode 100644 index 0000000..cfc6bba --- /dev/null +++ b/crates/core/src/list.rs @@ -0,0 +1,163 @@ +//! What a list holds. +//! +//! A list is the entries of one account on one service. An entry is two +//! things at once: a work as the service knows it, and where the account +//! stands on it. + +use std::time::Duration; + +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize}; + +/// An entry, by the number the service gives the work: `Media.id` on +/// AniList. +/// +/// The number of the work and not of the account's entry for it, so it is +/// the same on every account and outlives the entry leaving a list. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct EntryId(pub i64); + +/// One entry of a list, as the service gives it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + /// The work. + pub id: EntryId, + /// The title a person is shown. + pub title: String, + /// Every other spelling the service gives of the title. + pub spellings: Vec, + /// What kind of release the work is, and none where the service does + /// not say. + pub format: Option, + /// How many episodes the work has, and none where the service holds no + /// count. + pub episodes: Option, + /// How long one episode is, and none where the service does not say. + pub episode_length: Option, + /// The year the work began, and none where the service does not say. + pub year: Option, + /// Whether any of the work has been released. + pub released: bool, + /// Where the account stands on the work. + pub status: Status, + /// How many episodes the account has seen. + pub progress: u32, + /// How many times the account has seen the work again. + pub rewatches: u32, + /// The account's score, and none where it gave none. + pub score: Option, +} + +/// What kind of release a work is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Format { + /// `TV` on AniList. + Tv, + /// `TV_SHORT` on AniList. + TvShort, + /// `MOVIE` on AniList. + Film, + /// `SPECIAL` on AniList. + Special, + /// `OVA` on AniList. + Ova, + /// `ONA` on AniList. + Ona, + /// `MUSIC` on AniList. + Music, +} + +/// Where a show stands on a list. +/// +/// `Rewatching` is a status here because it is one on AniList and on +/// Shikimori. MyAnimeList and Kitsu keep it as a flag beside the status. The +/// other five are on all four lists, which name them differently. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Status { + /// Meant to be watched and not begun. + Planned, + /// Being watched. + Watching, + /// Being watched again. + Rewatching, + /// Watched to the end. + Completed, + /// Set aside, to be taken up again. + OnHold, + /// Given up. + Dropped, +} + +/// A score in whole points, from 1 to 100. +/// +/// On AniList each user chooses the format a score is shown in, and the list +/// takes and answers a score in a hundred points whichever was chosen. +/// MyAnimeList and Shikimori score out of ten and Kitsu out of twenty, and +/// both multiply into a hundred exactly. Nought is not a rating: an entry +/// with no score carries none. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct Rating(u8); + +impl Rating { + /// A rating, or nothing where the points are not from 1 to 100. + #[must_use] + pub fn new(points: u8) -> Option { + (1..=100).contains(&points).then_some(Self(points)) + } + + /// The points, from 1 to 100. + #[must_use] + pub const fn points(self) -> u8 { + self.0 + } +} + +/// Read through [`Rating::new`], so that a score out of range is refused +/// where a derived implementation would let it in. +impl<'de> Deserialize<'de> for Rating { + fn deserialize>(deserializer: D) -> Result { + let points = u8::deserialize(deserializer)?; + Self::new(points).ok_or_else(|| { + D::Error::custom(format_args!( + "rating is from 1 to 100, and this is {points}" + )) + }) + } +} + +#[cfg(test)] +mod tests { + use super::Rating; + + #[test] + fn a_rating_is_from_one_to_a_hundred() { + // Nought is no rating, and no rating is `None`: two ways to say it + // would be one too many. + assert_eq!(Rating::new(0), None); + assert_eq!(Rating::new(1).map(Rating::points), Some(1)); + assert_eq!(Rating::new(100).map(Rating::points), Some(100)); + assert_eq!(Rating::new(101), None); + } + + #[test] + fn a_rating_read_out_of_range_is_refused_with_its_points() { + for points in [0, 101] { + let refused = serde_json::from_str::(&points.to_string()) + .expect_err("no rating") + .to_string(); + + assert_eq!( + refused, + format!("rating is from 1 to 100, and this is {points}") + ); + } + assert_eq!( + serde_json::from_str::("80") + .map(Rating::points) + .ok(), + Some(80) + ); + } +} diff --git a/crates/daemon/src/detection.rs b/crates/daemon/src/detection.rs index e1284b7..ec97d2f 100644 --- a/crates/daemon/src/detection.rs +++ b/crates/daemon/src/detection.rs @@ -170,6 +170,8 @@ fn refused(error: benshi_store::Error) -> TaskError { | Error::Write(_) | Error::Operation { .. } | Error::Orphan { .. } + | Error::Entry { .. } + | Error::Showless { .. } | Error::Media { .. } | Error::Instant { .. } => TaskError::Permanent(error.into()), } diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 6b7b938..61008ec 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -1,8 +1,8 @@ //! The canonical local store. //! -//! One SQLite database holds what was watched, what is being watched, and the -//! operations still to be delivered to a list. Migrations are numbered and each -//! has a test. +//! One SQLite database holds what was watched, what is being watched, the +//! operations still to be delivered to a list, and what each list held when it +//! was last read. Migrations are numbered and each has a test. //! //! Recording an episode does not depend on a list being reachable. A network //! problem, a dead API or an expired token is a delivery problem, so marking an @@ -17,6 +17,7 @@ //! television, where the system may delete it between runs. Which directory //! that is belongs to the layer that knows the platform. +mod list; mod migrate; pub mod queue; mod record; @@ -27,6 +28,7 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use benshi_core::ShowId; +use benshi_core::list::EntryId; use rusqlite::Connection; pub use migrate::SCHEMA_VERSION; @@ -112,6 +114,31 @@ pub enum Error { /// The row's `id` in `sync_queue`. id: i64, }, + /// A row of `list_entries` that cannot be read as an entry: a status or + /// a format this build does not know, a score that is not a rating, or + /// spellings that are not a list of text. + #[error("entry {} of {backend} cannot be read: {cause}", .entry.0)] + Entry { + /// The backend whose list holds the row. + backend: String, + /// The entry the row is of. + entry: EntryId, + /// What the column failed on. + #[source] + cause: serde_json::Error, + }, + /// A row of `list_entries` with no show: its link in `backend_ids` or the + /// show the link names is not there. + /// + /// Foreign keys keep one from being written through this crate, as they + /// keep an [`Error::Orphan`]. + #[error("entry {} of {backend} has no show", .entry.0)] + Showless { + /// The backend whose list holds the row. + backend: String, + /// The entry the row is of. + entry: EntryId, + }, /// A row of `watching` whose media is not a path as this crate writes /// one: a per-cent sign with no two hexadecimal digits after it. #[error("media of row {id} in `watching` is not an escaped path: {text:?}")] @@ -297,6 +324,7 @@ mod tests { use std::time::{Duration, SystemTime, UNIX_EPOCH}; use benshi_core::ShowId; + use benshi_core::list::EntryId; use benshi_core::path::RawPath; use tempfile::TempDir; @@ -547,6 +575,17 @@ mod tests { assert!(matches!(refused, Err(Error::Busy(_))), "got {refused:?}"); } + #[test] + fn a_list_filed_while_another_connection_writes_is_refused_as_busy() { + let (_home, database) = a_home(); + let mut store = a_store_in_a_hurry(&database); + let _writer = a_writer(&database); + + let refused = store.file("anilist", &[]); + + assert!(matches!(refused, Err(Error::Busy(_))), "got {refused:?}"); + } + #[test] fn an_episode_recorded_while_another_connection_writes_is_refused_as_busy() { let (_home, database) = a_home(); @@ -687,6 +726,23 @@ mod tests { Error::Orphan { id: 7 }.to_string(), "queued operation 7 is for a show that is not there" ); + assert_eq!( + Error::Entry { + backend: "anilist".to_owned(), + entry: EntryId(21), + cause: unread() + } + .to_string(), + format!("entry 21 of anilist cannot be read: {}", unread()) + ); + assert_eq!( + Error::Showless { + backend: "anilist".to_owned(), + entry: EntryId(21) + } + .to_string(), + "entry 21 of anilist has no show" + ); assert_eq!( Error::Media { id: 7, diff --git a/crates/store/src/list.rs b/crates/store/src/list.rs new file mode 100644 index 0000000..5195caa --- /dev/null +++ b/crates/store/src/list.rs @@ -0,0 +1,683 @@ +//! What each list held when it was last read. +//! +//! A list is filed whole: every entry of one backend's list, in one +//! transaction. An entry the backend has not linked to a show yet is filed as +//! a show of its own and linked to it in `backend_ids`. **A link outlives the +//! entry leaving the list**, so an entry that comes back is the show it was, +//! with what was watched of it. What the entries hold, in `list_entries`, is +//! replaced whole at each filing. + +use std::time::Duration; + +use benshi_core::ShowId; +use benshi_core::list::{Entry, EntryId, Rating}; +use rusqlite::{OptionalExtension, Row, Transaction, params}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::{Error, Store}; + +impl Store { + /// File every entry of one backend's list, in place of what it held. + /// + /// An entry with no show is filed as a show titled as the entry is, and + /// linked to it. A show that is linked takes the entry's title. An entry + /// of the list before that is not among these keeps its show and its + /// link. + /// + /// The length of an episode is kept in whole milliseconds, and one longer + /// than the column holds is kept as the longest it holds. + /// + /// # Errors + /// + /// [`Error::Busy`] when another connection has the database, and + /// [`Error::Write`] when a statement fails for any other reason, an entry + /// that is in the list twice among them. Nothing is filed with either. + pub fn file(&mut self, backend: &str, entries: &[Entry]) -> Result<(), Error> { + let transaction = self.connection.transaction()?; + transaction.execute("DELETE FROM list_entries WHERE backend = ?1", [backend])?; + for entry in entries { + link(&transaction, backend, entry)?; + transaction.execute( + "INSERT INTO list_entries (backend, entry, spellings, format, episodes, \ + episode_ms, year, released, status, progress, rewatches, score) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + params![ + backend, + entry.id.0, + serde_json::to_string(&entry.spellings).map_err(unwritable)?, + entry.format.map(name).transpose()?, + entry.episodes, + entry + .episode_length + .map(|length| i64::try_from(length.as_millis()).unwrap_or(i64::MAX)), + entry.year, + entry.released, + name(entry.status)?, + entry.progress, + entry.rewatches, + entry.score.map(Rating::points), + ], + )?; + } + transaction.commit()?; + + Ok(()) + } + + /// Every show of a backend's list and what its entry holds, lowest show + /// first. + /// + /// # Errors + /// + /// [`Error::Busy`] when the database cannot be read because another + /// connection has it, and [`Error::Write`] when it cannot be read for any + /// other reason, a count, a length or a year out of the range of its + /// field included. [`Error::Showless`] for a row whose show is not there + /// and [`Error::Entry`] for a row that cannot be read as an entry, a + /// score out of range among them, each naming the entry: the reading + /// fails whole rather than answering without it. + pub fn listed(&self, backend: &str) -> Result, Error> { + let mut statement = self.connection.prepare( + "SELECT backend_ids.show, shows.title, entry, spellings, format, episodes, \ + episode_ms, year, released, status, progress, rewatches, score \ + FROM list_entries \ + LEFT JOIN backend_ids USING (backend, entry) \ + LEFT JOIN shows ON shows.id = backend_ids.show \ + WHERE backend = ?1 \ + ORDER BY backend_ids.show", + )?; + + statement + .query_and_then([backend], |row| read(backend, row))? + .collect() + } +} + +/// Link an entry to its show, filing the show where the backend has none, +/// and give the show the entry's title. +fn link(transaction: &Transaction, backend: &str, entry: &Entry) -> Result<(), Error> { + let linked: Option = transaction + .query_row( + "SELECT show FROM backend_ids WHERE backend = ?1 AND entry = ?2", + params![backend, entry.id.0], + |row| row.get(0), + ) + .optional()?; + if let Some(show) = linked { + transaction.execute( + "UPDATE shows SET title = ?2 WHERE id = ?1", + params![show, entry.title], + )?; + } else { + let show: i64 = transaction.query_row( + "INSERT INTO shows (title) VALUES (?1) RETURNING id", + [&entry.title], + |row| row.get(0), + )?; + transaction.execute( + "INSERT INTO backend_ids (show, backend, entry) VALUES (?1, ?2, ?3)", + params![show, backend, entry.id.0], + )?; + } + + Ok(()) +} + +/// The show and the entry one row of [`Store::listed`] holds. +fn read(backend: &str, row: &Row) -> Result<(ShowId, Entry), Error> { + let id = EntryId(row.get(2)?); + let show: Option = row.get(0)?; + let title: Option = row.get(1)?; + let (Some(show), Some(title)) = (show, title) else { + return Err(Error::Showless { + backend: backend.to_owned(), + entry: id, + }); + }; + let unread = |cause| Error::Entry { + backend: backend.to_owned(), + entry: id, + cause, + }; + let episode_ms: Option = row.get(6)?; + let episode_length = episode_ms + .map(|ms| { + u64::try_from(ms) + .map(Duration::from_millis) + .map_err(|_| Error::Write(rusqlite::Error::IntegralValueOutOfRange(6, ms))) + }) + .transpose()?; + let format: Option = row.get(4)?; + let score: Option = row.get(12)?; + + Ok(( + ShowId(show), + Entry { + id, + title, + spellings: serde_json::from_str(&row.get::<_, String>(3)?).map_err(unread)?, + format: format.map(named).transpose().map_err(unread)?, + episodes: row.get(5)?, + episode_length, + year: row.get(7)?, + released: row.get(8)?, + status: named(row.get(9)?).map_err(unread)?, + progress: row.get(10)?, + rewatches: row.get(11)?, + score: serde_json::from_value(Value::from(score)).map_err(unread)?, + }, + )) +} + +/// The name a variant is kept under: the text serde writes it as. +fn name(variant: impl Serialize) -> Result { + serde_json::to_value(variant) + .and_then(serde_json::from_value) + .map_err(unwritable) +} + +/// The variant a name is kept for. +fn named(name: String) -> serde_json::Result { + serde_json::from_value(Value::String(name)) +} + +/// A value that could not be made into a column. +fn unwritable(cause: serde_json::Error) -> Error { + Error::Write(rusqlite::Error::ToSqlConversionFailure(cause.into())) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use benshi_core::ShowId; + use benshi_core::list::{Entry, EntryId, Format, Rating, Status}; + use rusqlite::types::Value; + use tempfile::TempDir; + + use crate::{Error, Store}; + + const BACKEND: &str = "anilist"; + + fn a_store() -> (TempDir, Store) { + let home = tempfile::tempdir().expect("a directory of our own"); + let store = Store::open(&home.path().join("benshi.db")).expect("a fresh database opens"); + (home, store) + } + + /// A series of twelve episodes, three of them seen. + fn a_series(id: i64, title: &str) -> Entry { + Entry { + id: EntryId(id), + title: title.to_owned(), + spellings: vec![format!("{title} Spelt Otherwise"), format!("{title} 2")], + format: Some(Format::Tv), + episodes: Some(12), + episode_length: Some(Duration::from_mins(24)), + year: Some(2024), + released: true, + status: Status::Watching, + progress: 3, + rewatches: 0, + score: None, + } + } + + /// A film seen twice, with a score. + fn a_film(id: i64) -> Entry { + Entry { + id: EntryId(id), + title: "A Film".to_owned(), + spellings: Vec::new(), + format: Some(Format::Film), + episodes: Some(1), + episode_length: Some(Duration::from_mins(95)), + year: Some(1988), + released: true, + status: Status::Completed, + progress: 1, + rewatches: 1, + score: Some(Rating::new(80).expect("a rating from 1 to 100")), + } + } + + /// A work that has not been released, of which the service says little. + fn a_work_to_come(id: i64) -> Entry { + Entry { + id: EntryId(id), + title: "A Work To Come".to_owned(), + spellings: Vec::new(), + format: None, + episodes: None, + episode_length: None, + year: None, + released: false, + status: Status::Planned, + progress: 0, + rewatches: 0, + score: None, + } + } + + /// Every row of a table, in the order of its key. + fn rows(store: &Store, table: &str) -> Vec> { + let order = match table { + "shows" => "id", + _ => "backend, entry", + }; + let mut statement = store + .connection + .prepare(&format!("SELECT * FROM {table} ORDER BY {order}")) + .expect("the table is there"); + let width = statement.column_count(); + statement + .query_map([], |row| (0..width).map(|at| row.get(at)).collect()) + .expect("the rows are read") + .collect::>() + .expect("every value is read") + } + + /// Every row of the three tables a list is filed in. + fn every_row(store: &Store) -> [Vec>; 3] { + ["shows", "backend_ids", "list_entries"].map(|table| rows(store, table)) + } + + fn count(store: &Store, table: &str) -> usize { + rows(store, table).len() + } + + #[test] + fn a_list_filed_is_listed_as_it_was_filed() { + let (_home, mut store) = a_store(); + let list = [a_series(21, "Show Title"), a_film(437), a_work_to_come(9)]; + + store.file(BACKEND, &list).expect("the list is filed"); + + assert_eq!( + store.listed(BACKEND).expect("the list is read"), + [ + (ShowId(1), list[0].clone()), + (ShowId(2), list[1].clone()), + (ShowId(3), list[2].clone()), + ] + ); + } + + #[test] + fn two_entries_of_one_title_are_two_shows() { + // A series and its special can carry one romaji title. + let (_home, mut store) = a_store(); + + let special = Entry { + format: Some(Format::Special), + ..a_series(2319, "Show Title") + }; + store + .file(BACKEND, &[a_series(443, "Show Title"), special]) + .expect("the list is filed"); + + let shows: Vec = store + .listed(BACKEND) + .expect("the list is read") + .into_iter() + .map(|(show, _)| show) + .collect(); + assert_eq!(shows, [ShowId(1), ShowId(2)]); + } + + #[test] + fn an_entry_that_left_the_list_keeps_its_show_and_its_link() { + // What was watched of it stays with a show, and the show stays the + // entry's. + let (_home, mut store) = a_store(); + store + .file(BACKEND, &[a_series(21, "Show Title"), a_film(437)]) + .expect("the list is filed"); + + store + .file(BACKEND, &[a_series(21, "Show Title")]) + .expect("the list is filed again"); + + assert_eq!( + store.listed(BACKEND).expect("the list is read"), + [(ShowId(1), a_series(21, "Show Title"))] + ); + assert_eq!(count(&store, "shows"), 2); + assert_eq!( + rows(&store, "backend_ids"), + [ + [ + Value::Integer(1), + Value::Text(BACKEND.to_owned()), + Value::Integer(21) + ], + [ + Value::Integer(2), + Value::Text(BACKEND.to_owned()), + Value::Integer(437) + ], + ] + ); + assert_eq!(count(&store, "list_entries"), 1); + } + + #[test] + fn an_entry_that_comes_back_to_the_list_is_the_show_it_was() { + let (_home, mut store) = a_store(); + let list = [a_series(21, "Show Title"), a_film(437)]; + store.file(BACKEND, &list).expect("the list is filed"); + let first = store.listed(BACKEND).expect("the list is read"); + store + .file(BACKEND, &list[..1]) + .expect("the list is filed without the film"); + + store + .file(BACKEND, &list) + .expect("the list is filed with the film again"); + + assert_eq!(store.listed(BACKEND).expect("the list is read"), first); + assert_eq!(count(&store, "shows"), 2); + } + + #[test] + fn filing_the_same_list_twice_changes_nothing() { + let (_home, mut store) = a_store(); + let list = [a_series(21, "Show Title"), a_film(437), a_work_to_come(9)]; + store.file(BACKEND, &list).expect("the list is filed"); + let once = every_row(&store); + + store.file(BACKEND, &list).expect("the list is filed again"); + + assert_eq!(every_row(&store), once); + } + + #[test] + fn what_an_entry_holds_is_replaced_by_the_next_filing() { + let (_home, mut store) = a_store(); + store + .file(BACKEND, &[a_series(21, "Show Title")]) + .expect("the list is filed"); + let watched_on = Entry { + progress: 4, + score: Rating::new(70), + ..a_series(21, "Show Title") + }; + + store + .file(BACKEND, std::slice::from_ref(&watched_on)) + .expect("the list is filed again"); + + assert_eq!( + store.listed(BACKEND).expect("the list is read"), + [(ShowId(1), watched_on)] + ); + } + + #[test] + fn a_title_the_service_changed_is_the_title_of_the_show_from_then_on() { + let (_home, mut store) = a_store(); + store + .file(BACKEND, &[a_series(21, "Show Title")]) + .expect("the list is filed"); + let renamed = Entry { + title: "Show Title Renamed".to_owned(), + ..a_series(21, "Show Title") + }; + + store + .file(BACKEND, std::slice::from_ref(&renamed)) + .expect("the list is filed again"); + + assert_eq!( + rows(&store, "shows"), + [[ + Value::Integer(1), + Value::Text("Show Title Renamed".to_owned()) + ]] + ); + assert_eq!( + store.listed(BACKEND).expect("the list is read"), + [(ShowId(1), renamed)] + ); + } + + #[test] + fn one_number_on_two_backends_is_two_shows_each_listed_by_its_own() { + // One number on two services names two works. + let (_home, mut store) = a_store(); + store + .file(BACKEND, &[a_series(21, "Show Title")]) + .expect("one list is filed"); + + store + .file("another", &[a_series(21, "Another Show")]) + .expect("the other list is filed"); + + assert_eq!( + store.listed(BACKEND).expect("the list is read"), + [(ShowId(1), a_series(21, "Show Title"))] + ); + assert_eq!( + store.listed("another").expect("the list is read"), + [(ShowId(2), a_series(21, "Another Show"))] + ); + } + + #[test] + fn the_list_of_one_backend_leaves_the_list_of_another_as_it_was() { + let (_home, mut store) = a_store(); + store + .file(BACKEND, &[a_series(21, "Show Title")]) + .expect("one list is filed"); + store + .file("another", &[a_series(21, "Another Show")]) + .expect("the other list is filed"); + + store + .file("another", &[]) + .expect("the other list is filed empty"); + + assert_eq!( + store.listed(BACKEND).expect("the list is read"), + [(ShowId(1), a_series(21, "Show Title"))] + ); + } + + #[test] + fn a_list_that_holds_an_entry_twice_is_refused_and_nothing_of_it_is_filed() { + let (_home, mut store) = a_store(); + store + .file(BACKEND, &[a_series(21, "Show Title")]) + .expect("the list is filed"); + let before = every_row(&store); + + let refused = store.file(BACKEND, &[a_film(437), a_film(437)]); + + assert!( + matches!( + &refused, + Err(Error::Write(rusqlite::Error::SqliteFailure(failure, _))) + if failure.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY + ), + "got {refused:?}" + ); + assert_eq!(every_row(&store), before); + } + + #[test] + fn an_entry_is_kept_in_columns_sqlite3_reads() { + let (_home, mut store) = a_store(); + + store + .file(BACKEND, &[a_film(437)]) + .expect("the list is filed"); + + assert_eq!( + rows(&store, "list_entries"), + [[ + Value::Text(BACKEND.to_owned()), + Value::Integer(437), + Value::Text("[]".to_owned()), + Value::Text("film".to_owned()), + Value::Integer(1), + Value::Integer(5_700_000), + Value::Integer(1988), + Value::Integer(1), + Value::Text("completed".to_owned()), + Value::Integer(1), + Value::Integer(1), + Value::Integer(80), + ]] + ); + } + + #[test] + fn every_format_is_kept_under_a_name_of_its_own() { + // The name is what the file holds, so a variant renamed in the code + // must not rename it in the file. + let (_home, mut store) = a_store(); + let every = [ + (Format::Tv, "tv"), + (Format::TvShort, "tv_short"), + (Format::Film, "film"), + (Format::Special, "special"), + (Format::Ova, "ova"), + (Format::Ona, "ona"), + (Format::Music, "music"), + ]; + let list: Vec = (1..) + .zip(every) + .map(|(id, (format, _))| { + match format { + Format::Tv + | Format::TvShort + | Format::Film + | Format::Special + | Format::Ova + | Format::Ona + | Format::Music => {} + } + Entry { + format: Some(format), + ..a_series(id, "Show Title") + } + }) + .collect(); + + store.file(BACKEND, &list).expect("the list is filed"); + + let kept: Vec = store + .connection + .prepare("SELECT format FROM list_entries ORDER BY entry") + .expect("the statement prepares") + .query_map([], |row| row.get(0)) + .expect("the statement runs") + .collect::>() + .expect("every row has a format"); + assert_eq!(kept, every.map(|(_, name)| name)); + } + + #[test] + fn an_entry_that_cannot_be_read_is_refused_and_named() { + // Refused whole rather than read around: an entry left out would be + // a show no file is matched to, and nothing would say so. + for (column, value) in [ + ("status", "'binging'"), + ("format", "'serial'"), + ("score", "0"), + ("score", "101"), + ("spellings", "'Show Title'"), + ] { + let (_home, mut store) = a_store(); + store + .file(BACKEND, &[a_series(21, "Show Title")]) + .expect("the list is filed"); + store + .connection + .execute(&format!("UPDATE list_entries SET {column} = {value}"), []) + .expect("the row is changed by hand"); + + let refused = store.listed(BACKEND); + + assert!( + matches!( + &refused, + Err(Error::Entry { backend, entry: EntryId(21), .. }) if backend == BACKEND + ), + "{column} of {value} got {refused:?}" + ); + } + } + + #[test] + fn an_entry_whose_show_is_not_there_is_refused_and_named() { + // Foreign keys keep such a row from being written through the store. + // `sqlite3` leaves them off unless it is asked, so a link or a show + // deleted by hand leaves one. + for deleted in ["DELETE FROM backend_ids", "DELETE FROM shows"] { + let (_home, mut store) = a_store(); + store + .file(BACKEND, &[a_series(21, "Show Title")]) + .expect("the list is filed"); + store + .connection + .execute_batch(&format!( + "PRAGMA foreign_keys = OFF; {deleted}; PRAGMA foreign_keys = ON;" + )) + .expect("the row goes with nothing checking it"); + + let refused = store.listed(BACKEND); + + assert!( + matches!( + &refused, + Err(Error::Showless { backend, entry: EntryId(21) }) if backend == BACKEND + ), + "{deleted} got {refused:?}" + ); + } + } + + #[test] + fn a_length_longer_than_the_column_holds_is_kept_as_the_longest_it_holds() { + let (_home, mut store) = a_store(); + let endless = Entry { + episode_length: Some(Duration::MAX), + ..a_series(21, "Show Title") + }; + + store.file(BACKEND, &[endless]).expect("the list is filed"); + + let episode_ms: i64 = store + .connection + .query_row("SELECT episode_ms FROM list_entries", [], |row| row.get(0)) + .expect("one row"); + assert_eq!(episode_ms, i64::MAX); + } + + #[test] + fn a_length_below_nought_is_refused() { + let (_home, mut store) = a_store(); + store + .file(BACKEND, &[a_series(21, "Show Title")]) + .expect("the list is filed"); + store + .connection + .execute("UPDATE list_entries SET episode_ms = -5", []) + .expect("the row is changed by hand"); + + let refused = store.listed(BACKEND); + + assert!( + matches!( + refused, + Err(Error::Write(rusqlite::Error::IntegralValueOutOfRange( + 6, -5 + ))) + ), + "got {refused:?}" + ); + } +} diff --git a/crates/store/src/migrate.rs b/crates/store/src/migrate.rs index dd419e6..bda421f 100644 --- a/crates/store/src/migrate.rs +++ b/crates/store/src/migrate.rs @@ -14,7 +14,7 @@ use crate::Error; /// /// Written by hand rather than computed, so that adding a migration is a /// change in two places that a test holds together. -pub const SCHEMA_VERSION: u32 = 2; +pub const SCHEMA_VERSION: u32 = 3; /// Every migration, in order, each a script of statements. /// @@ -67,6 +67,39 @@ pub(crate) const MIGRATIONS: &[&str] = &[ INSERT INTO new_shows (id, title) SELECT id, title FROM shows; DROP TABLE shows; ALTER TABLE new_shows RENAME TO shows;", + // `backend_ids` says which entry of which backend a show is, and a show + // is at most one entry of a backend. A link outlives its entry leaving + // the list, so an entry that comes back is the show it was. `entry` is + // the service's number for the work. `list_entries` is what each list + // held when it was last read, a row per entry, and every row of a + // backend is replaced when its list is filed. `spellings` is a JSON array + // of text, `episode_ms` the length of one episode in milliseconds, + // `format` and `status` the names serde gives them in `benshi_core::list`, + // `released` a flag, 0 or 1, and `score` from 1 to 100, NULL where there + // is none. + "CREATE TABLE backend_ids ( + show INTEGER NOT NULL REFERENCES shows(id), + backend TEXT NOT NULL, + entry INTEGER NOT NULL, + PRIMARY KEY (backend, entry), + UNIQUE (show, backend) + ); + CREATE TABLE list_entries ( + backend TEXT NOT NULL, + entry INTEGER NOT NULL, + spellings TEXT NOT NULL, + format TEXT, + episodes INTEGER, + episode_ms INTEGER, + year INTEGER, + released INTEGER NOT NULL, + status TEXT NOT NULL, + progress INTEGER NOT NULL, + rewatches INTEGER NOT NULL, + score INTEGER, + PRIMARY KEY (backend, entry), + FOREIGN KEY (backend, entry) REFERENCES backend_ids(backend, entry) + );", ]; /// Apply every migration the database has not seen, in order. @@ -282,7 +315,7 @@ mod tests { .expect("a row goes into every table"); let before = every_row(&connection); - apply(&mut connection, MIGRATIONS).expect("the second migration applies"); + apply(&mut connection, &MIGRATIONS[..2]).expect("the second migration applies"); assert_eq!(version(&connection), 2); assert_eq!(every_row(&connection), before); @@ -298,6 +331,114 @@ mod tests { assert_eq!(broken, 0, "every row points at a show that is there"); } + #[test] + fn migration_three_creates_the_two_tables_of_a_list() { + let mut connection = Connection::open_in_memory().expect("memory opens"); + + apply(&mut connection, &MIGRATIONS[..3]).expect("the third migration applies"); + + assert_eq!( + columns(&connection, "backend_ids"), + ["show", "backend", "entry"] + ); + assert_eq!( + columns(&connection, "list_entries"), + [ + "backend", + "entry", + "spellings", + "format", + "episodes", + "episode_ms", + "year", + "released", + "status", + "progress", + "rewatches", + "score" + ] + ); + } + + /// A database at every migration with the foreign keys on, as a store's + /// connection has them, and one show in it, of `id` 1. + fn a_database_with_a_show() -> Connection { + let mut connection = Connection::open_in_memory().expect("memory opens"); + connection + .pragma_update(None, "foreign_keys", true) + .expect("the foreign keys are enforced"); + apply(&mut connection, MIGRATIONS).expect("every migration applies"); + connection + .execute("INSERT INTO shows (id, title) VALUES (1, 'Show Title')", []) + .expect("the show goes in"); + connection + } + + /// Link the show of this `id` to an entry of a backend. + fn link( + connection: &Connection, + show: i64, + backend: &str, + entry: i64, + ) -> rusqlite::Result { + connection.execute( + "INSERT INTO backend_ids (show, backend, entry) VALUES (?1, ?2, ?3)", + rusqlite::params![show, backend, entry], + ) + } + + /// Whether a statement was refused by a constraint of this kind, and not + /// for anything else, such as a table that is not there. + fn refused_by(result: &rusqlite::Result, constraint: std::ffi::c_int) -> bool { + matches!( + result, + Err(rusqlite::Error::SqliteFailure(failure, _)) if failure.extended_code == constraint + ) + } + + #[test] + fn a_link_to_a_show_that_is_not_there_is_refused() { + let connection = a_database_with_a_show(); + + let refused = link(&connection, 2, "anilist", 21); + + assert!( + refused_by(&refused, rusqlite::ffi::SQLITE_CONSTRAINT_FOREIGNKEY), + "got {refused:?}" + ); + } + + #[test] + fn a_show_is_at_most_one_entry_of_a_backend() { + // An episode of the show will be sent to the entry its link names. + let connection = a_database_with_a_show(); + link(&connection, 1, "anilist", 21).expect("the first link goes in"); + + let refused = link(&connection, 1, "anilist", 22); + + assert!( + refused_by(&refused, rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE), + "got {refused:?}" + ); + link(&connection, 1, "another", 22).expect("a link to another backend goes in"); + } + + #[test] + fn an_entry_of_a_list_with_no_link_is_refused() { + let connection = a_database_with_a_show(); + + let refused = connection.execute( + "INSERT INTO list_entries (backend, entry, spellings, released, status, \ + progress, rewatches) VALUES ('anilist', 21, '[]', 1, 'watching', 3, 0)", + [], + ); + + assert!( + refused_by(&refused, rusqlite::ffi::SQLITE_CONSTRAINT_FOREIGNKEY), + "got {refused:?}" + ); + } + #[test] fn foreign_keys_that_were_off_are_left_off() { // The SQLite this crate builds turns them on in every connection it diff --git a/crates/store/src/queue.rs b/crates/store/src/queue.rs index 49e5d79..8250815 100644 --- a/crates/store/src/queue.rs +++ b/crates/store/src/queue.rs @@ -16,9 +16,9 @@ //! can tell whether the first one arrived. use benshi_core::ShowId; +use benshi_core::list::{Rating, Status}; use rusqlite::{Connection, params}; -use serde::de::Error as _; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use crate::{Error, Store}; @@ -70,65 +70,6 @@ pub enum Kind { }, } -/// Where a show stands on a list. -/// -/// `Rewatching` is a status here because it is one on AniList and on -/// Shikimori. MyAnimeList and Kitsu keep it as a flag beside the status. The -/// other five are on all four lists, which name them differently. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Status { - /// Meant to be watched and not begun. - Planned, - /// Being watched. - Watching, - /// Being watched again. - Rewatching, - /// Watched to the end. - Completed, - /// Set aside, to be taken up again. - OnHold, - /// Given up. - Dropped, -} - -/// A score in whole points, from 1 to 100. -/// -/// On AniList each user chooses the format a score is shown in, and the list -/// takes and answers a score in a hundred points whichever was chosen. -/// MyAnimeList and Shikimori score out of ten and Kitsu out of twenty, and -/// both multiply into a hundred exactly. Nought is not a rating: an entry -/// with no score carries none. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub struct Rating(u8); - -impl Rating { - /// A rating, or nothing where the points are not from 1 to 100. - #[must_use] - pub fn new(points: u8) -> Option { - (1..=100).contains(&points).then_some(Self(points)) - } - - /// The points, from 1 to 100. - #[must_use] - pub const fn points(self) -> u8 { - self.0 - } -} - -/// Read through [`Rating::new`], so that a score out of range in a payload is -/// refused where a derived implementation would let it in. -impl<'de> Deserialize<'de> for Rating { - fn deserialize>(deserializer: D) -> Result { - let points = u8::deserialize(deserializer)?; - Self::new(points).ok_or_else(|| { - D::Error::custom(format_args!( - "rating is from 1 to 100, and this is {points}" - )) - }) - } -} - /// One operation as it waits in the queue. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Operation { @@ -662,16 +603,6 @@ mod tests { ); } - #[test] - fn a_rating_is_from_one_to_a_hundred() { - // Nought is no rating, and no rating is `None`: two ways to say it - // would be one too many. - assert_eq!(Rating::new(0), None); - assert_eq!(Rating::new(1).map(Rating::points), Some(1)); - assert_eq!(Rating::new(100).map(Rating::points), Some(100)); - assert_eq!(Rating::new(101), None); - } - #[test] fn a_score_that_is_not_a_rating_is_refused() { for score in [0, 101] {