diff --git a/tests/test_database.py b/tests/test_database.py index e3fc425..1c69339 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1426,59 +1426,40 @@ async def test_create_next_fixture_allocates_weeks_per_guild(self, temp_db_path) assert guild_one_second["guild_id"] == "111111" -class TestSchemaMigration: - """Test suite for automatic schema migration.""" +class TestSchemaValidation: + """Test suite for startup schema validation.""" @pytest.mark.asyncio - async def test_initialize_rejects_fixtures_without_guild_ownership(self, temp_db_path): - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute( - """ - CREATE TABLE fixtures ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - week_number INTEGER NOT NULL, - games TEXT NOT NULL, - deadline DATETIME NOT NULL, - status TEXT DEFAULT 'open' - ) - """ - ) - await conn.commit() - + async def test_initialize_is_safe_for_current_schema_existing_data(self, temp_db_path): db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_prediction( + fixture_id, + "user-1", + "User One", + ["2-1"], + public_message_id="message-1", + public_message_kind="thread_prediction", + ) + await db.save_results(fixture_id, ["2-1"]) - with pytest.raises(RuntimeError, match="fixtures.guild_id is missing"): - await db.initialize() + restarted_db = Database(temp_db_path) + await restarted_db.initialize() + await restarted_db.save_results(fixture_id, ["3-1"]) - @pytest.mark.asyncio - @pytest.mark.parametrize("guild_id", [None, "", " "]) - async def test_initialize_rejects_blank_fixture_guild_ownership(self, temp_db_path, guild_id): - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute( - """ - CREATE TABLE fixtures ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT, - week_number INTEGER NOT NULL, - games TEXT NOT NULL, - deadline DATETIME NOT NULL, - status TEXT DEFAULT 'open' - ) - """ - ) - await conn.execute( - "INSERT INTO fixtures (guild_id, week_number, games, deadline, status) VALUES (?, 1, 'A - B', ?, 'open')", - (guild_id, datetime.now(UTC).isoformat()), - ) - await conn.commit() - - db = Database(temp_db_path) + fixture = await restarted_db.get_fixture_by_id(fixture_id, "111111") + prediction = await restarted_db.get_prediction(fixture_id, "user-1", "111111") + results = await restarted_db.get_results(fixture_id) - with pytest.raises(RuntimeError, match="fixtures.guild_id has empty rows"): - await db.initialize() + assert fixture is not None + assert fixture["guild_id"] == "111111" + assert prediction["public_message_id"] == "message-1" + assert prediction["public_message_kind"] == "thread_prediction" + assert results == ["3-1"] @pytest.mark.asyncio - async def test_initialize_preserves_manually_migrated_legacy_fixture_graph(self, temp_db_path): + async def test_initialize_rejects_duplicate_result_rows_without_mutating(self, temp_db_path): async with aiosqlite.connect(temp_db_path) as conn: await conn.executescript( """ @@ -1487,6 +1468,10 @@ async def test_initialize_preserves_manually_migrated_legacy_fixture_graph(self, guild_id TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', + exact_score_points INTEGER NOT NULL DEFAULT 3, + correct_outcome_points INTEGER NOT NULL DEFAULT 1, + wrong_outcome_points INTEGER NOT NULL DEFAULT 0, + late_prediction_points INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, ended_at DATETIME ); @@ -1498,7 +1483,9 @@ async def test_initialize_preserves_manually_migrated_legacy_fixture_graph(self, games TEXT NOT NULL, deadline DATETIME NOT NULL, status TEXT DEFAULT 'open', - message_id TEXT + message_id TEXT, + channel_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE predictions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1507,12 +1494,22 @@ async def test_initialize_preserves_manually_migrated_legacy_fixture_graph(self, user_name TEXT NOT NULL, predictions TEXT NOT NULL, submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, - is_late BOOLEAN DEFAULT FALSE + is_late BOOLEAN DEFAULT FALSE, + late_penalty_waived BOOLEAN DEFAULT FALSE, + admin_edited_at DATETIME, + admin_edited_by TEXT, + predicted_game_indexes TEXT, + pending_partial_approval BOOLEAN DEFAULT FALSE, + public_message_id TEXT, + public_message_kind TEXT, + UNIQUE(fixture_id, user_id) ); CREATE TABLE results ( id INTEGER PRIMARY KEY AUTOINCREMENT, fixture_id INTEGER NOT NULL, - results TEXT NOT NULL + results TEXT NOT NULL, + calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE scores ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1521,64 +1518,103 @@ async def test_initialize_preserves_manually_migrated_legacy_fixture_graph(self, user_name TEXT NOT NULL, points INTEGER NOT NULL, exact_scores INTEGER DEFAULT 0, - correct_results INTEGER DEFAULT 0 + correct_results INTEGER DEFAULT 0, + UNIQUE(fixture_id, user_id) + ); + CREATE TABLE guild_config ( + guild_id TEXT PRIMARY KEY, + admin_role_id TEXT NOT NULL, + league_channel_id TEXT NOT NULL, + active_season_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); """ ) await conn.execute( - "INSERT INTO seasons (id, guild_id, name, status) VALUES (1, '111111', 'Migrated Season', 'active')" + "INSERT INTO seasons (id, guild_id, name, status) VALUES (1, '111111', 'Current Season', 'active')" ) await conn.execute( - "INSERT INTO fixtures (id, guild_id, season_id, week_number, games, deadline, status, message_id) VALUES (1, '111111', 1, 1, 'A - B', ?, 'closed', '789012')", + "INSERT INTO fixtures (id, guild_id, season_id, week_number, games, deadline, status) VALUES (1, '111111', 1, 1, 'A - B', ?, 'open')", (datetime.now(UTC).isoformat(),), ) await conn.execute( - "INSERT INTO predictions (fixture_id, user_id, user_name, predictions, submitted_at, is_late) VALUES (1, 'user-1', 'User One', '2-1', ?, 0)", - (datetime.now(UTC).isoformat(),), + "INSERT INTO results (fixture_id, results, calculated_at, updated_at) VALUES (1, '1-0', '2024-01-01T10:00:00+00:00', '2024-01-01T10:00:00+00:00')" ) - await conn.execute("INSERT INTO results (fixture_id, results) VALUES (1, '2-1')") await conn.execute( - "INSERT INTO scores (fixture_id, user_id, user_name, points, exact_scores, correct_results) VALUES (1, 'user-1', 'User One', 3, 1, 0)" + "INSERT INTO results (fixture_id, results, calculated_at, updated_at) VALUES (1, '2-0', '2024-01-01T12:00:00+00:00', '2024-01-01T12:00:00+00:00')" ) await conn.commit() db = Database(temp_db_path) + + with pytest.raises( + RuntimeError, + match=r"results has duplicate rows for fixture_id\(s\): 1.*Keep one result row per fixture", + ): + await db.initialize() + + async with ( + aiosqlite.connect(temp_db_path) as conn, + conn.execute("SELECT results FROM results ORDER BY id") as cursor, + ): + assert await cursor.fetchall() == [("1-0",), ("2-0",)] + + @pytest.mark.asyncio + async def test_initialize_creates_missing_result_unique_index_for_current_schema( + self, temp_db_path + ): + db = Database(temp_db_path) await db.initialize() + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["1-0"]) + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute("DROP INDEX idx_results_fixture_id_unique") + await conn.commit() - fixture = await db.get_fixture_by_id(1, "111111") - other_guild_fixture = await db.get_fixture_by_id(1, "222222") - season = await db.get_active_season("111111") - prediction = await db.get_prediction(1, "user-1", "111111") - results = await db.get_results(1) - standings = await db.get_standings("111111") - other_guild_standings = await db.get_standings("222222") + await db.initialize() + await db.save_results(fixture_id, ["2-0"]) - assert fixture is not None - assert other_guild_fixture is None - assert season is not None - assert fixture["season_id"] == season["id"] - assert prediction["predictions"] == ["2-1"] - assert results == ["2-1"] - assert [row["user_id"] for row in standings] == ["user-1"] - assert other_guild_standings == [] + assert await db.get_results(fixture_id) == ["2-0"] + async with ( + aiosqlite.connect(temp_db_path) as conn, + conn.execute( + "SELECT COUNT(*) FROM results WHERE fixture_id = ?", (fixture_id,) + ) as cursor, + ): + assert await cursor.fetchone() == (1,) @pytest.mark.asyncio - async def test_initialize_adds_missing_columns(self, temp_db_path): - """Should automatically add missing columns during initialization.""" + async def test_initialize_rejects_partial_result_unique_index(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute("DROP INDEX idx_results_fixture_id_unique") await conn.execute( + "CREATE UNIQUE INDEX idx_results_fixture_id_unique ON results(fixture_id) WHERE fixture_id > 0" + ) + await conn.commit() + + with pytest.raises(RuntimeError, match=r"results\(fixture_id\)"): + await db.initialize() + + @pytest.mark.asyncio + async def test_initialize_rejects_missing_prediction_unique_constraint(self, temp_db_path): + async with aiosqlite.connect(temp_db_path) as conn: + await conn.executescript( """ CREATE TABLE seasons ( id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', + exact_score_points INTEGER NOT NULL DEFAULT 3, + correct_outcome_points INTEGER NOT NULL DEFAULT 1, + wrong_outcome_points INTEGER NOT NULL DEFAULT 0, + late_prediction_points INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, ended_at DATETIME - ) - """ - ) - await conn.execute(""" + ); CREATE TABLE fixtures ( id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id TEXT NOT NULL, @@ -1587,49 +1623,77 @@ async def test_initialize_adds_missing_columns(self, temp_db_path): games TEXT NOT NULL, deadline DATETIME NOT NULL, status TEXT DEFAULT 'open', + message_id TEXT, + channel_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - """) - await conn.execute( - "INSERT INTO seasons (id, guild_id, name, status) VALUES (1, '111111', 'Migrated Season', 'active')" + ); + CREATE TABLE predictions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fixture_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + user_name TEXT NOT NULL, + predictions TEXT NOT NULL, + submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_late BOOLEAN DEFAULT FALSE, + late_penalty_waived BOOLEAN DEFAULT FALSE, + admin_edited_at DATETIME, + admin_edited_by TEXT, + predicted_game_indexes TEXT, + pending_partial_approval BOOLEAN DEFAULT FALSE, + public_message_id TEXT, + public_message_kind TEXT + ); + CREATE TABLE results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fixture_id INTEGER NOT NULL, + results TEXT NOT NULL, + calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fixture_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + user_name TEXT NOT NULL, + points INTEGER NOT NULL, + exact_scores INTEGER DEFAULT 0, + correct_results INTEGER DEFAULT 0, + UNIQUE(fixture_id, user_id) + ); + CREATE TABLE guild_config ( + guild_id TEXT PRIMARY KEY, + admin_role_id TEXT NOT NULL, + league_channel_id TEXT NOT NULL, + active_season_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """ ) await conn.commit() db = Database(temp_db_path) - await db.initialize() - - await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) - fixture = await db.get_current_fixture("111111") - scoring_rules = await db.get_active_scoring_rules("111111") - assert fixture is not None - assert "message_id" in fixture - assert fixture["week_number"] == 1 - assert scoring_rules == { - "exact_score_points": 3, - "correct_outcome_points": 1, - "wrong_outcome_points": 0, - "late_prediction_points": 0, - } + with pytest.raises(RuntimeError, match=r"predictions\(fixture_id, user_id\)"): + await db.initialize() @pytest.mark.asyncio - async def test_initialize_migrates_legacy_results_to_unique_latest_row(self, temp_db_path): - """Legacy duplicate result rows should collapse to the newest saved value.""" + async def test_initialize_rejects_stale_schema_without_required_columns(self, temp_db_path): async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute( + await conn.executescript( """ CREATE TABLE seasons ( id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', + exact_score_points INTEGER NOT NULL DEFAULT 3, + correct_outcome_points INTEGER NOT NULL DEFAULT 1, + wrong_outcome_points INTEGER NOT NULL DEFAULT 0, + late_prediction_points INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, ended_at DATETIME - ) - """ - ) - await conn.execute( - """ + ); CREATE TABLE fixtures ( id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id TEXT NOT NULL, @@ -1637,15 +1701,11 @@ async def test_initialize_migrates_legacy_results_to_unique_latest_row(self, tem week_number INTEGER NOT NULL, games TEXT NOT NULL, deadline DATETIME NOT NULL, - status TEXT DEFAULT 'open' - ) - """ - ) - await conn.execute( - "INSERT INTO seasons (id, guild_id, name, status) VALUES (1, '111111', 'Migrated Season', 'active')" - ) - await conn.execute( - """ + status TEXT DEFAULT 'open', + message_id TEXT, + channel_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); CREATE TABLE predictions ( id INTEGER PRIMARY KEY AUTOINCREMENT, fixture_id INTEGER NOT NULL, @@ -1654,135 +1714,104 @@ async def test_initialize_migrates_legacy_results_to_unique_latest_row(self, tem predictions TEXT NOT NULL, submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, is_late BOOLEAN DEFAULT FALSE, + late_penalty_waived BOOLEAN DEFAULT FALSE, + admin_edited_at DATETIME, + admin_edited_by TEXT, + predicted_game_indexes TEXT, + pending_partial_approval BOOLEAN DEFAULT FALSE, + public_message_kind TEXT, UNIQUE(fixture_id, user_id) - ) - """ - ) - await conn.execute( - """ + ); CREATE TABLE results ( id INTEGER PRIMARY KEY AUTOINCREMENT, fixture_id INTEGER NOT NULL, results TEXT NOT NULL, - calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) + calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fixture_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + user_name TEXT NOT NULL, + points INTEGER NOT NULL, + exact_scores INTEGER DEFAULT 0, + correct_results INTEGER DEFAULT 0, + UNIQUE(fixture_id, user_id) + ); + CREATE TABLE guild_config ( + guild_id TEXT PRIMARY KEY, + admin_role_id TEXT NOT NULL, + league_channel_id TEXT NOT NULL, + active_season_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); """ ) - await conn.execute( - "INSERT INTO fixtures (id, guild_id, season_id, week_number, games, deadline, status) VALUES (1, '111111', 1, 1, 'A - B', ?, 'open')", - (datetime.now(UTC).isoformat(),), - ) - await conn.execute( - "INSERT INTO results (fixture_id, results, calculated_at) VALUES (1, '1-0', '2024-01-01T10:00:00+00:00')" - ) - await conn.execute( - "INSERT INTO results (fixture_id, results, calculated_at) VALUES (1, '2-0', '2024-01-01T12:00:00+00:00')" - ) await conn.commit() db = Database(temp_db_path) - await db.initialize() - - assert await db.get_results(1) == ["2-0"] - async with ( - aiosqlite.connect(temp_db_path) as conn, - conn.execute("SELECT COUNT(*) FROM results WHERE fixture_id = 1") as cursor, - ): - row = await cursor.fetchone() - assert row is not None - assert row[0] == 1 + with pytest.raises(RuntimeError, match="predictions.public_message_id"): + await db.initialize() - await db.save_results(1, ["3-0"]) - assert await db.get_results(1) == ["3-0"] + @pytest.mark.asyncio + async def test_initialize_rejects_existing_schema_with_missing_table(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute("DROP TABLE scores") + await conn.commit() - async with ( - aiosqlite.connect(temp_db_path) as conn, - conn.execute("SELECT COUNT(*) FROM results WHERE fixture_id = 1") as cursor, - ): - row = await cursor.fetchone() - assert row is not None - assert row[0] == 1 + with pytest.raises(RuntimeError, match="scores.fixture_id"): + await db.initialize() @pytest.mark.asyncio - async def test_initialize_adds_prediction_override_columns_with_safe_defaults( - self, temp_db_path - ): - """Legacy prediction rows should gain admin-override fields without mutating existing facts.""" + @pytest.mark.parametrize("guild_id", ["", " "]) + async def test_initialize_rejects_blank_fixture_guild_ownership(self, temp_db_path, guild_id): + db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) async with aiosqlite.connect(temp_db_path) as conn: await conn.execute( - """ - CREATE TABLE seasons ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT NOT NULL, - name TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - ended_at DATETIME - ) - """ + "UPDATE fixtures SET guild_id = ? WHERE id = ?", (guild_id, fixture_id) ) + await conn.commit() + + with pytest.raises(RuntimeError, match="fixtures.guild_id has empty rows"): + await db.initialize() + + @pytest.mark.asyncio + async def test_initialize_rejects_null_fixture_guild_ownership(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute("DROP TABLE fixtures") await conn.execute( """ CREATE TABLE fixtures ( id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT NOT NULL, + guild_id TEXT, season_id INTEGER, week_number INTEGER NOT NULL, games TEXT NOT NULL, deadline DATETIME NOT NULL, - status TEXT DEFAULT 'open' - ) - """ - ) - await conn.execute( - "INSERT INTO seasons (id, guild_id, name, status) VALUES (1, '111111', 'Migrated Season', 'active')" - ) - await conn.execute( - """ - CREATE TABLE predictions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - fixture_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - user_name TEXT NOT NULL, - predictions TEXT NOT NULL, - submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, - is_late BOOLEAN DEFAULT FALSE, - UNIQUE(fixture_id, user_id) - ) - """ - ) - await conn.execute( - """ - CREATE TABLE results ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - fixture_id INTEGER NOT NULL, - results TEXT NOT NULL, - calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP + status TEXT DEFAULT 'open', + message_id TEXT, + channel_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """ ) await conn.execute( - "INSERT INTO fixtures (id, guild_id, season_id, week_number, games, deadline, status) VALUES (1, '111111', 1, 1, 'A - B', ?, 'open')", + "INSERT INTO fixtures (guild_id, season_id, week_number, games, deadline, status) VALUES (NULL, NULL, 1, 'A - B', ?, 'open')", (datetime.now(UTC).isoformat(),), ) - await conn.execute( - """ - INSERT INTO predictions (fixture_id, user_id, user_name, predictions, submitted_at, is_late) - VALUES (1, 'user-1', 'User One', '1-0', '2024-01-01T10:00:00+00:00', 1) - """ - ) await conn.commit() - db = Database(temp_db_path) - await db.initialize() - - prediction = await db.get_prediction(1, "user-1", "111111") - assert prediction is not None - assert prediction["is_late"] == 1 - assert prediction["late_penalty_waived"] == 0 - assert prediction["admin_edited_at"] is None - assert prediction["admin_edited_by"] is None + with pytest.raises(RuntimeError, match="fixtures.guild_id has empty rows"): + await db.initialize() @pytest.fixture diff --git a/typer_bot/database/connection.py b/typer_bot/database/connection.py index 77f328d..2badfe5 100644 --- a/typer_bot/database/connection.py +++ b/typer_bot/database/connection.py @@ -1,4 +1,4 @@ -"""Database composition root — schema initialisation and migrations.""" +"""Database composition root — schema initialisation and validation.""" import logging from pathlib import Path @@ -18,6 +18,67 @@ __all__ = ["Database", "SaveResult"] +REQUIRED_COLUMNS = { + "seasons": { + "id", + "guild_id", + "name", + "status", + "exact_score_points", + "correct_outcome_points", + "wrong_outcome_points", + "late_prediction_points", + "created_at", + "ended_at", + }, + "fixtures": { + "id", + "guild_id", + "season_id", + "week_number", + "games", + "deadline", + "status", + "message_id", + "channel_id", + "created_at", + }, + "predictions": { + "id", + "fixture_id", + "user_id", + "user_name", + "predictions", + "submitted_at", + "is_late", + "late_penalty_waived", + "admin_edited_at", + "admin_edited_by", + "predicted_game_indexes", + "pending_partial_approval", + "public_message_id", + "public_message_kind", + }, + "results": {"id", "fixture_id", "results", "calculated_at", "updated_at"}, + "scores": { + "id", + "fixture_id", + "user_id", + "user_name", + "points", + "exact_scores", + "correct_results", + }, + "guild_config": { + "guild_id", + "admin_role_id", + "league_channel_id", + "active_season_id", + "created_at", + "updated_at", + }, +} + async def _table_columns(db: aiosqlite.Connection, table_name: str) -> set[str]: async with db.execute(f"PRAGMA table_info({table_name})") as cursor: @@ -25,121 +86,37 @@ async def _table_columns(db: aiosqlite.Connection, table_name: str) -> set[str]: return {col[1] for col in columns} -async def _migrate_results_table(db: aiosqlite.Connection) -> None: - columns = await _table_columns(db, "results") - if not columns: - return - +async def _has_existing_schema(db: aiosqlite.Connection) -> bool: async with db.execute( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_results_fixture_id_unique'" + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1" ) as cursor: - row = await cursor.fetchone() - unique_index_exists = bool(row and row[0] > 0) + return await cursor.fetchone() is not None - if unique_index_exists: - return - timestamp_expr = ( - "COALESCE(calculated_at, CURRENT_TIMESTAMP)" - if "calculated_at" in columns - else "CURRENT_TIMESTAMP" - ) +async def _has_unique_index( + db: aiosqlite.Connection, + table_name: str, + column_names: tuple[str, ...], +) -> bool: + async with db.execute(f"PRAGMA index_list({table_name})") as cursor: + indexes = await cursor.fetchall() - logger.info("Migrating results table for deterministic result updates") - await db.execute("BEGIN IMMEDIATE") - try: - await db.execute("DROP TABLE IF EXISTS results_migrated") - await db.execute( - """ - CREATE TABLE results_migrated ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - fixture_id INTEGER NOT NULL, - results TEXT NOT NULL, - calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (fixture_id) REFERENCES fixtures(id) - ) - """ - ) - await db.execute( - f""" - INSERT INTO results_migrated (fixture_id, results, calculated_at, updated_at) - SELECT fixture_id, - results, - {timestamp_expr}, - {timestamp_expr} - FROM results old - WHERE old.id IN ( - SELECT MAX(id) - FROM results - GROUP BY fixture_id - ) - """ - ) - await db.execute("DROP TABLE results") - await db.execute("ALTER TABLE results_migrated RENAME TO results") - await db.execute("CREATE UNIQUE INDEX idx_results_fixture_id_unique ON results(fixture_id)") - await db.commit() - except Exception: - await db.rollback() - raise - - -async def _migrate_prediction_columns(db: aiosqlite.Connection) -> None: - columns = await _table_columns(db, "predictions") - - if "late_penalty_waived" not in columns: - logger.info("Adding late_penalty_waived column to predictions table") - await db.execute( - "ALTER TABLE predictions ADD COLUMN late_penalty_waived BOOLEAN DEFAULT FALSE" - ) - - if "admin_edited_at" not in columns: - logger.info("Adding admin_edited_at column to predictions table") - await db.execute("ALTER TABLE predictions ADD COLUMN admin_edited_at DATETIME") - - if "admin_edited_by" not in columns: - logger.info("Adding admin_edited_by column to predictions table") - await db.execute("ALTER TABLE predictions ADD COLUMN admin_edited_by TEXT") - - if "predicted_game_indexes" not in columns: - logger.info("Adding predicted_game_indexes column to predictions table") - await db.execute("ALTER TABLE predictions ADD COLUMN predicted_game_indexes TEXT") - - if "pending_partial_approval" not in columns: - logger.info("Adding pending_partial_approval column to predictions table") - await db.execute( - "ALTER TABLE predictions ADD COLUMN pending_partial_approval BOOLEAN DEFAULT FALSE" - ) - - if "public_message_id" not in columns: - logger.info("Adding public_message_id column to predictions table") - await db.execute("ALTER TABLE predictions ADD COLUMN public_message_id TEXT") - - if "public_message_kind" not in columns: - logger.info("Adding public_message_kind column to predictions table") - await db.execute("ALTER TABLE predictions ADD COLUMN public_message_kind TEXT") - - -async def _migrate_season_columns(db: aiosqlite.Connection) -> None: - columns = await _table_columns(db, "seasons") - scoring_columns = { - "exact_score_points": "INTEGER NOT NULL DEFAULT 3", - "correct_outcome_points": "INTEGER NOT NULL DEFAULT 1", - "wrong_outcome_points": "INTEGER NOT NULL DEFAULT 0", - "late_prediction_points": "INTEGER NOT NULL DEFAULT 0", - } - for column_name, column_definition in scoring_columns.items(): - if column_name not in columns: - logger.info("Adding %s column to seasons table", column_name) - await db.execute(f"ALTER TABLE seasons ADD COLUMN {column_name} {column_definition}") + for index in indexes: + if not index[2] or index[4]: + continue + index_name = index[1] + async with db.execute(f"PRAGMA index_info({index_name})") as cursor: + index_columns = await cursor.fetchall() + if tuple(row[2] for row in index_columns) == column_names: + return True + return False async def _validate_fixture_guild_ownership(db: aiosqlite.Connection) -> None: columns = await _table_columns(db, "fixtures") if "guild_id" not in columns: raise RuntimeError( - "fixtures.guild_id is missing. Run the one-time v2.0.0 guild ownership migration before starting the bot." + "fixtures.guild_id is missing. Manually port the database to the current schema before starting the bot." ) async with db.execute( @@ -148,15 +125,61 @@ async def _validate_fixture_guild_ownership(db: aiosqlite.Connection) -> None: row = await cursor.fetchone() if row and row[0] > 0: raise RuntimeError( - "fixtures.guild_id has empty rows. Backfill every fixture with the owning Discord guild ID before starting the bot." + "fixtures.guild_id has empty rows. Set every fixture to its owning Discord guild ID before starting the bot." ) +async def _validate_current_schema(db: aiosqlite.Connection) -> None: + for table_name, required_columns in REQUIRED_COLUMNS.items(): + columns = await _table_columns(db, table_name) + missing_columns = required_columns - columns + if missing_columns: + missing_list = ", ".join(f"{table_name}.{column}" for column in sorted(missing_columns)) + raise RuntimeError( + f"Database schema is missing required column(s): {missing_list}. " + "Manually port the database to the current schema before starting the bot." + ) + + required_unique_constraints = { + "predictions": ("fixture_id", "user_id"), + "scores": ("fixture_id", "user_id"), + } + for table_name, column_names in required_unique_constraints.items(): + if not await _has_unique_index(db, table_name, column_names): + joined_columns = ", ".join(column_names) + raise RuntimeError( + f"Database schema is missing required unique constraint: {table_name}({joined_columns}). " + "Manually port the database to the current schema before starting the bot." + ) + + +async def _validate_unique_results(db: aiosqlite.Connection) -> None: + async with db.execute( + """ + SELECT fixture_id, COUNT(*) AS row_count + FROM results + GROUP BY fixture_id + HAVING row_count > 1 + ORDER BY fixture_id + LIMIT 5 + """ + ) as cursor: + rows = await cursor.fetchall() + if not rows: + return + + fixture_ids = ", ".join(str(row[0]) for row in rows) + raise RuntimeError( + "results has duplicate rows for fixture_id(s): " + f"{fixture_ids}. Keep one result row per fixture before starting the bot." + ) + + class Database: """Composition root for SQLite setup and the bot's stable data facade. Callers use this facade instead of reaching into repositories directly. It - owns path setup, schema initialization, startup migrations, and the focused + owns path setup, schema initialization, startup validation, and the focused repository objects that perform the actual reads and writes. """ @@ -175,21 +198,28 @@ def __init__(self, db_path: str | None = None) -> None: self._seasons = SeasonRepository(self.db_path) async def initialize(self) -> None: - """Create tables, enable WAL mode, and apply startup migrations. + """Create tables, enable WAL mode, and validate existing schema invariants. - Fresh databases get the current schema. Existing databases are migrated - in place by adding missing columns and by collapsing ``results`` rows - into the current one-row-per-fixture layout. + Fresh databases get the current schema. Existing databases must already + match the current schema, except for explicit fail-fast checks that + produce actionable errors for unsafe live data. Raises: - RuntimeError: Existing databases must have a populated - ``fixtures.guild_id`` before v2 startup can continue. + RuntimeError: Existing databases must match the current schema and + contain data safe for current unique constraints before startup + can continue. """ async with aiosqlite.connect(self.db_path) as db: async with db.execute("PRAGMA journal_mode=WAL") as cur: row = await cur.fetchone() if row and row[0] != "wal": logger.warning("WAL mode not applied; journal_mode=%s", row[0]) + has_existing_schema = await _has_existing_schema(db) + if has_existing_schema: + await _validate_current_schema(db) + await _validate_fixture_guild_ownership(db) + await _validate_unique_results(db) + await db.execute(""" CREATE TABLE IF NOT EXISTS seasons ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -215,6 +245,7 @@ async def initialize(self) -> None: deadline DATETIME NOT NULL, status TEXT DEFAULT 'open', message_id TEXT, + channel_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) @@ -276,26 +307,19 @@ async def initialize(self) -> None: ) """) - column_names = await _table_columns(db, "fixtures") + if not has_existing_schema: + await _validate_current_schema(db) + await _validate_fixture_guild_ownership(db) + await _validate_unique_results(db) - if "message_id" not in column_names: - logger.info("Adding message_id column to fixtures table") - await db.execute("ALTER TABLE fixtures ADD COLUMN message_id TEXT") - - if "channel_id" not in column_names: - logger.info("Adding channel_id column to fixtures table") - await db.execute("ALTER TABLE fixtures ADD COLUMN channel_id TEXT") - - await _validate_fixture_guild_ownership(db) - - await _migrate_season_columns(db) - await _migrate_prediction_columns(db) - await _migrate_results_table(db) - - # Keep one results row per fixture on both fresh installs and upgraded DBs. await db.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_results_fixture_id_unique ON results(fixture_id)" ) + if not await _has_unique_index(db, "results", ("fixture_id",)): + raise RuntimeError( + "Database schema is missing required unique constraint: results(fixture_id). " + "Manually port the database to the current schema before starting the bot." + ) await db.execute( "CREATE INDEX IF NOT EXISTS idx_fixtures_guild_status_week ON fixtures(guild_id, status, week_number)" )