Files
scrap/pricewatch/app/db/connection.py
Gilles Soulier d0b73b9319 codex2
2026-01-14 21:54:55 +01:00

239 lines
6.3 KiB
Python

"""
Gestion des connexions PostgreSQL pour PriceWatch Phase 2.
Fournit:
- Engine SQLAlchemy avec connection pooling
- Session factory avec context manager
- Initialisation des tables
- Health check
Justification technique:
- Connection pooling: réutilisation connexions pour performance
- Context manager: garantit fermeture session (pas de leak)
- pool_pre_ping: vérifie connexion avant usage (robustesse)
- echo=debug: logs SQL en mode debug
"""
from contextlib import contextmanager
from typing import Generator, Optional
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
from sqlalchemy.engine.url import make_url
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy.orm import Session, sessionmaker
from pricewatch.app.core.config import AppConfig, get_config
from pricewatch.app.core.logging import get_logger
from pricewatch.app.db.models import Base
logger = get_logger("db.connection")
# Global engine instance (singleton)
_engine: Optional[Engine] = None
_session_factory: Optional[sessionmaker] = None
def get_engine(config: Optional[AppConfig] = None) -> Engine:
"""
Récupère ou crée l'Engine SQLAlchemy (singleton).
Args:
config: Configuration app (utilise get_config() si None)
Returns:
Engine SQLAlchemy configuré
Justification:
- Singleton: une seule pool de connexions par application
- pool_pre_ping: vérifie connexion avant usage (évite "connection closed")
- pool_size=5, max_overflow=10: limite connexions (15 max)
- echo=debug: logs SQL pour debugging
"""
global _engine
if _engine is None:
if config is None:
config = get_config()
db_url = config.db.url
url = make_url(db_url)
is_sqlite = url.get_backend_name() == "sqlite"
logger.info(f"Creating database engine: {db_url}")
engine_kwargs = {
"pool_pre_ping": True,
"pool_recycle": 3600,
"echo": config.debug,
}
if not is_sqlite:
engine_kwargs.update(
{
"pool_size": 5,
"max_overflow": 10,
}
)
_engine = create_engine(db_url, **engine_kwargs)
logger.info("Database engine created successfully")
return _engine
def init_db(config: Optional[AppConfig] = None) -> None:
"""
Initialise la base de données (crée toutes les tables).
Args:
config: Configuration app (utilise get_config() si None)
Raises:
OperationalError: Si connexion impossible
SQLAlchemyError: Si création tables échoue
Note:
Utilise Base.metadata.create_all() - idempotent (ne crash pas si tables existent)
"""
if config is None:
config = get_config()
logger.info("Initializing database...")
try:
engine = get_engine(config)
# Créer toutes les tables définies dans Base.metadata
Base.metadata.create_all(bind=engine)
logger.info("Database initialized successfully")
logger.info(f"Tables created: {', '.join(Base.metadata.tables.keys())}")
except OperationalError as e:
logger.error(f"Failed to connect to database: {e}")
raise
except SQLAlchemyError as e:
logger.error(f"Failed to create tables: {e}")
raise
def get_session_factory(config: Optional[AppConfig] = None) -> sessionmaker:
"""
Récupère ou crée la session factory (singleton).
Args:
config: Configuration app (utilise get_config() si None)
Returns:
Session factory SQLAlchemy
Justification:
- expire_on_commit=False: objets restent accessibles après commit
- autocommit=False, autoflush=False: contrôle explicite
"""
global _session_factory
if _session_factory is None:
engine = get_engine(config)
_session_factory = sessionmaker(
bind=engine,
expire_on_commit=False, # Objets restent accessibles après commit
autocommit=False, # Contrôle explicite du commit
autoflush=False, # Contrôle explicite du flush
)
logger.debug("Session factory created")
return _session_factory
@contextmanager
def get_session(config: Optional[AppConfig] = None) -> Generator[Session, None, None]:
"""
Context manager pour session SQLAlchemy.
Args:
config: Configuration app (utilise get_config() si None)
Yields:
Session SQLAlchemy
Usage:
with get_session() as session:
product = session.query(Product).filter_by(reference="B08N5WRWNW").first()
session.commit()
Justification:
- Context manager: garantit fermeture session (pas de leak)
- Rollback automatique sur exception
- Close automatique en fin de bloc
"""
factory = get_session_factory(config)
session = factory()
try:
logger.debug("Session opened")
yield session
except Exception as e:
logger.error(f"Session error, rolling back: {e}")
session.rollback()
raise
finally:
logger.debug("Session closed")
session.close()
def check_db_connection(config: Optional[AppConfig] = None) -> bool:
"""
Vérifie la connexion à la base de données (health check).
Args:
config: Configuration app (utilise get_config() si None)
Returns:
True si connexion OK, False sinon
Note:
Execute une query simple: SELECT 1
"""
if config is None:
config = get_config()
try:
engine = get_engine(config)
with engine.connect() as conn:
result = conn.execute(text("SELECT 1"))
result.scalar()
logger.info("Database connection OK")
return True
except OperationalError as e:
logger.error(f"Database connection failed: {e}")
return False
except SQLAlchemyError as e:
logger.error(f"Database health check failed: {e}")
return False
def reset_engine() -> None:
"""
Reset l'engine global (pour tests).
Note:
Dispose l'engine et reset les singletons.
"""
global _engine, _session_factory
if _engine is not None:
logger.debug("Disposing database engine")
_engine.dispose()
_engine = None
_session_factory = None
logger.debug("Engine reset complete")