"""Couche base de données — SQLite via SQLAlchemy 2.0. POC : SQLite, zéro infrastructure. La couche est volontairement isolée pour pouvoir migrer vers PostgreSQL + PostGIS + pgvector plus tard sans toucher au reste de l'application. """ from collections.abc import Generator from sqlalchemy import create_engine from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from . import config class Base(DeclarativeBase): pass # check_same_thread n'est valable que pour SQLite. pool_pre_ping évite les # connexions Postgres mortes (utile sur Neon/serverless qui se met en veille). _is_sqlite = config.DATABASE_URL.startswith("sqlite") engine = create_engine( config.DATABASE_URL, connect_args={"check_same_thread": False} if _is_sqlite else {}, pool_pre_ping=not _is_sqlite, ) # Audit (round 3) : SQLite ignore les FK par défaut → les violations d'intégrité # référentielle passaient inaperçues en test alors qu'elles sont FATALES sur Neon # Postgres (NO ACTION). On force PRAGMA foreign_keys=ON pour que les tests # reproduisent fidèlement la prod et attrapent les régressions de suppression. if _is_sqlite: from sqlalchemy import event @event.listens_for(engine, "connect") def _sqlite_fk_pragma(dbapi_connection, connection_record): # noqa: ANN001 cur = dbapi_connection.cursor() cur.execute("PRAGMA foreign_keys=ON") cur.close() SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) def init_db() -> None: """Met la base à jour via Alembic (migrations propres, plus de CHATS_RESET_DB). - Base vierge → applique toutes les migrations (crée les tables). - Schéma existant sans Alembic (ex. ancienne base create_all) → l'« adopte » (stamp) sans tout recréer. - Schéma en retard → applique les migrations manquantes (ALTER, etc.). CHATS_RESET_DB=1 reste dispo en secours (⚠️ efface tout). """ import os from . import models # noqa: F401 (enregistre les modèles sur Base) if os.environ.get("CHATS_RESET_DB", "").lower() in {"1", "true", "yes"}: Base.metadata.drop_all(bind=engine) _drop_alembic_version() _run_migrations() def _drop_alembic_version() -> None: from sqlalchemy import text with engine.begin() as conn: conn.execute(text("DROP TABLE IF EXISTS alembic_version")) def _run_migrations() -> None: from pathlib import Path from alembic import command from alembic.config import Config from alembic.runtime.migration import MigrationContext from sqlalchemy import inspect base = Path(__file__).resolve().parent.parent # .../backend cfg = Config(str(base / "alembic.ini")) cfg.set_main_option("script_location", str(base / "migrations")) try: with engine.connect() as conn: current = MigrationContext.configure(conn).get_current_revision() has_tables = inspect(engine).has_table("cats") if current is None and has_tables: command.stamp(cfg, "head") # adopter un schéma déjà en place else: command.upgrade(cfg, "head") # vierge → crée tout ; sinon applique le retard except Exception: Base.metadata.create_all(bind=engine) # filet de sécurité def get_db() -> Generator[Session, None, None]: """Dépendance FastAPI : fournit une session et la ferme proprement.""" db = SessionLocal() try: yield db finally: db.close()