Merge pull request #64 from christianlouis/copilot/check-data-persistence

fix: ensure data persistence across container reinstallations
This commit is contained in:
Christian Krakau-Louis
2026-03-29 20:59:12 +02:00
committed by GitHub
6 changed files with 139 additions and 5 deletions
+3 -1
View File
@@ -23,7 +23,9 @@ class Settings(BaseSettings):
API_V1_STR: str = "/api/v1"
# Database
DATABASE_URL: str = "sqlite:///./dmarq.db"
# Default to a sub-directory so the SQLite file lives in a location that
# can be persisted via a Docker volume mount (e.g. /app/data).
DATABASE_URL: str = "sqlite:///./data/dmarq.db"
# JWT Authentication
SECRET_KEY: Optional[str] = None
+27 -1
View File
@@ -1,7 +1,9 @@
import os
from typing import Generator
from urllib.parse import urlparse, urlunparse
from sqlalchemy import create_engine
from sqlalchemy.engine import make_url
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
@@ -30,10 +32,34 @@ def _make_sync_db_url(url: str) -> str:
return urlunparse(parsed._replace(scheme=sync_scheme))
def _ensure_sqlite_dir(url: str) -> None:
"""Create the parent directory for a SQLite database file if needed.
For SQLite URLs (``sqlite:///relative/path`` or ``sqlite:////absolute/path``),
the parent directory must exist before SQLAlchemy tries to open (or create)
the file. This is a no-op for in-memory databases (``sqlite://``) and for
non-SQLite URLs.
"""
sa_url = make_url(url)
if not sa_url.drivername.startswith("sqlite"):
return
db_path = sa_url.database
if not db_path or db_path == ":memory:":
return # in-memory nothing to create
parent = os.path.dirname(db_path)
if parent:
os.makedirs(parent, exist_ok=True)
settings = get_settings()
_sync_url = _make_sync_db_url(settings.DATABASE_URL)
# Ensure the parent directory exists before SQLAlchemy tries to open the file
_ensure_sqlite_dir(_sync_url)
# Configure SQLAlchemy (normalise async driver schemes to their sync equivalents)
engine = create_engine(_make_sync_db_url(settings.DATABASE_URL), pool_pre_ping=True)
engine = create_engine(_sync_url, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create base class for SQLAlchemy models
+43 -1
View File
@@ -7,7 +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
from app.core.database import _ensure_sqlite_dir, _make_sync_db_url
class TestBackendCorsOriginsValidator:
@@ -96,3 +96,45 @@ class TestMakeSyncDbUrl:
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)
class TestEnsureSqliteDir:
"""Tests for the _ensure_sqlite_dir() helper."""
def test_relative_sqlite_path_creates_directory(self, tmp_path, monkeypatch):
"""A relative SQLite URL creates its parent directory."""
monkeypatch.chdir(tmp_path)
_ensure_sqlite_dir("sqlite:///./subdir/dmarq.db")
assert (tmp_path / "subdir").is_dir()
def test_absolute_sqlite_path_creates_directory(self, tmp_path):
"""An absolute SQLite URL creates its parent directory."""
db_path = tmp_path / "nested" / "dmarq.db"
_ensure_sqlite_dir(f"sqlite:///{db_path}")
assert db_path.parent.is_dir()
def test_in_memory_sqlite_no_directory_created(self, tmp_path, monkeypatch):
"""An in-memory SQLite URL does not create any directory."""
monkeypatch.chdir(tmp_path)
_ensure_sqlite_dir("sqlite://")
_ensure_sqlite_dir("sqlite:///:memory:")
# tmp_path itself exists but no new subdirectories should appear
assert list(tmp_path.iterdir()) == []
def test_postgres_url_no_directory_created(self, tmp_path, monkeypatch):
"""Non-SQLite URLs are ignored entirely."""
monkeypatch.chdir(tmp_path)
_ensure_sqlite_dir("postgresql://user:pass@db:5432/mydb")
assert list(tmp_path.iterdir()) == []
def test_existing_directory_is_noop(self, tmp_path):
"""Calling _ensure_sqlite_dir when the directory already exists is a no-op."""
existing = tmp_path / "data"
existing.mkdir()
_ensure_sqlite_dir(f"sqlite:///{existing}/dmarq.db") # should not raise
assert existing.is_dir()
def test_default_database_url_uses_data_subdir(self):
"""Default DATABASE_URL places the SQLite file inside a data/ subdirectory."""
settings = Settings()
assert settings.DATABASE_URL.endswith("data/dmarq.db")