diff --git a/mongoengine/__init__.py b/mongoengine/__init__.py index 08c9e9ab6..d61a844f3 100644 --- a/mongoengine/__init__.py +++ b/mongoengine/__init__.py @@ -12,7 +12,7 @@ __all__ = (document.__all__ + fields.__all__ + connection.__all__ + queryset.__all__ + signals.__all__) -VERSION = (0, 6, 24) +VERSION = (0, 6, 25) def get_version(): diff --git a/mongoengine/document.py b/mongoengine/document.py index d46d20db8..a6d043484 100644 --- a/mongoengine/document.py +++ b/mongoengine/document.py @@ -4,7 +4,7 @@ from mongoengine import signals from base import (DocumentMetaclass, TopLevelDocumentMetaclass, BaseDocument, BaseDict, BaseList) -from queryset import OperationError +from queryset import OperationError, QuerySet from connection import get_db, DEFAULT_CONNECTION_NAME __all__ = ['Document', 'EmbeddedDocument', 'DynamicDocument', @@ -286,6 +286,16 @@ def update(self, **kwargs): # Need to add shard key to query, or you get an error return self.__class__.objects(**self._object_key).update_one(**kwargs) + + @property + def _qs(self): + """ + Returns the queryset to use for updating / reloading / deletions + """ + if not hasattr(self, '__objects'): + self.__objects = QuerySet(self, self._get_collection()) + return self.__objects + def delete(self, w=1): """Delete the :class:`~mongoengine.Document` from the database. This will only take effect if the document has been previously saved. @@ -293,7 +303,8 @@ def delete(self, w=1): signals.pre_delete.send(self.__class__, document=self) try: - self.__class__.objects(**self._object_key).delete(w=w) + self._qs.filter(**self._object_key).delete( + w=w, _from_doc_delete=True) except pymongo.errors.OperationFailure, err: message = u'Could not delete document (%s)' % err.message raise OperationError(message) diff --git a/mongoengine/queryset.py b/mongoengine/queryset.py index 5338bb845..39009f9bb 100644 --- a/mongoengine/queryset.py +++ b/mongoengine/queryset.py @@ -367,7 +367,7 @@ def clone(self): for prop in copy_props: val = getattr(self, prop) - setattr(c, prop, copy.deepcopy(val)) + setattr(c, prop, copy.copy(val)) return c @@ -1216,16 +1216,27 @@ def read_preference(self, read_preference): self._read_preference = read_preference return self - def delete(self, w=1): + def delete(self, w=1, _from_doc_delete=False): """Delete the documents matched by the query. """ - doc = self._document + queryset = self.clone() + + # This is taken from actual MongoEngine, url + # https://github.com/MongoEngine/mongoengine/pull/105 + has_delete_signal = ( + signals.pre_delete.has_receivers_for(self._document) or + signals.post_delete.has_receivers_for(self._document)) + + if has_delete_signal and not _from_doc_delete: + for d in queryset: + d.delete() + return # Check for DENY rules before actually deleting/nullifying any other # references - for rule_entry in doc._meta['delete_rules']: + for rule_entry in queryset._document._meta['delete_rules']: document_cls, field_name = rule_entry - rule = doc._meta['delete_rules'][rule_entry] + rule = queryset._document._meta['delete_rules'][rule_entry] if rule == DENY \ and document_cls.objects(**{field_name + '__in': self}).count() > 0: # noqa msg = \ @@ -1233,15 +1244,15 @@ def delete(self, w=1): % (document_cls.__name__, field_name) raise OperationError(msg) - for rule_entry in doc._meta['delete_rules']: + for rule_entry in queryset._document._meta['delete_rules']: document_cls, field_name = rule_entry - rule = doc._meta['delete_rules'][rule_entry] + rule = queryset._document._meta['delete_rules'][rule_entry] if rule == CASCADE: document_cls.objects(**{field_name + '__in': self}).delete(w=w) elif rule == NULLIFY: document_cls.objects(**{field_name + '__in': self}).update( - w=w, - **{'unset__%s' % field_name: 1}) + w=w, + **{'unset__%s' % field_name: 1}) self._collection.remove(self._query, w=w) diff --git a/tests/document.py b/tests/document.py index dc8f5df71..2077fe4ae 100644 --- a/tests/document.py +++ b/tests/document.py @@ -19,6 +19,7 @@ from mongoengine.base import NotRegistered, InvalidDocumentError from mongoengine.queryset import InvalidQueryError from mongoengine.connection import get_db, register_db +from mongoengine import signals class DocumentTest(unittest.TestCase): @@ -2023,6 +2024,193 @@ class BlogPost(Document): author.delete() self.assertEqual(len(BlogPost.objects), 0) + def test_shallow_cascade_triggers_pre_delete_signal(self): + class Editor(self.Person): + review_queue = IntField(default=0) + + class BlogPost(Document): + content = StringField() + author = ReferenceField(self.Person, reverse_delete_rule=CASCADE) + editor = ReferenceField(Editor) + + @classmethod + def pre_delete(cls, sender, document, **kwargs): + document.editor.update(dec__review_queue=1) + + signals.pre_delete.connect(BlogPost.pre_delete, sender=BlogPost) + + self.Person.drop_collection() + BlogPost.drop_collection() + Editor.drop_collection() + + author = self.Person(name='Will S.') + author.save() + editor = Editor(name='Max P.', review_queue=1) + editor.save() + BlogPost(content='wrote some books', author=author, + editor=editor).save() + + author.delete() + self.assertEqual(BlogPost.objects.all().count(), 0) + editor = Editor.objects(name='Max P.').get() + self.assertEqual(editor.review_queue, 0) + + def test_shallow_cascade_triggers_post_delete_signal(self): + + class Editor(self.Person): + review_queue = IntField(default=0) + + class BlogPost(Document): + content = StringField() + author = ReferenceField(self.Person, reverse_delete_rule=CASCADE) + editor = ReferenceField(Editor) + + @classmethod + def post_delete(cls, sender, document, **kwargs): + document.editor.update(dec__review_queue=1) + + signals.post_delete.connect(BlogPost.post_delete, sender=BlogPost) + + self.Person.drop_collection() + BlogPost.drop_collection() + Editor.drop_collection() + + author = self.Person(name='Will S.') + author.save() + editor = Editor(name='Max P.', review_queue=1) + editor.save() + BlogPost(content='wrote some books', author=author, + editor=editor).save() + + author.delete() + self.assertEqual(BlogPost.objects.all().count(), 0) + editor = Editor.objects(name='Max P.').get() + self.assertEqual(editor.review_queue, 0) + + def test_mid_cascade_signal_triggered_on_deep_delete(self): + class Editor(self.Person): + review_queue = IntField(default=0) + + class BlogPost(Document): + content = StringField() + author = ReferenceField(self.Person, reverse_delete_rule=CASCADE) + editor = ReferenceField(Editor) + + @classmethod + def post_delete(cls, sender, document, **kwargs): + # decrement the docs-to-review count + document.editor.update(dec__review_queue=1) + + class Comment(Document): + blogpost = ReferenceField(BlogPost, reverse_delete_rule=CASCADE) + + signals.post_delete.connect(BlogPost.post_delete, sender=BlogPost) + + self.Person.drop_collection() + BlogPost.drop_collection() + Editor.drop_collection() + + author = self.Person(name='Will S.') + author.save() + editor = Editor(name='Max P.', review_queue=1) + editor.save() + BlogPost(content='wrote some books', author=author, + editor=editor).save() + + author.delete() + self.assertEqual(BlogPost.objects.all().count(), 0) + editor = Editor.objects(name='Max P.').get() + self.assertEqual(editor.review_queue, 0) + + def test_delete_hooks_trigger_through_deep_cascades(self): + + class Editor(self.Person): + comments_to_review = IntField(default=0) + review_queue = IntField(default=0) + + class BlogPost(Document): + content = StringField() + author = ReferenceField(self.Person, reverse_delete_rule=CASCADE) + editor = ReferenceField(Editor) + + @classmethod + def pre_delete(cls, sender, document, **kwargs): + document.editor.update(dec__review_queue=1) + + class Comment(Document): + blogpost = ReferenceField(BlogPost, reverse_delete_rule=CASCADE) + + @classmethod + def pre_delete(cls, sender, document, **kwargs): + document.blogpost.editor.update(dec__comments_to_review=1) + + signals.pre_delete.connect(BlogPost.pre_delete, sender=BlogPost) + signals.pre_delete.connect(Comment.pre_delete, sender=Comment) + + self.Person.drop_collection() + BlogPost.drop_collection() + Editor.drop_collection() + + author = self.Person(name='Will S.') + author.save() + editor = Editor(name='Max P.', review_queue=1, comments_to_review=1) + editor.save() + blogpost = BlogPost(content='wrote some books', author=author, + editor=editor) + blogpost.save() + Comment(blogpost=blogpost).save() + + author.delete() + + self.assertEqual(BlogPost.objects.all().count(), 0) + self.assertEqual(Comment.objects.all().count(), 0) + editor = Editor.objects(name='Max P.').get() + self.assertEqual(editor.review_queue, 0) + self.assertEqual(editor.comments_to_review, 0) + + def test_leaf_delete_hooks_trigger_with_no_parent_trigger(self): + + class Editor(self.Person): + comments_to_review = IntField(default=0) + + class BlogPost(Document): + content = StringField() + author = ReferenceField(self.Person, reverse_delete_rule=CASCADE) + editor = ReferenceField(Editor) + + class Comment(Document): + blogpost = ReferenceField(BlogPost, reverse_delete_rule=CASCADE) + + @classmethod + def pre_delete(cls, sender, document, **kwargs): + # decrement the comments-to-review count + document.blogpost.editor.update(dec__comments_to_review=1) + + signals.pre_delete.connect(Comment.pre_delete, sender=Comment) + + self.Person.drop_collection() + BlogPost.drop_collection() + Editor.drop_collection() + + author = self.Person(name='Will S.') + author.save() + editor = Editor(name='Max P.', comments_to_review=1) + editor.save() + blogpost = BlogPost(content='wrote some books', author=author, + editor=editor) + blogpost.save() + + Comment(blogpost=blogpost).save() + + # delete the author, the post is also deleted due to the CASCADE rule + author.delete() + + self.assertEqual(BlogPost.objects.all().count(), 0) + self.assertEqual(Comment.objects.all().count(), 0) + # the pre-delete signal should have decremented the editor's queue + editor = Editor.objects(name='Max P.').get() + self.assertEqual(editor.comments_to_review, 0) + def test_invalid_reverse_delete_rules_raise_errors(self): def throw_invalid_document_error():