diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 918a1d7..d235ebe 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -18,7 +18,9 @@ if config.config_file_name is not None: # Override sqlalchemy.url with DATABASE_URL environment variable when present database_url = os.environ.get("DATABASE_URL") if database_url: - config.set_main_option("sqlalchemy.url", database_url) + from app.core.database import _make_sync_db_url # noqa: E402 + + config.set_main_option("sqlalchemy.url", _make_sync_db_url(database_url)) # Import all models so that autogenerate can detect them from app.core.database import Base # noqa: E402 diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 3e809fe..baa851c 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,4 +1,5 @@ from typing import Generator +from urllib.parse import urlparse, urlunparse from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base @@ -6,10 +7,33 @@ from sqlalchemy.orm import sessionmaker from app.core.config import get_settings +_ASYNC_TO_SYNC_SCHEMES = { + "postgresql+asyncpg": "postgresql+psycopg2", +} + + +def _make_sync_db_url(url: str) -> str: + """Return the synchronous-driver equivalent of *url*. + + Kubernetes and docker-compose deployments sometimes configure DATABASE_URL + with an async driver scheme (e.g. ``postgresql+asyncpg://``). Alembic and + the synchronous SQLAlchemy engine used here require a sync driver, so we + map known async schemes to their psycopg2 equivalents. + + Only the scheme component of the URL is rewritten; all other parts + (credentials, host, path, query) are left untouched. + """ + parsed = urlparse(url) + sync_scheme = _ASYNC_TO_SYNC_SCHEMES.get(parsed.scheme) + if sync_scheme is None: + return url + return urlunparse(parsed._replace(scheme=sync_scheme)) + + settings = get_settings() -# Configure SQLAlchemy -engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True) +# Configure SQLAlchemy (normalise async driver schemes to their sync equivalents) +engine = create_engine(_make_sync_db_url(settings.DATABASE_URL), pool_pre_ping=True) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) # Create base class for SQLAlchemy models diff --git a/backend/app/tests/test_config.py b/backend/app/tests/test_config.py index ef2e62f..69c4160 100644 --- a/backend/app/tests/test_config.py +++ b/backend/app/tests/test_config.py @@ -7,6 +7,7 @@ could run (see: pydantic_settings sources/providers/env.py decode_complex_value) """ from app.core.config import Settings +from app.core.database import _make_sync_db_url class TestBackendCorsOriginsValidator: @@ -64,3 +65,34 @@ class TestBackendCorsOriginsValidator: "https://a.example.com", "https://b.example.com", ] + + +class TestMakeSyncDbUrl: + """Tests for the _make_sync_db_url() URL normalization helper.""" + + def test_asyncpg_replaced_with_psycopg2(self): + """asyncpg scheme is converted to psycopg2.""" + url = "postgresql+asyncpg://user:pass@db:5432/mydb" + assert _make_sync_db_url(url) == "postgresql+psycopg2://user:pass@db:5432/mydb" + + def test_plain_postgresql_unchanged(self): + """Plain postgresql:// URLs are not modified.""" + url = "postgresql://user:pass@db:5432/mydb" + assert _make_sync_db_url(url) == url + + def test_psycopg2_url_unchanged(self): + """URLs already using psycopg2 are not modified.""" + url = "postgresql+psycopg2://user:pass@db:5432/mydb" + assert _make_sync_db_url(url) == url + + def test_sqlite_url_unchanged(self): + """SQLite URLs are not modified.""" + url = "sqlite:///./dmarq.db" + assert _make_sync_db_url(url) == url + + def test_database_url_setting_asyncpg(self): + """Settings with an asyncpg DATABASE_URL still initialise correctly.""" + settings = Settings(DATABASE_URL="postgresql+asyncpg://user:pass@db:5432/mydb") + assert settings.DATABASE_URL == "postgresql+asyncpg://user:pass@db:5432/mydb" + # Normalised URL used by the engine must not contain asyncpg + assert "asyncpg" not in _make_sync_db_url(settings.DATABASE_URL)