[repo] soft delete changes
This commit is contained in:
parent
c26247cca6
commit
444893e1fd
8 changed files with 33 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue