Auf SQLite baut Alembic (render_as_batch) eine Tabelle um, indem es eine Kopie anlegt und das Original löscht. Bei DROP TABLE führt SQLite ein implizites DELETE aus – mit aktiven Fremdschlüsseln schlägt das per ON DELETE CASCADE auf abhängige Tabellen durch. Migration 0013 fügte `categories` nur ein Feld hinzu und vernichtete dabei alle Schlagwörter und Budget-Vorlagen und setzte die Kategorie sämtlicher Buchungen auf NULL. Reproduziert und behoben: Fremdschlüssel werden für die Dauer der Migration abgeschaltet (vor der Transaktion, sonst ignoriert SQLite den Befehl) und danach wieder aktiviert. Lässt sich das Abschalten nicht bestätigen, bricht die Migration ab, statt Daten zu riskieren. Regressionstest legt Daten auf Stand 0012 an, migriert auf head und prüft, dass Schlagwörter, Budget-Vorlagen und Kategoriezuordnungen erhalten bleiben. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
69 lines
3.0 KiB
Python
69 lines
3.0 KiB
Python
"""Migrationen dürfen keine Daten vernichten.
|
||
|
||
Auf SQLite baut Alembic (``render_as_batch``) eine Tabelle um, indem es eine
|
||
Kopie anlegt und das Original löscht. Bei DROP TABLE führt SQLite ein implizites
|
||
DELETE aus – mit aktiven Fremdschlüsseln schlägt das per ON DELETE CASCADE auf
|
||
abhängige Tabellen durch. Ein zusätzliches Feld an ``categories`` löschte so
|
||
sämtliche Schlagwörter und Budget-Vorlagen und setzte die Kategorie aller
|
||
Buchungen auf NULL. Dieser Test hält das fest.
|
||
"""
|
||
|
||
import sqlite3
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
BACKEND = Path(__file__).resolve().parents[1]
|
||
|
||
# Ein Datensatz je Tabelle, die per Fremdschlüssel an categories hängt.
|
||
SETUP = """
|
||
INSERT INTO users (username,password_hash,role,is_active,must_change_password)
|
||
VALUES ('t','x','user',1,0);
|
||
INSERT INTO category_groups (owner_id,name,kind,icon,color,position,is_builtin)
|
||
VALUES (1,'G','expense','list','#000',0,0);
|
||
INSERT INTO categories (owner_id,group_id,name,type,icon,color,position,is_builtin,is_archived)
|
||
VALUES (1,1,'Lebensmittel','expense','x','#000',0,0,0);
|
||
INSERT INTO accounts (owner_id,name,type,currency,icon,color,opening_balance_cents,is_archived,position)
|
||
VALUES (1,'Giro','girokonto','EUR','x','#000',0,0,0);
|
||
INSERT INTO categorization_rules
|
||
(owner_id,match_field,pattern,category_id,priority,hits,source,applied_count,overridden_count,confirmed,is_transfer,kind)
|
||
VALUES (1,'any','rewe',1,10,0,'manual',0,0,0,0,'expense');
|
||
INSERT INTO budget_template_lines (owner_id,category_id,planned_cents) VALUES (1,1,20000);
|
||
INSERT INTO transactions (owner_id,type,date,account_id,amount_cents,amount_eur_cents,category_id)
|
||
VALUES (1,'expense','2026-03-01',1,5000,5000,1);
|
||
"""
|
||
|
||
|
||
def _alembic(data_dir: Path, ziel: str) -> None:
|
||
subprocess.run(
|
||
[sys.executable, "-c",
|
||
f"from alembic import command; from alembic.config import Config;"
|
||
f" command.upgrade(Config('alembic.ini'), {ziel!r})"],
|
||
cwd=BACKEND, check=True, capture_output=True,
|
||
env={"PATH": "/usr/bin:/bin", "APP_DATA_DIR": str(data_dir),
|
||
"APP_ECB_FETCH_ON_START": "false", "APP_SESSION_SECRET": "test",
|
||
"PYTHONPATH": str(BACKEND)},
|
||
)
|
||
|
||
|
||
def test_batch_migration_keeps_dependent_rows():
|
||
with tempfile.TemporaryDirectory(prefix="cf-mig-") as tmp:
|
||
data_dir = Path(tmp)
|
||
# Stand vor dem Umbau von `categories`, dann Testdaten einspielen.
|
||
_alembic(data_dir, "0012")
|
||
con = sqlite3.connect(data_dir / "app.db")
|
||
con.executescript(SETUP)
|
||
con.commit()
|
||
con.close()
|
||
|
||
_alembic(data_dir, "head")
|
||
|
||
con = sqlite3.connect(data_dir / "app.db")
|
||
zahl = lambda s: con.execute(s).fetchone()[0] # noqa: E731
|
||
assert zahl("SELECT COUNT(*) FROM categorization_rules") == 1, "Schlagwörter verloren"
|
||
assert zahl("SELECT COUNT(*) FROM budget_template_lines") == 1, "Budget-Vorlage verloren"
|
||
assert zahl("SELECT COUNT(*) FROM transactions WHERE category_id IS NOT NULL") == 1, \
|
||
"Kategorie der Buchung verloren"
|
||
con.close()
|