Files
Bjorn/db_utils/base.py

160 lines
6.3 KiB
Python

# db_utils/base.py
# Base database connection and transaction management
import sqlite3
import time
from contextlib import contextmanager
from threading import RLock
from typing import Any, Dict, Iterable, List, Optional, Tuple
import logging
from logger import Logger
logger = Logger(name="db_utils.base", level=logging.DEBUG)
class DatabaseBase:
"""
Base database manager providing connection, transaction, and query primitives.
All specialized operation modules inherit access to these primitives.
"""
def __init__(self, db_path: str):
self.db_path = db_path
# Connection with optimized settings for constrained devices (e.g., Raspberry Pi)
self._conn = sqlite3.connect(
self.db_path,
check_same_thread=False,
isolation_level=None # Autocommit mode (we manage transactions explicitly)
)
self._conn.row_factory = sqlite3.Row
self._lock = RLock()
# Small in-process cache for frequently refreshed UI counters
self._cache_ttl = 5.0 # seconds
self._stats_cache = {'data': None, 'timestamp': 0}
# Apply PRAGMA tuning
with self._lock:
cur = self._conn.cursor()
# Optimize SQLite for Raspberry Pi / flash storage
cur.execute("PRAGMA journal_mode=WAL;")
cur.execute("PRAGMA synchronous=NORMAL;")
cur.execute("PRAGMA foreign_keys=ON;")
cur.execute("PRAGMA cache_size=2000;") # Increase page cache
cur.execute("PRAGMA temp_store=MEMORY;") # Use RAM for temporary objects
cur.close()
logger.info(f"DatabaseBase initialized: {db_path}")
# =========================================================================
# CORE CONCURRENCY + SQL PRIMITIVES
# =========================================================================
@contextmanager
def _cursor(self):
"""Thread-safe cursor context manager"""
with self._lock:
cur = self._conn.cursor()
try:
yield cur
finally:
cur.close()
@contextmanager
def transaction(self, immediate: bool = True):
"""Transactional block with automatic rollback on error"""
with self._lock:
try:
self._conn.execute("BEGIN IMMEDIATE;" if immediate else "BEGIN;")
yield
self._conn.execute("COMMIT;")
except Exception:
self._conn.execute("ROLLBACK;")
raise
def execute(self, sql: str, params: Iterable[Any] = (), many: bool = False) -> int:
"""Execute a DML statement. Supports batch mode via `many=True`"""
with self._cursor() as c:
if many and params and isinstance(params, (list, tuple)) and isinstance(params[0], (list, tuple)):
c.executemany(sql, params)
return c.rowcount if c.rowcount is not None else 0
c.execute(sql, params)
return c.rowcount if c.rowcount is not None else 0
def executemany(self, sql: str, seq_of_params: Iterable[Iterable[Any]]) -> int:
"""Convenience wrapper around `execute(..., many=True)`"""
return self.execute(sql, seq_of_params, many=True)
def query(self, sql: str, params: Iterable[Any] = ()) -> List[Dict[str, Any]]:
"""Execute a SELECT and return rows as list[dict]"""
with self._cursor() as c:
c.execute(sql, params)
rows = c.fetchall()
return [dict(r) for r in rows]
def query_one(self, sql: str, params: Iterable[Any] = ()) -> Optional[Dict[str, Any]]:
"""Execute a SELECT and return a single row as dict (or None)"""
with self._cursor() as c:
c.execute(sql, params)
row = c.fetchone()
return dict(row) if row else None
# =========================================================================
# CACHE MANAGEMENT
# =========================================================================
def invalidate_stats_cache(self):
"""Invalidate the small in-memory stats cache"""
self._stats_cache = {'data': None, 'timestamp': 0}
# =========================================================================
# SCHEMA HELPERS
# =========================================================================
def _table_exists(self, name: str) -> bool:
"""Return True if a table exists in the current database"""
row = self.query("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (name,))
return bool(row)
def _column_names(self, table: str) -> List[str]:
"""Return a list of column names for a given table (empty if table missing)"""
with self._cursor() as c:
c.execute(f"PRAGMA table_info({table});")
return [r[1] for r in c.fetchall()]
def _ensure_column(self, table: str, column: str, ddl: str) -> None:
"""Add a column with the provided DDL if it does not exist yet"""
cols = self._column_names(table) if self._table_exists(table) else []
if column not in cols:
self.execute(f"ALTER TABLE {table} ADD COLUMN {ddl};")
# =========================================================================
# MAINTENANCE OPERATIONS
# =========================================================================
def checkpoint(self, mode: str = "TRUNCATE") -> Tuple[int, int, int]:
"""
Force a WAL checkpoint. Returns (busy, log_frames, checkpointed_frames).
mode ∈ {PASSIVE, FULL, RESTART, TRUNCATE}
"""
mode = (mode or "PASSIVE").upper()
if mode not in {"PASSIVE", "FULL", "RESTART", "TRUNCATE"}:
mode = "PASSIVE"
with self._cursor() as c:
c.execute(f"PRAGMA wal_checkpoint({mode});")
row = c.fetchone()
if not row:
return (0, 0, 0)
vals = tuple(row)
return (int(vals[0]), int(vals[1]), int(vals[2]))
def optimize(self) -> None:
"""Run PRAGMA optimize to help the query planner update statistics"""
self.execute("PRAGMA optimize;")
def vacuum(self) -> None:
"""Vacuum the database to reclaim space (use sparingly on flash media)"""
self.execute("VACUUM;")