Added single and multifield uniqueness constraints

This commit is contained in:
Harry Marr 2010-01-08 12:04:11 +00:00
parent 45080d3fd1
commit 4d695a3544
7 changed files with 144 additions and 24 deletions

View File

@ -138,6 +138,21 @@ field::
The :class:`User` object is automatically turned into a reference behind the The :class:`User` object is automatically turned into a reference behind the
scenes, and dereferenced when the :class:`Page` object is retrieved. scenes, and dereferenced when the :class:`Page` object is retrieved.
Uniqueness constraints
^^^^^^^^^^^^^^^^^^^^^^
MongoEngine allows you to specify that a field should be unique across a
collection by providing ``unique=True`` to a :class:`~mongoengine.Field`\ 's
constructor. If you try to save a document that has the same value for a unique
field as a document that is already in the database, a
:class:`~mongoengine.ValidationError` will be raised. You may also specify
multi-field uniqueness constraints by using :attr:`unique_with`, which may be
either a single field name, or a list or tuple of field names::
class User(Document):
username = StringField(unique=True)
first_name = StringField()
last_name = StringField(unique_with='last_name')
Document collections Document collections
-------------------- --------------------
Document classes that inherit **directly** from :class:`~mongoengine.Document` Document classes that inherit **directly** from :class:`~mongoengine.Document`
@ -172,10 +187,10 @@ Indexes
------- -------
You can specify indexes on collections to make querying faster. This is done You can specify indexes on collections to make querying faster. This is done
by creating a list of index specifications called :attr:`indexes` in the by creating a list of index specifications called :attr:`indexes` in the
:attr:`~Document.meta` dictionary, where an index specification may either be :attr:`~mongoengine.Document.meta` dictionary, where an index specification may
a single field name, or a tuple containing multiple field names. A direction either be a single field name, or a tuple containing multiple field names. A
may be specified on fields by prefixing the field name with a **+** or a **-** direction may be specified on fields by prefixing the field name with a **+**
sign. Note that direction only matters on multi-field indexes. :: or a **-** sign. Note that direction only matters on multi-field indexes. ::
class Page(Document): class Page(Document):
title = StringField() title = StringField()
@ -186,11 +201,11 @@ sign. Note that direction only matters on multi-field indexes. ::
Ordering Ordering
-------- --------
A default ordering can be specified for your
A default ordering can be specified for your :class:`~mongoengine.queryset.QuerySet` :class:`~mongoengine.queryset.QuerySet` using the :attr:`ordering` attribute of
using the :attr:`ordering` attributeof :attr:`~Document.meta`. :attr:`~mongoengine.Document.meta`. Ordering will be applied when the
Ordering will be applied when the ``QuerySet`` is created, and can be :class:`~mongoengine.queryset.QuerySet` is created, and can be overridden by
overridden by subsequent calls to :meth:`~mongoengine.QuerySet.order_by`. :: subsequent calls to :meth:`~mongoengine.queryset.QuerySet.order_by`. ::
from datetime import datetime from datetime import datetime
@ -202,9 +217,14 @@ overridden by subsequent calls to :meth:`~mongoengine.QuerySet.order_by`. ::
'ordering': ['-published_date'] 'ordering': ['-published_date']
} }
blog_post_1 = BlogPost(title="Blog Post #1", published_date=datetime(2010, 1, 5, 0, 0 ,0)) blog_post_1 = BlogPost(title="Blog Post #1")
blog_post_2 = BlogPost(title="Blog Post #2", published_date=datetime(2010, 1, 6, 0, 0 ,0)) blog_post_1.published_date = datetime(2010, 1, 5, 0, 0 ,0))
blog_post_3 = BlogPost(title="Blog Post #3", published_date=datetime(2010, 1, 7, 0, 0 ,0))
blog_post_2 = BlogPost(title="Blog Post #2")
blog_post_2.published_date = datetime(2010, 1, 6, 0, 0 ,0))
blog_post_3 = BlogPost(title="Blog Post #3")
blog_post_3.published_date = datetime(2010, 1, 7, 0, 0 ,0))
blog_post_1.save() blog_post_1.save()
blog_post_2.save() blog_post_2.save()

View File

@ -1,4 +1,4 @@
from queryset import QuerySetManager from queryset import QuerySet, QuerySetManager
import pymongo import pymongo
@ -12,10 +12,13 @@ class BaseField(object):
may be added to subclasses of `Document` to define a document's schema. may be added to subclasses of `Document` to define a document's schema.
""" """
def __init__(self, name=None, required=False, default=None): def __init__(self, name=None, required=False, default=None, unique=False,
unique_with=None):
self.name = name self.name = name
self.required = required self.required = required
self.default = default self.default = default
self.unique = bool(unique or unique_with)
self.unique_with = unique_with
def __get__(self, instance, owner): def __get__(self, instance, owner):
"""Descriptor for retrieving a value from a field in a document. Do """Descriptor for retrieving a value from a field in a document. Do
@ -176,6 +179,35 @@ class TopLevelDocumentMetaclass(DocumentMetaclass):
new_class = super_new(cls, name, bases, attrs) new_class = super_new(cls, name, bases, attrs)
new_class.objects = QuerySetManager() new_class.objects = QuerySetManager()
# Generate a list of indexes needed by uniqueness constraints
unique_indexes = []
for field_name, field in new_class._fields.items():
if field.unique:
field.required = True
unique_fields = [field_name]
# Add any unique_with fields to the back of the index spec
if field.unique_with:
if isinstance(field.unique_with, basestring):
field.unique_with = [field.unique_with]
# Convert unique_with field names to real field names
unique_with = []
for other_name in field.unique_with:
parts = other_name.split('.')
# Lookup real name
parts = QuerySet._lookup_field(new_class, parts)
name_parts = [part.name for part in parts]
unique_with.append('.'.join(name_parts))
# Unique field should be required
parts[-1].required = True
unique_fields += unique_with
# Add the new index to the list
index = [(field, pymongo.ASCENDING) for field in unique_fields]
unique_indexes.append(index)
new_class._meta['unique_indexes'] = unique_indexes
return new_class return new_class

View File

@ -2,8 +2,10 @@ from base import (DocumentMetaclass, TopLevelDocumentMetaclass, BaseDocument,
ValidationError) ValidationError)
from connection import _get_db from connection import _get_db
import pymongo
__all__ = ['Document', 'EmbeddedDocument']
__all__ = ['Document', 'EmbeddedDocument', 'ValidationError']
class EmbeddedDocument(BaseDocument): class EmbeddedDocument(BaseDocument):
@ -53,13 +55,18 @@ class Document(BaseDocument):
__metaclass__ = TopLevelDocumentMetaclass __metaclass__ = TopLevelDocumentMetaclass
def save(self): def save(self, safe=True):
"""Save the :class:`~mongoengine.Document` to the database. If the """Save the :class:`~mongoengine.Document` to the database. If the
document already exists, it will be updated, otherwise it will be document already exists, it will be updated, otherwise it will be
created. created.
""" """
self.validate() self.validate()
object_id = self.__class__.objects._collection.save(self.to_mongo()) doc = self.to_mongo()
try:
object_id = self.__class__.objects._collection.save(doc, safe=safe)
except pymongo.errors.OperationFailure, err:
raise ValidationError('Tried to safe duplicate unique keys (%s)'
% str(err))
self.id = self._fields['id'].to_python(object_id) self.id = self._fields['id'].to_python(object_id)
def delete(self): def delete(self):

View File

@ -17,7 +17,8 @@ class QuerySet(object):
def __init__(self, document, collection): def __init__(self, document, collection):
self._document = document self._document = document
self._collection = collection self._collection_obj = collection
self._accessed_collection = False
self._query = {} self._query = {}
# If inheritance is allowed, only return instances and instances of # If inheritance is allowed, only return instances and instances of
@ -59,17 +60,30 @@ class QuerySet(object):
return self return self
@property @property
def _cursor(self): def _collection(self):
if not self._cursor_obj: """Property that returns the collection object. This allows us to
perform operations only if the collection is accessed.
"""
if not self._accessed_collection:
self._accessed_collection = True
# Ensure document-defined indexes are created # Ensure document-defined indexes are created
if self._document._meta['indexes']: if self._document._meta['indexes']:
for key_or_list in self._document._meta['indexes']: for key_or_list in self._document._meta['indexes']:
self.ensure_index(key_or_list) self.ensure_index(key_or_list)
# Ensure indexes created by uniqueness constraints
for index in self._document._meta['unique_indexes']:
self._collection.ensure_index(index, unique=True)
# If _types is being used (for polymorphism), it needs an index # If _types is being used (for polymorphism), it needs an index
if '_types' in self._query: if '_types' in self._query:
self._collection.ensure_index('_types') self._collection.ensure_index('_types')
return self._collection_obj
@property
def _cursor(self):
if not self._cursor_obj:
self._cursor_obj = self._collection.find(self._query) self._cursor_obj = self._collection.find(self._query)
# apply default ordering # apply default ordering

View File

@ -237,7 +237,7 @@ class DocumentTest(unittest.TestCase):
BlogPost.drop_collection() BlogPost.drop_collection()
info = BlogPost.objects._collection.index_information() info = BlogPost.objects._collection.index_information()
self.assertEqual(len(info), 0) self.assertEqual(len(info), 4) # _id, types, '-date', ('cat', 'date')
# Indexes are lazy so use list() to perform query # Indexes are lazy so use list() to perform query
list(BlogPost.objects) list(BlogPost.objects)
@ -248,6 +248,45 @@ class DocumentTest(unittest.TestCase):
BlogPost.drop_collection() BlogPost.drop_collection()
def test_unique(self):
"""Ensure that uniqueness constraints are applied to fields.
"""
class BlogPost(Document):
title = StringField()
slug = StringField(unique=True)
BlogPost.drop_collection()
post1 = BlogPost(title='test1', slug='test')
post1.save()
# Two posts with the same slug is not allowed
post2 = BlogPost(title='test2', slug='test')
self.assertRaises(ValidationError, post2.save)
class Date(EmbeddedDocument):
year = IntField(name='yr')
class BlogPost(Document):
title = StringField()
date = EmbeddedDocumentField(Date)
slug = StringField(unique_with='date.year')
BlogPost.drop_collection()
post1 = BlogPost(title='test1', date=Date(year=2009), slug='test')
post1.save()
# day is different so won't raise exception
post2 = BlogPost(title='test2', date=Date(year=2010), slug='test')
post2.save()
# Now there will be two docs with the same slug and the same day: fail
post3 = BlogPost(title='test3', date=Date(year=2010), slug='test')
self.assertRaises(ValidationError, post3.save)
BlogPost.drop_collection()
def test_creation(self): def test_creation(self):
"""Ensure that document may be created using keyword arguments. """Ensure that document may be created using keyword arguments.
""" """

View File

@ -231,6 +231,9 @@ class FieldTest(unittest.TestCase):
content = StringField() content = StringField()
author = ReferenceField(User) author = ReferenceField(User)
User.drop_collection()
BlogPost.drop_collection()
self.assertRaises(ValidationError, ReferenceField, EmbeddedDocument) self.assertRaises(ValidationError, ReferenceField, EmbeddedDocument)
user = User(name='Test User') user = User(name='Test User')

View File

@ -144,9 +144,12 @@ class QuerySetTest(unittest.TestCase):
'ordering': ['-published_date'] 'ordering': ['-published_date']
} }
blog_post_1 = BlogPost(title="Blog Post #1", published_date=datetime(2010, 1, 5, 0, 0 ,0)) blog_post_1 = BlogPost(title="Blog Post #1",
blog_post_2 = BlogPost(title="Blog Post #2", published_date=datetime(2010, 1, 6, 0, 0 ,0)) published_date=datetime(2010, 1, 5, 0, 0 ,0))
blog_post_3 = BlogPost(title="Blog Post #3", published_date=datetime(2010, 1, 7, 0, 0 ,0)) blog_post_2 = BlogPost(title="Blog Post #2",
published_date=datetime(2010, 1, 6, 0, 0 ,0))
blog_post_3 = BlogPost(title="Blog Post #3",
published_date=datetime(2010, 1, 7, 0, 0 ,0))
blog_post_1.save() blog_post_1.save()
blog_post_2.save() blog_post_2.save()
@ -161,6 +164,8 @@ class QuerySetTest(unittest.TestCase):
first_post = BlogPost.objects.order_by("+published_date").first() first_post = BlogPost.objects.order_by("+published_date").first()
self.assertEqual(first_post.title, "Blog Post #1") self.assertEqual(first_post.title, "Blog Post #1")
BlogPost.drop_collection()
def test_find_embedded(self): def test_find_embedded(self):
"""Ensure that an embedded document is properly returned from a query. """Ensure that an embedded document is properly returned from a query.
""" """