diff --git a/AGENTS.md b/AGENTS.md index f53d45b..7e9c932 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,8 @@ uv run repub crawl -c repub.toml - Keep `treefmt.nix`, `flake.nix`, and `pyproject.toml` aligned. - Prefer updating the flake-exported package and checks rather than adding ad hoc scripts. +- Put new SQLite schema objects in numbered files under `repub/sql/` such as `002_*.sql`. +- For backward-compatible column additions on existing SQLite databases, use Peewee's `playhouse.migrate` helpers instead of raw ad hoc `ALTER TABLE` logic. - Do not commit, amend, or stage unrelated files unless explicitly asked. - Final verication `nix flake check` must be greenbefore claiming task completeness diff --git a/repub/model.py b/repub/model.py index 2126d50..5e2cd65 100644 --- a/repub/model.py +++ b/repub/model.py @@ -19,6 +19,7 @@ from peewee import ( SqliteDatabase, TextField, ) +from playhouse.migrate import SchemaMigrator, migrate DEFAULT_DB_PATH = Path("republisher.db") DATABASE_PRAGMAS = { @@ -79,45 +80,50 @@ def initialize_database(db_path: str | Path | None = None) -> Path: database.init(str(resolved_path), pragmas=DATABASE_PRAGMAS) database.connect(reuse_if_open=True) try: - connection = database.connection() for path in schema_paths(): - connection.executescript(path.read_text(encoding="utf-8")) - _ensure_schema(connection) + database.connection().executescript(path.read_text(encoding="utf-8")) + _run_legacy_migrations() finally: database.close() return resolved_path -def _ensure_schema(connection: Any) -> None: - connection.execute( - """ - CREATE TABLE IF NOT EXISTS app_setting ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - """ - ) - - job_columns = { - row[1] for row in connection.execute("PRAGMA table_info('job')").fetchall() - } +def _run_legacy_migrations() -> None: + job_columns = {column.name for column in database.get_columns("job")} + operations = [] + migrator = SchemaMigrator.from_database(database) if "convert_images" not in job_columns: - connection.execute( - """ - ALTER TABLE job - ADD COLUMN convert_images INTEGER NOT NULL DEFAULT 1 - CHECK (convert_images IN (0, 1)) - """ + operations.extend( + ( + migrator.add_column( + "job", + "convert_images", + BooleanField( + default=True, + constraints=[Check("convert_images IN (0, 1)")], + ), + ), + migrator.add_column_default("job", "convert_images", 1), + ) ) if "convert_video" not in job_columns: - connection.execute( - """ - ALTER TABLE job - ADD COLUMN convert_video INTEGER NOT NULL DEFAULT 1 - CHECK (convert_video IN (0, 1)) - """ + operations.extend( + ( + migrator.add_column( + "job", + "convert_video", + BooleanField( + default=True, + constraints=[Check("convert_video IN (0, 1)")], + ), + ), + migrator.add_column_default("job", "convert_video", 1), + ) ) + if operations: + with database.atomic(): + migrate(*operations) def source_slug_exists(slug: str) -> bool: diff --git a/repub/sql/002_app_setting.sql b/repub/sql/002_app_setting.sql new file mode 100644 index 0000000..060d5ab --- /dev/null +++ b/repub/sql/002_app_setting.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS app_setting ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); diff --git a/tests/test_model.py b/tests/test_model.py index 1553354..3d0729d 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -15,6 +15,7 @@ from repub.model import ( load_max_concurrent_jobs, resolve_database_path, save_setting, + schema_paths, ) @@ -71,6 +72,39 @@ def test_initialize_database_bootstraps_schema_from_sql_files(tmp_path: Path) -> connection.close() +def test_initialize_database_applies_newer_sql_files_to_existing_databases( + tmp_path: Path, +) -> None: + db_path = tmp_path / "existing.db" + connection = sqlite3.connect(db_path) + try: + connection.executescript(schema_paths()[0].read_text(encoding="utf-8")) + finally: + connection.close() + + initialize_database(db_path) + + connection = sqlite3.connect(db_path) + try: + table_names = { + row[0] + for row in connection.execute( + """ + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + """ + ) + } + assert "app_setting" in table_names + + job_columns = {row[1] for row in connection.execute("PRAGMA table_info('job')")} + assert "convert_images" in job_columns + assert "convert_video" in job_columns + finally: + connection.close() + + def test_initialize_database_configures_sqlite_pragmas(tmp_path: Path) -> None: db_path = tmp_path / "pragmas.db"