[repo] soft delete changes

This commit is contained in:
Piotr Domański 2026-04-07 19:07:59 +02:00
parent c26247cca6
commit 444893e1fd
8 changed files with 33 additions and 33 deletions

View file

@ -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

View file

@ -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]

View file

@ -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())
async def delete(self, obj: T) -> None:
if self._is_soft_delete:
soft_obj = cast(SoftDeletable, obj)
soft_obj.deleted_at = utc_now()
else:
stmt = sa_delete(self.model)
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()

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)