[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` - `auth_client` fixture provides a pre-authenticated `AsyncClient`
- Test data is created via controllers inside `ctx.repo.transaction()` — same path as production - Test data is created via controllers inside `ctx.repo.transaction()` — same path as production
- External I/O (e.g. `product_finder.find`) is monkeypatched - 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 datetime import datetime
from typing import Any, Protocol, runtime_checkable
from sqlalchemy import DateTime from sqlalchemy import DateTime
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column 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: class PasswordMixin:
hashed_password: Mapped[str] 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.ext.asyncio import AsyncSession
from sqlalchemy import ( from sqlalchemy import (
Delete,
Update,
ColumnElement, ColumnElement,
select, select,
delete as sa_delete,
update as sa_update,
) )
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import StaleDataError from sqlalchemy.orm.exc import StaleDataError
from sqlalchemy.sql import Select 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.exc import Conflict, NotFound
from fooder.utils.datetime import utc_now from fooder.utils.datetime import utc_now
@ -25,6 +21,7 @@ class RepositoryBase(Generic[T]):
def __init__(self, model: Type[T], session: AsyncSession): def __init__(self, model: Type[T], session: AsyncSession):
self.model = model self.model = model
self.session = session self.session = session
self._is_soft_delete = hasattr(self.model, "deleted_at")
def _build_select( def _build_select(
self, *expressions: ColumnElement, stmt: Select | None = None self, *expressions: ColumnElement, stmt: Select | None = None
@ -32,8 +29,9 @@ class RepositoryBase(Generic[T]):
if stmt is None: if stmt is None:
stmt = select(self.model) stmt = select(self.model)
if hasattr(self.model, "deleted_at"): if self._is_soft_delete:
stmt = stmt.where(self.model.deleted_at.is_(None)) # type: ignore[attr-defined] cls = cast(Type[SoftDeletable], self.model)
stmt = stmt.where(cls.deleted_at.is_(None))
if expressions: if expressions:
stmt = stmt.where(*expressions) stmt = stmt.where(*expressions)
@ -42,8 +40,7 @@ class RepositoryBase(Generic[T]):
async def _get(self, *expressions: ColumnElement, stmt: Select | None = None) -> T: async def _get(self, *expressions: ColumnElement, stmt: Select | None = None) -> T:
stmt = self._build_select(*expressions, stmt=stmt) stmt = self._build_select(*expressions, stmt=stmt)
result = await self.session.execute(stmt) obj = await self.session.scalar(stmt)
obj = result.scalar_one_or_none()
if obj is None: if obj is None:
raise NotFound() raise NotFound()
@ -54,8 +51,7 @@ class RepositoryBase(Generic[T]):
self, *expressions: ColumnElement, stmt: Select | None = None self, *expressions: ColumnElement, stmt: Select | None = None
) -> T: ) -> T:
stmt = self._build_select(*expressions, stmt=stmt).with_for_update() stmt = self._build_select(*expressions, stmt=stmt).with_for_update()
result = await self.session.execute(stmt) obj = await self.session.scalar(stmt)
obj = result.scalar_one_or_none()
if obj is None: if obj is None:
raise NotFound() raise NotFound()
@ -98,15 +94,14 @@ class RepositoryBase(Generic[T]):
raise Conflict() raise Conflict()
return obj return obj
async def _delete( async def delete(self, obj: T) -> None:
self, *expressions: ColumnElement, stmt: Update | Delete | None = None if self._is_soft_delete:
): soft_obj = cast(SoftDeletable, obj)
if stmt is None: soft_obj.deleted_at = utc_now()
if hasattr(self.model, "deleted_at"): else:
stmt = sa_update(self.model).values(deleted_at=utc_now()) await self.session.delete(obj)
else:
stmt = sa_delete(self.model)
if expressions: try:
stmt = stmt.where(*expressions) await self.session.flush()
await self.session.execute(stmt) except IntegrityError:
raise Conflict()

View file

@ -5,6 +5,3 @@ from fooder.repository.base import RepositoryBase
class EntryRepository(RepositoryBase[Entry]): class EntryRepository(RepositoryBase[Entry]):
async def get_by_id_and_meal(self, entry_id: int, meal_id: int) -> 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) 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: 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) 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: async def next_order(self, diary_id: int) -> int:
stmt = select(func.max(Meal.order)).where(Meal.diary_id == diary_id) stmt = select(func.max(Meal.order)).where(Meal.diary_id == diary_id)
result = await self.session.execute(stmt) 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): async def test_delete_removes_record(test_repo, test_model):
model = await test_repo.create(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): with pytest.raises(NotFound):
await test_repo._get(TestModel.id == model.id) await test_repo._get(TestModel.id == model.id)

View file

@ -52,4 +52,5 @@ async def delete_entry(
async with ctx.repo.transaction(): async with ctx.repo.transaction():
diary = await ctx.repo.diary.get_by_user_and_date(ctx.user.id, date) 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.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(): async with ctx.repo.transaction():
diary = await ctx.repo.diary.get_by_user_and_date(ctx.user.id, date) 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) meal = 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) await ctx.repo.meal.delete(meal)