Files
LumaOps-Public/lumaops/backend/src/lumaops_backend/database.py
T

113 lines
4.2 KiB
Python

"""SQLite connection management and ordered migration runner."""
from __future__ import annotations
import contextlib
import logging
import shutil
import sqlite3
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
from .config import Settings
LOGGER = logging.getLogger(__name__)
class Database:
def __init__(self, settings: Settings) -> None:
self.path = settings.database_path
self.backup_dir = settings.data_dir / "backups"
self.migrations_dir = Path(__file__).with_name("migrations")
def initialize(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.backup_dir.mkdir(parents=True, exist_ok=True)
with self.connection() as conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations "
"(version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"
)
applied = {
row["version"] for row in conn.execute("SELECT version FROM schema_migrations")
}
for migration in sorted(self.migrations_dir.glob("*.sql")):
version = migration.stem
if version in applied:
continue
sql = migration.read_text(encoding="utf-8")
if "-- destructive: true" in sql.lower() and self.path.exists():
self.backup("pre-migration")
LOGGER.info("Applying database migration %s", version)
conn.executescript(sql)
conn.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(version, utc_now()),
)
conn.commit()
@contextlib.contextmanager
def connection(self) -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(self.path, timeout=5, isolation_level=None, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute("PRAGMA busy_timeout = 5000")
try:
yield conn
finally:
conn.close()
@contextlib.contextmanager
def transaction(self) -> Iterator[sqlite3.Connection]:
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
try:
yield conn
except Exception:
conn.rollback()
raise
else:
conn.commit()
def backup(self, reason: str = "manual") -> Path:
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
target = self.backup_dir / f"lumaops-{timestamp}-{safe_filename(reason)}.db"
with self.connection() as source, sqlite3.connect(target) as destination:
source.backup(destination)
return target
def restore(self, backup_path: Path) -> Path:
resolved = backup_path.resolve()
if self.backup_dir.resolve() not in resolved.parents:
raise ValueError("Backupbestand valt buiten de beheerde back-upmap")
if not resolved.is_file():
raise FileNotFoundError(resolved)
safety = self.backup("pre-restore")
temp = self.path.with_suffix(".restore.tmp")
shutil.copy2(resolved, temp)
with sqlite3.connect(temp) as candidate:
result = candidate.execute("PRAGMA integrity_check").fetchone()
if result is None or result[0] != "ok":
temp.unlink(missing_ok=True)
raise ValueError("De back-up slaagt niet voor de SQLite-integriteitscontrole")
temp.replace(self.path)
return safety
def ping(self) -> bool:
try:
with self.connection() as conn:
return bool(conn.execute("SELECT 1").fetchone()[0] == 1)
except sqlite3.Error:
return False
def utc_now() -> str:
return datetime.now(UTC).isoformat()
def safe_filename(value: str) -> str:
return "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in value)[:48]