personal-lifecycle-tracker / backend / app / database.py
database.py
Raw
from __future__ import annotations

import os
import time
from contextlib import contextmanager
from typing import Generator, Optional

from sqlalchemy import create_engine, event, text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import declarative_base, sessionmaker, Session

# Environment variable for database connection
DEFAULT_DATABASE_URL = "sqlite:///./lifecycle.db"


def get_database_url() -> str:
    """
    Resolve the database connection URL from environment variables.
    Checks DATABASE_URL first, then DB_URL as fallback, defaulting to local SQLite.
    Normalizes legacy postgres:// scheme to postgresql:// for SQLAlchemy.
    """
    raw_url = os.getenv("DATABASE_URL") or os.getenv("DB_URL") or DEFAULT_DATABASE_URL
    if raw_url.startswith("postgres://"):
        raw_url = raw_url.replace("postgres://", "postgresql://", 1)
    return raw_url


def is_postgres_url(url: str) -> bool:
    """Check whether a URL targets a PostgreSQL database."""
    return url.startswith("postgresql") or url.startswith("postgres")


def create_engine_for_url(url: Optional[str] = None) -> Engine:
    """
    Factory function creating a database-agnostic SQLAlchemy Engine.
    Handles dialect-specific connection arguments (such as SQLite foreign keys and thread safety,
    or PostgreSQL connection pooling and pre-ping checks).
    """
    db_url = url or get_database_url()
    connect_args = {}

    pool_pre_ping_env = os.getenv("DB_POOL_PRE_PING")
    if pool_pre_ping_env is not None:
        pool_pre_ping = pool_pre_ping_env.lower() in ("true", "1", "yes")
    else:
        # Eliminate cross-region SELECT 1 ping roundtrips in AWS Lambda; keep True for container/local setups
        is_lambda = bool(os.getenv("AWS_LAMBDA_FUNCTION_NAME") or os.getenv("AWS_EXECUTION_ENV"))
        pool_pre_ping = not is_lambda

    engine_kwargs = {
        "pool_pre_ping": pool_pre_ping,
    }

    if db_url.startswith("sqlite"):
        connect_args["check_same_thread"] = False
        # Ensure parent directory exists for file-based SQLite databases
        if not db_url.startswith("sqlite:///:memory:"):
            # Strip prefix (e.g. 'sqlite:///' or 'sqlite://')
            cleaned_path = db_url.replace("sqlite:///", "", 1) if db_url.startswith("sqlite:///") else db_url.replace("sqlite://", "", 1)
            # Remove query parameters if any
            cleaned_path = cleaned_path.split("?")[0]
            db_dir = os.path.dirname(cleaned_path)
            if db_dir and not os.path.exists(db_dir):
                os.makedirs(db_dir, exist_ok=True)
    elif is_postgres_url(db_url):
        is_lambda = bool(os.getenv("AWS_LAMBDA_FUNCTION_NAME") or os.getenv("AWS_EXECUTION_ENV"))
        default_pool_recycle = "300" if is_lambda else "1800"
        default_pool_size = "5" if is_lambda else "10"
        default_max_overflow = "10" if is_lambda else "20"
        # Connection pooling optimizations for PostgreSQL
        engine_kwargs.update({
            "pool_size": int(os.getenv("DB_POOL_SIZE", default_pool_size)),
            "max_overflow": int(os.getenv("DB_MAX_OVERFLOW", default_max_overflow)),
            "pool_timeout": int(os.getenv("DB_POOL_TIMEOUT", "30")),
            "pool_recycle": int(os.getenv("DB_POOL_RECYCLE", default_pool_recycle)),
        })

    engine = create_engine(
        db_url,
        connect_args=connect_args,
        **engine_kwargs,
    )

    if db_url.startswith("sqlite"):
        @event.listens_for(engine, "connect")
        def set_sqlite_pragma(dbapi_connection, connection_record):
            if hasattr(dbapi_connection, "cursor"):
                cursor = dbapi_connection.cursor()
                cursor.execute("PRAGMA foreign_keys=ON")
                cursor.close()

    return engine


Base = declarative_base()

# Global default engine and session factory
_default_engine: Optional[Engine] = None
_default_sessionmaker: Optional[sessionmaker] = None


def get_engine(url: Optional[str] = None) -> Engine:
    global _default_engine, _default_sessionmaker
    target_url = url or get_database_url()
    if _default_engine is None or (url is not None and str(_default_engine.url) != target_url):
        _default_engine = create_engine_for_url(target_url)
        _default_sessionmaker = sessionmaker(
            autocommit=False,
            autoflush=False,
            expire_on_commit=False,
            bind=_default_engine,
        )
    return _default_engine


def get_session_factory(engine: Optional[Engine] = None) -> sessionmaker:
    global _default_sessionmaker
    if engine is not None:
        return sessionmaker(autocommit=False, autoflush=False, expire_on_commit=False, bind=engine)
    if _default_sessionmaker is None:
        get_engine()
    return _default_sessionmaker


@contextmanager
def get_db_session(session_factory: Optional[sessionmaker] = None) -> Generator[Session, None, None]:
    """Provide a transactional scope around a series of operations."""
    factory = session_factory or get_session_factory()
    session: Session = factory()
    try:
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()


def check_db_health(engine: Optional[Engine] = None) -> bool:
    """Executes a lightweight query to verify active database connectivity."""
    eng = engine or get_engine()
    try:
        with eng.connect() as conn:
            conn.execute(text("SELECT 1"))
        return True
    except Exception:
        return False


def wait_for_db(engine: Optional[Engine] = None, max_retries: int = 15, delay: float = 0.5) -> bool:
    """
    Waits for the database to become reachable.
    Useful during container orchestration startup when PostgreSQL is initializing.
    """
    eng = engine or get_engine()
    for _ in range(max_retries):
        if check_db_health(eng):
            return True
        time.sleep(delay)
    return False


def init_db(engine: Optional[Engine] = None) -> None:
    """Create all database tables defined on Base metadata."""
    eng = engine or get_engine()
    Base.metadata.create_all(bind=eng)