From 444893e1fd3bbb9ba0b7ee0c2a74fad046dc1a97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Doma=C5=84ski?= Date: Tue, 7 Apr 2026 19:07:59 +0200 Subject: [PATCH] [repo] soft delete changes --- ARCHITECTURE.md | 4 +++ fooder/domain/base.py | 6 +++++ fooder/repository/base.py | 41 +++++++++++++---------------- fooder/repository/entry.py | 3 --- fooder/repository/meal.py | 3 --- fooder/test/repository/test_base.py | 2 +- fooder/view/entry.py | 3 ++- fooder/view/meal.py | 4 +-- 8 files changed, 33 insertions(+), 33 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bd2ef11..790a982 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -109,3 +109,7 @@ All exceptions inherit `ApiException` with a class-level `HTTP_CODE`. The FastAP - `auth_client` fixture provides a pre-authenticated `AsyncClient` - Test data is created via controllers inside `ctx.repo.transaction()` — same path as production - External I/O (e.g. `product_finder.find`) is monkeypatched + +## Misc rules + +- Use absolute imports diff --git a/fooder/domain/base.py b/fooder/domain/base.py index 6b7035b..6b1e63f 100644 --- a/fooder/domain/base.py +++ b/fooder/domain/base.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Any, Protocol, runtime_checkable from sqlalchemy import DateTime from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column @@ -32,6 +33,11 @@ class SoftDeleteMixin: ) +@runtime_checkable +class SoftDeletable(Protocol): + deleted_at: Any + + class PasswordMixin: hashed_password: Mapped[str] diff --git a/fooder/repository/base.py b/fooder/repository/base.py index 17f431d..a1cace7 100644 --- a/fooder/repository/base.py +++ b/fooder/repository/base.py @@ -1,17 +1,13 @@ -from typing import TypeVar, Generic, Type, Sequence +from typing import TypeVar, Generic, Type, Sequence, cast from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import ( - Delete, - Update, ColumnElement, select, - delete as sa_delete, - update as sa_update, ) from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.exc import StaleDataError from sqlalchemy.sql import Select -from fooder.domain import Base +from fooder.domain.base import Base, SoftDeletable from fooder.exc import Conflict, NotFound from fooder.utils.datetime import utc_now @@ -25,6 +21,7 @@ class RepositoryBase(Generic[T]): def __init__(self, model: Type[T], session: AsyncSession): self.model = model self.session = session + self._is_soft_delete = hasattr(self.model, "deleted_at") def _build_select( self, *expressions: ColumnElement, stmt: Select | None = None @@ -32,8 +29,9 @@ class RepositoryBase(Generic[T]): if stmt is None: stmt = select(self.model) - if hasattr(self.model, "deleted_at"): - stmt = stmt.where(self.model.deleted_at.is_(None)) # type: ignore[attr-defined] + if self._is_soft_delete: + cls = cast(Type[SoftDeletable], self.model) + stmt = stmt.where(cls.deleted_at.is_(None)) if expressions: stmt = stmt.where(*expressions) @@ -42,8 +40,7 @@ class RepositoryBase(Generic[T]): async def _get(self, *expressions: ColumnElement, stmt: Select | None = None) -> T: stmt = self._build_select(*expressions, stmt=stmt) - result = await self.session.execute(stmt) - obj = result.scalar_one_or_none() + obj = await self.session.scalar(stmt) if obj is None: raise NotFound() @@ -54,8 +51,7 @@ class RepositoryBase(Generic[T]): self, *expressions: ColumnElement, stmt: Select | None = None ) -> T: stmt = self._build_select(*expressions, stmt=stmt).with_for_update() - result = await self.session.execute(stmt) - obj = result.scalar_one_or_none() + obj = await self.session.scalar(stmt) if obj is None: raise NotFound() @@ -98,15 +94,14 @@ class RepositoryBase(Generic[T]): raise Conflict() return obj - async def _delete( - self, *expressions: ColumnElement, stmt: Update | Delete | None = None - ): - if stmt is None: - if hasattr(self.model, "deleted_at"): - stmt = sa_update(self.model).values(deleted_at=utc_now()) - else: - stmt = sa_delete(self.model) + async def delete(self, obj: T) -> None: + if self._is_soft_delete: + soft_obj = cast(SoftDeletable, obj) + soft_obj.deleted_at = utc_now() + else: + await self.session.delete(obj) - if expressions: - stmt = stmt.where(*expressions) - await self.session.execute(stmt) + try: + await self.session.flush() + except IntegrityError: + raise Conflict() diff --git a/fooder/repository/entry.py b/fooder/repository/entry.py index 3fbdc6b..d44297d 100644 --- a/fooder/repository/entry.py +++ b/fooder/repository/entry.py @@ -5,6 +5,3 @@ from fooder.repository.base import RepositoryBase class EntryRepository(RepositoryBase[Entry]): async def get_by_id_and_meal(self, entry_id: int, meal_id: int) -> Entry: return await self._get(Entry.id == entry_id, Entry.meal_id == meal_id) - - async def delete_by_id_and_meal(self, entry_id: int, meal_id: int) -> None: - await self._delete(Entry.id == entry_id, Entry.meal_id == meal_id) diff --git a/fooder/repository/meal.py b/fooder/repository/meal.py index e179586..b9df096 100644 --- a/fooder/repository/meal.py +++ b/fooder/repository/meal.py @@ -8,9 +8,6 @@ class MealRepository(RepositoryBase[Meal]): async def get_by_id_and_diary(self, meal_id: int, diary_id: int) -> Meal: return await self._get(Meal.id == meal_id, Meal.diary_id == diary_id) - async def delete_by_id_and_diary(self, meal_id: int, diary_id: int) -> None: - await self._delete(Meal.id == meal_id, Meal.diary_id == diary_id) - async def next_order(self, diary_id: int) -> int: stmt = select(func.max(Meal.order)).where(Meal.diary_id == diary_id) result = await self.session.execute(stmt) diff --git a/fooder/test/repository/test_base.py b/fooder/test/repository/test_base.py index 7f0bd66..1ec969b 100644 --- a/fooder/test/repository/test_base.py +++ b/fooder/test/repository/test_base.py @@ -63,6 +63,6 @@ async def test_list_returns_empty_when_no_match(test_repo, test_model_factory): async def test_delete_removes_record(test_repo, test_model): model = await test_repo.create(test_model) - await test_repo._delete(TestModel.id == model.id) + await test_repo.delete(model) with pytest.raises(NotFound): await test_repo._get(TestModel.id == model.id) diff --git a/fooder/view/entry.py b/fooder/view/entry.py index a48a805..8e0388a 100644 --- a/fooder/view/entry.py +++ b/fooder/view/entry.py @@ -52,4 +52,5 @@ async def delete_entry( async with ctx.repo.transaction(): diary = await ctx.repo.diary.get_by_user_and_date(ctx.user.id, date) await ctx.repo.meal.get_by_id_and_diary(meal_id, diary.id) - await ctx.repo.entry.delete_by_id_and_meal(entry_id, meal_id) + entry = await ctx.repo.entry.get_by_id_and_meal(entry_id, meal_id) + await ctx.repo.entry.delete(entry) diff --git a/fooder/view/meal.py b/fooder/view/meal.py index e8ef43e..e768701 100644 --- a/fooder/view/meal.py +++ b/fooder/view/meal.py @@ -45,5 +45,5 @@ async def delete_meal( ): async with ctx.repo.transaction(): diary = await ctx.repo.diary.get_by_user_and_date(ctx.user.id, date) - await ctx.repo.meal.get_by_id_and_diary(meal_id, diary.id) - await ctx.repo.meal.delete_by_id_and_diary(meal_id, diary.id) + meal = await ctx.repo.meal.get_by_id_and_diary(meal_id, diary.id) + await ctx.repo.meal.delete(meal)