Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion docs/v2/repositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Write mixins:
- `UpsertMixin` -> `upsert()`
- `UpdateMixin` -> `update()` / `update_by()`
- `DeleteMixin` -> `delete()` / `delete_by()`
- `SoftDeleteMixin` -> `soft_delete()` / `soft_delete_by()`
- `SoftDeleteMixin` -> `soft_delete()` / `soft_delete_by()` / `restore()` / `restore_by()`

## RepoConfig

Expand Down Expand Up @@ -162,3 +162,24 @@ repo = SoftDeleteRepository(
config=RepoConfig(apply_soft_delete_filter=False),
)
```

### Restore soft-deleted rows

`restore` and `restore_by` clear `deleted_at` back to `NULL`. They bypass the
soft-delete default filter so they can target already-deleted records:

```python
# Restore by primary key
query = repo.restore(user_id)

# Restore by filters
query = repo.restore_by(
filters=[lambda m: m.email == "a@b.com"],
Comment on lines +168 to +177
)

# Restore with extra payload (e.g. updated_by)
query = repo.restore(
user_id,
additional_payload={"updated_by": current_user_id},
)
```
27 changes: 27 additions & 0 deletions docs/v2/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Services combine repository statements, async execution, and serialization.
`ListSchema` defaults to `DetailSchema` if omitted.

Both include create/update/delete/retrieve/list/paginate helpers.
`SoftDeleteRepositoryService` also exposes `soft_delete` / `soft_delete_by` and
`restore` / `restore_by`.

## Query execution

Expand Down Expand Up @@ -195,3 +197,28 @@ entity = await service.upsert(
rows = await service.list(session, limit=20, offset=0)
page = await service.paginate(session, limit=20, offset=0)
```

### Soft delete and restore

```python
# Soft delete by primary key
deleted = await service.soft_delete(session, user_id)

# Soft delete by filters
deleted = await service.soft_delete_by(
session,
filters=[lambda m: m.email == "a@b.com"],
)

# Restore by primary key
restored = await service.restore(session, user_id)

# Restore by filters
restored = await service.restore_by(
session,
filters=[lambda m: m.email == "a@b.com"],
)

# With actor tracking
restored = await service.restore(session, user_id, actor_id=current_user_id)
```
15 changes: 15 additions & 0 deletions src/notora/v1/persistence/repos/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,3 +330,18 @@ def soft_delete_by(
query = update(self.model).values({'deleted_at': now()})
query = self.add_filters(query, filters)
return query.returning(self.model)

def restore(
self,
entity_id: PKType,
) -> TypedReturnsRows[tuple[ModelType]]:
return self.restore_by([Filter(field='id', op='eq', value=entity_id)])

def restore_by(
self,
filters: Iterable[Filters] = (),
) -> TypedReturnsRows[tuple[ModelType]]:
query = update(self.model).values({'deleted_at': None})
for predicate in self._get_query_predicates(filters):
Comment on lines +340 to +345
query = query.where(predicate)
return query.returning(self.model)
23 changes: 23 additions & 0 deletions src/notora/v1/services/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,3 +443,26 @@ async def soft_delete_all_raw_by(
query = self.repo.soft_delete_by(filters)
result = await db_session.scalars(query)
return result

async def restore(self, db_session: AsyncSession, entity_id: PKType) -> ModelClass:
query = self.repo.restore(entity_id)
result = await self.execute_for_one(db_session, query)
return result

async def restore_one_by(
self,
db_session: AsyncSession,
filters: Iterable[Filters],
) -> ModelClass:
query = self.repo.restore_by(filters)
result = await self.execute_for_one(db_session, query)
return result

async def restore_all_raw_by(
self,
db_session: AsyncSession,
filters: Iterable[Filters],
) -> Iterable[ModelClass]:
query = self.repo.restore_by(filters)
result = await db_session.scalars(query)
return result
16 changes: 16 additions & 0 deletions src/notora/v2/repositories/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,19 @@ def soft_delete_by(
options: Iterable[OptionSpec[ModelType]] | None = None,
additional_payload: dict[str, Any] | None = None,
) -> TypedReturnsRows[tuple[ModelType]]: ...

def restore(
self,
pk: PKType,
*,
options: Iterable[OptionSpec[ModelType]] | None = None,
additional_payload: dict[str, Any] | None = None,
) -> TypedReturnsRows[tuple[ModelType]]: ...

def restore_by(
self,
*,
filters: Iterable[FilterSpec[ModelType]] | None = None,
options: Iterable[OptionSpec[ModelType]] | None = None,
additional_payload: dict[str, Any] | None = None,
) -> TypedReturnsRows[tuple[ModelType]]: ...
29 changes: 29 additions & 0 deletions src/notora/v2/repositories/mixins/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,32 @@ def soft_delete(
options=options,
additional_payload=additional_payload,
)

def restore_by(
self,
*,
filters: Iterable[FilterSpec[ModelType]] | None = None,
options: Iterable[OptionSpec[ModelType]] | None = None,
additional_payload: dict[str, Any] | None = None,
) -> TypedReturnsRows[tuple[ModelType]]:
payload: dict[str, Any] = {self.deleted_attribute: None}
if additional_payload:
payload.update(additional_payload)
stmt = update(self.model).values(**payload)
stmt = self.apply_filters(stmt, filters, apply_default_filters=False)
stmt = stmt.returning(self.model)
return self.apply_options(stmt, options)
Comment on lines +188 to +192

def restore(
self,
pk: PKType,
*,
options: Iterable[OptionSpec[ModelType]] | None = None,
additional_payload: dict[str, Any] | None = None,
) -> TypedReturnsRows[tuple[ModelType]]:
filters = (cast(FilterClause, self.pk_column == pk),)
return self.restore_by(
filters=filters,
options=options,
additional_payload=additional_payload,
)
51 changes: 51 additions & 0 deletions src/notora/v2/services/mixins/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,54 @@ async def soft_delete_by(
) -> list[ListSchema]:
entities = await self.soft_delete_by_raw(session, filters, actor_id=actor_id)
return self.serialize_many(entities, schema=schema)

async def restore_raw(
self,
session: AsyncSession,
pk: PKType,
*,
actor_id: Any | None = None,
) -> ModelType:
additional_payload = self._apply_updated_by({}, actor_id) or None
return await self.execute_for_one(
session,
self.repo.restore(pk, additional_payload=additional_payload),
)

async def restore(
self,
session: AsyncSession,
pk: PKType,
*,
actor_id: Any | None = None,
schema: type[DetailSchema] | None = None,
) -> DetailSchema:
entity = await self.restore_raw(session, pk, actor_id=actor_id)
return self.serialize_one(entity, schema=schema)

async def restore_by_raw(
self,
session: AsyncSession,
filters: Iterable[FilterSpec[ModelType]],
*,
actor_id: Any | None = None,
) -> list[ModelType]:
additional_payload = self._apply_updated_by({}, actor_id) or None
return await self.execute_for_many(
session,
self.repo.restore_by(
filters=filters,
additional_payload=additional_payload,
),
)

async def restore_by(
self,
session: AsyncSession,
filters: Iterable[FilterSpec[ModelType]],
*,
actor_id: Any | None = None,
schema: type[ListSchema] | None = None,
) -> list[ListSchema]:
entities = await self.restore_by_raw(session, filters, actor_id=actor_id)
return self.serialize_many(entities, schema=schema)
50 changes: 50 additions & 0 deletions src/notora/v2/services/mixins/delete.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,53 @@ class SoftDeleteServiceMixin[
actor_id: Any | None = None,
schema: type[SchemaT],
) -> list[SchemaT]: ...
async def restore_raw(
self,
session: AsyncSession,
pk: PKType,
*,
actor_id: Any | None = None,
) -> ModelType: ...
@overload
async def restore(
self,
session: AsyncSession,
pk: PKType,
*,
actor_id: Any | None = None,
schema: None = ...,
) -> DetailSchema: ...
@overload
async def restore[SchemaT: BaseResponseSchema](
self,
session: AsyncSession,
pk: PKType,
*,
actor_id: Any | None = None,
schema: type[SchemaT],
) -> SchemaT: ...
async def restore_by_raw(
self,
session: AsyncSession,
filters: Iterable[FilterSpec[ModelType]],
*,
actor_id: Any | None = None,
) -> list[ModelType]: ...
@overload
async def restore_by(
self,
session: AsyncSession,
filters: Iterable[FilterSpec[ModelType]],
*,
actor_id: Any | None = None,
schema: None = ...,
) -> list[ListSchema]: ...
@overload
async def restore_by[SchemaT: BaseResponseSchema](
self,
session: AsyncSession,
filters: Iterable[FilterSpec[ModelType]],
*,
actor_id: Any | None = None,
schema: type[SchemaT],
) -> list[SchemaT]: ...
47 changes: 47 additions & 0 deletions tests/v1/test_integration/test_base_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,50 @@ async def test_soft_delete_is_not_hard(db_session: AsyncSession, mock_repo: Mock
res = (await db_session.scalars(select(mock_repo.model))).all()
assert len(res) == objs_count
assert obj_to_delete in res


async def test_restore(db_session: AsyncSession, mock_repo: MockRepo) -> None:
objs = await _setup(db_session, 1)
obj_to_restore = objs[0]

query = mock_repo.soft_delete(obj_to_restore.id)
await db_session.execute(query)
await db_session.commit()

refreshed = await db_session.get(MockModel, obj_to_restore.id)
assert refreshed
assert refreshed.deleted_at is not None

query = mock_repo.restore(obj_to_restore.id)
await db_session.execute(query)
await db_session.commit()

refreshed = await db_session.get(MockModel, obj_to_restore.id)
assert refreshed
assert refreshed.deleted_at is None

query = mock_repo.select()
res = (await db_session.scalars(query)).all()
assert len(res) == 1
assert res[0].id == obj_to_restore.id


async def test_restore_by(db_session: AsyncSession, mock_repo: MockRepo) -> None:
objs = await _setup(db_session, 3)
ids_to_restore = [obj.id for obj in objs[:2]]

query = mock_repo.soft_delete_by(filters=[Filter(field='id', op='in', value=ids_to_restore)])
await db_session.execute(query)
await db_session.commit()

query = mock_repo.restore_by(filters=[Filter(field='id', op='in', value=ids_to_restore)])
await db_session.execute(query)
await db_session.commit()

for obj_id in ids_to_restore:
obj = await _select_one(db_session, obj_id)
assert obj.deleted_at is None

remaining_obj = await _select_one(db_session, objs[2].id)
assert remaining_obj
assert remaining_obj.deleted_at is None
Loading
Loading