feat: Task 20 — SQLAlchemy-Persistenz

This commit is contained in:
hermes
2026-06-22 13:04:34 +00:00
parent f825165596
commit cd3d9c7072
5 changed files with 208 additions and 52 deletions
+46
View File
@@ -0,0 +1,46 @@
"""SQLAlchemy-Engine, Session und Base für BSN-Livesystem."""
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
class Base(DeclarativeBase):
"""Base-Klasse für SQLAlchemy-Modelle."""
pass
def create_engine_connection() -> object:
"""Erstellt die SQLAlchemy-Engine mit SQLite."""
return create_engine(
"sqlite:////tmp/bsn_livesystem.db",
connect_args={"check_same_thread": False},
)
def create_session_factory(engine: object) -> type:
"""Erzeugt eine Session-Fakton für die Engine."""
return sessionmaker(bind=engine)
# Globale Instanzen
_engine = create_engine_connection()
_session_factory = create_session_factory(_engine)
def get_engine():
return _engine
def get_session():
return _session_factory()
def reset_database(engine=None) -> None:
"""Löscht und erstellt alle Tabellen neu."""
if engine is None:
engine = get_engine()
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
+22
View File
@@ -0,0 +1,22 @@
"""SQLAlchemy-Modell für Brettspiele."""
from __future__ import annotations
from sqlalchemy import Float, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from src.database import Base
class SpielDB(Base):
"""SQLAlchemy-Modell für ein Brettspiel."""
__tablename__ = "spiele"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
min_spieler: Mapped[int] = mapped_column(Integer, default=1)
max_spieler: Mapped[int] = mapped_column(Integer, default=10)
dauer_min: Mapped[int] = mapped_column(Integer, default=30)
komplexitaet: Mapped[float] = mapped_column(Float, default=2.5)
kategorie: Mapped[str] = mapped_column(String(100), default="Familienspiel")
+50 -52
View File
@@ -4,6 +4,9 @@ from __future__ import annotations
from pydantic import BaseModel, Field
from src.database import Base, get_engine, get_session
from src.models.spiel import SpielDB
class Spiel(BaseModel):
"""Ein Brettspiel-Datenmodell."""
@@ -18,67 +21,62 @@ class Spiel(BaseModel):
class GameService:
"""Verwaltet die Spiele-Datenbank (In-Memory für jetzt, später DB)."""
"""Verwaltet die Spiele-Datenbank (SQLAlchemy mit SQLite-Persistenz)."""
def __init__(self) -> None:
self._spiele: dict[int, Spiel] = {}
self._next_id = 1
engine = get_engine()
Base.metadata.create_all(bind=engine)
self._session = get_session()
def _to_pydantic(self, spiel_db: SpielDB) -> Spiel:
"""Konvertiert SpielDB nach Pydantic."""
return Spiel(
name=spiel_db.name,
id=spiel_db.id,
min_spieler=spiel_db.min_spieler,
max_spieler=spiel_db.max_spieler,
dauer_min=spiel_db.dauer_min,
komplexitaet=spiel_db.komplexitaet,
kategorie=spiel_db.kategorie,
)
def hinzufuegen(self, spiel: Spiel) -> Spiel:
"""Fügt ein Spiel hinzu und weist eine ID zu.
Args:
spiel: Das hinzuzufügende Spiel.
Returns:
Das zugewiesene Spiel mit ID.
"""
spiel.id = self._next_id
self._spiele[self._next_id] = spiel
self._next_id += 1
return spiel
"""Fügt ein Spiel hinzu und weist eine ID zu."""
spiel_db = SpielDB(
name=spiel.name,
min_spieler=spiel.min_spieler,
max_spieler=spiel.max_spieler,
dauer_min=spiel.dauer_min,
komplexitaet=spiel.komplexitaet,
kategorie=spiel.kategorie,
)
self._session.add(spiel_db)
self._session.commit()
return self._to_pydantic(spiel_db)
def alle(self) -> list[Spiel]:
"""Gibt alle Spiele zurück.
Returns:
Liste aller Spiele.
"""
return list(self._spiele.values())
"""Gibt alle Spiele zurück."""
return [self._to_pydantic(s) for s in self._session.query(SpielDB).all()]
def suchen(self, name: str) -> list[Spiel]:
"""Sucht Spiele nach Name (case-insensitive Teilstring).
Args:
name: Der Suchbegriff.
Returns:
Liste der gefundenen Spiele.
"""
name_lower = name.lower()
return [spiel for spiel in self._spiele.values() if name_lower in spiel.name.lower()]
"""Sucht Spiele nach Name (case-insensitive)."""
spiele_db = self._session.query(SpielDB).filter(
SpielDB.name.ilike(f"%{name}%")
).all()
return [self._to_pydantic(s) for s in spiele_db]
def nach_id(self, spiel_id: int) -> Spiel | None:
"""Holt ein Spiel anhand seiner ID.
Args:
spiel_id: Die ID des Spiels.
Returns:
Das gefundene Spiel oder None.
"""
return self._spiele.get(spiel_id)
"""Holt ein Spiel anhand seiner ID."""
spiel_db = self._session.get(SpielDB, spiel_id)
if spiel_db is None:
return None
return self._to_pydantic(spiel_db)
def loesche(self, spiel_id: int) -> bool:
"""Löscht ein Spiel anhand seiner ID.
Args:
spiel_id: Die ID des zu löschenden Spiels.
Returns:
True wenn gelöscht, False wenn nicht existiert.
"""
if spiel_id in self._spiele:
del self._spiele[spiel_id]
return True
return False
"""Löscht ein Spiel anhand seiner ID."""
spiel_db = self._session.get(SpielDB, spiel_id)
if spiel_db is None:
return False
self._session.delete(spiel_db)
self._session.commit()
return True
+44
View File
@@ -0,0 +1,44 @@
"""Pytest-Konfiguration: Redis-Mock und Datenbank-Reset."""
from __future__ import annotations
import sys
import unittest.mock as mock
import fakeredis
import pytest
from src.database import Base, _engine
# --- Fake redis module that provides real-like exceptions ---
import redis as _real_redis
class _FakeRedis:
"""Wraps fakeredis with the correct redis.exceptions hierarchy."""
@staticmethod
def exceptions():
return _real_redis.exceptions
def __init__(self, *args, **kwargs):
self._conn = fakeredis.FakeStrictRedis(decode_responses=True)
def __getattr__(self, name):
return getattr(self._conn, name)
@property
def exceptions(self):
return _real_redis.exceptions
# Install BEFORE any app code runs
sys.modules["redis"] = mock.MagicMock()
sys.modules["redis"].Redis = _FakeRedis
sys.modules["redis"].exceptions = _real_redis.exceptions
@pytest.fixture(autouse=True)
def reset_db() -> None:
"""Setzt die Datenbank vor jedem Test zurueck."""
Base.metadata.drop_all(bind=_engine)
Base.metadata.create_all(bind=_engine)
+46
View File
@@ -0,0 +1,46 @@
"""Tests für src.database — SQLAlchemy-Engine und Session."""
from __future__ import annotations
from sqlalchemy import inspect
from src.database import Base, get_engine, get_session
from src.models.spiel import SpielDB
class TestDatabase:
"""Tests für die Datenbank-Schicht."""
def test_engine_is_available(self) -> None:
"""engine ist eine SQLAlchemy-Engine."""
engine = get_engine()
assert engine is not None
def test_session_ist_ernzeugbar(self) -> None:
"""session kann erstellt werden."""
session = get_session()
assert session is not None
session.close()
def test_base_metadata_contains_spiel(self) -> None:
"""Base.metadata registriert spiele-Tabelle."""
engine = get_engine()
inspector = inspect(engine)
metadata_tables = set(inspector.get_table_names())
# Nach create_all in game_service sollte spiele existieren
assert "spiele" in metadata_tables, f"Keine spiele-Tabelle in {metadata_tables}"
def test_sqlalchemy_spiel_modell_erstellt(self) -> None:
"""SpielDB-Modell hat korrekte Spalten."""
assert hasattr(SpielDB, "name")
assert hasattr(SpielDB, "min_spieler")
assert hasattr(SpielDB, "max_spieler")
assert hasattr(SpielDB, "dauer_min")
assert hasattr(SpielDB, "komplexitaet")
assert hasattr(SpielDB, "kategorie")
def test_session_get_bind(self) -> None:
"""session.get_bind() gibt engine zurück."""
session = get_session()
assert session.get_bind() is not None
session.close()