Ledgerly/ledgerly/repository.py
2026-10-27 11:04:50 -05:00

223 lines
7.9 KiB
Python

"""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()