""" 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")