Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

134
tools/check-schema.py Normal file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Vergleicht db/schema.sql mit den SQLAlchemy-Modellen.
python3 tools/check-schema.py
Die SQL-Datei ist von Hand geschrieben und kann beim nächsten
Modell-Umbau vergessen werden. Dieser Vergleich findet fehlende oder
überzählige Tabellen und Spalten - bevor jemand die Datei zum Aufsetzen
einer neuen Instanz benutzt und sich wundert.
Arbeitet rein textlich, ohne SQLAlchemy zu laden: Damit läuft die
Prüfung auch außerhalb des Containers.
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MODELS = ROOT / "backend" / "app" / "models.py"
SCHEMA = ROOT / "db" / "schema.sql"
# Tabellen, die nicht aus Modellen stammen
EXTRA_TABLES = {"alembic_version"}
def from_models() -> dict[str, set[str]]:
"""__tablename__ und mapped_column-Namen je Klasse einsammeln."""
source = MODELS.read_text(encoding="utf-8")
tables: dict[str, set[str]] = {}
current: str | None = None
for line in source.splitlines():
table = re.match(r'\s*__tablename__\s*=\s*"([^"]+)"', line)
if table:
current = table.group(1)
tables[current] = set()
continue
if current is None:
continue
# Beziehungen sind keine Spalten
if "relationship(" in line:
continue
column = re.match(r"\s*(\w+):\s*Mapped\[", line)
if column:
tables[current].add(column.group(1))
return tables
def from_schema() -> dict[str, set[str]]:
"""CREATE TABLE ... ( ... ) auswerten."""
source = SCHEMA.read_text(encoding="utf-8")
tables: dict[str, set[str]] = {}
for match in re.finditer(
r"CREATE TABLE `(\w+)` \((.*?)\n\) ENGINE", source, re.S
):
name, body = match.group(1), match.group(2)
columns = set()
for line in body.splitlines():
line = line.strip()
if line.startswith("--") or not line:
continue
# Spaltendefinitionen beginnen mit `name` gefolgt von einem Typ
col = re.match(r"`(\w+)`\s+[A-Z]", line)
if col:
columns.add(col.group(1))
tables[name] = columns
return tables
def main() -> int:
models = from_models()
schema = from_schema()
problems = 0
missing = set(models) - set(schema)
surplus = set(schema) - set(models) - EXTRA_TABLES
for name in sorted(missing):
print(f" FEHLT in schema.sql: Tabelle {name}")
problems += 1
for name in sorted(surplus):
print(f" ÜBERZÄHLIG in schema.sql: Tabelle {name}")
problems += 1
for name in sorted(set(models) & set(schema)):
only_model = models[name] - schema[name]
only_schema = schema[name] - models[name]
for column in sorted(only_model):
print(f" FEHLT in schema.sql: {name}.{column}")
problems += 1
for column in sorted(only_schema):
print(f" ÜBERZÄHLIG in schema.sql: {name}.{column}")
problems += 1
# Migrationsstand muss zur höchsten Revision passen
versions = ROOT / "backend" / "alembic" / "versions"
revisions = sorted(
m.group(1)
for f in versions.glob("*.py")
if (m := re.search(r'^revision:\s*str\s*=\s*"(\w+)"', f.read_text(), re.M))
)
head = revisions[-1] if revisions else None
stamped = re.search(
r"INSERT INTO `alembic_version`.*VALUES \('(\w+)'\)", SCHEMA.read_text()
)
if head and (not stamped or stamped.group(1) != head):
print(
f" Migrationsstand passt nicht: schema.sql trägt "
f"{stamped.group(1) if stamped else 'nichts'}, "
f"höchste Revision ist {head}"
)
problems += 1
if problems:
print(f"\n{problems} Abweichung(en) zwischen Modellen und schema.sql")
return 1
print(
f"{len(models)} Tabellen, "
f"{sum(len(c) for c in models.values())} Spalten "
f"schema.sql stimmt mit den Modellen überein (Revision {head})"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())