diff --git a/src/example/note/sqlite_repository.py b/src/example/note/sqlite_repository.py new file mode 100644 index 0000000..b178b62 --- /dev/null +++ b/src/example/note/sqlite_repository.py @@ -0,0 +1,52 @@ +"""SQLite-backed Note repository using SQLAlchemy Core.""" + +from nene2.database import DatabaseQueryExecutorInterface + +from .entity import Note +from .repository import NoteRepositoryInterface + + +class SqliteNoteRepository(NoteRepositoryInterface): + """Persistent Note repository backed by a SQL database via SQLAlchemy Core.""" + + def __init__(self, executor: DatabaseQueryExecutorInterface) -> None: + self._executor = executor + + def find_all(self, limit: int, offset: int) -> list[Note]: + rows = self._executor.fetch_all( + "SELECT id, title, body FROM notes ORDER BY id LIMIT :limit OFFSET :offset", + {"limit": limit, "offset": offset}, + ) + return [Note(id=row["id"], title=row["title"], body=row["body"]) for row in rows] + + def find_by_id(self, note_id: int) -> Note | None: + row = self._executor.fetch_one( + "SELECT id, title, body FROM notes WHERE id = :id", + {"id": note_id}, + ) + return Note(id=row["id"], title=row["title"], body=row["body"]) if row else None + + def save(self, title: str, body: str) -> Note: + new_id = self._executor.write( + "INSERT INTO notes (title, body) VALUES (:title, :body)", + {"title": title, "body": body}, + ) + return Note(id=new_id, title=title, body=body) + + def update(self, note_id: int, title: str, body: str) -> Note | None: + affected = self._executor.write( + "UPDATE notes SET title = :title, body = :body WHERE id = :id", + {"title": title, "body": body, "id": note_id}, + ) + return Note(id=note_id, title=title, body=body) if affected > 0 else None + + def delete(self, note_id: int) -> bool: + affected = self._executor.write( + "DELETE FROM notes WHERE id = :id", + {"id": note_id}, + ) + return affected > 0 + + def count(self) -> int: + row = self._executor.fetch_one("SELECT COUNT(*) AS cnt FROM notes") + return int(row["cnt"]) if row else 0 diff --git a/src/example/tag/sqlite_repository.py b/src/example/tag/sqlite_repository.py new file mode 100644 index 0000000..5aaf8df --- /dev/null +++ b/src/example/tag/sqlite_repository.py @@ -0,0 +1,52 @@ +"""SQLite-backed Tag repository using SQLAlchemy Core.""" + +from nene2.database import DatabaseQueryExecutorInterface + +from .entity import Tag +from .repository import TagRepositoryInterface + + +class SqliteTagRepository(TagRepositoryInterface): + """Persistent Tag repository backed by a SQL database via SQLAlchemy Core.""" + + def __init__(self, executor: DatabaseQueryExecutorInterface) -> None: + self._executor = executor + + def find_all(self, limit: int, offset: int) -> list[Tag]: + rows = self._executor.fetch_all( + "SELECT id, name FROM tags ORDER BY id LIMIT :limit OFFSET :offset", + {"limit": limit, "offset": offset}, + ) + return [Tag(id=row["id"], name=row["name"]) for row in rows] + + def find_by_id(self, tag_id: int) -> Tag | None: + row = self._executor.fetch_one( + "SELECT id, name FROM tags WHERE id = :id", + {"id": tag_id}, + ) + return Tag(id=row["id"], name=row["name"]) if row else None + + def save(self, name: str) -> Tag: + new_id = self._executor.write( + "INSERT INTO tags (name) VALUES (:name)", + {"name": name}, + ) + return Tag(id=new_id, name=name) + + def update(self, tag_id: int, name: str) -> Tag | None: + affected = self._executor.write( + "UPDATE tags SET name = :name WHERE id = :id", + {"name": name, "id": tag_id}, + ) + return Tag(id=tag_id, name=name) if affected > 0 else None + + def delete(self, tag_id: int) -> bool: + affected = self._executor.write( + "DELETE FROM tags WHERE id = :id", + {"id": tag_id}, + ) + return affected > 0 + + def count(self) -> int: + row = self._executor.fetch_one("SELECT COUNT(*) AS cnt FROM tags") + return int(row["cnt"]) if row else 0 diff --git a/tests/example/note/test_note_repository.py b/tests/example/note/test_note_repository.py new file mode 100644 index 0000000..a1da2a2 --- /dev/null +++ b/tests/example/note/test_note_repository.py @@ -0,0 +1,89 @@ +"""Repository contract tests — run against both InMemory and SQLite implementations.""" + +import pytest +from sqlalchemy import create_engine + +from nene2.database import SqlAlchemyQueryExecutor +from src.example.note.entity import Note +from src.example.note.repository import InMemoryNoteRepository, NoteRepositoryInterface +from src.example.note.sqlite_repository import SqliteNoteRepository + + +def _create_schema(executor: SqlAlchemyQueryExecutor) -> None: + executor.write( + "CREATE TABLE IF NOT EXISTS notes (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "title TEXT NOT NULL," + "body TEXT NOT NULL," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP" + ")" + ) + + +def _sqlite_repo() -> SqliteNoteRepository: + engine = create_engine("sqlite:///:memory:") + executor = SqlAlchemyQueryExecutor(engine) + _create_schema(executor) + return SqliteNoteRepository(executor) + + +@pytest.fixture(params=["inmemory", "sqlite"]) +def repo(request: pytest.FixtureRequest) -> NoteRepositoryInterface: + if request.param == "inmemory": + return InMemoryNoteRepository() + return _sqlite_repo() + + +def test_save_and_find_by_id(repo: NoteRepositoryInterface) -> None: + note = repo.save("Hello", "World") + found = repo.find_by_id(note.id) + assert found == note + + +def test_find_by_id_returns_none_when_missing(repo: NoteRepositoryInterface) -> None: + assert repo.find_by_id(9999) is None + + +def test_find_all_returns_saved_notes(repo: NoteRepositoryInterface) -> None: + repo.save("A", "a") + repo.save("B", "b") + items = repo.find_all(limit=10, offset=0) + assert len(items) == 2 + assert items[0].title == "A" + assert items[1].title == "B" + + +def test_find_all_respects_limit_and_offset(repo: NoteRepositoryInterface) -> None: + for i in range(5): + repo.save(f"Note {i}", "body") + page = repo.find_all(limit=2, offset=2) + assert len(page) == 2 + assert page[0].title == "Note 2" + + +def test_count_reflects_saved_notes(repo: NoteRepositoryInterface) -> None: + assert repo.count() == 0 + repo.save("T", "B") + repo.save("T2", "B2") + assert repo.count() == 2 + + +def test_update_changes_title_and_body(repo: NoteRepositoryInterface) -> None: + note = repo.save("Old", "old body") + updated = repo.update(note.id, "New", "new body") + assert updated == Note(id=note.id, title="New", body="new body") + + +def test_update_returns_none_for_missing_id(repo: NoteRepositoryInterface) -> None: + assert repo.update(9999, "T", "B") is None + + +def test_delete_removes_note(repo: NoteRepositoryInterface) -> None: + note = repo.save("T", "B") + assert repo.delete(note.id) is True + assert repo.find_by_id(note.id) is None + + +def test_delete_returns_false_for_missing_id(repo: NoteRepositoryInterface) -> None: + assert repo.delete(9999) is False diff --git a/tests/example/tag/test_tag_repository.py b/tests/example/tag/test_tag_repository.py new file mode 100644 index 0000000..a823f30 --- /dev/null +++ b/tests/example/tag/test_tag_repository.py @@ -0,0 +1,84 @@ +"""Repository contract tests — run against both InMemory and SQLite implementations.""" + +import pytest +from sqlalchemy import create_engine + +from nene2.database import SqlAlchemyQueryExecutor +from src.example.tag.entity import Tag +from src.example.tag.repository import InMemoryTagRepository, TagRepositoryInterface +from src.example.tag.sqlite_repository import SqliteTagRepository + + +def _create_schema(executor: SqlAlchemyQueryExecutor) -> None: + executor.write( + "CREATE TABLE IF NOT EXISTS tags (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "name TEXT NOT NULL UNIQUE," + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP" + ")" + ) + + +def _sqlite_repo() -> SqliteTagRepository: + engine = create_engine("sqlite:///:memory:") + executor = SqlAlchemyQueryExecutor(engine) + _create_schema(executor) + return SqliteTagRepository(executor) + + +@pytest.fixture(params=["inmemory", "sqlite"]) +def repo(request: pytest.FixtureRequest) -> TagRepositoryInterface: + if request.param == "inmemory": + return InMemoryTagRepository() + return _sqlite_repo() + + +def test_save_and_find_by_id(repo: TagRepositoryInterface) -> None: + tag = repo.save("python") + found = repo.find_by_id(tag.id) + assert found == tag + + +def test_find_by_id_returns_none_when_missing(repo: TagRepositoryInterface) -> None: + assert repo.find_by_id(9999) is None + + +def test_find_all_returns_saved_tags(repo: TagRepositoryInterface) -> None: + repo.save("python") + repo.save("fastapi") + items = repo.find_all(limit=10, offset=0) + assert len(items) == 2 + + +def test_find_all_respects_limit_and_offset(repo: TagRepositoryInterface) -> None: + for name in ["a", "b", "c", "d", "e"]: + repo.save(name) + page = repo.find_all(limit=2, offset=2) + assert len(page) == 2 + + +def test_count_reflects_saved_tags(repo: TagRepositoryInterface) -> None: + assert repo.count() == 0 + repo.save("x") + repo.save("y") + assert repo.count() == 2 + + +def test_update_changes_name(repo: TagRepositoryInterface) -> None: + tag = repo.save("old") + updated = repo.update(tag.id, "new") + assert updated == Tag(id=tag.id, name="new") + + +def test_update_returns_none_for_missing_id(repo: TagRepositoryInterface) -> None: + assert repo.update(9999, "x") is None + + +def test_delete_removes_tag(repo: TagRepositoryInterface) -> None: + tag = repo.save("temp") + assert repo.delete(tag.id) is True + assert repo.find_by_id(tag.id) is None + + +def test_delete_returns_false_for_missing_id(repo: TagRepositoryInterface) -> None: + assert repo.delete(9999) is False