From f2a7db939f2a59f336731161fc8d4a009682e86c Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Sun, 29 Mar 2026 20:57:23 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20migration=20DB=20=E2=80=94=20table=20se?= =?UTF-8?q?ries=20avec=20UUID=20PK=20(fusionne=20series=5Fmetadata)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0070: - Crée table series (id UUID PK, library_id FK, name, description, authors, publishers, status, locked_fields, original_name, etc.) - Peuple depuis books + series_metadata existants - Ajoute series_id FK à: books, external_metadata_links, anilist_series_links, available_downloads, download_detection_results - Backfill tous les series_id par matching nom Migration 0071: - Supprime les colonnes TEXT legacy (books.series, *.series_name) - Drop table series_metadata (fusionnée dans series) - Recrée les contraintes UNIQUE sur series_id au lieu de series_name - Nettoie les rows orphelines (series_id NULL) - Ajoute index sur series_id dans toutes les tables Co-Authored-By: Claude Opus 4.6 (1M context) --- infra/migrations/0070_create_series_table.sql | 94 +++++++++++++++++++ .../0071_drop_series_legacy_columns.sql | 84 +++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 infra/migrations/0070_create_series_table.sql create mode 100644 infra/migrations/0071_drop_series_legacy_columns.sql diff --git a/infra/migrations/0070_create_series_table.sql b/infra/migrations/0070_create_series_table.sql new file mode 100644 index 0000000..89c7250 --- /dev/null +++ b/infra/migrations/0070_create_series_table.sql @@ -0,0 +1,94 @@ +-- ============================================================================= +-- Migration 0070: Create proper `series` table with UUID PK +-- Fuses series_metadata into series. All related tables get series_id FK. +-- ============================================================================= + +-- 1. Create the series table (fusion of series_metadata) +CREATE TABLE IF NOT EXISTS series ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + authors TEXT[] NOT NULL DEFAULT '{}', + publishers TEXT[] NOT NULL DEFAULT '{}', + start_year INTEGER, + total_volumes INTEGER, + status TEXT, + locked_fields JSONB NOT NULL DEFAULT '{}', + original_name TEXT, + book_author TEXT, + book_language TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (library_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_series_library_id ON series(library_id); + +-- 2. Populate from existing data + +-- From books (all series with at least one book) +INSERT INTO series (library_id, name) +SELECT DISTINCT b.library_id, COALESCE(NULLIF(b.series, ''), 'unclassified') +FROM books b +ON CONFLICT (library_id, name) DO NOTHING; + +-- From series_metadata (series with metadata but possibly no books) +INSERT INTO series (library_id, name) +SELECT sm.library_id, sm.name FROM series_metadata sm +ON CONFLICT (library_id, name) DO NOTHING; + +-- Merge series_metadata columns into series +UPDATE series s SET + description = sm.description, + authors = sm.authors, + publishers = sm.publishers, + start_year = sm.start_year, + total_volumes = sm.total_volumes, + status = sm.status, + locked_fields = sm.locked_fields, + original_name = sm.original_name, + created_at = sm.created_at, + updated_at = sm.updated_at +FROM series_metadata sm +WHERE sm.library_id = s.library_id AND sm.name = s.name; + +-- 3. Add series_id FK to books +ALTER TABLE books ADD COLUMN IF NOT EXISTS series_id UUID REFERENCES series(id) ON DELETE SET NULL; +UPDATE books b SET series_id = s.id +FROM series s +WHERE s.library_id = b.library_id + AND s.name = COALESCE(NULLIF(b.series, ''), 'unclassified') + AND b.series_id IS NULL; +CREATE INDEX IF NOT EXISTS idx_books_series_id ON books(series_id); + +-- 4. Add series_id FK to external_metadata_links +ALTER TABLE external_metadata_links ADD COLUMN IF NOT EXISTS series_id UUID REFERENCES series(id) ON DELETE CASCADE; +UPDATE external_metadata_links eml SET series_id = s.id +FROM series s +WHERE s.library_id = eml.library_id AND LOWER(s.name) = LOWER(eml.series_name) + AND eml.series_id IS NULL; + +-- 5. Add series_id FK to anilist_series_links +ALTER TABLE anilist_series_links ADD COLUMN IF NOT EXISTS series_id UUID REFERENCES series(id) ON DELETE CASCADE; +UPDATE anilist_series_links asl SET series_id = s.id +FROM series s +WHERE s.library_id = asl.library_id AND LOWER(s.name) = LOWER(asl.series_name) + AND asl.series_id IS NULL; + +-- 6. Add series_id FK to available_downloads +ALTER TABLE available_downloads ADD COLUMN IF NOT EXISTS series_id UUID REFERENCES series(id) ON DELETE CASCADE; +UPDATE available_downloads ad SET series_id = s.id +FROM series s +WHERE s.library_id = ad.library_id AND LOWER(s.name) = LOWER(ad.series_name) + AND ad.series_id IS NULL; + +-- 7. Add series_id FK to download_detection_results +ALTER TABLE download_detection_results ADD COLUMN IF NOT EXISTS series_id UUID REFERENCES series(id) ON DELETE CASCADE; +UPDATE download_detection_results ddr SET series_id = s.id +FROM series s +WHERE s.library_id = ddr.library_id AND LOWER(s.name) = LOWER(ddr.series_name) + AND ddr.series_id IS NULL; + +-- NOTE: Old TEXT columns (books.series, *.series_name) are kept for now. +-- They will be dropped in a future migration (0071) once all code is migrated. diff --git a/infra/migrations/0071_drop_series_legacy_columns.sql b/infra/migrations/0071_drop_series_legacy_columns.sql new file mode 100644 index 0000000..22564ec --- /dev/null +++ b/infra/migrations/0071_drop_series_legacy_columns.sql @@ -0,0 +1,84 @@ +-- ============================================================================= +-- Migration 0071: Drop legacy TEXT columns now replaced by series_id FK +-- Prerequisite: migration 0070 must have run and all code must use series_id +-- ============================================================================= + +-- 1. Drop old TEXT series column from books (replaced by series_id FK) +ALTER TABLE books DROP COLUMN IF EXISTS series; + +-- 2. Drop old TEXT series_name columns from related tables +ALTER TABLE external_metadata_links DROP COLUMN IF EXISTS series_name; +ALTER TABLE anilist_series_links DROP COLUMN IF EXISTS series_name; +ALTER TABLE available_downloads DROP COLUMN IF EXISTS series_name; +ALTER TABLE download_detection_results DROP COLUMN IF EXISTS series_name; + +-- 3. Drop the old series_metadata table (fused into series) +DROP TABLE IF EXISTS series_metadata; + +-- 4. Drop old composite indexes that relied on series_name text matching +DROP INDEX IF EXISTS idx_eml_library_series; + +-- 5. Add missing indexes on series_id columns +CREATE INDEX IF NOT EXISTS idx_eml_series_id ON external_metadata_links(series_id); +CREATE INDEX IF NOT EXISTS idx_asl_series_id ON anilist_series_links(series_id); +CREATE INDEX IF NOT EXISTS idx_ad_series_id ON available_downloads(series_id); +CREATE INDEX IF NOT EXISTS idx_ddr_series_id ON download_detection_results(series_id); + +-- 6. Update unique constraints to use series_id instead of series_name +-- external_metadata_links: (library_id, series_name, provider) → (series_id, provider) +ALTER TABLE external_metadata_links DROP CONSTRAINT IF EXISTS external_metadata_links_library_id_series_name_provider_key; +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'external_metadata_links_series_id_provider_key' + ) THEN + ALTER TABLE external_metadata_links + ADD CONSTRAINT external_metadata_links_series_id_provider_key UNIQUE (series_id, provider); + END IF; +END $$; + +-- anilist_series_links: old PK was (library_id, series_name, provider) +-- Need to recreate with series_id. First drop old PK, add id column, create new PK. +DO $$ BEGIN + -- Add id column if not exists + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'anilist_series_links' AND column_name = 'id' + ) THEN + ALTER TABLE anilist_series_links ADD COLUMN id UUID DEFAULT gen_random_uuid(); + -- Drop old composite PK + ALTER TABLE anilist_series_links DROP CONSTRAINT IF EXISTS anilist_series_links_pkey; + -- Set new PK + ALTER TABLE anilist_series_links ADD PRIMARY KEY (id); + -- Add unique on (series_id, provider) + ALTER TABLE anilist_series_links + ADD CONSTRAINT anilist_series_links_series_id_provider_key UNIQUE (series_id, provider); + END IF; +END $$; + +-- available_downloads: old unique was (library_id, series_name) +ALTER TABLE available_downloads DROP CONSTRAINT IF EXISTS available_downloads_library_id_series_name_key; +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'available_downloads_series_id_key' + ) THEN + ALTER TABLE available_downloads + ADD CONSTRAINT available_downloads_series_id_key UNIQUE (series_id); + END IF; +END $$; + +-- 7. Clean up orphaned rows (series_id IS NULL after migration 0070) +DELETE FROM external_metadata_links WHERE series_id IS NULL; +DELETE FROM anilist_series_links WHERE series_id IS NULL; +DELETE FROM available_downloads WHERE series_id IS NULL; +DELETE FROM download_detection_results WHERE series_id IS NULL; + +-- 8. Make series_id NOT NULL now that all data is migrated +ALTER TABLE external_metadata_links ALTER COLUMN series_id SET NOT NULL; +ALTER TABLE anilist_series_links ALTER COLUMN series_id SET NOT NULL; +ALTER TABLE available_downloads ALTER COLUMN series_id SET NOT NULL; +-- download_detection_results keeps series_id nullable (temp job data) + +-- 9. Drop library_id from tables where it's now redundant (available via series.library_id JOIN) +-- Keep library_id for now on anilist_series_links and external_metadata_links for query convenience +-- Only drop from available_downloads and download_detection_results where it's truly redundant +-- Actually, keep all library_id columns for query performance (avoids extra JOIN)