Skip to content

Commit e02291b

Browse files
feat(v2): return deleted entities from delete/soft-delete service methods
Add schema type-params to DeleteServiceMixin and SoftDeleteServiceMixin. Methods now return DetailSchema/list[ListSchema] with _raw variants returning ModelType/list[ModelType], matching create/update pattern. BREAKING CHANGE: delete/soft-delete methods no longer return None. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d050e74 commit e02291b

5 files changed

Lines changed: 336 additions & 26 deletions

File tree

docs/v2/recipes.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,25 @@ entity = await service.upsert(
102102
repo = SoftDeleteRepository(User)
103103
service = SoftDeleteRepositoryService(repo)
104104

105-
await service.soft_delete(session, user_id)
105+
# Returns the deleted entity as a schema.
106+
deleted = await service.soft_delete(session, user_id)
106107

107108
# Track who performed the deletion (requires UpdatedByMixin on the model).
108-
await service.soft_delete(session, user_id, actor_id=current_user_id)
109+
deleted = await service.soft_delete(session, user_id, actor_id=current_user_id)
110+
111+
# Bulk soft delete by filters — returns a list of deleted schemas.
112+
deleted_list = await service.soft_delete_by(
113+
session,
114+
filters=[User.is_active == False],
115+
)
116+
117+
# Raw variants return ORM models instead of schemas.
118+
entity = await service.soft_delete_raw(session, user_id)
119+
entities = await service.soft_delete_by_raw(session, filters=[User.is_active == False])
120+
121+
# Hard delete also returns entities.
122+
deleted = await service.delete(session, user_id)
123+
deleted_list = await service.delete_by(session, filters=[User.is_active == False])
109124

110125
# Customize column name if your model differs.
111126
repo.deleted_attribute = "removed_at"
@@ -125,9 +140,9 @@ repo = SoftDeleteRepository(
125140

126141
```python
127142
# Model should include UpdatedByMixin / UpdatedByUserMixin to store actor id.
128-
await service.create(session, data, actor_id=current_user_id)
129-
await service.update(session, user_id, data, actor_id=current_user_id)
130-
await service.soft_delete(session, user_id, actor_id=current_user_id)
143+
created = await service.create(session, data, actor_id=current_user_id)
144+
updated = await service.update(session, user_id, data, actor_id=current_user_id)
145+
deleted = await service.soft_delete(session, user_id, actor_id=current_user_id)
131146
```
132147

133148
If your field is not named `updated_by`, override `updated_by_attribute` on the

src/notora/v2/services/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class RepositoryService[
2525
UpsertServiceMixin[PKType, ModelType, DetailSchema, ListSchema],
2626
UpdateServiceMixin[PKType, ModelType, DetailSchema, ListSchema],
2727
UpdateByFilterServiceMixin[PKType, ModelType, DetailSchema, ListSchema],
28-
DeleteServiceMixin[PKType, ModelType],
28+
DeleteServiceMixin[PKType, ModelType, DetailSchema, ListSchema],
2929
):
3030
"""Turnkey async service that glues repository access and serialization together."""
3131

@@ -51,7 +51,7 @@ class SoftDeleteRepositoryService[
5151
ListSchema: BaseResponseSchema = DetailSchema,
5252
](
5353
RepositoryService[PKType, ModelType, DetailSchema, ListSchema],
54-
SoftDeleteServiceMixin[PKType, ModelType],
54+
SoftDeleteServiceMixin[PKType, ModelType, DetailSchema, ListSchema],
5555
):
5656
"""Repository service variant that exposes soft-delete helpers."""
5757

src/notora/v2/services/mixins/delete.py

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,57 +6,112 @@
66
from notora.v2.models.base import GenericBaseModel
77
from notora.v2.repositories.base import SoftDeleteRepositoryProtocol
88
from notora.v2.repositories.types import FilterSpec
9+
from notora.v2.schemas.base import BaseResponseSchema
910
from notora.v2.services.mixins.accessors import RepositoryAccessorMixin
1011
from notora.v2.services.mixins.executor import SessionExecutorMixin
12+
from notora.v2.services.mixins.serializer import SerializerProtocol
1113
from notora.v2.services.mixins.updated_by import UpdatedByServiceMixin
1214

1315

14-
class DeleteServiceMixin[PKType, ModelType: GenericBaseModel](
16+
class DeleteServiceMixin[
17+
PKType,
18+
ModelType: GenericBaseModel,
19+
DetailSchema: BaseResponseSchema,
20+
ListSchema: BaseResponseSchema = DetailSchema,
21+
](
1522
SessionExecutorMixin[PKType, ModelType],
1623
RepositoryAccessorMixin[PKType, ModelType],
24+
UpdatedByServiceMixin[PKType, ModelType],
25+
SerializerProtocol[ModelType, DetailSchema, ListSchema],
1726
):
18-
async def delete(self, session: AsyncSession, pk: PKType) -> None:
19-
await self.execute(session, self.repo.delete(pk))
27+
async def delete_raw(self, session: AsyncSession, pk: PKType) -> ModelType:
28+
return await self.execute_for_one(session, self.repo.delete(pk))
29+
30+
async def delete(
31+
self,
32+
session: AsyncSession,
33+
pk: PKType,
34+
*,
35+
schema: type[DetailSchema] | None = None,
36+
) -> DetailSchema:
37+
entity = await self.delete_raw(session, pk)
38+
return self.serialize_one(entity, schema=schema)
39+
40+
async def delete_by_raw(
41+
self,
42+
session: AsyncSession,
43+
filters: Iterable[FilterSpec[ModelType]],
44+
) -> list[ModelType]:
45+
return await self.execute_for_many(session, self.repo.delete_by(filters=filters))
2046

2147
async def delete_by(
2248
self,
2349
session: AsyncSession,
2450
filters: Iterable[FilterSpec[ModelType]],
25-
) -> None:
26-
await self.execute(session, self.repo.delete_by(filters=filters))
51+
*,
52+
schema: type[ListSchema] | None = None,
53+
) -> list[ListSchema]:
54+
entities = await self.delete_by_raw(session, filters)
55+
return self.serialize_many(entities, schema=schema)
2756

2857

29-
class SoftDeleteServiceMixin[PKType, ModelType: GenericBaseModel](
30-
DeleteServiceMixin[PKType, ModelType],
31-
UpdatedByServiceMixin[PKType, ModelType],
58+
class SoftDeleteServiceMixin[
59+
PKType,
60+
ModelType: GenericBaseModel,
61+
DetailSchema: BaseResponseSchema,
62+
ListSchema: BaseResponseSchema = DetailSchema,
63+
](
64+
DeleteServiceMixin[PKType, ModelType, DetailSchema, ListSchema],
3265
):
3366
repo: SoftDeleteRepositoryProtocol[PKType, ModelType]
3467

35-
async def soft_delete(
68+
async def soft_delete_raw(
3669
self,
3770
session: AsyncSession,
3871
pk: PKType,
3972
*,
4073
actor_id: Any | None = None,
41-
) -> None:
74+
) -> ModelType:
4275
additional_payload = self._apply_updated_by({}, actor_id) or None
43-
await self.execute_for_one(
76+
return await self.execute_for_one(
4477
session,
4578
self.repo.soft_delete(pk, additional_payload=additional_payload),
4679
)
4780

48-
async def soft_delete_by(
81+
async def soft_delete(
82+
self,
83+
session: AsyncSession,
84+
pk: PKType,
85+
*,
86+
actor_id: Any | None = None,
87+
schema: type[DetailSchema] | None = None,
88+
) -> DetailSchema:
89+
entity = await self.soft_delete_raw(session, pk, actor_id=actor_id)
90+
return self.serialize_one(entity, schema=schema)
91+
92+
async def soft_delete_by_raw(
4993
self,
5094
session: AsyncSession,
5195
filters: Iterable[FilterSpec[ModelType]],
5296
*,
5397
actor_id: Any | None = None,
54-
) -> None:
98+
) -> list[ModelType]:
5599
additional_payload = self._apply_updated_by({}, actor_id) or None
56-
await self.execute(
100+
return await self.execute_for_many(
57101
session,
58102
self.repo.soft_delete_by(
59103
filters=filters,
60104
additional_payload=additional_payload,
61105
),
62106
)
107+
108+
async def soft_delete_by(
109+
self,
110+
session: AsyncSession,
111+
filters: Iterable[FilterSpec[ModelType]],
112+
*,
113+
actor_id: Any | None = None,
114+
schema: type[ListSchema] | None = None,
115+
) -> list[ListSchema]:
116+
entities = await self.soft_delete_by_raw(session, filters, actor_id=actor_id)
117+
return self.serialize_many(entities, schema=schema)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from collections.abc import Iterable
2+
from typing import Any, overload
3+
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
6+
from notora.v2.models.base import GenericBaseModel
7+
from notora.v2.repositories.base import SoftDeleteRepositoryProtocol
8+
from notora.v2.repositories.types import FilterSpec
9+
from notora.v2.schemas.base import BaseResponseSchema
10+
from notora.v2.services.mixins.accessors import RepositoryAccessorMixin
11+
from notora.v2.services.mixins.executor import SessionExecutorMixin
12+
from notora.v2.services.mixins.serializer import SerializerProtocol
13+
from notora.v2.services.mixins.updated_by import UpdatedByServiceMixin
14+
15+
__all__ = ['DeleteServiceMixin', 'SoftDeleteServiceMixin']
16+
17+
class DeleteServiceMixin[
18+
PKType,
19+
ModelType: GenericBaseModel,
20+
DetailSchema: BaseResponseSchema,
21+
ListSchema: BaseResponseSchema = DetailSchema,
22+
](
23+
SessionExecutorMixin[PKType, ModelType],
24+
RepositoryAccessorMixin[PKType, ModelType],
25+
UpdatedByServiceMixin[PKType, ModelType],
26+
SerializerProtocol[ModelType, DetailSchema, ListSchema],
27+
):
28+
__type_params__: tuple[object, ...]
29+
30+
async def delete_raw(
31+
self,
32+
session: AsyncSession,
33+
pk: PKType,
34+
) -> ModelType: ...
35+
@overload
36+
async def delete(
37+
self,
38+
session: AsyncSession,
39+
pk: PKType,
40+
*,
41+
schema: None = ...,
42+
) -> DetailSchema: ...
43+
@overload
44+
async def delete[SchemaT: BaseResponseSchema](
45+
self,
46+
session: AsyncSession,
47+
pk: PKType,
48+
*,
49+
schema: type[SchemaT],
50+
) -> SchemaT: ...
51+
async def delete_by_raw(
52+
self,
53+
session: AsyncSession,
54+
filters: Iterable[FilterSpec[ModelType]],
55+
) -> list[ModelType]: ...
56+
@overload
57+
async def delete_by(
58+
self,
59+
session: AsyncSession,
60+
filters: Iterable[FilterSpec[ModelType]],
61+
*,
62+
schema: None = ...,
63+
) -> list[ListSchema]: ...
64+
@overload
65+
async def delete_by[SchemaT: BaseResponseSchema](
66+
self,
67+
session: AsyncSession,
68+
filters: Iterable[FilterSpec[ModelType]],
69+
*,
70+
schema: type[SchemaT],
71+
) -> list[SchemaT]: ...
72+
73+
class SoftDeleteServiceMixin[
74+
PKType,
75+
ModelType: GenericBaseModel,
76+
DetailSchema: BaseResponseSchema,
77+
ListSchema: BaseResponseSchema = DetailSchema,
78+
](
79+
DeleteServiceMixin[PKType, ModelType, DetailSchema, ListSchema],
80+
):
81+
__type_params__: tuple[object, ...]
82+
83+
repo: SoftDeleteRepositoryProtocol[PKType, ModelType]
84+
85+
async def soft_delete_raw(
86+
self,
87+
session: AsyncSession,
88+
pk: PKType,
89+
*,
90+
actor_id: Any | None = None,
91+
) -> ModelType: ...
92+
@overload
93+
async def soft_delete(
94+
self,
95+
session: AsyncSession,
96+
pk: PKType,
97+
*,
98+
actor_id: Any | None = None,
99+
schema: None = ...,
100+
) -> DetailSchema: ...
101+
@overload
102+
async def soft_delete[SchemaT: BaseResponseSchema](
103+
self,
104+
session: AsyncSession,
105+
pk: PKType,
106+
*,
107+
actor_id: Any | None = None,
108+
schema: type[SchemaT],
109+
) -> SchemaT: ...
110+
async def soft_delete_by_raw(
111+
self,
112+
session: AsyncSession,
113+
filters: Iterable[FilterSpec[ModelType]],
114+
*,
115+
actor_id: Any | None = None,
116+
) -> list[ModelType]: ...
117+
@overload
118+
async def soft_delete_by(
119+
self,
120+
session: AsyncSession,
121+
filters: Iterable[FilterSpec[ModelType]],
122+
*,
123+
actor_id: Any | None = None,
124+
schema: None = ...,
125+
) -> list[ListSchema]: ...
126+
@overload
127+
async def soft_delete_by[SchemaT: BaseResponseSchema](
128+
self,
129+
session: AsyncSession,
130+
filters: Iterable[FilterSpec[ModelType]],
131+
*,
132+
actor_id: Any | None = None,
133+
schema: type[SchemaT],
134+
) -> list[SchemaT]: ...

0 commit comments

Comments
 (0)