From 62cc8d2ab3a00cc2af4c8cce2f8942bd2b13a3f1 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Sun, 5 Dec 2010 00:13:55 -0800 Subject: [PATCH 01/25] Fix: redefinition of "datetime" from line 6. --- tests/queryset.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/queryset.py b/tests/queryset.py index 374fdb54..6362555d 100644 --- a/tests/queryset.py +++ b/tests/queryset.py @@ -351,8 +351,6 @@ class QuerySetTest(unittest.TestCase): def test_filter_chaining(self): """Ensure filters can be chained together. """ - from datetime import datetime - class BlogPost(Document): title = StringField() is_published = BooleanField() From 67fcdca6d4f1b4fd791ad3485eff0a1b76b26e9b Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Sun, 5 Dec 2010 14:30:19 +0100 Subject: [PATCH 02/25] Fix: PyFlakes pointed out this missing import. --- mongoengine/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mongoengine/base.py b/mongoengine/base.py index 6b74cb07..3dd2cb02 100644 --- a/mongoengine/base.py +++ b/mongoengine/base.py @@ -492,6 +492,7 @@ class BaseDocument(object): if sys.version_info < (2, 5): # Prior to Python 2.5, Exception was an old-style class def subclass_exception(name, parents, unused): + import types return types.ClassType(name, parents, {}) else: def subclass_exception(name, parents, module): From 4f3eacd72cc807344bc06e69306b5174994be4eb Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Sun, 5 Dec 2010 14:30:50 +0100 Subject: [PATCH 03/25] Fix: whitespace. This broke my Vim auto-folds. --- tests/fields.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/fields.py b/tests/fields.py index 5602cdec..d36a0804 100644 --- a/tests/fields.py +++ b/tests/fields.py @@ -523,29 +523,29 @@ class FieldTest(unittest.TestCase): Link.drop_collection() Post.drop_collection() Bookmark.drop_collection() - + link_1 = Link(title="Pitchfork") link_1.save() - + post_1 = Post(title="Behind the Scenes of the Pavement Reunion") post_1.save() - + bm = Bookmark(bookmark_object=post_1) bm.save() - + bm = Bookmark.objects(bookmark_object=post_1).first() - + self.assertEqual(bm.bookmark_object, post_1) self.assertTrue(isinstance(bm.bookmark_object, Post)) - + bm.bookmark_object = link_1 bm.save() - + bm = Bookmark.objects(bookmark_object=link_1).first() - + self.assertEqual(bm.bookmark_object, link_1) self.assertTrue(isinstance(bm.bookmark_object, Link)) - + Link.drop_collection() Post.drop_collection() Bookmark.drop_collection() @@ -555,23 +555,23 @@ class FieldTest(unittest.TestCase): """ class Link(Document): title = StringField() - + class Post(Document): title = StringField() - + class User(Document): bookmarks = ListField(GenericReferenceField()) - + Link.drop_collection() Post.drop_collection() User.drop_collection() - + link_1 = Link(title="Pitchfork") link_1.save() - + post_1 = Post(title="Behind the Scenes of the Pavement Reunion") post_1.save() - + user = User(bookmarks=[post_1, link_1]) user.save() From 86233bcdf539874c9cddef6f883abd84f68329a3 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Sun, 5 Dec 2010 08:08:55 -0800 Subject: [PATCH 04/25] Added initial implementation of cascading document deletion. The current implementation is still very basic and needs some polish. The essence of it is that each Document gets a new meta attribute called "delete_rules" that is a dictionary containing (documentclass, fieldname) as key and the actual delete rule as a value. (Possible values are DO_NOTHING, NULLIFY, CASCADE and DENY. Of those, only CASCADE is currently implented.) --- mongoengine/base.py | 3 +++ mongoengine/document.py | 27 ++++++++++++++++++++++++++- mongoengine/fields.py | 3 ++- tests/document.py | 25 +++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/mongoengine/base.py b/mongoengine/base.py index 3dd2cb02..29de82fa 100644 --- a/mongoengine/base.py +++ b/mongoengine/base.py @@ -190,6 +190,8 @@ class DocumentMetaclass(type): new_class = super_new(cls, name, bases, attrs) for field in new_class._fields.values(): field.owner_document = new_class + if hasattr(field, 'delete_rule') and field.delete_rule: + field.document_type._meta['delete_rules'][(new_class, field.name)] = field.delete_rule module = attrs.get('__module__') @@ -258,6 +260,7 @@ class TopLevelDocumentMetaclass(DocumentMetaclass): 'index_drop_dups': False, 'index_opts': {}, 'queryset_class': QuerySet, + 'delete_rules': {}, } meta.update(base_meta) diff --git a/mongoengine/document.py b/mongoengine/document.py index fef737db..06867168 100644 --- a/mongoengine/document.py +++ b/mongoengine/document.py @@ -6,9 +6,16 @@ from connection import _get_db import pymongo -__all__ = ['Document', 'EmbeddedDocument', 'ValidationError', 'OperationError'] +__all__ = ['Document', 'EmbeddedDocument', 'ValidationError', 'OperationError', + 'DO_NOTHING', 'NULLIFY', 'CASCADE', 'DENY'] +# Delete rules +DO_NOTHING = 0 +NULLIFY = 1 +CASCADE = 2 +DENY = 3 + class EmbeddedDocument(BaseDocument): """A :class:`~mongoengine.Document` that isn't stored in its own collection. :class:`~mongoengine.EmbeddedDocument`\ s should be used as @@ -92,6 +99,13 @@ class Document(BaseDocument): :param safe: check if the operation succeeded before returning """ + for rule_entry in self._meta['delete_rules']: + document_cls, field_name = rule_entry + rule = self._meta['delete_rules'][rule_entry] + + if rule == CASCADE: + document_cls.objects(**{field_name: self.id}).delete(safe=safe) + id_field = self._meta['id_field'] object_id = self._fields[id_field].to_mongo(self[id_field]) try: @@ -100,6 +114,17 @@ class Document(BaseDocument): message = u'Could not delete document (%s)' % err.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. This could go into the Document class. + """ + if rule == DO_NOTHING: + return + + cls._meta['delete_rules'][(document_cls, field_name)] = rule + + def reload(self): """Reloads all attributes from the database. diff --git a/mongoengine/fields.py b/mongoengine/fields.py index e95fd65e..01ec1f7b 100644 --- a/mongoengine/fields.py +++ b/mongoengine/fields.py @@ -417,12 +417,13 @@ class ReferenceField(BaseField): access (lazily). """ - def __init__(self, document_type, **kwargs): + def __init__(self, document_type, delete_rule=None, **kwargs): if not isinstance(document_type, basestring): if not issubclass(document_type, (Document, basestring)): raise ValidationError('Argument to ReferenceField constructor ' 'must be a document class or a string') self.document_type_obj = document_type + self.delete_rule = delete_rule super(ReferenceField, self).__init__(**kwargs) @property diff --git a/tests/document.py b/tests/document.py index c0567632..d5807c90 100644 --- a/tests/document.py +++ b/tests/document.py @@ -624,6 +624,31 @@ class DocumentTest(unittest.TestCase): BlogPost.drop_collection() + + def test_cascade_delete(self): + """Ensure that a referenced document is also deleted upon deletion. + """ + + class BlogPost(Document): + meta = {'collection': 'blogpost_1'} + content = StringField() + author = ReferenceField(self.Person, delete_rule=CASCADE) + + 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, which should lead to deletion of the BlogPost, too + author.delete() + self.assertEqual(len(BlogPost.objects), 0) + + def tearDown(self): self.Person.drop_collection() From bba3aeb4fa06091561e601bf9d5dd72690416ddb Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Sun, 5 Dec 2010 11:10:11 -0800 Subject: [PATCH 05/25] Actually *use* the register_delete_rule classmethod, since it's there. --- mongoengine/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mongoengine/base.py b/mongoengine/base.py index 29de82fa..9f8c1e7b 100644 --- a/mongoengine/base.py +++ b/mongoengine/base.py @@ -191,7 +191,8 @@ class DocumentMetaclass(type): for field in new_class._fields.values(): field.owner_document = new_class if hasattr(field, 'delete_rule') and field.delete_rule: - field.document_type._meta['delete_rules'][(new_class, field.name)] = field.delete_rule + field.document_type.register_delete_rule(new_class, field.name, + field.delete_rule) module = attrs.get('__module__') From dd21ce9eac4156936f17e7106c1b048fe6069015 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Sun, 5 Dec 2010 13:40:39 -0800 Subject: [PATCH 06/25] Initial implementation of the NULLIFY rule. --- mongoengine/document.py | 13 +++++++++++++ tests/document.py | 11 ++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/mongoengine/document.py b/mongoengine/document.py index 06867168..3b812abb 100644 --- a/mongoengine/document.py +++ b/mongoengine/document.py @@ -105,6 +105,19 @@ class Document(BaseDocument): if rule == CASCADE: document_cls.objects(**{field_name: self.id}).delete(safe=safe) + elif rule == NULLIFY: + # TODO: For now, this makes the nullify test pass, but it would + # be nicer to use any of these two atomic versions: + # + # document_cls.objects(**{field_name: self.id}).update(**{'unset__%s' % field_name: 1}) + # or + # document_cls.objects(**{field_name: self.id}).update(**{'set__%s' % field_name: None}) + # + # However, I'm getting ValidationError: 1/None is not a valid ObjectId + # Anybody got a clue? + for doc in document_cls.objects(**{field_name: self.id}): + doc.reviewer = None + doc.save() id_field = self._meta['id_field'] object_id = self._fields[id_field].to_mongo(self[id_field]) diff --git a/tests/document.py b/tests/document.py index d5807c90..7f92320d 100644 --- a/tests/document.py +++ b/tests/document.py @@ -625,7 +625,7 @@ class DocumentTest(unittest.TestCase): BlogPost.drop_collection() - def test_cascade_delete(self): + def test_delete_rule_cascade_and_nullify(self): """Ensure that a referenced document is also deleted upon deletion. """ @@ -633,6 +633,7 @@ class DocumentTest(unittest.TestCase): meta = {'collection': 'blogpost_1'} content = StringField() author = ReferenceField(self.Person, delete_rule=CASCADE) + reviewer = ReferenceField(self.Person, delete_rule=NULLIFY) self.Person.drop_collection() BlogPost.drop_collection() @@ -640,10 +641,18 @@ class DocumentTest(unittest.TestCase): 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) From ad1aa5bd3e4f66ba18ae98b04af16a2e8aa60291 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Sun, 5 Dec 2010 13:47:32 -0800 Subject: [PATCH 07/25] Add tests that need to be satisfied. --- tests/document.py | 12 ++++++++++++ tests/queryset.py | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/tests/document.py b/tests/document.py index 7f92320d..dc63e32b 100644 --- a/tests/document.py +++ b/tests/document.py @@ -657,6 +657,18 @@ class DocumentTest(unittest.TestCase): author.delete() self.assertEqual(len(BlogPost.objects), 0) + def test_delete_rule_cascade_recurs(self): + """Ensure that a recursive chain of documents is also deleted upon + cascaded deletion. + """ + self.fail() + + def test_delete_rule_deny(self): + """Ensure that a document cannot be referenced if there are still + documents referring to it. + """ + self.fail() + def tearDown(self): self.Person.drop_collection() diff --git a/tests/queryset.py b/tests/queryset.py index 6362555d..32bbc4bf 100644 --- a/tests/queryset.py +++ b/tests/queryset.py @@ -734,6 +734,21 @@ class QuerySetTest(unittest.TestCase): self.Person.objects.delete() self.assertEqual(len(self.Person.objects), 0) + def test_delete_rule_cascade(self): + """Ensure cascading deletion of referring documents from the database. + """ + self.fail() + + def test_delete_rule_nullify(self): + """Ensure nullification of references to deleted documents. + """ + self.fail() + + def test_delete_rule_deny(self): + """Ensure deletion gets denied on documents that still have references to them. + """ + self.fail() + def test_update(self): """Ensure that atomic updates work properly. """ From d21434dfd648332f903b0ebe99d10197f740ce03 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Sun, 5 Dec 2010 22:40:01 -0800 Subject: [PATCH 08/25] Make the nullification an atomic operation. This shortcut works now, since hmarr fixed the unset bug in dev. --- mongoengine/document.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/mongoengine/document.py b/mongoengine/document.py index 3b812abb..39442f6f 100644 --- a/mongoengine/document.py +++ b/mongoengine/document.py @@ -106,18 +106,7 @@ class Document(BaseDocument): if rule == CASCADE: document_cls.objects(**{field_name: self.id}).delete(safe=safe) elif rule == NULLIFY: - # TODO: For now, this makes the nullify test pass, but it would - # be nicer to use any of these two atomic versions: - # - # document_cls.objects(**{field_name: self.id}).update(**{'unset__%s' % field_name: 1}) - # or - # document_cls.objects(**{field_name: self.id}).update(**{'set__%s' % field_name: None}) - # - # However, I'm getting ValidationError: 1/None is not a valid ObjectId - # Anybody got a clue? - for doc in document_cls.objects(**{field_name: self.id}): - doc.reviewer = None - doc.save() + document_cls.objects(**{field_name: self.id}).update(**{'unset__%s' % field_name: 1}) id_field = self._meta['id_field'] object_id = self._fields[id_field].to_mongo(self[id_field]) From f3da5bc092df9e8ae78a3b81f3bb3af2506d55f5 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Sun, 5 Dec 2010 23:03:40 -0800 Subject: [PATCH 09/25] Fix: potential NameError bug in test case. --- tests/document.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/document.py b/tests/document.py index dc63e32b..e5ff3b12 100644 --- a/tests/document.py +++ b/tests/document.py @@ -502,7 +502,7 @@ class DocumentTest(unittest.TestCase): try: recipient.save(validate=False) except ValidationError: - fail() + self.fail() def test_delete(self): """Ensure that document may be deleted using the delete method. From b06d7948543870cc4ca0bb41a4450e18d76053ec Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Sun, 5 Dec 2010 23:43:19 -0800 Subject: [PATCH 10/25] Implementation of DENY rules. --- mongoengine/document.py | 14 +++++++++++++- tests/document.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/mongoengine/document.py b/mongoengine/document.py index 39442f6f..38831b22 100644 --- a/mongoengine/document.py +++ b/mongoengine/document.py @@ -99,6 +99,17 @@ class Document(BaseDocument): :param safe: check if the operation succeeded before returning """ + # Check for DENY rules before actually deleting/nullifying any other + # references + for rule_entry in self._meta['delete_rules']: + document_cls, field_name = rule_entry + rule = self._meta['delete_rules'][rule_entry] + if rule == DENY and document_cls.objects(**{field_name: self.id}).count() > 0: + msg = u'Could not delete document (at least %s.%s refers to it)' % \ + (document_cls.__name__, field_name) + logging.error(msg) + raise OperationError(msg) + for rule_entry in self._meta['delete_rules']: document_cls, field_name = rule_entry rule = self._meta['delete_rules'][rule_entry] @@ -106,7 +117,8 @@ class Document(BaseDocument): if rule == CASCADE: document_cls.objects(**{field_name: self.id}).delete(safe=safe) elif rule == NULLIFY: - document_cls.objects(**{field_name: self.id}).update(**{'unset__%s' % field_name: 1}) + document_cls.objects(**{field_name: + self.id}).update(**{'unset__%s' % field_name: 1}) id_field = self._meta['id_field'] object_id = self._fields[id_field].to_mongo(self[id_field]) diff --git a/tests/document.py b/tests/document.py index e5ff3b12..99657993 100644 --- a/tests/document.py +++ b/tests/document.py @@ -667,7 +667,36 @@ class DocumentTest(unittest.TestCase): """Ensure that a document cannot be referenced if there are still documents referring to it. """ - self.fail() + + class BlogPost(Document): + content = StringField() + author = ReferenceField(self.Person, 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): From 20eb920cb487457c016e1524348fcd57eace6d50 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Mon, 6 Dec 2010 00:06:03 -0800 Subject: [PATCH 11/25] Change test docstring. --- tests/document.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/document.py b/tests/document.py index 99657993..11af8b22 100644 --- a/tests/document.py +++ b/tests/document.py @@ -658,8 +658,8 @@ class DocumentTest(unittest.TestCase): self.assertEqual(len(BlogPost.objects), 0) def test_delete_rule_cascade_recurs(self): - """Ensure that a recursive chain of documents is also deleted upon - cascaded deletion. + """Ensure that a chain of documents is also deleted upon cascaded + deletion. """ self.fail() From 3c98a4bff56be07e495f83d2df52e29131005b5b Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Mon, 6 Dec 2010 00:07:30 -0800 Subject: [PATCH 12/25] Remove accidentally left behind debugging message. --- mongoengine/document.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mongoengine/document.py b/mongoengine/document.py index 38831b22..d89d6872 100644 --- a/mongoengine/document.py +++ b/mongoengine/document.py @@ -107,7 +107,6 @@ class Document(BaseDocument): if rule == DENY and document_cls.objects(**{field_name: self.id}).count() > 0: msg = u'Could not delete document (at least %s.%s refers to it)' % \ (document_cls.__name__, field_name) - logging.error(msg) raise OperationError(msg) for rule_entry in self._meta['delete_rules']: From 07dae64d660235e681427494bd5a2d2dfc0f05dd Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Mon, 13 Dec 2010 12:36:24 -0800 Subject: [PATCH 13/25] More the deletion code over to the QuerySet object. The Document object doens't have any delete_rule specific code anymore, and leverages the QuerySet's ability to deny/cascade/nullify its relations. --- mongoengine/document.py | 20 -------------------- mongoengine/queryset.py | 22 ++++++++++++++++++++++ tests/document.py | 30 +++++++++++++++++++++++++++++- tests/queryset.py | 15 ++++++++++++++- 4 files changed, 65 insertions(+), 22 deletions(-) diff --git a/mongoengine/document.py b/mongoengine/document.py index d89d6872..d1a031ab 100644 --- a/mongoengine/document.py +++ b/mongoengine/document.py @@ -99,26 +99,6 @@ class Document(BaseDocument): :param safe: check if the operation succeeded before returning """ - # Check for DENY rules before actually deleting/nullifying any other - # references - for rule_entry in self._meta['delete_rules']: - document_cls, field_name = rule_entry - rule = self._meta['delete_rules'][rule_entry] - if rule == DENY and document_cls.objects(**{field_name: self.id}).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 self._meta['delete_rules']: - document_cls, field_name = rule_entry - rule = self._meta['delete_rules'][rule_entry] - - if rule == CASCADE: - document_cls.objects(**{field_name: self.id}).delete(safe=safe) - elif rule == NULLIFY: - document_cls.objects(**{field_name: - self.id}).update(**{'unset__%s' % field_name: 1}) - id_field = self._meta['id_field'] object_id = self._fields[id_field].to_mongo(self[id_field]) try: diff --git a/mongoengine/queryset.py b/mongoengine/queryset.py index 49c8f69d..82efd4f7 100644 --- a/mongoengine/queryset.py +++ b/mongoengine/queryset.py @@ -882,6 +882,28 @@ class QuerySet(object): :param safe: check if the operation succeeded before returning """ + from document import CASCADE, DENY, NULLIFY + + 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(**{'unset__%s' % field_name: 1}) + self._collection.remove(self._query, safe=safe) @classmethod diff --git a/tests/document.py b/tests/document.py index 11af8b22..221d22b7 100644 --- a/tests/document.py +++ b/tests/document.py @@ -661,7 +661,35 @@ class DocumentTest(unittest.TestCase): """Ensure that a chain of documents is also deleted upon cascaded deletion. """ - self.fail() + + class BlogPost(Document): + content = StringField() + author = ReferenceField(self.Person, delete_rule=CASCADE) + + class Comment(Document): + text = StringField() + post = ReferenceField(BlogPost, 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_delete_rule_deny(self): """Ensure that a document cannot be referenced if there are still diff --git a/tests/queryset.py b/tests/queryset.py index 32bbc4bf..fecbaecc 100644 --- a/tests/queryset.py +++ b/tests/queryset.py @@ -737,7 +737,20 @@ class QuerySetTest(unittest.TestCase): def test_delete_rule_cascade(self): """Ensure cascading deletion of referring documents from the database. """ - self.fail() + class BlogPost(Document): + content = StringField() + author = ReferenceField(self.Person, delete_rule=CASCADE) + BlogPost.drop_collection() + + me = self.Person(name='Test User') + me.save() + + post = BlogPost(content='Watching TV', author=me) + post.save() + + self.assertEqual(1, BlogPost.objects.count()) + self.Person.objects.delete() + self.assertEqual(0, BlogPost.objects.count()) def test_delete_rule_nullify(self): """Ensure nullification of references to deleted documents. From 5b118f64ec0b32cca5909d4fa4809227e4794034 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Mon, 13 Dec 2010 12:54:26 -0800 Subject: [PATCH 14/25] Add tests for nullification and denial on the queryset. --- tests/queryset.py | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/tests/queryset.py b/tests/queryset.py index fecbaecc..132549de 100644 --- a/tests/queryset.py +++ b/tests/queryset.py @@ -755,12 +755,46 @@ class QuerySetTest(unittest.TestCase): def test_delete_rule_nullify(self): """Ensure nullification of references to deleted documents. """ - self.fail() + class Category(Document): + name = StringField() + + class BlogPost(Document): + content = StringField() + category = ReferenceField(Category, 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_delete_rule_deny(self): - """Ensure deletion gets denied on documents that still have references to them. + """Ensure deletion gets denied on documents that still have references + to them. """ - self.fail() + class BlogPost(Document): + content = StringField() + author = ReferenceField(self.Person, 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): """Ensure that atomic updates work properly. From 4d5164c5804882978ec607b695c13cfcbaf4b7be Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Mon, 13 Dec 2010 13:24:20 -0800 Subject: [PATCH 15/25] Use multiple objects in the test. This is to ensure only the intended subset is deleted and not all objects. --- tests/queryset.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/queryset.py b/tests/queryset.py index 132549de..d6ec46bb 100644 --- a/tests/queryset.py +++ b/tests/queryset.py @@ -744,13 +744,16 @@ class QuerySetTest(unittest.TestCase): me = self.Person(name='Test User') me.save() + someoneelse = self.Person(name='Some-one Else') + someoneelse.save() - post = BlogPost(content='Watching TV', author=me) - post.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()) - self.Person.objects.delete() - self.assertEqual(0, BlogPost.objects.count()) def test_delete_rule_nullify(self): """Ensure nullification of references to deleted documents. From 3b55deb472638cb98a94fa59f7163709660393ed Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Mon, 13 Dec 2010 13:25:49 -0800 Subject: [PATCH 16/25] Remove unused meta data. --- tests/document.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/document.py b/tests/document.py index 221d22b7..e768b54f 100644 --- a/tests/document.py +++ b/tests/document.py @@ -630,7 +630,6 @@ class DocumentTest(unittest.TestCase): """ class BlogPost(Document): - meta = {'collection': 'blogpost_1'} content = StringField() author = ReferenceField(self.Person, delete_rule=CASCADE) reviewer = ReferenceField(self.Person, delete_rule=NULLIFY) From f30fd71c5ee6af832cdb9e01f9fdb915fef421ea Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Mon, 13 Dec 2010 13:42:01 -0800 Subject: [PATCH 17/25] Refactor: put the delete rule constants into the queryset module, too. --- mongoengine/document.py | 13 +++---------- mongoengine/queryset.py | 11 ++++++++--- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/mongoengine/document.py b/mongoengine/document.py index d1a031ab..504e14eb 100644 --- a/mongoengine/document.py +++ b/mongoengine/document.py @@ -1,21 +1,14 @@ from base import (DocumentMetaclass, TopLevelDocumentMetaclass, BaseDocument, ValidationError) -from queryset import OperationError +from queryset import OperationError, DO_NOTHING from connection import _get_db import pymongo -__all__ = ['Document', 'EmbeddedDocument', 'ValidationError', 'OperationError', - 'DO_NOTHING', 'NULLIFY', 'CASCADE', 'DENY'] +__all__ = ['Document', 'EmbeddedDocument', 'ValidationError', 'OperationError'] -# Delete rules -DO_NOTHING = 0 -NULLIFY = 1 -CASCADE = 2 -DENY = 3 - class EmbeddedDocument(BaseDocument): """A :class:`~mongoengine.Document` that isn't stored in its own collection. :class:`~mongoengine.EmbeddedDocument`\ s should be used as @@ -110,7 +103,7 @@ class Document(BaseDocument): @classmethod def register_delete_rule(cls, document_cls, field_name, rule): """This method registers the delete rules to apply when removing this - object. This could go into the Document class. + object. """ if rule == DO_NOTHING: return diff --git a/mongoengine/queryset.py b/mongoengine/queryset.py index 82efd4f7..c400a614 100644 --- a/mongoengine/queryset.py +++ b/mongoengine/queryset.py @@ -10,11 +10,18 @@ import copy import itertools __all__ = ['queryset_manager', 'Q', 'InvalidQueryError', - 'InvalidCollectionError'] + 'InvalidCollectionError', 'DO_NOTHING', 'NULLIFY', 'CASCADE', 'DENY'] + # The maximum number of items to display in a QuerySet.__repr__ REPR_OUTPUT_SIZE = 20 +# Delete rules +DO_NOTHING = 0 +NULLIFY = 1 +CASCADE = 2 +DENY = 3 + class DoesNotExist(Exception): pass @@ -882,8 +889,6 @@ class QuerySet(object): :param safe: check if the operation succeeded before returning """ - from document import CASCADE, DENY, NULLIFY - doc = self._document # Check for DENY rules before actually deleting/nullifying any other From 620f4a222ea8c3d0177a741e234223909f433555 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Tue, 14 Dec 2010 02:01:25 -0800 Subject: [PATCH 18/25] Don't check for DO_NOTHING in the delete rule registration method. It is already checked before it is invoked. This saves the ugly import of DO_NOTHING inside document.py. --- mongoengine/base.py | 3 ++- mongoengine/document.py | 5 +---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/mongoengine/base.py b/mongoengine/base.py index 9f8c1e7b..42db460f 100644 --- a/mongoengine/base.py +++ b/mongoengine/base.py @@ -1,5 +1,6 @@ from queryset import QuerySet, QuerySetManager from queryset import DoesNotExist, MultipleObjectsReturned +from queryset import DO_NOTHING import sys import pymongo @@ -190,7 +191,7 @@ class DocumentMetaclass(type): new_class = super_new(cls, name, bases, attrs) for field in new_class._fields.values(): field.owner_document = new_class - if hasattr(field, 'delete_rule') and field.delete_rule: + if hasattr(field, 'delete_rule') and field.delete_rule > DO_NOTHING: field.document_type.register_delete_rule(new_class, field.name, field.delete_rule) diff --git a/mongoengine/document.py b/mongoengine/document.py index 504e14eb..e64092e8 100644 --- a/mongoengine/document.py +++ b/mongoengine/document.py @@ -1,6 +1,6 @@ from base import (DocumentMetaclass, TopLevelDocumentMetaclass, BaseDocument, ValidationError) -from queryset import OperationError, DO_NOTHING +from queryset import OperationError from connection import _get_db import pymongo @@ -105,9 +105,6 @@ class Document(BaseDocument): """This method registers the delete rules to apply when removing this object. """ - if rule == DO_NOTHING: - return - cls._meta['delete_rules'][(document_cls, field_name)] = rule From 16e1f72e657895e6491f4111e621c510784f596a Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Tue, 14 Dec 2010 03:39:14 -0800 Subject: [PATCH 19/25] Avoid confusing semantics when comparing delete rules. --- mongoengine/base.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mongoengine/base.py b/mongoengine/base.py index 42db460f..405f642c 100644 --- a/mongoengine/base.py +++ b/mongoengine/base.py @@ -191,9 +191,10 @@ class DocumentMetaclass(type): new_class = super_new(cls, name, bases, attrs) for field in new_class._fields.values(): field.owner_document = new_class - if hasattr(field, 'delete_rule') and field.delete_rule > DO_NOTHING: + delete_rule = getattr(field, 'delete_rule', DO_NOTHING) + if delete_rule != DO_NOTHING: field.document_type.register_delete_rule(new_class, field.name, - field.delete_rule) + delete_rule) module = attrs.get('__module__') From ffc8b21f67c1e43617f5cd33f71192974a12ed99 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Tue, 14 Dec 2010 03:50:49 -0800 Subject: [PATCH 20/25] Some tests broke over the default None value. --- mongoengine/fields.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mongoengine/fields.py b/mongoengine/fields.py index 01ec1f7b..235694a6 100644 --- a/mongoengine/fields.py +++ b/mongoengine/fields.py @@ -1,4 +1,5 @@ from base import BaseField, ObjectIdField, ValidationError, get_document +from queryset import DO_NOTHING from document import Document, EmbeddedDocument from connection import _get_db from operator import itemgetter @@ -417,7 +418,7 @@ class ReferenceField(BaseField): access (lazily). """ - def __init__(self, document_type, delete_rule=None, **kwargs): + def __init__(self, document_type, delete_rule=DO_NOTHING, **kwargs): if not isinstance(document_type, basestring): if not issubclass(document_type, (Document, basestring)): raise ValidationError('Argument to ReferenceField constructor ' From e05e6b89f38562c6063154e9e9cb87fca40dde39 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Thu, 16 Dec 2010 11:53:12 +0100 Subject: [PATCH 21/25] Add safe_update parameter to updates. --- mongoengine/queryset.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mongoengine/queryset.py b/mongoengine/queryset.py index c400a614..e12b308c 100644 --- a/mongoengine/queryset.py +++ b/mongoengine/queryset.py @@ -907,7 +907,9 @@ class QuerySet(object): if rule == CASCADE: document_cls.objects(**{field_name + '__in': self}).delete(safe=safe) elif rule == NULLIFY: - document_cls.objects(**{field_name + '__in': self}).update(**{'unset__%s' % field_name: 1}) + document_cls.objects(**{field_name + '__in': self}).update( + safe_update=safe, + **{'unset__%s' % field_name: 1}) self._collection.remove(self._query, safe=safe) From 52f5deb456eea6e9e06236f084c9b13364c0e33a Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Mon, 20 Dec 2010 05:23:27 -0800 Subject: [PATCH 22/25] Add documentation for the delete_rule argument. --- docs/guide/defining-documents.rst | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/guide/defining-documents.rst b/docs/guide/defining-documents.rst index 106d4ec8..a2c598c6 100644 --- a/docs/guide/defining-documents.rst +++ b/docs/guide/defining-documents.rst @@ -193,6 +193,37 @@ as the constructor's argument:: class ProfilePage(Document): 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:`delete_rule` attribute on the :class:`ReferenceField` +definition, like this:: + + class Employee(Document): + ... + profile_page = ReferenceField('ProfilePage', delete_rule=mongoengine.NULLIFY) + +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. + + Generic reference fields '''''''''''''''''''''''' A second kind of reference field also exists, From 07ef58c1a7e757c211bf6036768839d0471dc976 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Mon, 20 Dec 2010 05:50:42 -0800 Subject: [PATCH 23/25] Rename delete_rule -> reverse_delete_rule. --- docs/guide/defining-documents.rst | 4 ++-- mongoengine/base.py | 2 +- mongoengine/fields.py | 4 ++-- tests/document.py | 16 ++++++++-------- tests/queryset.py | 12 ++++++------ 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/guide/defining-documents.rst b/docs/guide/defining-documents.rst index a2c598c6..2b64ca36 100644 --- a/docs/guide/defining-documents.rst +++ b/docs/guide/defining-documents.rst @@ -200,8 +200,8 @@ 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:`delete_rule` attribute on the :class:`ReferenceField` -definition, like this:: +supplying the :attr:`reverse_delete_rule` attributes on the +:class:`ReferenceField` definition, like this:: class Employee(Document): ... diff --git a/mongoengine/base.py b/mongoengine/base.py index 405f642c..a59cdbac 100644 --- a/mongoengine/base.py +++ b/mongoengine/base.py @@ -191,7 +191,7 @@ class DocumentMetaclass(type): new_class = super_new(cls, name, bases, attrs) for field in new_class._fields.values(): field.owner_document = new_class - delete_rule = getattr(field, 'delete_rule', DO_NOTHING) + 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) diff --git a/mongoengine/fields.py b/mongoengine/fields.py index 235694a6..5fdde1ee 100644 --- a/mongoengine/fields.py +++ b/mongoengine/fields.py @@ -418,13 +418,13 @@ class ReferenceField(BaseField): access (lazily). """ - def __init__(self, document_type, delete_rule=DO_NOTHING, **kwargs): + def __init__(self, document_type, reverse_delete_rule=DO_NOTHING, **kwargs): if not isinstance(document_type, basestring): if not issubclass(document_type, (Document, basestring)): raise ValidationError('Argument to ReferenceField constructor ' 'must be a document class or a string') self.document_type_obj = document_type - self.delete_rule = delete_rule + self.reverse_delete_rule = reverse_delete_rule super(ReferenceField, self).__init__(**kwargs) @property diff --git a/tests/document.py b/tests/document.py index e768b54f..67c21a46 100644 --- a/tests/document.py +++ b/tests/document.py @@ -625,14 +625,14 @@ class DocumentTest(unittest.TestCase): BlogPost.drop_collection() - def test_delete_rule_cascade_and_nullify(self): + 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, delete_rule=CASCADE) - reviewer = ReferenceField(self.Person, delete_rule=NULLIFY) + author = ReferenceField(self.Person, reverse_delete_rule=CASCADE) + reviewer = ReferenceField(self.Person, reverse_delete_rule=NULLIFY) self.Person.drop_collection() BlogPost.drop_collection() @@ -656,18 +656,18 @@ class DocumentTest(unittest.TestCase): author.delete() self.assertEqual(len(BlogPost.objects), 0) - def test_delete_rule_cascade_recurs(self): + 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, delete_rule=CASCADE) + author = ReferenceField(self.Person, reverse_delete_rule=CASCADE) class Comment(Document): text = StringField() - post = ReferenceField(BlogPost, delete_rule=CASCADE) + post = ReferenceField(BlogPost, reverse_delete_rule=CASCADE) author = self.Person(name='Test User') @@ -690,14 +690,14 @@ class DocumentTest(unittest.TestCase): BlogPost.drop_collection() Comment.drop_collection() - def test_delete_rule_deny(self): + 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, delete_rule=DENY) + author = ReferenceField(self.Person, reverse_delete_rule=DENY) self.Person.drop_collection() BlogPost.drop_collection() diff --git a/tests/queryset.py b/tests/queryset.py index d6ec46bb..f95974e2 100644 --- a/tests/queryset.py +++ b/tests/queryset.py @@ -734,12 +734,12 @@ class QuerySetTest(unittest.TestCase): self.Person.objects.delete() self.assertEqual(len(self.Person.objects), 0) - def test_delete_rule_cascade(self): + 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, delete_rule=CASCADE) + author = ReferenceField(self.Person, reverse_delete_rule=CASCADE) BlogPost.drop_collection() me = self.Person(name='Test User') @@ -755,7 +755,7 @@ class QuerySetTest(unittest.TestCase): self.Person.objects(name='Test User').delete() self.assertEqual(1, BlogPost.objects.count()) - def test_delete_rule_nullify(self): + def test_reverse_delete_rule_nullify(self): """Ensure nullification of references to deleted documents. """ class Category(Document): @@ -763,7 +763,7 @@ class QuerySetTest(unittest.TestCase): class BlogPost(Document): content = StringField() - category = ReferenceField(Category, delete_rule=NULLIFY) + category = ReferenceField(Category, reverse_delete_rule=NULLIFY) BlogPost.drop_collection() Category.drop_collection() @@ -780,13 +780,13 @@ class QuerySetTest(unittest.TestCase): self.assertEqual(1, BlogPost.objects.count()) self.assertEqual(None, BlogPost.objects.first().category) - def test_delete_rule_deny(self): + 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, delete_rule=DENY) + author = ReferenceField(self.Person, reverse_delete_rule=DENY) BlogPost.drop_collection() self.Person.drop_collection() From 0f68df3b4a9c7c770b25ca72f0e912e54c205b5c Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Mon, 20 Dec 2010 05:52:21 -0800 Subject: [PATCH 24/25] Fix line width. --- docs/guide/defining-documents.rst | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/guide/defining-documents.rst b/docs/guide/defining-documents.rst index 2b64ca36..80d2cd38 100644 --- a/docs/guide/defining-documents.rst +++ b/docs/guide/defining-documents.rst @@ -205,23 +205,22 @@ supplying the :attr:`reverse_delete_rule` attributes on the class Employee(Document): ... - profile_page = ReferenceField('ProfilePage', delete_rule=mongoengine.NULLIFY) + profile_page = ReferenceField('ProfilePage', reverse_delete_rule=mongoengine.NULLIFY) 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. + 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. + 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. + Any object containing fields that are refererring to the object being deleted + are deleted first. Generic reference fields From 03a757bc6efa52d85cf26a9d0d5f8086c0234571 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Tue, 21 Dec 2010 01:19:27 -0800 Subject: [PATCH 25/25] Add a safety note on using the new delete rules. --- docs/guide/defining-documents.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/guide/defining-documents.rst b/docs/guide/defining-documents.rst index 80d2cd38..de0e7272 100644 --- a/docs/guide/defining-documents.rst +++ b/docs/guide/defining-documents.rst @@ -193,6 +193,7 @@ as the constructor's argument:: class ProfilePage(Document): content = StringField() + Dealing with deletion of referred documents ''''''''''''''''''''''''''''''''''''''''''' By default, MongoDB doesn't check the integrity of your data, so deleting @@ -207,6 +208,11 @@ supplying the :attr:`reverse_delete_rule` attributes on the ... 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` @@ -223,6 +229,23 @@ Its value can take any of the following constants: 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 '''''''''''''''''''''''' A second kind of reference field also exists,