[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`
|
- `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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue