feat: Task 20 — SQLAlchemy-Persistenz
This commit is contained in:
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -4,6 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from src.database import Base, get_engine, get_session
|
||||||
|
from src.models.spiel import SpielDB
|
||||||
|
|
||||||
|
|
||||||
class Spiel(BaseModel):
|
class Spiel(BaseModel):
|
||||||
"""Ein Brettspiel-Datenmodell."""
|
"""Ein Brettspiel-Datenmodell."""
|
||||||
@@ -18,67 +21,62 @@ class Spiel(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class GameService:
|
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:
|
def __init__(self) -> None:
|
||||||
self._spiele: dict[int, Spiel] = {}
|
engine = get_engine()
|
||||||
self._next_id = 1
|
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:
|
def hinzufuegen(self, spiel: Spiel) -> Spiel:
|
||||||
"""Fügt ein Spiel hinzu und weist eine ID zu.
|
"""Fügt ein Spiel hinzu und weist eine ID zu."""
|
||||||
|
spiel_db = SpielDB(
|
||||||
Args:
|
name=spiel.name,
|
||||||
spiel: Das hinzuzufügende Spiel.
|
min_spieler=spiel.min_spieler,
|
||||||
|
max_spieler=spiel.max_spieler,
|
||||||
Returns:
|
dauer_min=spiel.dauer_min,
|
||||||
Das zugewiesene Spiel mit ID.
|
komplexitaet=spiel.komplexitaet,
|
||||||
"""
|
kategorie=spiel.kategorie,
|
||||||
spiel.id = self._next_id
|
)
|
||||||
self._spiele[self._next_id] = spiel
|
self._session.add(spiel_db)
|
||||||
self._next_id += 1
|
self._session.commit()
|
||||||
return spiel
|
return self._to_pydantic(spiel_db)
|
||||||
|
|
||||||
def alle(self) -> list[Spiel]:
|
def alle(self) -> list[Spiel]:
|
||||||
"""Gibt alle Spiele zurück.
|
"""Gibt alle Spiele zurück."""
|
||||||
|
return [self._to_pydantic(s) for s in self._session.query(SpielDB).all()]
|
||||||
Returns:
|
|
||||||
Liste aller Spiele.
|
|
||||||
"""
|
|
||||||
return list(self._spiele.values())
|
|
||||||
|
|
||||||
def suchen(self, name: str) -> list[Spiel]:
|
def suchen(self, name: str) -> list[Spiel]:
|
||||||
"""Sucht Spiele nach Name (case-insensitive Teilstring).
|
"""Sucht Spiele nach Name (case-insensitive)."""
|
||||||
|
spiele_db = self._session.query(SpielDB).filter(
|
||||||
Args:
|
SpielDB.name.ilike(f"%{name}%")
|
||||||
name: Der Suchbegriff.
|
).all()
|
||||||
|
return [self._to_pydantic(s) for s in spiele_db]
|
||||||
Returns:
|
|
||||||
Liste der gefundenen Spiele.
|
|
||||||
"""
|
|
||||||
name_lower = name.lower()
|
|
||||||
return [spiel for spiel in self._spiele.values() if name_lower in spiel.name.lower()]
|
|
||||||
|
|
||||||
def nach_id(self, spiel_id: int) -> Spiel | None:
|
def nach_id(self, spiel_id: int) -> Spiel | None:
|
||||||
"""Holt ein Spiel anhand seiner ID.
|
"""Holt ein Spiel anhand seiner ID."""
|
||||||
|
spiel_db = self._session.get(SpielDB, spiel_id)
|
||||||
Args:
|
if spiel_db is None:
|
||||||
spiel_id: Die ID des Spiels.
|
return None
|
||||||
|
return self._to_pydantic(spiel_db)
|
||||||
Returns:
|
|
||||||
Das gefundene Spiel oder None.
|
|
||||||
"""
|
|
||||||
return self._spiele.get(spiel_id)
|
|
||||||
|
|
||||||
def loesche(self, spiel_id: int) -> bool:
|
def loesche(self, spiel_id: int) -> bool:
|
||||||
"""Löscht ein Spiel anhand seiner ID.
|
"""Löscht ein Spiel anhand seiner ID."""
|
||||||
|
spiel_db = self._session.get(SpielDB, spiel_id)
|
||||||
Args:
|
if spiel_db is None:
|
||||||
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
|
return False
|
||||||
|
self._session.delete(spiel_db)
|
||||||
|
self._session.commit()
|
||||||
|
return True
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user