223 lines
7.9 KiB
Python
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()
|