170 lines
6.2 KiB
Python
170 lines
6.2 KiB
Python
# backend/database/connection.py
|
||
"""
|
||
Thin convenience layer over the builtin ``sqlite3`` module.
|
||
|
||
Why we need a wrapper
|
||
---------------------
|
||
* The repository (`repository.py`) expects the following public API:
|
||
- ``execute`` – run an INSERT/UPDATE/DELETE.
|
||
- ``fetchone`` / ``fetchall`` – run a SELECT and get the result(s).
|
||
- ``lastrowid`` – primary‑key of the most recent INSERT.
|
||
- ``close`` – close the DB connection.
|
||
* A wrapper lets us:
|
||
• Set a sensible ``row_factory`` (``sqlite3.Row``) so column names are
|
||
accessible as ``row["ColumnName"]``.
|
||
• Centralise ``commit``/``rollback`` handling.
|
||
• Provide type hints and a context‑manager interface
|
||
(``with DatabaseConnection(...):``) which is handy for tests.
|
||
* No external dependencies – everything stays pure‑Python/SQLite.
|
||
|
||
The implementation below is deliberately tiny: it only does what the
|
||
application needs while remaining easy to extend later (e.g. add
|
||
connection‑pooling or logging).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
from pathlib import Path
|
||
from typing import Any, Iterable, Tuple, List, Optional, Union
|
||
|
||
|
||
class DatabaseConnection:
|
||
"""
|
||
Simple wrapper around a SQLite connection.
|
||
|
||
Core behaviour
|
||
---------------
|
||
* ``row_factory`` is set to :class:`sqlite3.Row` – callers can use
|
||
``row["col_name"]`` or treat the row like a mapping.
|
||
* All ``execute`` calls are automatically committed.
|
||
If an exception bubbles out, the transaction is rolled back.
|
||
* ``execute`` returns the cursor so callers can chain
|
||
``cursor.lastrowid`` if they need the autogenerated PK.
|
||
* Implements the context‑manager protocol (``with ... as db:``).
|
||
|
||
The public API matches what ``Repository`` expects:
|
||
- execute(sql, params=None) → None
|
||
- fetchone(sql, params=None) → Optional[sqlite3.Row]
|
||
- fetchall(sql, params=None) → List[sqlite3.Row]
|
||
- lastrowid → int
|
||
- close()
|
||
"""
|
||
|
||
# -----------------------------------------------------------------
|
||
# Construction / context‑manager protocol
|
||
# -----------------------------------------------------------------
|
||
def __init__(
|
||
self,
|
||
db_path: Union[str, Path],
|
||
*,
|
||
timeout: float = 5.0,
|
||
detect_types: int = sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES,
|
||
) -> None:
|
||
"""
|
||
Parameters
|
||
----------
|
||
db_path
|
||
Path to the SQLite file. ``":memory:"`` works for tests.
|
||
timeout
|
||
Seconds to wait for a lock before raising ``sqlite3.OperationalError``.
|
||
detect_types
|
||
Enable type conversion (so DATE/DATETIME are returned as ``datetime``).
|
||
"""
|
||
self._conn: sqlite3.Connection = sqlite3.connect(
|
||
str(db_path), timeout=timeout, detect_types=detect_types
|
||
)
|
||
# ``Row`` makes column access dictionary‑like and preserves order.
|
||
self._conn.row_factory = sqlite3.Row
|
||
self._cursor: sqlite3.Cursor = self._conn.cursor()
|
||
|
||
def __enter__(self) -> "DatabaseConnection":
|
||
"""Allow ``with DatabaseConnection(...) as db:`` usage."""
|
||
return self
|
||
|
||
def __exit__(self, exc_type, exc_val, exc_tb) -> Optional[bool]:
|
||
"""
|
||
On normal exit commit the transaction, otherwise roll back.
|
||
Returning ``False`` propagates any exception.
|
||
"""
|
||
if exc_type is None:
|
||
try:
|
||
self._conn.commit()
|
||
finally:
|
||
self.close()
|
||
else:
|
||
# Something went wrong – roll back to keep the DB clean.
|
||
self._conn.rollback()
|
||
self.close()
|
||
# ``None`` means “don’t suppress exceptions”
|
||
return None
|
||
|
||
# -----------------------------------------------------------------
|
||
# Low‑level helpers used by the repository
|
||
# -----------------------------------------------------------------
|
||
@property
|
||
def cursor(self) -> sqlite3.Cursor:
|
||
"""Expose the underlying cursor – rarely needed outside the repo."""
|
||
return self._cursor
|
||
|
||
@property
|
||
def lastrowid(self) -> int:
|
||
"""PK of the most recent ``INSERT`` executed on this connection."""
|
||
return self._cursor.lastrowid
|
||
|
||
# -----------------------------------------------------------------
|
||
# Public API – the four methods used throughout the code base
|
||
# -----------------------------------------------------------------
|
||
def execute(self, sql: str, params: Optional[Tuple[Any, ...]] = None) -> None:
|
||
"""
|
||
Run an INSERT/UPDATE/DELETE statement and commit immediately.
|
||
|
||
``params`` may be ``None`` (no placeholders) or a tuple of values.
|
||
"""
|
||
try:
|
||
if params is None:
|
||
self._cursor.execute(sql)
|
||
else:
|
||
self._cursor.execute(sql, params)
|
||
self._conn.commit()
|
||
except Exception:
|
||
# Ensure we don’t leave the connection in a half‑committed state.
|
||
self._conn.rollback()
|
||
raise
|
||
|
||
def fetchone(
|
||
self, sql: str, params: Optional[Tuple[Any, ...]] = None
|
||
) -> Optional[sqlite3.Row]:
|
||
"""
|
||
Execute a SELECT that returns at most one row.
|
||
|
||
Returns ``None`` when the result set is empty.
|
||
"""
|
||
if params is None:
|
||
self._cursor.execute(sql)
|
||
else:
|
||
self._cursor.execute(sql, params)
|
||
return self._cursor.fetchone()
|
||
|
||
def fetchall(
|
||
self, sql: str, params: Optional[Tuple[Any, ...]] = None
|
||
) -> List[sqlite3.Row]:
|
||
"""
|
||
Execute a SELECT and return **all** rows as a list.
|
||
|
||
The rows are ``sqlite3.Row`` instances, which behave like dicts.
|
||
"""
|
||
if params is None:
|
||
self._cursor.execute(sql)
|
||
else:
|
||
self._cursor.execute(sql, params)
|
||
return self._cursor.fetchall()
|
||
|
||
def close(self) -> None:
|
||
"""Close the underlying SQLite connection."""
|
||
# ``cursor`` is automatically closed when the connection closes,
|
||
# but we explicitly close it for clarity.
|
||
try:
|
||
self._cursor.close()
|
||
finally:
|
||
self._conn.close() |