fix(db): drop 0.8 rebuild workarounds, enforce title

With sqlx 0.9 honouring `-- no-transaction`, 0007 loses its
movie_releases_backup stash like 0014 did, and 0021 gains
the CHECK (title <> '') that #153 abandoned because the 0.8
migrator could not run a rebuild with foreign keys off. The
rebuild test now enters at migration 6 so the child links
ride through all three rebuilds, and a new test proves the
'' -> 'TBA' backfill and the rejection of new empty titles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-24 18:39:22 +01:00
parent ef4722e1dd
commit 93a3485c50
3 changed files with 103 additions and 16 deletions
@@ -1,13 +1,13 @@
-- no-transaction
-- #73. movies.state used missing|grabbed|imported, predating arr-core's
-- canonical MediaState vocabulary (missing|downloading|available) that
-- series/episodes (0005) already follow.
--
-- SQLite can't ALTER a CHECK constraint, so this rebuilds the table -- which
-- means dropping the old copy. `movie_releases` references it with
-- ON DELETE CASCADE, so the drop would take its rows down too; stash them
-- first and restore them once the new `movies` exists with the same ids.
-- SQLite can't ALTER a CHECK constraint, so this rebuilds the table. It runs
-- outside a transaction (#155) so `PRAGMA foreign_keys = OFF` takes effect
-- and dropping the old `movies` does not cascade into `movie_releases`.
CREATE TABLE movie_releases_backup AS SELECT * FROM movie_releases;
PRAGMA foreign_keys = OFF;
CREATE TABLE movies_new (
id INTEGER PRIMARY KEY,
@@ -68,6 +68,4 @@ BEGIN
SELECT RAISE(ABORT, 'movies require a movie root');
END;
INSERT INTO movie_releases SELECT * FROM movie_releases_backup;
DROP TABLE movie_releases_backup;
PRAGMA foreign_keys = ON;
@@ -1,11 +1,51 @@
-- no-transaction
-- #153. `episodes.title` accepted the empty string, which TMDB sends for an
-- unaired episode it has not named yet. Empty titles leaked into §9.2's
-- search haystack, §7.4 filenames and the compat shim as if they were real
-- text. Rows already carrying `''` take the same "TBA" placeholder the TMDB
-- boundary now substitutes — #121's guarded update replaces it once TMDB
-- fills the title in.
--
-- A hard CHECK (title <> '') would need a table rebuild with foreign_keys
-- off, which sqlx 0.8's migrator cannot run (it always wraps a migration in
-- a transaction, where that pragma is a no-op); enforcement lives in code.
-- fills the title in — and the rebuilt table enforces non-empty with a
-- CHECK. The rebuild runs outside a transaction (#155) so
-- `PRAGMA foreign_keys = OFF` takes effect and dropping the old `episodes`
-- does not cascade into `episode_releases`.
UPDATE episodes SET title = 'TBA', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE title = '';
PRAGMA foreign_keys = OFF;
CREATE TABLE episodes_new (
id INTEGER PRIMARY KEY,
season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE,
number INTEGER NOT NULL CHECK (number >= 0),
title TEXT NOT NULL CHECK (title <> ''),
air_date TEXT,
wanted INTEGER NOT NULL DEFAULT 0 CHECK (wanted IN (0, 1)),
state TEXT NOT NULL DEFAULT 'missing'
CHECK (state IN ('missing', 'downloading', 'available', 'parked')),
search_attempts INTEGER NOT NULL DEFAULT 0,
last_searched_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
vanished INTEGER NOT NULL DEFAULT 0 CHECK (vanished IN (0, 1)),
UNIQUE (season_id, number)
) STRICT;
INSERT INTO episodes_new (
id, season_id, number, title, air_date, wanted, state, search_attempts,
last_searched_at, created_at, updated_at, vanished
)
SELECT
id, season_id, number, title, air_date, wanted, state, search_attempts,
last_searched_at, created_at, updated_at, vanished
FROM episodes;
DROP TABLE episodes;
ALTER TABLE episodes_new RENAME TO episodes;
CREATE INDEX episodes_pending_search
ON episodes (last_searched_at)
WHERE wanted = 1 AND state = 'missing';
CREATE INDEX episodes_state ON episodes (state);
PRAGMA foreign_keys = ON;
+52 -3
View File
@@ -336,7 +336,7 @@ mod tests {
assert_eq!(renamed_from_imported, "available");
}
/// #155: migration 0014 rebuilds `movies` and `episodes` outside a
/// #155: the table-rebuild migrations (0007, 0014, 0021) run outside a
/// transaction so `PRAGMA foreign_keys = OFF` holds and dropping the old
/// tables does not cascade-delete `movie_releases`/`episode_releases`.
#[tokio::test]
@@ -347,9 +347,9 @@ mod tests {
.expect("connect");
MIGRATOR
.run_to(13, db.pool())
.run_to(6, db.pool())
.await
.expect("migrations before the 0014 rebuild");
.expect("migrations before the first rebuild");
sqlx::query(
"INSERT INTO movies (tmdb_id, title, root_id)
@@ -419,6 +419,55 @@ mod tests {
);
}
/// #153: an empty episode title takes the TBA placeholder during 0021's
/// backfill, and the rebuilt table's CHECK rejects new empty titles.
#[tokio::test]
async fn empty_episode_titles_are_backfilled_and_then_rejected() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Db::connect(dir.path().join("arr.db"))
.await
.expect("connect");
MIGRATOR
.run_to(20, db.pool())
.await
.expect("migrations before the #153 backfill");
let series_id = sqlx::query(
"INSERT INTO series (tmdb_id, title, root_id)
SELECT 82728, 'Bluey', id FROM roots WHERE kind = 'tv' LIMIT 1",
)
.execute(db.pool())
.await
.expect("series")
.last_insert_rowid();
let season_id = sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)")
.bind(series_id)
.execute(db.pool())
.await
.expect("season")
.last_insert_rowid();
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 1, '')")
.bind(season_id)
.execute(db.pool())
.await
.expect("empty title, valid before 0021");
db.migrate().await.expect("remaining migrations");
let title: String = sqlx::query_scalar("SELECT title FROM episodes")
.fetch_one(db.pool())
.await
.expect("episode row survives the rebuild");
assert_eq!(title, "TBA");
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, '')")
.bind(season_id)
.execute(db.pool())
.await
.expect_err("the rebuilt column rejects the empty string");
}
#[tokio::test]
async fn seeds_two_tv_roots_with_distinct_policies() {
let (_dir, db) = fresh().await;