feat(db): category repository with crud

This commit is contained in:
DumiFlex
2026-04-26 15:54:28 +03:00
parent 759626b914
commit 746f3ad2db
2 changed files with 190 additions and 0 deletions
+95
View File
@@ -186,3 +186,98 @@ class ModuleRepository:
params.append(offset)
rows = self._conn.execute(sql, params).fetchall()
return [_row_to_module(r) for r in rows]
class CategoryNotFound(LookupError):
"""Raised when a requested category id does not exist."""
def _row_to_category(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"name": row["name"],
"color": row["color"],
"icon": row["icon"],
"sort_order": row["sort_order"],
}
class CategoryRepository:
def __init__(self, conn: sqlite3.Connection) -> None:
self._conn = conn
def create(
self,
*,
name: str,
color: str | None,
icon: str | None,
sort_order: int = 0,
) -> dict[str, Any]:
cid = _slug(name)
try:
with self._conn:
self._conn.execute(
"INSERT INTO module_categories("
"id, name, color, icon, sort_order"
") VALUES(?, ?, ?, ?, ?);",
(cid, name, color, icon, sort_order),
)
except sqlite3.IntegrityError as e:
raise ValueError(f"category name not unique: {name}") from e
return self.get(cid)
def get(self, category_id: str) -> dict[str, Any]:
row = self._conn.execute(
"SELECT * FROM module_categories WHERE id = ?;",
(category_id,),
).fetchone()
if row is None:
raise CategoryNotFound(category_id)
return _row_to_category(row)
def update(
self,
category_id: str,
*,
name: str | None = None,
color: str | None | _Unset = _UNSET,
icon: str | None | _Unset = _UNSET,
sort_order: int | None = None,
) -> dict[str, Any]:
existing = self.get(category_id)
new = {
"name": existing["name"] if name is None else name,
"color": existing["color"] if isinstance(color, _Unset) else color,
"icon": existing["icon"] if isinstance(icon, _Unset) else icon,
"sort_order": (
existing["sort_order"] if sort_order is None else sort_order
),
}
with self._conn:
self._conn.execute(
"UPDATE module_categories SET "
"name = ?, color = ?, icon = ?, sort_order = ? "
"WHERE id = ?;",
(
new["name"], new["color"], new["icon"],
new["sort_order"], category_id,
),
)
return self.get(category_id)
def delete(self, category_id: str) -> None:
with self._conn:
cur = self._conn.execute(
"DELETE FROM module_categories WHERE id = ?;",
(category_id,),
)
if cur.rowcount == 0:
raise CategoryNotFound(category_id)
def list(self) -> list[dict[str, Any]]:
rows = self._conn.execute(
"SELECT * FROM module_categories "
"ORDER BY sort_order ASC, name COLLATE NOCASE ASC;"
).fetchall()
return [_row_to_category(r) for r in rows]
@@ -0,0 +1,95 @@
"""Tests for CategoryRepository."""
import pytest
from engine.db.repositories import CategoryNotFound, CategoryRepository
def test_create_returns_row_with_id(wp_db):
repo = CategoryRepository(wp_db)
row = repo.create(name="Style", color="#a970ff", icon="pi pi-palette")
assert row["id"] == "style"
assert row["name"] == "Style"
assert row["color"] == "#a970ff"
assert row["icon"] == "pi pi-palette"
assert row["sort_order"] == 0
def test_create_lowercase_collision_raises_value_error(wp_db):
repo = CategoryRepository(wp_db)
repo.create(name="Style", color=None, icon=None)
with pytest.raises(ValueError):
repo.create(name="STYLE", color=None, icon=None)
def test_get_returns_existing(wp_db):
repo = CategoryRepository(wp_db)
repo.create(name="Camera", color=None, icon=None)
got = repo.get("camera")
assert got["name"] == "Camera"
def test_get_missing_raises(wp_db):
repo = CategoryRepository(wp_db)
with pytest.raises(CategoryNotFound):
repo.get("ghost")
def test_update_changes_fields(wp_db):
repo = CategoryRepository(wp_db)
repo.create(name="A", color=None, icon=None)
updated = repo.update("a", color="#ff0000", icon="pi pi-tag", sort_order=10)
assert updated["color"] == "#ff0000"
assert updated["icon"] == "pi pi-tag"
assert updated["sort_order"] == 10
def test_update_clears_color_with_explicit_none(wp_db):
repo = CategoryRepository(wp_db)
repo.create(name="A", color="#ff0000", icon="pi pi-tag")
updated = repo.update("a", color=None)
assert updated["color"] is None
assert updated["icon"] == "pi pi-tag" # untouched
def test_update_keeps_unchanged_fields(wp_db):
repo = CategoryRepository(wp_db)
repo.create(name="A", color="#ff0000", icon="pi pi-tag", sort_order=5)
updated = repo.update("a", name="B") # only name
assert updated["name"] == "B"
assert updated["color"] == "#ff0000"
assert updated["icon"] == "pi pi-tag"
assert updated["sort_order"] == 5
def test_update_missing_raises(wp_db):
repo = CategoryRepository(wp_db)
with pytest.raises(CategoryNotFound):
repo.update("ghost", name="X")
def test_delete_removes(wp_db):
repo = CategoryRepository(wp_db)
repo.create(name="Tmp", color=None, icon=None)
repo.delete("tmp")
with pytest.raises(CategoryNotFound):
repo.get("tmp")
def test_delete_missing_raises(wp_db):
repo = CategoryRepository(wp_db)
with pytest.raises(CategoryNotFound):
repo.delete("ghost")
def test_list_orders_by_sort_order_then_name(wp_db):
repo = CategoryRepository(wp_db)
repo.create(name="Bravo", color=None, icon=None, sort_order=10)
repo.create(name="Alpha", color=None, icon=None, sort_order=10)
repo.create(name="Charlie", color=None, icon=None, sort_order=0)
rows = repo.list()
assert [r["name"] for r in rows] == ["Charlie", "Alpha", "Bravo"]
def test_list_empty(wp_db):
repo = CategoryRepository(wp_db)
assert repo.list() == []