Merge remote branch 'nvie/dev' into dev
This commit is contained in:
commit
ce8b3ea0a1
@ -193,6 +193,59 @@ as the constructor's argument::
|
|||||||
class ProfilePage(Document):
|
class ProfilePage(Document):
|
||||||
content = StringField()
|
content = StringField()
|
||||||
|
|
||||||
|
|
||||||
|
Dealing with deletion of referred documents
|
||||||
|
'''''''''''''''''''''''''''''''''''''''''''
|
||||||
|
By default, MongoDB doesn't check the integrity of your data, so deleting
|
||||||
|
documents that other documents still hold references to will lead to consistency
|
||||||
|
issues. Mongoengine's :class:`ReferenceField` adds some functionality to
|
||||||
|
safeguard against these kinds of database integrity problems, providing each
|
||||||
|
reference with a delete rule specification. A delete rule is specified by
|
||||||
|
supplying the :attr:`reverse_delete_rule` attributes on the
|
||||||
|
:class:`ReferenceField` definition, like this::
|
||||||
|
|
||||||
|
class Employee(Document):
|
||||||
|
...
|
||||||
|
profile_page = ReferenceField('ProfilePage', reverse_delete_rule=mongoengine.NULLIFY)
|
||||||
|
|
||||||
|
The declaration in this example means that when an :class:`Employee` object is
|
||||||
|
removed, the :class:`ProfilePage` that belongs to that employee is removed as
|
||||||
|
well. If a whole batch of employees is removed, all profile pages that are
|
||||||
|
linked are removed as well.
|
||||||
|
|
||||||
|
Its value can take any of the following constants:
|
||||||
|
|
||||||
|
:const:`mongoengine.DO_NOTHING`
|
||||||
|
This is the default and won't do anything. Deletes are fast, but may cause
|
||||||
|
database inconsistency or dangling references.
|
||||||
|
:const:`mongoengine.DENY`
|
||||||
|
Deletion is denied if there still exist references to the object being
|
||||||
|
deleted.
|
||||||
|
:const:`mongoengine.NULLIFY`
|
||||||
|
Any object's fields still referring to the object being deleted are removed
|
||||||
|
(using MongoDB's "unset" operation), effectively nullifying the relationship.
|
||||||
|
:const:`mongoengine.CASCADE`
|
||||||
|
Any object containing fields that are refererring to the object being deleted
|
||||||
|
are deleted first.
|
||||||
|
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
A safety note on setting up these delete rules! Since the delete rules are
|
||||||
|
not recorded on the database level by MongoDB itself, but instead at runtime,
|
||||||
|
in-memory, by the MongoEngine module, it is of the upmost importance
|
||||||
|
that the module that declares the relationship is loaded **BEFORE** the
|
||||||
|
delete is invoked.
|
||||||
|
|
||||||
|
If, for example, the :class:`Employee` object lives in the
|
||||||
|
:mod:`payroll` app, and the :class:`ProfilePage` in the :mod:`people`
|
||||||
|
app, it is extremely important that the :mod:`people` app is loaded
|
||||||
|
before any employee is removed, because otherwise, MongoEngine could
|
||||||
|
never know this relationship exists.
|
||||||
|
|
||||||
|
In Django, be sure to put all apps that have such delete rule declarations in
|
||||||
|
their :file:`models.py` in the :const:`INSTALLED_APPS` tuple.
|
||||||
|
|
||||||
|
|
||||||
Generic reference fields
|
Generic reference fields
|
||||||
''''''''''''''''''''''''
|
''''''''''''''''''''''''
|
||||||
A second kind of reference field also exists,
|
A second kind of reference field also exists,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from queryset import QuerySet, QuerySetManager
|
from queryset import QuerySet, QuerySetManager
|
||||||
from queryset import DoesNotExist, MultipleObjectsReturned
|
from queryset import DoesNotExist, MultipleObjectsReturned
|
||||||
|
from queryset import DO_NOTHING
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import pymongo
|
import pymongo
|
||||||
@ -203,6 +204,10 @@ class DocumentMetaclass(type):
|
|||||||
new_class = super_new(cls, name, bases, attrs)
|
new_class = super_new(cls, name, bases, attrs)
|
||||||
for field in new_class._fields.values():
|
for field in new_class._fields.values():
|
||||||
field.owner_document = new_class
|
field.owner_document = new_class
|
||||||
|
delete_rule = getattr(field, 'reverse_delete_rule', DO_NOTHING)
|
||||||
|
if delete_rule != DO_NOTHING:
|
||||||
|
field.document_type.register_delete_rule(new_class, field.name,
|
||||||
|
delete_rule)
|
||||||
|
|
||||||
module = attrs.get('__module__')
|
module = attrs.get('__module__')
|
||||||
|
|
||||||
@ -271,6 +276,7 @@ class TopLevelDocumentMetaclass(DocumentMetaclass):
|
|||||||
'index_drop_dups': False,
|
'index_drop_dups': False,
|
||||||
'index_opts': {},
|
'index_opts': {},
|
||||||
'queryset_class': QuerySet,
|
'queryset_class': QuerySet,
|
||||||
|
'delete_rules': {},
|
||||||
}
|
}
|
||||||
meta.update(base_meta)
|
meta.update(base_meta)
|
||||||
|
|
||||||
@ -506,6 +512,7 @@ if sys.version_info < (2, 5):
|
|||||||
# Prior to Python 2.5, Exception was an old-style class
|
# Prior to Python 2.5, Exception was an old-style class
|
||||||
import types
|
import types
|
||||||
def subclass_exception(name, parents, unused):
|
def subclass_exception(name, parents, unused):
|
||||||
|
import types
|
||||||
return types.ClassType(name, parents, {})
|
return types.ClassType(name, parents, {})
|
||||||
else:
|
else:
|
||||||
def subclass_exception(name, parents, module):
|
def subclass_exception(name, parents, module):
|
||||||
|
@ -100,6 +100,14 @@ class Document(BaseDocument):
|
|||||||
message = u'Could not delete document (%s)' % err.message
|
message = u'Could not delete document (%s)' % err.message
|
||||||
raise OperationError(message)
|
raise OperationError(message)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register_delete_rule(cls, document_cls, field_name, rule):
|
||||||
|
"""This method registers the delete rules to apply when removing this
|
||||||
|
object.
|
||||||
|
"""
|
||||||
|
cls._meta['delete_rules'][(document_cls, field_name)] = rule
|
||||||
|
|
||||||
|
|
||||||
def reload(self):
|
def reload(self):
|
||||||
"""Reloads all attributes from the database.
|
"""Reloads all attributes from the database.
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from base import BaseField, ObjectIdField, ValidationError, get_document
|
from base import BaseField, ObjectIdField, ValidationError, get_document
|
||||||
|
from queryset import DO_NOTHING
|
||||||
from document import Document, EmbeddedDocument
|
from document import Document, EmbeddedDocument
|
||||||
from connection import _get_db
|
from connection import _get_db
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
@ -455,12 +456,13 @@ class ReferenceField(BaseField):
|
|||||||
access (lazily).
|
access (lazily).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, document_type, **kwargs):
|
def __init__(self, document_type, reverse_delete_rule=DO_NOTHING, **kwargs):
|
||||||
if not isinstance(document_type, basestring):
|
if not isinstance(document_type, basestring):
|
||||||
if not issubclass(document_type, (Document, basestring)):
|
if not issubclass(document_type, (Document, basestring)):
|
||||||
raise ValidationError('Argument to ReferenceField constructor '
|
raise ValidationError('Argument to ReferenceField constructor '
|
||||||
'must be a document class or a string')
|
'must be a document class or a string')
|
||||||
self.document_type_obj = document_type
|
self.document_type_obj = document_type
|
||||||
|
self.reverse_delete_rule = reverse_delete_rule
|
||||||
super(ReferenceField, self).__init__(**kwargs)
|
super(ReferenceField, self).__init__(**kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -10,11 +10,18 @@ import copy
|
|||||||
import itertools
|
import itertools
|
||||||
|
|
||||||
__all__ = ['queryset_manager', 'Q', 'InvalidQueryError',
|
__all__ = ['queryset_manager', 'Q', 'InvalidQueryError',
|
||||||
'InvalidCollectionError']
|
'InvalidCollectionError', 'DO_NOTHING', 'NULLIFY', 'CASCADE', 'DENY']
|
||||||
|
|
||||||
|
|
||||||
# The maximum number of items to display in a QuerySet.__repr__
|
# The maximum number of items to display in a QuerySet.__repr__
|
||||||
REPR_OUTPUT_SIZE = 20
|
REPR_OUTPUT_SIZE = 20
|
||||||
|
|
||||||
|
# Delete rules
|
||||||
|
DO_NOTHING = 0
|
||||||
|
NULLIFY = 1
|
||||||
|
CASCADE = 2
|
||||||
|
DENY = 3
|
||||||
|
|
||||||
|
|
||||||
class DoesNotExist(Exception):
|
class DoesNotExist(Exception):
|
||||||
pass
|
pass
|
||||||
@ -947,6 +954,28 @@ class QuerySet(object):
|
|||||||
|
|
||||||
:param safe: check if the operation succeeded before returning
|
:param safe: check if the operation succeeded before returning
|
||||||
"""
|
"""
|
||||||
|
doc = self._document
|
||||||
|
|
||||||
|
# Check for DENY rules before actually deleting/nullifying any other
|
||||||
|
# references
|
||||||
|
for rule_entry in doc._meta['delete_rules']:
|
||||||
|
document_cls, field_name = rule_entry
|
||||||
|
rule = doc._meta['delete_rules'][rule_entry]
|
||||||
|
if rule == DENY and document_cls.objects(**{field_name + '__in': self}).count() > 0:
|
||||||
|
msg = u'Could not delete document (at least %s.%s refers to it)' % \
|
||||||
|
(document_cls.__name__, field_name)
|
||||||
|
raise OperationError(msg)
|
||||||
|
|
||||||
|
for rule_entry in doc._meta['delete_rules']:
|
||||||
|
document_cls, field_name = rule_entry
|
||||||
|
rule = doc._meta['delete_rules'][rule_entry]
|
||||||
|
if rule == CASCADE:
|
||||||
|
document_cls.objects(**{field_name + '__in': self}).delete(safe=safe)
|
||||||
|
elif rule == NULLIFY:
|
||||||
|
document_cls.objects(**{field_name + '__in': self}).update(
|
||||||
|
safe_update=safe,
|
||||||
|
**{'unset__%s' % field_name: 1})
|
||||||
|
|
||||||
self._collection.remove(self._query, safe=safe)
|
self._collection.remove(self._query, safe=safe)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -502,7 +502,7 @@ class DocumentTest(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
recipient.save(validate=False)
|
recipient.save(validate=False)
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
fail()
|
self.fail()
|
||||||
|
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
"""Ensure that document may be deleted using the delete method.
|
"""Ensure that document may be deleted using the delete method.
|
||||||
@ -624,6 +624,108 @@ class DocumentTest(unittest.TestCase):
|
|||||||
|
|
||||||
BlogPost.drop_collection()
|
BlogPost.drop_collection()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reverse_delete_rule_cascade_and_nullify(self):
|
||||||
|
"""Ensure that a referenced document is also deleted upon deletion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class BlogPost(Document):
|
||||||
|
content = StringField()
|
||||||
|
author = ReferenceField(self.Person, reverse_delete_rule=CASCADE)
|
||||||
|
reviewer = ReferenceField(self.Person, reverse_delete_rule=NULLIFY)
|
||||||
|
|
||||||
|
self.Person.drop_collection()
|
||||||
|
BlogPost.drop_collection()
|
||||||
|
|
||||||
|
author = self.Person(name='Test User')
|
||||||
|
author.save()
|
||||||
|
|
||||||
|
reviewer = self.Person(name='Re Viewer')
|
||||||
|
reviewer.save()
|
||||||
|
|
||||||
|
post = BlogPost(content = 'Watched some TV')
|
||||||
|
post.author = author
|
||||||
|
post.reviewer = reviewer
|
||||||
|
post.save()
|
||||||
|
|
||||||
|
reviewer.delete()
|
||||||
|
self.assertEqual(len(BlogPost.objects), 1) # No effect on the BlogPost
|
||||||
|
self.assertEqual(BlogPost.objects.get().reviewer, None)
|
||||||
|
|
||||||
|
# Delete the Person, which should lead to deletion of the BlogPost, too
|
||||||
|
author.delete()
|
||||||
|
self.assertEqual(len(BlogPost.objects), 0)
|
||||||
|
|
||||||
|
def test_reverse_delete_rule_cascade_recurs(self):
|
||||||
|
"""Ensure that a chain of documents is also deleted upon cascaded
|
||||||
|
deletion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class BlogPost(Document):
|
||||||
|
content = StringField()
|
||||||
|
author = ReferenceField(self.Person, reverse_delete_rule=CASCADE)
|
||||||
|
|
||||||
|
class Comment(Document):
|
||||||
|
text = StringField()
|
||||||
|
post = ReferenceField(BlogPost, reverse_delete_rule=CASCADE)
|
||||||
|
|
||||||
|
|
||||||
|
author = self.Person(name='Test User')
|
||||||
|
author.save()
|
||||||
|
|
||||||
|
post = BlogPost(content = 'Watched some TV')
|
||||||
|
post.author = author
|
||||||
|
post.save()
|
||||||
|
|
||||||
|
comment = Comment(text = 'Kudos.')
|
||||||
|
comment.post = post
|
||||||
|
comment.save()
|
||||||
|
|
||||||
|
# Delete the Person, which should lead to deletion of the BlogPost, and,
|
||||||
|
# recursively to the Comment, too
|
||||||
|
author.delete()
|
||||||
|
self.assertEqual(len(Comment.objects), 0)
|
||||||
|
|
||||||
|
self.Person.drop_collection()
|
||||||
|
BlogPost.drop_collection()
|
||||||
|
Comment.drop_collection()
|
||||||
|
|
||||||
|
def test_reverse_delete_rule_deny(self):
|
||||||
|
"""Ensure that a document cannot be referenced if there are still
|
||||||
|
documents referring to it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class BlogPost(Document):
|
||||||
|
content = StringField()
|
||||||
|
author = ReferenceField(self.Person, reverse_delete_rule=DENY)
|
||||||
|
|
||||||
|
self.Person.drop_collection()
|
||||||
|
BlogPost.drop_collection()
|
||||||
|
|
||||||
|
author = self.Person(name='Test User')
|
||||||
|
author.save()
|
||||||
|
|
||||||
|
post = BlogPost(content = 'Watched some TV')
|
||||||
|
post.author = author
|
||||||
|
post.save()
|
||||||
|
|
||||||
|
# Delete the Person should be denied
|
||||||
|
self.assertRaises(OperationError, author.delete) # Should raise denied error
|
||||||
|
self.assertEqual(len(BlogPost.objects), 1) # No objects may have been deleted
|
||||||
|
self.assertEqual(len(self.Person.objects), 1)
|
||||||
|
|
||||||
|
# Other users, that don't have BlogPosts must be removable, like normal
|
||||||
|
author = self.Person(name='Another User')
|
||||||
|
author.save()
|
||||||
|
|
||||||
|
self.assertEqual(len(self.Person.objects), 2)
|
||||||
|
author.delete()
|
||||||
|
self.assertEqual(len(self.Person.objects), 1)
|
||||||
|
|
||||||
|
self.Person.drop_collection()
|
||||||
|
BlogPost.drop_collection()
|
||||||
|
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.Person.drop_collection()
|
self.Person.drop_collection()
|
||||||
|
|
||||||
|
@ -351,8 +351,6 @@ class QuerySetTest(unittest.TestCase):
|
|||||||
def test_filter_chaining(self):
|
def test_filter_chaining(self):
|
||||||
"""Ensure filters can be chained together.
|
"""Ensure filters can be chained together.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class BlogPost(Document):
|
class BlogPost(Document):
|
||||||
title = StringField()
|
title = StringField()
|
||||||
is_published = BooleanField()
|
is_published = BooleanField()
|
||||||
@ -848,6 +846,71 @@ class QuerySetTest(unittest.TestCase):
|
|||||||
self.Person.objects.delete()
|
self.Person.objects.delete()
|
||||||
self.assertEqual(len(self.Person.objects), 0)
|
self.assertEqual(len(self.Person.objects), 0)
|
||||||
|
|
||||||
|
def test_reverse_delete_rule_cascade(self):
|
||||||
|
"""Ensure cascading deletion of referring documents from the database.
|
||||||
|
"""
|
||||||
|
class BlogPost(Document):
|
||||||
|
content = StringField()
|
||||||
|
author = ReferenceField(self.Person, reverse_delete_rule=CASCADE)
|
||||||
|
BlogPost.drop_collection()
|
||||||
|
|
||||||
|
me = self.Person(name='Test User')
|
||||||
|
me.save()
|
||||||
|
someoneelse = self.Person(name='Some-one Else')
|
||||||
|
someoneelse.save()
|
||||||
|
|
||||||
|
BlogPost(content='Watching TV', author=me).save()
|
||||||
|
BlogPost(content='Chilling out', author=me).save()
|
||||||
|
BlogPost(content='Pro Testing', author=someoneelse).save()
|
||||||
|
|
||||||
|
self.assertEqual(3, BlogPost.objects.count())
|
||||||
|
self.Person.objects(name='Test User').delete()
|
||||||
|
self.assertEqual(1, BlogPost.objects.count())
|
||||||
|
|
||||||
|
def test_reverse_delete_rule_nullify(self):
|
||||||
|
"""Ensure nullification of references to deleted documents.
|
||||||
|
"""
|
||||||
|
class Category(Document):
|
||||||
|
name = StringField()
|
||||||
|
|
||||||
|
class BlogPost(Document):
|
||||||
|
content = StringField()
|
||||||
|
category = ReferenceField(Category, reverse_delete_rule=NULLIFY)
|
||||||
|
|
||||||
|
BlogPost.drop_collection()
|
||||||
|
Category.drop_collection()
|
||||||
|
|
||||||
|
lameness = Category(name='Lameness')
|
||||||
|
lameness.save()
|
||||||
|
|
||||||
|
post = BlogPost(content='Watching TV', category=lameness)
|
||||||
|
post.save()
|
||||||
|
|
||||||
|
self.assertEqual(1, BlogPost.objects.count())
|
||||||
|
self.assertEqual('Lameness', BlogPost.objects.first().category.name)
|
||||||
|
Category.objects.delete()
|
||||||
|
self.assertEqual(1, BlogPost.objects.count())
|
||||||
|
self.assertEqual(None, BlogPost.objects.first().category)
|
||||||
|
|
||||||
|
def test_reverse_delete_rule_deny(self):
|
||||||
|
"""Ensure deletion gets denied on documents that still have references
|
||||||
|
to them.
|
||||||
|
"""
|
||||||
|
class BlogPost(Document):
|
||||||
|
content = StringField()
|
||||||
|
author = ReferenceField(self.Person, reverse_delete_rule=DENY)
|
||||||
|
|
||||||
|
BlogPost.drop_collection()
|
||||||
|
self.Person.drop_collection()
|
||||||
|
|
||||||
|
me = self.Person(name='Test User')
|
||||||
|
me.save()
|
||||||
|
|
||||||
|
post = BlogPost(content='Watching TV', author=me)
|
||||||
|
post.save()
|
||||||
|
|
||||||
|
self.assertRaises(OperationError, self.Person.objects.delete)
|
||||||
|
|
||||||
def test_update(self):
|
def test_update(self):
|
||||||
"""Ensure that atomic updates work properly.
|
"""Ensure that atomic updates work properly.
|
||||||
"""
|
"""
|
||||||
|
Loading…
x
Reference in New Issue
Block a user