"""Persistence layer: the ``Repository`` protocol and its SQLite implementation. This is the only module that speaks SQL. Everything above it talks to the ``Repository`` protocol (dependency inversion), so services can be reasoned about — and tested — without knowing SQLite exists. """ from __future__ import annotations import sqlite3 from pathlib import Path from typing import Protocol, runtime_checkable from .db import connect, seed_default_categories from .errors import ConflictError __all__ = ["Repository", "SqliteRepository"] @runtime_checkable class Repository(Protocol): """Storage contract used by the domain services (DIP).""" # categories def create_category(self, name: str, kind: str) -> None: ... def find_category(self, name: str) -> tuple[int, str, str] | None: # pragma: no cover """Return ``(id, stored_name, kind)`` or None (case-insensitive).""" def list_categories(self) -> list[tuple[str, str]]: ... def category_in_use(self, category_id: int) -> bool: ... def delete_category(self, category_id: int) -> None: ... # transactions def insert_transaction( self, amount_cents: int, category_id: int, day: str, note: str | None ) -> int: ... def list_transactions( self, start: str | None, end: str | None, category_id: int | None ) -> list[sqlite3.Row]: ... def transaction_exists(self, tx_id: int) -> bool: ... def delete_transaction(self, tx_id: int) -> None: ... # budgets def set_budget(self, category_id: int, amount_cents: int) -> None: ... def get_budget(self, category_id: int) -> int | None: ... def list_budgets(self) -> list[sqlite3.Row]: ... def delete_budget(self, category_id: int) -> bool: ... # reporting def income_expense_totals(self, start: str, end: str) -> tuple[int, int]: ... def category_totals(self, start: str, end: str) -> list[sqlite3.Row]: ... def close(self) -> None: ... class SqliteRepository: """SQLite-backed repository; owns one connection for its lifetime.""" def __init__(self, db_path: str | Path) -> None: self._conn = connect(db_path) seed_default_categories(self._conn) def close(self) -> None: self._conn.close() def __enter__(self) -> SqliteRepository: return self def __exit__(self, *exc_info: object) -> None: self.close() # -- categories -------------------------------------------------------- def create_category(self, name: str, kind: str) -> None: try: self._conn.execute( "INSERT INTO categories (name, kind) VALUES (?, ?)", (name, kind) ) except sqlite3.IntegrityError as exc: raise ConflictError(f"category {name!r} already exists") from exc self._conn.commit() def find_category(self, name: str) -> tuple[int, str, str] | None: row = self._conn.execute( "SELECT id, name, kind FROM categories " "WHERE name = ? COLLATE NOCASE LIMIT 1", (name,), ).fetchone() if row is None: return None return (row["id"], row["name"], row["kind"]) def list_categories(self) -> list[tuple[str, str]]: rows = self._conn.execute( "SELECT name, kind FROM categories ORDER BY kind DESC, name" ).fetchall() return [(row["name"], row["kind"]) for row in rows] def category_in_use(self, category_id: int) -> bool: row = self._conn.execute( "SELECT (SELECT COUNT(*) FROM transactions WHERE category_id = ?) " " + (SELECT COUNT(*) FROM budgets WHERE category_id = ?) AS used", (category_id, category_id), ).fetchone() return bool(row["used"]) def delete_category(self, category_id: int) -> None: self._conn.execute("DELETE FROM categories WHERE id = ?", (category_id,)) self._conn.commit() # -- transactions ------------------------------------------------------ def insert_transaction( self, amount_cents: int, category_id: int, day: str, note: str | None ) -> int: cur = self._conn.execute( "INSERT INTO transactions (amount_cents, category_id, date, note) " "VALUES (?, ?, ?, ?)", (amount_cents, category_id, day, note), ) self._conn.commit() return int(cur.lastrowid) def list_transactions( self, start: str | None, end: str | None, category_id: int | None ) -> list[sqlite3.Row]: query = ( "SELECT t.id, t.amount_cents, t.date, t.note, " " c.name AS category, c.kind " "FROM transactions t JOIN categories c ON c.id = t.category_id" ) clauses: list[str] = [] params: list[object] = [] if start is not None: clauses.append("t.date >= ?") params.append(start) if end is not None: clauses.append("t.date <= ?") params.append(end) if category_id is not None: clauses.append("t.category_id = ?") params.append(category_id) if clauses: query += " WHERE " + " AND ".join(clauses) query += " ORDER BY t.date DESC, t.id DESC" return self._conn.execute(query, params).fetchall() def transaction_exists(self, tx_id: int) -> bool: row = self._conn.execute( "SELECT 1 FROM transactions WHERE id = ?", (tx_id,) ).fetchone() return row is not None def delete_transaction(self, tx_id: int) -> None: self._conn.execute("DELETE FROM transactions WHERE id = ?", (tx_id,)) self._conn.commit() # -- budgets ----------------------------------------------------------- def set_budget(self, category_id: int, amount_cents: int) -> None: self._conn.execute( "INSERT INTO budgets (category_id, amount_cents) VALUES (?, ?) " "ON CONFLICT (category_id) DO UPDATE " "SET amount_cents = excluded.amount_cents", (category_id, amount_cents), ) self._conn.commit() def get_budget(self, category_id: int) -> int | None: row = self._conn.execute( "SELECT amount_cents FROM budgets WHERE category_id = ?", (category_id,) ).fetchone() return None if row is None else int(row["amount_cents"]) def list_budgets(self) -> list[sqlite3.Row]: return self._conn.execute( "SELECT c.name AS category, c.kind AS kind, b.amount_cents " "FROM budgets b JOIN categories c ON c.id = b.category_id " "ORDER BY c.name" ).fetchall() def delete_budget(self, category_id: int) -> bool: cur = self._conn.execute( "DELETE FROM budgets WHERE category_id = ?", (category_id,) ) self._conn.commit() return cur.rowcount > 0 # -- reporting --------------------------------------------------------- def income_expense_totals(self, start: str, end: str) -> tuple[int, int]: row = self._conn.execute( "SELECT COALESCE(SUM(CASE WHEN c.kind = 'income' " " THEN t.amount_cents END), 0) AS income, " " COALESCE(SUM(CASE WHEN c.kind = 'expense' " " THEN t.amount_cents END), 0) AS expenses " "FROM transactions t JOIN categories c ON c.id = t.category_id " "WHERE t.date >= ? AND t.date <= ?", (start, end), ).fetchone() return (int(row["income"]), int(row["expenses"])) def category_totals(self, start: str, end: str) -> list[sqlite3.Row]: return self._conn.execute( "SELECT c.name AS category, c.kind AS kind, " " SUM(t.amount_cents) AS total_cents " "FROM transactions t JOIN categories c ON c.id = t.category_id " "WHERE t.date >= ? AND t.date <= ? " "GROUP BY c.id " "ORDER BY c.kind DESC, total_cents DESC, c.name", (start, end), ).fetchall()