Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: soft delete #901

Merged
merged 7 commits into from
May 1, 2024
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
7 changes: 6 additions & 1 deletion beanie/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
from beanie.odm.bulk import BulkWriter
from beanie.odm.custom_types import DecimalAnnotation
from beanie.odm.custom_types.bson.binary import BsonBinary
from beanie.odm.documents import Document, MergeStrategy
from beanie.odm.documents import (
Document,
DocumentWithSoftDelete,
MergeStrategy,
)
from beanie.odm.enums import SortDirection
from beanie.odm.fields import (
BackLink,
Expand All @@ -37,6 +41,7 @@
__all__ = [
# ODM
"Document",
"DocumentWithSoftDelete",
"View",
"UnionDoc",
"init_beanie",
Expand Down
138 changes: 138 additions & 0 deletions beanie/odm/documents.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import asyncio
import warnings
from datetime import datetime
from enum import Enum
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Dict,
Iterable,
List,
Mapping,
Optional,
Tuple,
Type,
TypeVar,
Union,
Expand Down Expand Up @@ -48,6 +51,7 @@
)
from beanie.odm.bulk import BulkWriter, Operation
from beanie.odm.cache import LRUCache
from beanie.odm.enums import SortDirection
from beanie.odm.fields import (
BackLink,
DeleteRules,
Expand Down Expand Up @@ -79,6 +83,7 @@
from beanie.odm.operators.update.general import (
Set as SetOperator,
)
from beanie.odm.queries.find import FindMany, FindOne
from beanie.odm.queries.update import UpdateMany, UpdateResponse
from beanie.odm.settings.document import DocumentSettings
from beanie.odm.utils.dump import get_dict, get_top_level_nones
Expand All @@ -103,6 +108,10 @@
if IS_PYDANTIC_V2:
from pydantic import model_validator

if TYPE_CHECKING:
from beanie.odm.views import View

FindType = TypeVar("FindType", bound=Union["Document", "View"])
DocType = TypeVar("DocType", bound="Document")
DocumentProjectionType = TypeVar("DocumentProjectionType", bound=BaseModel)

Expand Down Expand Up @@ -1187,3 +1196,132 @@ async def distinct(
def link_from_id(cls, id: Any):
ref = DBRef(id=id, collection=cls.get_collection_name())
return Link(ref, document_class=cls)


class DocumentWithSoftDelete(Document):
deleted_at: Optional[datetime] = None

def is_deleted(self) -> bool:
return self.deleted_at is not None

async def hard_delete(
self,
session: Optional[ClientSession] = None,
bulk_writer: Optional[BulkWriter] = None,
link_rule: DeleteRules = DeleteRules.DO_NOTHING,
skip_actions: Optional[List[Union[ActionDirections, str]]] = None,
**pymongo_kwargs,
) -> Optional[DeleteResult]:
return await super().delete(
session=session,
bulk_writer=bulk_writer,
link_rule=link_rule,
skip_actions=skip_actions,
**pymongo_kwargs,
)

async def delete(
self,
session: Optional[ClientSession] = None,
bulk_writer: Optional[BulkWriter] = None,
link_rule: DeleteRules = DeleteRules.DO_NOTHING,
skip_actions: Optional[List[Union[ActionDirections, str]]] = None,
**pymongo_kwargs,
) -> Optional[DeleteResult]:
self.deleted_at = datetime.utcnow()
await self.save()
return None

@classmethod
def find_many_in_all( # type: ignore
cls: Type[FindType],
*args: Union[Mapping[str, Any], bool],
projection_model: None = None,
skip: Optional[int] = None,
limit: Optional[int] = None,
sort: Union[None, str, List[Tuple[str, SortDirection]]] = None,
session: Optional[ClientSession] = None,
ignore_cache: bool = False,
fetch_links: bool = False,
with_children: bool = False,
lazy_parse: bool = False,
nesting_depth: Optional[int] = None,
nesting_depths_per_field: Optional[Dict[str, int]] = None,
**pymongo_kwargs,
) -> Union[FindMany[FindType], FindMany["DocumentProjectionType"]]:
return cls._find_many_query_class(document_model=cls).find_many(
*args,
sort=sort,
skip=skip,
limit=limit,
projection_model=projection_model,
session=session,
ignore_cache=ignore_cache,
fetch_links=fetch_links,
lazy_parse=lazy_parse,
nesting_depth=nesting_depth,
nesting_depths_per_field=nesting_depths_per_field,
**pymongo_kwargs,
)

@classmethod
def find_many( # type: ignore
cls: Type[FindType],
*args: Union[Mapping[str, Any], bool],
projection_model: Optional[Type["DocumentProjectionType"]] = None,
skip: Optional[int] = None,
limit: Optional[int] = None,
sort: Union[None, str, List[Tuple[str, SortDirection]]] = None,
session: Optional[ClientSession] = None,
ignore_cache: bool = False,
fetch_links: bool = False,
with_children: bool = False,
lazy_parse: bool = False,
nesting_depth: Optional[int] = None,
nesting_depths_per_field: Optional[Dict[str, int]] = None,
**pymongo_kwargs,
) -> Union[FindMany[FindType], FindMany["DocumentProjectionType"]]:
args = cls._add_class_id_filter(args, with_children) + (
{"deleted_at": None},
)
return cls._find_many_query_class(document_model=cls).find_many(
*args,
sort=sort,
skip=skip,
limit=limit,
projection_model=projection_model,
session=session,
ignore_cache=ignore_cache,
fetch_links=fetch_links,
lazy_parse=lazy_parse,
nesting_depth=nesting_depth,
nesting_depths_per_field=nesting_depths_per_field,
**pymongo_kwargs,
)

@classmethod
def find_one( # type: ignore
cls: Type[FindType],
*args: Union[Mapping[str, Any], bool],
projection_model: Optional[Type["DocumentProjectionType"]] = None,
session: Optional[ClientSession] = None,
ignore_cache: bool = False,
fetch_links: bool = False,
with_children: bool = False,
nesting_depth: Optional[int] = None,
nesting_depths_per_field: Optional[Dict[str, int]] = None,
**pymongo_kwargs,
) -> Union[FindOne[FindType], FindOne["DocumentProjectionType"]]:
args = cls._add_class_id_filter(args, with_children) + (
{"deleted_at": None},
)
return cls._find_one_query_class(document_model=cls).find_one(
*args,
projection_model=projection_model,
session=session,
ignore_cache=ignore_cache,
fetch_links=fetch_links,
nesting_depth=nesting_depth,
nesting_depths_per_field=nesting_depths_per_field,
**pymongo_kwargs,
)
23 changes: 23 additions & 0 deletions tests/odm/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
DocumentTestModelWithIndexFlagsAliases,
DocumentTestModelWithLink,
DocumentTestModelWithSimpleIndex,
DocumentTestModelWithSoftDelete,
DocumentToBeLinked,
DocumentToTestSync,
DocumentUnion,
Expand Down Expand Up @@ -199,6 +200,7 @@ async def init(db):
DocumentWithExtras,
DocumentWithPydanticConfig,
DocumentTestModel,
DocumentTestModelWithSoftDelete,
DocumentTestModelWithLink,
DocumentTestModelWithCustomCollectionName,
DocumentTestModelWithSimpleIndex,
Expand Down Expand Up @@ -333,6 +335,27 @@ def generate_documents(
return generate_documents


@pytest.fixture
def document_soft_delete_not_inserted():
return DocumentTestModelWithSoftDelete(
test_int=randint(0, 1000000),
test_str="kipasa",
)


@pytest.fixture
def documents_soft_delete_not_inserted():
docs = []
for i in range(3):
docs.append(
DocumentTestModelWithSoftDelete(
test_int=randint(0, 1000000),
test_str="kipasa",
)
)
return docs


@pytest.fixture
async def document(document_not_inserted) -> DocumentTestModel:
return await document_not_inserted.insert()
Expand Down
110 changes: 110 additions & 0 deletions tests/odm/documents/test_soft_delete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from tests.odm.models import DocumentTestModelWithSoftDelete


async def test_get_item(document_soft_delete_not_inserted):
# insert a document with soft delete
result = await document_soft_delete_not_inserted.insert()

# get from db by id
document = await DocumentTestModelWithSoftDelete.get(document_id=result.id)

assert document.is_deleted() is False
assert document.deleted_at is None
assert document.test_int == result.test_int
assert document.test_str == result.test_str

# # delete the document
await document.delete()
assert document.is_deleted() is True

# check if document exist with `.get()`
document = await DocumentTestModelWithSoftDelete.get(document_id=result.id)
assert document is None

# check document exist in trashed
results = (
await DocumentTestModelWithSoftDelete.find_many_in_all().to_list()
)
assert len(results) == 1


async def test_find_one(document_soft_delete_not_inserted):
result = await document_soft_delete_not_inserted.insert()

# # delete the document
await result.delete()

# check if document exist with `.find_one()`
document = await DocumentTestModelWithSoftDelete.find_one(
DocumentTestModelWithSoftDelete.id == result.id
)
assert document is None


async def test_find(documents_soft_delete_not_inserted):
# insert 3 documents
inserted_docs = []
for doc in documents_soft_delete_not_inserted:
result = await doc.insert()
inserted_docs.append(result)

# use `.find_many()` to get them all
results = await DocumentTestModelWithSoftDelete.find().to_list()
assert len(results) == 3

# delete one of them
await inserted_docs[0].delete()

# check items in with `.find_many()`
results = await DocumentTestModelWithSoftDelete.find_many().to_list()

assert len(results) == 2

founded_documents_id = [doc.id for doc in results]
assert inserted_docs[0].id not in founded_documents_id

# check in trashed items
results = (
await DocumentTestModelWithSoftDelete.find_many_in_all().to_list()
)
assert len(results) == 3


async def test_find_many(documents_soft_delete_not_inserted):
# insert 2 documents
item_1 = await documents_soft_delete_not_inserted[0].insert()
item_2 = await documents_soft_delete_not_inserted[1].insert()

# use `.find_many()` to get them all
results = await DocumentTestModelWithSoftDelete.find_many().to_list()
assert len(results) == 2

# delete one of them
await item_1.delete()

# check items in with `.find_many()`
results = await DocumentTestModelWithSoftDelete.find_many().to_list()

assert len(results) == 1
assert results[0].id == item_2.id

# check in trashed items
results = (
await DocumentTestModelWithSoftDelete.find_many_in_all().to_list()
)
assert len(results) == 2


async def test_hard_delete(document_soft_delete_not_inserted):
result = await document_soft_delete_not_inserted.insert()
await result.hard_delete()

# check items in with `.find_many()`
results = await DocumentTestModelWithSoftDelete.find_many().to_list()
assert len(results) == 0

# check in trashed
results = (
await DocumentTestModelWithSoftDelete.find_many_in_all().to_list()
)
assert len(results) == 0
6 changes: 6 additions & 0 deletions tests/odm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from beanie import (
DecimalAnnotation,
Document,
DocumentWithSoftDelete,
Indexed,
Insert,
Replace,
Expand Down Expand Up @@ -140,6 +141,11 @@ class Sample(Document):
const: str = "TEST"


class DocumentTestModelWithSoftDelete(DocumentWithSoftDelete):
test_int: int
test_str: str


class SubDocument(BaseModel):
test_str: str
test_int: int = 42
Expand Down
Loading