diff --git a/docs/v2/repositories.md b/docs/v2/repositories.md index 9382a57..7d8a12a 100644 --- a/docs/v2/repositories.md +++ b/docs/v2/repositories.md @@ -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 @@ -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"], +) + +# Restore with extra payload (e.g. updated_by) +query = repo.restore( + user_id, + additional_payload={"updated_by": current_user_id}, +) +``` diff --git a/docs/v2/services.md b/docs/v2/services.md index 23df624..7183b22 100644 --- a/docs/v2/services.md +++ b/docs/v2/services.md @@ -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 @@ -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) +``` diff --git a/src/notora/v1/persistence/repos/base.py b/src/notora/v1/persistence/repos/base.py index d665e96..968485b 100644 --- a/src/notora/v1/persistence/repos/base.py +++ b/src/notora/v1/persistence/repos/base.py @@ -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): + query = query.where(predicate) + return query.returning(self.model) diff --git a/src/notora/v1/services/base.py b/src/notora/v1/services/base.py index 90b51bb..3b29860 100644 --- a/src/notora/v1/services/base.py +++ b/src/notora/v1/services/base.py @@ -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 diff --git a/src/notora/v2/repositories/base.py b/src/notora/v2/repositories/base.py index 7c33b30..778cb12 100644 --- a/src/notora/v2/repositories/base.py +++ b/src/notora/v2/repositories/base.py @@ -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]]: ... diff --git a/src/notora/v2/repositories/mixins/write.py b/src/notora/v2/repositories/mixins/write.py index da827a4..5563a48 100644 --- a/src/notora/v2/repositories/mixins/write.py +++ b/src/notora/v2/repositories/mixins/write.py @@ -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) + + 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, + ) diff --git a/src/notora/v2/services/mixins/delete.py b/src/notora/v2/services/mixins/delete.py index d07472d..bd64440 100644 --- a/src/notora/v2/services/mixins/delete.py +++ b/src/notora/v2/services/mixins/delete.py @@ -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) diff --git a/src/notora/v2/services/mixins/delete.pyi b/src/notora/v2/services/mixins/delete.pyi index 846e20d..0146ebd 100644 --- a/src/notora/v2/services/mixins/delete.pyi +++ b/src/notora/v2/services/mixins/delete.pyi @@ -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]: ... diff --git a/tests/v1/test_integration/test_base_repo.py b/tests/v1/test_integration/test_base_repo.py index 43af85c..b55e436 100644 --- a/tests/v1/test_integration/test_base_repo.py +++ b/tests/v1/test_integration/test_base_repo.py @@ -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 diff --git a/tests/v2/test_integration/test_repository_crud.py b/tests/v2/test_integration/test_repository_crud.py index 23ac62e..2114b4d 100644 --- a/tests/v2/test_integration/test_repository_crud.py +++ b/tests/v2/test_integration/test_repository_crud.py @@ -203,3 +203,84 @@ async def test_repo_soft_delete_with_additional_payload( assert refreshed is not None assert refreshed.deleted_at is not None assert refreshed.updated_by == actor_id + + +async def test_repo_restore( + db_session: AsyncSession, + user_repo: V2UserRepo, +) -> None: + user = await _create_user( + db_session, + user_repo, + email='restore@ex.com', + name='Restore', + ) + + await db_session.execute(user_repo.soft_delete(user.id)) + await db_session.commit() + + await db_session.execute(user_repo.restore(user.id)) + await db_session.commit() + + refreshed = await db_session.get(V2User, user.id) + assert refreshed is not None + assert refreshed.deleted_at is None + + listed = (await db_session.scalars(user_repo.list(limit=None))).all() + assert len(listed) == 1 + assert listed[0].id == user.id + + +async def test_repo_restore_by( + db_session: AsyncSession, + user_repo: V2UserRepo, +) -> None: + users = await _seed_users(db_session) + + await db_session.execute( + user_repo.soft_delete_by( + filters=[V2User.email.in_([users[0].email, users[1].email])] + ) + ) + await db_session.commit() + + await db_session.execute( + user_repo.restore_by( + filters=[V2User.email.in_([users[0].email, users[1].email])] + ) + ) + await db_session.commit() + + for user in users: + refreshed = await db_session.get(V2User, user.id) + assert refreshed is not None + assert refreshed.deleted_at is None + + listed = (await db_session.scalars(user_repo.list(limit=None))).all() + assert len(listed) == len(users) + + +async def test_repo_restore_with_additional_payload( + db_session: AsyncSession, + user_repo: V2UserRepo, +) -> None: + actor_id = uuid4() + user = await _create_user( + db_session, + user_repo, + email='restore-payload@ex.com', + name='RestorePayload', + ) + + await db_session.execute(user_repo.soft_delete(user.id)) + await db_session.commit() + + await db_session.execute( + user_repo.restore(user.id, additional_payload={'updated_by': actor_id}) + ) + await db_session.commit() + + refreshed = await db_session.get(V2User, user.id) + assert refreshed is not None + assert refreshed.deleted_at is None + assert refreshed.updated_by == actor_id diff --git a/tests/v2/test_integration/test_service_crud.py b/tests/v2/test_integration/test_service_crud.py index 3f4814e..307210f 100644 --- a/tests/v2/test_integration/test_service_crud.py +++ b/tests/v2/test_integration/test_service_crud.py @@ -381,7 +381,116 @@ async def test_service_soft_delete_by_returns_list( assert len(deleted) == len(payloads) assert all(isinstance(d, V2UserResponseSchema) for d in deleted) - assert {d.email for d in deleted} == {'sdel-by1@ex.com', 'sdel-by2@ex.com'} + + +async def test_service_restore( + db_session: AsyncSession, + user_service: V2UserService, +) -> None: + payload = _create_user_payload('restore@ex.com', 'Restore') + + created = await user_service.create(db_session, payload) + await db_session.commit() + + deleted = await user_service.soft_delete(db_session, created.id) + await db_session.commit() + assert isinstance(deleted, V2UserResponseSchema) + assert deleted.id == created.id + + restored = await user_service.restore(db_session, created.id) + await db_session.commit() + assert isinstance(restored, V2UserResponseSchema) + assert restored.id == created.id + + refreshed = await db_session.get(V2User, created.id) + assert refreshed is not None + assert refreshed.deleted_at is None + + +async def test_service_restore_by( + db_session: AsyncSession, + user_service: V2UserService, +) -> None: + payloads = [ + _create_user_payload('restore-by1@ex.com', 'RestoreBy1'), + _create_user_payload('restore-by2@ex.com', 'RestoreBy2'), + ] + for p in payloads: + await user_service.create(db_session, p) + await db_session.commit() + + deleted = await user_service.soft_delete_by( + db_session, + filters=[V2User.email.in_(['restore-by1@ex.com', 'restore-by2@ex.com'])], + ) + await db_session.commit() + assert len(deleted) == len(payloads) + + restored = await user_service.restore_by( + db_session, + filters=[V2User.email.in_(['restore-by1@ex.com', 'restore-by2@ex.com'])], + ) + await db_session.commit() + + assert len(restored) == len(payloads) + assert all(isinstance(r, V2UserResponseSchema) for r in restored) + assert {r.email for r in restored} == {'restore-by1@ex.com', 'restore-by2@ex.com'} + + +async def test_service_restore_with_actor_id( + db_session: AsyncSession, + user_service: V2UserService, +) -> None: + actor_id = uuid4() + payload = _create_user_payload('restore-actor@ex.com', 'RestoreActor') + + created = await user_service.create(db_session, payload) + await db_session.commit() + + await user_service.soft_delete(db_session, created.id) + await db_session.commit() + + restored = await user_service.restore(db_session, created.id, actor_id=actor_id) + await db_session.commit() + + assert isinstance(restored, V2UserResponseSchema) + assert restored.updated_by == actor_id + + refreshed = await db_session.get(V2User, created.id) + assert refreshed is not None + assert refreshed.deleted_at is None + assert refreshed.updated_by == actor_id + + +async def test_service_restore_raw( + db_session: AsyncSession, + user_service: V2UserService, +) -> None: + payload = _create_user_payload('restore-raw@ex.com', 'RestoreRaw') + + created = await user_service.create(db_session, payload) + await db_session.commit() + + await user_service.soft_delete(db_session, created.id) + await db_session.commit() + + restored = await user_service.restore_raw(db_session, created.id) + await db_session.commit() + + assert isinstance(restored, V2User) + assert restored.id == created.id + assert restored.deleted_at is None + + +async def test_service_restore_by_empty_result( + db_session: AsyncSession, + user_service: V2UserService, +) -> None: + restored = await user_service.restore_by( + db_session, + filters=[V2User.email == 'nonexistent@ex.com'], + ) + assert restored == [] async def test_service_soft_delete_by_empty_result(