113 lines
4.2 KiB
Python
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]
|