From 4ffd7d3c9de5e37029dcd6ee8405c7e496c7da28 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Sat, 22 Aug 2026 18:23:38 +0200 Subject: [PATCH] =?UTF-8?q?Verzovat=20sch=C3=A9ma=20datab=C3=A1ze?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dokud to běželo jen doma, šlo dice.db v nejhorším smazat. Teď jsou venku binárky, které si zakládají databázi v %LOCALAPPDATA% a v Library, a ta data patří cizím lidem — první sloupec, který přibude, na nich prostě nebude. Verze se drží v PRAGMA user_version. Existující databáze mají nulu a plné schéma zároveň, což vychází: SCHEMA je samé IF NOT EXISTS, takže se na nich jen orazítkuje a nic se neztratí. Databázi z novější verze aplikace radši odmítneme otevřít. Srozumitelná hláška je lepší než tiše rozbitá data, ke kterým neexistuje záloha. --- README.md | 6 ++++ storage.py | 41 +++++++++++++++++++++++++- tests/test_storage.py | 67 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 72d0dac..6073eab 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,12 @@ sad vedle sebe. Zálohovat i stěhovat jde prostým zkopírováním souboru; `DICE_DB` si ho najde kdekoli. +Schéma má verzi uloženou v `PRAGMA user_version` a `storage.migrate()` si +databázi při každém připojení dorovná. Starší soubory z verzí, kdy se verze +schématu ještě nepsala, se poznají podle nuly a orazítkují se. Databázi +založenou novější verzí počitadla appka odmítne otevřít, místo aby ji tiše +rozbila. + Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi může zapisovat. diff --git a/storage.py b/storage.py index 80eca90..db29428 100644 --- a/storage.py +++ b/storage.py @@ -86,6 +86,45 @@ CREATE TABLE IF NOT EXISTS turns ( CREATE INDEX IF NOT EXISTS turns_game ON turns(game_id, id); """ +# Každý krok posouvá schéma o jedno dál; krok na indexu 0 vede z verze 1 na 2. +# Přidávat jen na konec a nikdy nesahat na ten, který už někomu proběhl — +# databáze leží lidem na discích a zpátky se nedají stáhnout. +MIGRATIONS: list[str] = [] + +SCHEMA_VERSION = 1 + len(MIGRATIONS) + + +def migrate(conn: sqlite3.Connection) -> int: + """Dorovná databázi na aktuální schéma a vrátí její verzi. + + Verze se drží v `PRAGMA user_version`, což je celé číslo, které si SQLite + veze v hlavičce souboru a jinak si ho nevšímá. + + Nula znamená dvojí: buď je soubor prázdný, nebo pochází z doby, kdy se + verze ještě nezapisovala. Obojí se vyřeší stejně — `SCHEMA` je samé + `IF NOT EXISTS`, takže na hotové databázi neudělá nic a jen se orazítkuje. + """ + version = conn.execute("PRAGMA user_version").fetchone()[0] + + if version > SCHEMA_VERSION: + raise RuntimeError( + f"Databáze je ve verzi {version}, tahle aplikace umí " + f"{SCHEMA_VERSION}. Nejspíš ji založila novější verze počitadla; " + f"aktualizuj, ať o data nepřijdeš.") + + if version == 0: + conn.executescript(SCHEMA) + version = 1 + + for step in MIGRATIONS[version - 1:]: + conn.executescript(step) + version += 1 + + # PRAGMA neumí zástupné otazníky. Číslo sem teče z kódu, ne od uživatele. + conn.execute(f"PRAGMA user_version = {version}") + conn.commit() + return version + def connect(path: Path | str | None = None) -> sqlite3.Connection: target = Path(path or DB_PATH) @@ -93,7 +132,7 @@ def connect(path: Path | str | None = None) -> sqlite3.Connection: conn = sqlite3.connect(target) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") - conn.executescript(SCHEMA) + migrate(conn) return conn diff --git a/tests/test_storage.py b/tests/test_storage.py index a07e2c6..34af97a 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,3 +1,4 @@ +import sqlite3 from pathlib import Path import pytest @@ -182,3 +183,69 @@ def test_connect_zalozi_chybejici_adresar(tmp_path): conn.close() assert (tmp_path / "novy" / "adresar" / "dice.db").exists() + + +# --- verze schématu --------------------------------------------------------- + +def verze(conn) -> int: + return conn.execute("PRAGMA user_version").fetchone()[0] + + +def test_nova_databaze_je_orazitkovana(tmp_path): + conn = storage.connect(tmp_path / "nova.db") + try: + assert verze(conn) == storage.SCHEMA_VERSION + finally: + conn.close() + + +def test_databaze_z_doby_pred_verzovanim_prezije(tmp_path): + """Přesně to, co lidem leží na disku z verzí 1.0 a 1.1. + + Tabulky má, razítko ne. Nesmí se ani přijít o data, ani spadnout. + """ + path = tmp_path / "stara.db" + stara = sqlite3.connect(path) + stara.executescript(storage.SCHEMA) # bez PRAGMA user_version + stara.execute("INSERT INTO players (name, key, created_at) " + "VALUES ('Adam', 'adam', '2026-01-01T00:00:00')") + stara.commit() + stara.close() + + conn = storage.connect(path) + try: + assert verze(conn) == storage.SCHEMA_VERSION + assert [p["name"] for p in storage.known_players(conn)] == ["Adam"] + finally: + conn.close() + + +def test_migrace_dojede_jen_ty_chybejici(tmp_path, monkeypatch): + """Podruhé už krok proběhnout nesmí — jinak by ALTER TABLE spadl.""" + monkeypatch.setattr(storage, "MIGRATIONS", + ["ALTER TABLE players ADD COLUMN barva TEXT;"]) + monkeypatch.setattr(storage, "SCHEMA_VERSION", 2) + + path = tmp_path / "migrovana.db" + conn = storage.connect(path) + conn.close() + + conn = storage.connect(path) # druhé spuštění téže verze + try: + assert verze(conn) == 2 + sloupce = [r[1] for r in conn.execute("PRAGMA table_info(players)")] + assert "barva" in sloupce + finally: + conn.close() + + +def test_databaze_z_novejsi_verze_se_odmitne(tmp_path): + """Radši srozumitelná hláška než tiše rozbitá data.""" + path = tmp_path / "budouci.db" + conn = storage.connect(path) + conn.execute(f"PRAGMA user_version = {storage.SCHEMA_VERSION + 1}") + conn.commit() + conn.close() + + with pytest.raises(RuntimeError, match="novější verze"): + storage.connect(path)