feat(db): module repository with crud and filtered list

This commit is contained in:
DumiFlex
2026-04-26 15:41:39 +03:00
parent 3810a37348
commit 6c29f738c5
2 changed files with 327 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
"""Repository classes wrapping SQL access to library tables.
Repositories take a ``sqlite3.Connection``. Each public method opens a
transaction via ``with conn:`` so callers don't need explicit commit
calls. Rows are returned as plain dicts (sqlite3.Row converted).
"""
from __future__ import annotations
import datetime as _dt
import json
import re
import secrets
import sqlite3
from typing import Any
_TYPE_PREFIX = {"wildcard": "wc", "fixed_values": "fv"}
_SLUG_RE = re.compile(r"[^a-z0-9]+")
class ModuleNotFound(LookupError):
"""Raised when a requested module id does not exist."""
def _slug(name: str) -> str:
s = _SLUG_RE.sub("_", name.lower()).strip("_")
return s[:24] or "module"
def _now() -> str:
return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"type": row["type"],
"name": row["name"],
"description": row["description"],
"category_id": row["category_id"],
"tags": json.loads(row["tags"]),
"is_favorite": bool(row["is_favorite"]),
"payload": json.loads(row["payload"]),
"version": row["version"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
class ModuleRepository:
def __init__(self, conn: sqlite3.Connection) -> None:
self._conn = conn
def _gen_id(self, type: str, name: str) -> str:
prefix = _TYPE_PREFIX.get(type, "mod")
return f"{prefix}_{_slug(name)}_{secrets.token_hex(2)}"
def create(
self,
*,
type: str,
name: str,
description: str,
category_id: str | None,
tags: list[str],
payload: dict[str, Any],
is_favorite: bool = False,
) -> dict[str, Any]:
mid = self._gen_id(type, name)
now = _now()
with self._conn:
self._conn.execute(
"INSERT INTO modules("
"id, type, name, description, category_id, tags, "
"is_favorite, payload, version, created_at, updated_at"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);",
(
mid, type, name, description, category_id,
json.dumps(tags), int(is_favorite),
json.dumps(payload), now, now,
),
)
return self.get(mid)
def get(self, module_id: str) -> dict[str, Any]:
row = self._conn.execute(
"SELECT * FROM modules WHERE id = ?;", (module_id,),
).fetchone()
if row is None:
raise ModuleNotFound(module_id)
return _row_to_module(row)
def update(
self,
module_id: str,
*,
name: str | None = None,
description: str | None = None,
category_id: str | None | object = ...,
tags: list[str] | None = None,
payload: dict[str, Any] | None = None,
is_favorite: bool | None = None,
) -> dict[str, Any]:
existing = self.get(module_id)
new = {
"name": existing["name"] if name is None else name,
"description": existing["description"] if description is None else description,
"category_id": existing["category_id"] if category_id is ... else category_id,
"tags": existing["tags"] if tags is None else tags,
"payload": existing["payload"] if payload is None else payload,
"is_favorite": existing["is_favorite"] if is_favorite is None else is_favorite,
}
now = _now()
with self._conn:
self._conn.execute(
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, payload = ?, version = version + 1, "
"updated_at = ? "
"WHERE id = ?;",
(
new["name"], new["description"], new["category_id"],
json.dumps(new["tags"]), int(new["is_favorite"]),
json.dumps(new["payload"]), now, module_id,
),
)
return self.get(module_id)
def delete(self, module_id: str) -> None:
self.get(module_id) # raises ModuleNotFound if absent
with self._conn:
self._conn.execute("DELETE FROM modules WHERE id = ?;", (module_id,))
def list(
self,
*,
type: str | None = None,
category_id: str | None = None,
query: str | None = None,
favorites_only: bool = False,
limit: int | None = None,
offset: int = 0,
) -> list[dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if type is not None:
clauses.append("type = ?")
params.append(type)
if category_id is not None:
clauses.append("category_id = ?")
params.append(category_id)
if query:
clauses.append("name LIKE ? COLLATE NOCASE")
params.append(f"%{query}%")
if favorites_only:
clauses.append("is_favorite = 1")
sql = "SELECT * FROM modules"
if clauses:
sql += " WHERE " + " AND ".join(clauses)
sql += " ORDER BY updated_at DESC, id DESC"
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
rows = self._conn.execute(sql, params).fetchall()
return [_row_to_module(r) for r in rows]
+163
View File
@@ -0,0 +1,163 @@
"""Tests for ModuleRepository CRUD + filtering."""
import pytest
from engine.db.repositories import ModuleNotFound, ModuleRepository
def _new_payload() -> dict:
return {"options": [{"id": "opt_1", "value": "red", "weight": 1}]}
def test_create_assigns_id_and_timestamps(wp_db):
repo = ModuleRepository(wp_db)
row = repo.create(
type="wildcard", name="colors", description="",
category_id=None, tags=["a", "b"], payload=_new_payload(),
)
assert row["id"].startswith("wc_colors_")
assert row["version"] == 1
assert row["created_at"]
assert row["updated_at"] == row["created_at"]
assert row["tags"] == ["a", "b"]
assert row["payload"] == _new_payload()
def test_get_returns_existing_row(wp_db):
repo = ModuleRepository(wp_db)
created = repo.create(
type="fixed_values", name="lens", description="",
category_id=None, tags=[], payload={"values": []},
)
got = repo.get(created["id"])
assert got["id"] == created["id"]
assert got["type"] == "fixed_values"
def test_get_raises_module_not_found(wp_db):
repo = ModuleRepository(wp_db)
with pytest.raises(ModuleNotFound):
repo.get("does_not_exist")
def test_update_bumps_version_and_updated_at(wp_db):
import time
repo = ModuleRepository(wp_db)
created = repo.create(
type="wildcard", name="colors", description="",
category_id=None, tags=[], payload=_new_payload(),
)
time.sleep(1.05) # ensure ISO-second-precision updated_at differs
updated = repo.update(created["id"], name="palette", payload=_new_payload())
assert updated["name"] == "palette"
assert updated["version"] == 2
assert updated["updated_at"] >= created["updated_at"]
def test_delete_removes_row(wp_db):
repo = ModuleRepository(wp_db)
created = repo.create(
type="wildcard", name="x", description="",
category_id=None, tags=[], payload=_new_payload(),
)
repo.delete(created["id"])
with pytest.raises(ModuleNotFound):
repo.get(created["id"])
def test_delete_missing_raises(wp_db):
repo = ModuleRepository(wp_db)
with pytest.raises(ModuleNotFound):
repo.delete("ghost")
def test_list_filters_by_type(wp_db):
repo = ModuleRepository(wp_db)
repo.create(type="wildcard", name="a", description="",
category_id=None, tags=[], payload=_new_payload())
repo.create(type="fixed_values", name="b", description="",
category_id=None, tags=[], payload={"values": []})
wcs = repo.list(type="wildcard")
assert len(wcs) == 1
assert wcs[0]["type"] == "wildcard"
def test_list_filters_by_category(wp_db):
wp_db.execute(
"INSERT INTO module_categories(id, name) VALUES('style', 'Style');"
)
wp_db.commit()
repo = ModuleRepository(wp_db)
repo.create(type="wildcard", name="a", description="",
category_id="style", tags=[], payload=_new_payload())
repo.create(type="wildcard", name="b", description="",
category_id=None, tags=[], payload=_new_payload())
rows = repo.list(category_id="style")
assert len(rows) == 1
assert rows[0]["name"] == "a"
def test_list_search_matches_name_case_insensitive(wp_db):
repo = ModuleRepository(wp_db)
repo.create(type="wildcard", name="ColorPalette", description="",
category_id=None, tags=[], payload=_new_payload())
repo.create(type="wildcard", name="lighting", description="",
category_id=None, tags=[], payload=_new_payload())
rows = repo.list(query="color")
assert len(rows) == 1
assert rows[0]["name"] == "ColorPalette"
def test_list_orders_by_updated_at_desc(wp_db):
import time
repo = ModuleRepository(wp_db)
a = repo.create(type="wildcard", name="a", description="",
category_id=None, tags=[], payload=_new_payload())
time.sleep(1.05) # ISO-second-precision: ensure b > a
b = repo.create(type="wildcard", name="b", description="",
category_id=None, tags=[], payload=_new_payload())
rows = repo.list()
assert [r["id"] for r in rows] == [b["id"], a["id"]]
def test_list_favorites_only(wp_db):
repo = ModuleRepository(wp_db)
repo.create(type="wildcard", name="a", description="",
category_id=None, tags=[], payload=_new_payload(), is_favorite=True)
repo.create(type="wildcard", name="b", description="",
category_id=None, tags=[], payload=_new_payload())
rows = repo.list(favorites_only=True)
assert len(rows) == 1
assert rows[0]["name"] == "a"
def test_list_limit_offset(wp_db):
import time
repo = ModuleRepository(wp_db)
for n in ["a", "b", "c"]:
repo.create(type="wildcard", name=n, description="",
category_id=None, tags=[], payload=_new_payload())
time.sleep(1.05)
rows = repo.list(limit=1, offset=1)
assert len(rows) == 1
assert rows[0]["name"] == "b"
def test_update_keeps_unchanged_fields(wp_db):
repo = ModuleRepository(wp_db)
created = repo.create(
type="wildcard", name="x", description="hello",
category_id=None, tags=["t1"], payload=_new_payload(),
)
updated = repo.update(created["id"], name="y") # only name changes
assert updated["description"] == "hello"
assert updated["tags"] == ["t1"]
assert updated["payload"] == _new_payload()
def test_create_id_uses_fv_prefix_for_fixed_values(wp_db):
repo = ModuleRepository(wp_db)
row = repo.create(
type="fixed_values", name="lens", description="",
category_id=None, tags=[], payload={"values": []},
)
assert row["id"].startswith("fv_lens_")