From 3b37bf479404c59ef7620f2f5729781f49b4f9dd Mon Sep 17 00:00:00 2001 From: James Punteney Date: Sat, 9 Jan 2010 10:48:05 -0500 Subject: [PATCH 1/8] Adding __repr__ methods to the queryset and BaseDocument to make it easier to see the results in the console --- mongoengine/base.py | 12 ++++++++++++ mongoengine/queryset.py | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/mongoengine/base.py b/mongoengine/base.py index 76a2c0e9..b37edcbf 100644 --- a/mongoengine/base.py +++ b/mongoengine/base.py @@ -270,6 +270,18 @@ class BaseDocument(object): def __len__(self): return len(self._data) + def __repr__(self): + try: + u = unicode(self) + except (UnicodeEncodeError, UnicodeDecodeError): + u = '[Bad Unicode data]' + return u'<%s: %s>' % (self.__class__.__name__, u) + + def __str__(self): + if hasattr(self, '__unicode__'): + return unicode(self).encode('utf-8') + return '%s object' % self.__class__.__name__ + def to_mongo(self): """Return data dictionary ready for use with MongoDB. """ diff --git a/mongoengine/queryset.py b/mongoengine/queryset.py index 39cef283..75dad522 100644 --- a/mongoengine/queryset.py +++ b/mongoengine/queryset.py @@ -5,6 +5,9 @@ import pymongo __all__ = ['queryset_manager', 'InvalidQueryError', 'InvalidCollectionError'] +# The maximum number of items to display in a QuerySet.__repr__ +REPR_OUTPUT_SIZE = 20 + class InvalidQueryError(Exception): pass @@ -424,6 +427,11 @@ class QuerySet(object): """ return self.exec_js(freq_func, list_field, normalize=normalize) + def __repr__(self): + data = list(self[:REPR_OUTPUT_SIZE + 1]) + if len(data) > REPR_OUTPUT_SIZE: + data[-1] = "...(remaining elements truncated)..." + return repr(data) class InvalidCollectionError(Exception): pass From da2d282cf645d234208d2556f9cb2b6d3d2248dd Mon Sep 17 00:00:00 2001 From: Harry Marr Date: Sat, 9 Jan 2010 22:19:33 +0000 Subject: [PATCH 2/8] Added Q class for building advanced queries --- docs/userguide.rst | 26 ++++++++++++ mongoengine/queryset.py | 94 ++++++++++++++++++++++++++++++++++++++++- tests/queryset.py | 80 +++++++++++++++++++++++++++++++++-- 3 files changed, 195 insertions(+), 5 deletions(-) diff --git a/docs/userguide.rst b/docs/userguide.rst index bba6ffbb..c030ea61 100644 --- a/docs/userguide.rst +++ b/docs/userguide.rst @@ -454,6 +454,32 @@ would be generating "tag-clouds":: from operator import itemgetter top_tags = sorted(tag_freqs.items(), key=itemgetter(1), reverse=True)[:10] +Advanced queries +---------------- +Sometimes calling a :class:`~mongoengine.queryset.QuerySet` object with keyword +arguments can't fully express the query you want to use -- for example if you +need to combine a number of constraints using *and* and *or*. This is made +possible in MongoEngine through the :class:`~mongoengine.queryset.Q` class. +A :class:`~mongoengine.queryset.Q` object represents part of a query, and +can be initialised using the same keyword-argument syntax you use to query +documents. To build a complex query, you may combine +:class:`~mongoengine.queryset.Q` objects using the ``&`` (and) and ``|`` (or) +operators. To use :class:`~mongoengine.queryset.Q` objects, pass them in +as positional arguments to :attr:`Document.objects` when you filter it by +calling it with keyword arguments:: + + # Get published posts + Post.objects(Q(published=True) | Q(publish_date__lte=datetime.now())) + + # Get top posts + Post.objects((Q(featured=True) & Q(hits__gte=1000)) | Q(hits__gte=5000)) + +.. warning:: + Only use these advanced queries if absolutely necessary as they will execute + significantly slower than regular queries. This is because they are not + natively supported by MongoDB -- they are compiled to Javascript and sent + to the server for execution. + Atomic updates -------------- Documents may be updated atomically by using the diff --git a/mongoengine/queryset.py b/mongoengine/queryset.py index 39cef283..2a4f5383 100644 --- a/mongoengine/queryset.py +++ b/mongoengine/queryset.py @@ -1,9 +1,11 @@ from connection import _get_db import pymongo +import copy -__all__ = ['queryset_manager', 'InvalidQueryError', 'InvalidCollectionError'] +__all__ = ['queryset_manager', 'Q', 'InvalidQueryError', + 'InvalidCollectionError'] class InvalidQueryError(Exception): @@ -14,6 +16,88 @@ class OperationError(Exception): pass +class Q(object): + + OR = '||' + AND = '&&' + OPERATORS = { + 'eq': 'this.%(field)s == %(value)s', + 'neq': 'this.%(field)s != %(value)s', + 'gt': 'this.%(field)s > %(value)s', + 'gte': 'this.%(field)s >= %(value)s', + 'lt': 'this.%(field)s < %(value)s', + 'lte': 'this.%(field)s <= %(value)s', + 'lte': 'this.%(field)s <= %(value)s', + 'in': 'this.%(field)s.indexOf(%(value)s) != -1', + 'nin': 'this.%(field)s.indexOf(%(value)s) == -1', + 'mod': '%(field)s %% %(value)s', + 'all': ('%(value)s.every(function(a){' + 'return this.%(field)s.indexOf(a) != -1 })'), + 'size': 'this.%(field)s.length == %(value)s', + 'exists': 'this.%(field)s != null', + } + + def __init__(self, **query): + self.query = [query] + + def _combine(self, other, op): + obj = Q() + obj.query = ['('] + copy.deepcopy(self.query) + [op] + obj.query += copy.deepcopy(other.query) + [')'] + return obj + + def __or__(self, other): + return self._combine(other, self.OR) + + def __and__(self, other): + return self._combine(other, self.AND) + + def as_js(self, document): + js = [] + js_scope = {} + for i, item in enumerate(self.query): + if isinstance(item, dict): + item_query = QuerySet._transform_query(document, **item) + # item_query will values will either be a value or a dict + js.append(self._item_query_as_js(item_query, js_scope, i)) + else: + js.append(item) + return pymongo.code.Code(' '.join(js), js_scope) + + def _item_query_as_js(self, item_query, js_scope, item_num): + # item_query will be in one of the following forms + # {'age': 25, 'name': 'Test'} + # {'age': {'$lt': 25}, 'name': {'$in': ['Test', 'Example']} + # {'age': {'$lt': 25, '$gt': 18}} + js = [] + for i, (key, value) in enumerate(item_query.items()): + op = 'eq' + # Construct a variable name for the value in the JS + value_name = 'i%sf%s' % (item_num, i) + if isinstance(value, dict): + # Multiple operators for this field + for j, (op, value) in enumerate(value.items()): + # Create a custom variable name for this operator + op_value_name = '%so%s' % (value_name, j) + # Update the js scope with the value for this op + js_scope[op_value_name] = value + # Construct the JS that uses this op + operation_js = Q.OPERATORS[op.strip('$')] % { + 'field': key, + 'value': op_value_name + } + js.append(operation_js) + else: + js_scope[value_name] = value + # Construct the JS for this field + field_js = Q.OPERATORS[op.strip('$')] % { + 'field': key, + 'value': value_name + } + js.append(field_js) + return ' && '.join(js) + + class QuerySet(object): """A set of results returned from a query. Wraps a MongoDB cursor, providing :class:`~mongoengine.Document` objects as the results. @@ -24,6 +108,7 @@ class QuerySet(object): self._collection_obj = collection self._accessed_collection = False self._query = {} + self._where_clauses = [] # If inheritance is allowed, only return instances and instances of # subclasses of the class being used @@ -55,10 +140,12 @@ class QuerySet(object): self._collection.ensure_index(index_list) return self - def __call__(self, **query): + def __call__(self, *q_objs, **query): """Filter the selected documents by calling the :class:`~mongoengine.QuerySet` with a query. """ + for q in q_objs: + self._where_clauses.append(q.as_js(self._document)) query = QuerySet._transform_query(_doc_cls=self._document, **query) self._query.update(query) return self @@ -89,6 +176,9 @@ class QuerySet(object): def _cursor(self): if not self._cursor_obj: self._cursor_obj = self._collection.find(self._query) + # Apply where clauses to cursor + for js in self._where_clauses: + self._cursor_obj.where(js) # apply default ordering if self._document._meta['ordering']: diff --git a/tests/queryset.py b/tests/queryset.py index 698ada9c..fe0f6483 100644 --- a/tests/queryset.py +++ b/tests/queryset.py @@ -1,5 +1,6 @@ import unittest import pymongo +from datetime import datetime from mongoengine.queryset import QuerySet from mongoengine import * @@ -16,7 +17,7 @@ class QuerySetTest(unittest.TestCase): self.Person = Person def test_initialisation(self): - """Ensure that CollectionManager is correctly initialised. + """Ensure that a QuerySet is correctly initialised by QuerySetManager. """ self.assertTrue(isinstance(self.Person.objects, QuerySet)) self.assertEqual(self.Person.objects._collection.name(), @@ -48,6 +49,9 @@ class QuerySetTest(unittest.TestCase): person2 = self.Person(name="User B", age=30) person2.save() + q1 = Q(name='test') + q2 = Q(age__gte=18) + # Find all people in the collection people = self.Person.objects self.assertEqual(len(people), 2) @@ -134,8 +138,6 @@ class QuerySetTest(unittest.TestCase): def test_ordering(self): """Ensure default ordering is applied and can be overridden. """ - from datetime import datetime - class BlogPost(Document): title = StringField() published_date = DateTimeField() @@ -144,6 +146,8 @@ class QuerySetTest(unittest.TestCase): 'ordering': ['-published_date'] } + BlogPost.drop_collection() + blog_post_1 = BlogPost(title="Blog Post #1", published_date=datetime(2010, 1, 5, 0, 0 ,0)) blog_post_2 = BlogPost(title="Blog Post #2", @@ -176,6 +180,8 @@ class QuerySetTest(unittest.TestCase): content = StringField() author = EmbeddedDocumentField(User) + BlogPost.drop_collection() + post = BlogPost(content='Had a good coffee today...') post.author = User(name='Test User') post.save() @@ -186,6 +192,42 @@ class QuerySetTest(unittest.TestCase): BlogPost.drop_collection() + def test_q(self): + class BlogPost(Document): + publish_date = DateTimeField() + published = BooleanField() + + BlogPost.drop_collection() + + post1 = BlogPost(publish_date=datetime(2010, 1, 8), published=False) + post1.save() + + post2 = BlogPost(publish_date=datetime(2010, 1, 15), published=True) + post2.save() + + post3 = BlogPost(published=True) + post3.save() + + post4 = BlogPost(publish_date=datetime(2010, 1, 8)) + post4.save() + + post5 = BlogPost(publish_date=datetime(2010, 1, 15)) + post5.save() + + post6 = BlogPost(published=False) + post6.save() + + date = datetime(2010, 1, 10) + q = BlogPost.objects(Q(publish_date__lte=date) | Q(published=True)) + posts = [post.id for post in q] + + published_posts = (post1, post2, post3, post4) + self.assertTrue(all(obj.id in posts for obj in published_posts)) + + self.assertFalse(any(obj.id in posts for obj in [post5, post6])) + + BlogPost.drop_collection() + def test_delete(self): """Ensure that documents are properly deleted from the database. """ @@ -428,5 +470,37 @@ class QuerySetTest(unittest.TestCase): self.Person.drop_collection() +class QTest(unittest.TestCase): + + def test_or_and(self): + q1 = Q(name='test') + q2 = Q(age__gte=18) + + query = ['(', {'name': 'test'}, '||', {'age__gte': 18}, ')'] + self.assertEqual((q1 | q2).query, query) + + query = ['(', {'name': 'test'}, '&&', {'age__gte': 18}, ')'] + self.assertEqual((q1 & q2).query, query) + + query = ['(', '(', {'name': 'test'}, '&&', {'age__gte': 18}, ')', '||', + {'name': 'example'}, ')'] + self.assertEqual((q1 & q2 | Q(name='example')).query, query) + + def test_item_query_as_js(self): + """Ensure that the _item_query_as_js utilitiy method works properly. + """ + q = Q() + examples = [ + ({'name': 'test'}, 'this.name == i0f0', {'i0f0': 'test'}), + ({'age': {'$gt': 18}}, 'this.age > i0f0o0', {'i0f0o0': 18}), + ({'name': 'test', 'age': {'$gt': 18, '$lte': 65}}, + 'this.age <= i0f0o0 && this.age > i0f0o1 && this.name == i0f1', + {'i0f0o0': 65, 'i0f0o1': 18, 'i0f1': 'test'}), + ] + for item, js, scope in examples: + test_scope = {} + self.assertEqual(q._item_query_as_js(item, test_scope, 0), js) + self.assertEqual(scope, test_scope) + if __name__ == '__main__': unittest.main() From df7d4cbc4706b3a52409f682f2177f35ecef61d7 Mon Sep 17 00:00:00 2001 From: Harry Marr Date: Sun, 10 Jan 2010 03:04:46 +0000 Subject: [PATCH 3/8] Bump to v0.2 --- mongoengine/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mongoengine/__init__.py b/mongoengine/__init__.py index 8298a79c..b23a878a 100644 --- a/mongoengine/__init__.py +++ b/mongoengine/__init__.py @@ -12,7 +12,7 @@ __all__ = (document.__all__ + fields.__all__ + connection.__all__ + __author__ = 'Harry Marr' -VERSION = (0, 1, 3) +VERSION = (0, 2, 0) def get_version(): version = '%s.%s' % (VERSION[0], VERSION[1]) From ec927bdd63f3fdf1970f075ceb5f1dc497077f60 Mon Sep 17 00:00:00 2001 From: Harry Marr Date: Sun, 10 Jan 2010 17:13:56 +0000 Subject: [PATCH 4/8] Added support for user-defined primary keys (_ids) --- docs/changelog.rst | 10 +++++++++- docs/userguide.rst | 21 +++++++++++++++++++-- mongoengine/base.py | 36 +++++++++++++++++++++++++++--------- mongoengine/document.py | 11 +++++++---- mongoengine/queryset.py | 6 +++--- tests/document.py | 33 +++++++++++++++++++++++++++++++++ 6 files changed, 98 insertions(+), 19 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e6f11571..4f66bb70 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,7 +2,15 @@ Changelog ========= -Changes is v0.1.3 +Changes in v0.2 +=============== +- Added Q class for building advanced queries +- Added QuerySet methods for atomic updates to documents +- Fields may now specify ``unique=True`` to enforce uniqueness across a collection +- Added option for default document ordering +- Fixed bug in index definitions + +Changes in v0.1.3 ================= - Added Django authentication backend - Added Document.meta support for indexes, which are ensured just before diff --git a/docs/userguide.rst b/docs/userguide.rst index c030ea61..e2ee72d0 100644 --- a/docs/userguide.rst +++ b/docs/userguide.rst @@ -318,8 +318,25 @@ saved:: >>> page.id ObjectId('123456789abcdef000000000') -Alternatively, you may explicitly set the :attr:`id` before you save the -document, but the id must be a valid PyMongo :class:`ObjectId`. +Alternatively, you may define one of your own fields to be the document's +"primary key" by providing ``primary_key=True`` as a keyword argument to a +field's constructor. Under the hood, MongoEngine will use this field as the +:attr:`id`; in fact :attr:`id` is actually aliased to your primary key field so +you may still use :attr:`id` to access the primary key if you want:: + + >>> class User(Document): + ... email = StringField(primary_key=True) + ... name = StringField() + ... + >>> bob = User(email='bob@example.com', name='Bob') + >>> bob.save() + >>> bob.id == bob.email == 'bob@example.com' + True + +.. note:: + If you define your own primary key field, the field implicitly becomes + required, so a :class:`ValidationError` will be thrown if you don't provide + it. Querying the database ===================== diff --git a/mongoengine/base.py b/mongoengine/base.py index b37edcbf..30f13af7 100644 --- a/mongoengine/base.py +++ b/mongoengine/base.py @@ -13,12 +13,13 @@ class BaseField(object): """ def __init__(self, name=None, required=False, default=None, unique=False, - unique_with=None): - self.name = name - self.required = required + unique_with=None, primary_key=False): + self.name = name if not primary_key else '_id' + self.required = required or primary_key self.default = default self.unique = bool(unique or unique_with) self.unique_with = unique_with + self.primary_key = primary_key def __get__(self, instance, owner): """Descriptor for retrieving a value from a field in a document. Do @@ -72,7 +73,7 @@ class ObjectIdField(BaseField): def to_mongo(self, value): if not isinstance(value, pymongo.objectid.ObjectId): - return pymongo.objectid.ObjectId(value) + return pymongo.objectid.ObjectId(str(value)) return value def prepare_query_value(self, value): @@ -139,6 +140,7 @@ class TopLevelDocumentMetaclass(DocumentMetaclass): collection = name.lower() simple_class = True + id_field = None # Subclassed documents inherit collection from superclass for base in bases: @@ -153,13 +155,16 @@ class TopLevelDocumentMetaclass(DocumentMetaclass): simple_class = False collection = base._meta['collection'] + id_field = id_field or base._meta.get('id_field') + meta = { 'collection': collection, 'allow_inheritance': True, 'max_documents': None, 'max_size': None, 'ordering': [], # default ordering applied at runtime - 'indexes': [] # indexes to be ensured at runtime + 'indexes': [], # indexes to be ensured at runtime + 'id_field': id_field, } # Apply document-defined meta options @@ -172,16 +177,14 @@ class TopLevelDocumentMetaclass(DocumentMetaclass): '"allow_inheritance" to False') attrs['_meta'] = meta - attrs['id'] = ObjectIdField(name='_id') - # Set up collection manager, needs the class to have fields so use # DocumentMetaclass before instantiating CollectionManager object new_class = super_new(cls, name, bases, attrs) new_class.objects = QuerySetManager() - # Generate a list of indexes needed by uniqueness constraints unique_indexes = [] for field_name, field in new_class._fields.items(): + # Generate a list of indexes needed by uniqueness constraints if field.unique: field.required = True unique_fields = [field_name] @@ -204,10 +207,25 @@ class TopLevelDocumentMetaclass(DocumentMetaclass): unique_fields += unique_with # Add the new index to the list - index = [(field, pymongo.ASCENDING) for field in unique_fields] + index = [(f, pymongo.ASCENDING) for f in unique_fields] unique_indexes.append(index) + + # Check for custom primary key + if field.primary_key: + if not new_class._meta['id_field']: + new_class._meta['id_field'] = field_name + # Make 'Document.id' an alias to the real primary key field + new_class.id = field + #new_class._fields['id'] = field + else: + raise ValueError('Cannot override primary key field') + new_class._meta['unique_indexes'] = unique_indexes + if not new_class._meta['id_field']: + new_class._meta['id_field'] = 'id' + new_class.id = new_class._fields['id'] = ObjectIdField(name='_id') + return new_class diff --git a/mongoengine/document.py b/mongoengine/document.py index 8cbca5dc..3dda4267 100644 --- a/mongoengine/document.py +++ b/mongoengine/document.py @@ -68,19 +68,22 @@ class Document(BaseDocument): except pymongo.errors.OperationFailure, err: raise OperationError('Tried to save duplicate unique keys (%s)' % str(err)) - self.id = self._fields['id'].to_python(object_id) + id_field = self._meta['id_field'] + self[id_field] = self._fields[id_field].to_python(object_id) def delete(self): """Delete the :class:`~mongoengine.Document` from the database. This will only take effect if the document has been previously saved. """ - object_id = self._fields['id'].to_mongo(self.id) - self.__class__.objects(id=object_id).delete() + id_field = self._meta['id_field'] + object_id = self._fields[id_field].to_mongo(self[id_field]) + self.__class__.objects(**{id_field: object_id}).delete() def reload(self): """Reloads all attributes from the database. """ - obj = self.__class__.objects(id=self.id).first() + id_field = self._meta['id_field'] + obj = self.__class__.objects(**{id_field: self[id_field]}).first() for field in self._fields: setattr(self, field, obj[field]) diff --git a/mongoengine/queryset.py b/mongoengine/queryset.py index 0ebdc67e..cee57f01 100644 --- a/mongoengine/queryset.py +++ b/mongoengine/queryset.py @@ -270,10 +270,10 @@ class QuerySet(object): def with_id(self, object_id): """Retrieve the object matching the id provided. """ - if not isinstance(object_id, pymongo.objectid.ObjectId): - object_id = pymongo.objectid.ObjectId(str(object_id)) + id_field = self._document._meta['id_field'] + object_id = self._document._fields[id_field].to_mongo(object_id) - result = self._collection.find_one(object_id) + result = self._collection.find_one({'_id': object_id}) if result is not None: result = self._document._from_son(result) return result diff --git a/tests/document.py b/tests/document.py index 5448635c..41739482 100644 --- a/tests/document.py +++ b/tests/document.py @@ -287,6 +287,39 @@ class DocumentTest(unittest.TestCase): BlogPost.drop_collection() + def test_custom_id_field(self): + """Ensure that documents may be created with custom primary keys. + """ + class User(Document): + username = StringField(primary_key=True) + name = StringField() + + User.drop_collection() + + self.assertEqual(User._fields['username'].name, '_id') + self.assertEqual(User._meta['id_field'], 'username') + + def create_invalid_user(): + User(name='test').save() # no primary key field + self.assertRaises(ValidationError, create_invalid_user) + + def define_invalid_user(): + class EmailUser(User): + email = StringField(primary_key=True) + self.assertRaises(ValueError, define_invalid_user) + + user = User(username='test', name='test user') + user.save() + + user_obj = User.objects.first() + self.assertEqual(user_obj.id, 'test') + + user_son = User.objects._collection.find_one() + self.assertEqual(user_son['_id'], 'test') + self.assertTrue('username' not in user_son['_id']) + + User.drop_collection() + def test_creation(self): """Ensure that document may be created using keyword arguments. """ From 84d79871082c6468a5a52bf952c97339bd7b47eb Mon Sep 17 00:00:00 2001 From: Harry Marr Date: Sun, 10 Jan 2010 21:01:00 +0000 Subject: [PATCH 5/8] Added prepare_query_value for a couple of fields --- mongoengine/fields.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mongoengine/fields.py b/mongoengine/fields.py index 61fb385a..b3670032 100644 --- a/mongoengine/fields.py +++ b/mongoengine/fields.py @@ -131,6 +131,9 @@ class EmbeddedDocumentField(BaseField): def lookup_member(self, member_name): return self.document._fields.get(member_name) + def prepare_query_value(self, value): + return self.to_mongo(value) + class ListField(BaseField): """A list field that wraps a standard field, allowing multiple instances @@ -163,6 +166,9 @@ class ListField(BaseField): raise ValidationError('All items in a list field must be of the ' 'specified type') + def prepare_query_value(self, value): + return self.field.to_mongo(value) + def lookup_member(self, member_name): return self.field.lookup_member(member_name) From afd416c84e246a696875f3f07da4302a2d9ba4c0 Mon Sep 17 00:00:00 2001 From: Harry Marr Date: Mon, 11 Jan 2010 04:12:51 +0000 Subject: [PATCH 6/8] Updated docs, added force_insert to save() --- docs/apireference.rst | 2 ++ mongoengine/base.py | 4 ++++ mongoengine/document.py | 32 ++++++++++++++++++++++----- mongoengine/fields.py | 2 ++ mongoengine/queryset.py | 49 +++++++++++++++++++++++++++++++++++++++-- tests/document.py | 13 +++++++++++ 6 files changed, 94 insertions(+), 8 deletions(-) diff --git a/docs/apireference.rst b/docs/apireference.rst index 0b4bb480..03e44e63 100644 --- a/docs/apireference.rst +++ b/docs/apireference.rst @@ -27,6 +27,8 @@ Querying .. autoclass:: mongoengine.queryset.QuerySet :members: + .. automethod:: mongoengine.queryset.QuerySet.__call__ + .. autofunction:: mongoengine.queryset.queryset_manager Fields diff --git a/mongoengine/base.py b/mongoengine/base.py index 30f13af7..27b90e5f 100644 --- a/mongoengine/base.py +++ b/mongoengine/base.py @@ -141,6 +141,7 @@ class TopLevelDocumentMetaclass(DocumentMetaclass): simple_class = True id_field = None + base_indexes = [] # Subclassed documents inherit collection from superclass for base in bases: @@ -156,6 +157,7 @@ class TopLevelDocumentMetaclass(DocumentMetaclass): collection = base._meta['collection'] id_field = id_field or base._meta.get('id_field') + base_indexes += base._meta.get('indexes', []) meta = { 'collection': collection, @@ -169,6 +171,8 @@ class TopLevelDocumentMetaclass(DocumentMetaclass): # Apply document-defined meta options meta.update(attrs.get('meta', {})) + + meta['indexes'] += base_indexes # Only simple classes - direct subclasses of Document - may set # allow_inheritance to False diff --git a/mongoengine/document.py b/mongoengine/document.py index 3dda4267..3492fd76 100644 --- a/mongoengine/document.py +++ b/mongoengine/document.py @@ -56,31 +56,51 @@ class Document(BaseDocument): __metaclass__ = TopLevelDocumentMetaclass - def save(self, safe=True): + def save(self, safe=True, force_insert=False): """Save the :class:`~mongoengine.Document` to the database. If the document already exists, it will be updated, otherwise it will be created. + + If ``safe=True`` and the operation is unsuccessful, an + :class:`~mongoengine.OperationError` will be raised. + + :param safe: check if the operation succeeded before returning + :param force_insert: only try to create a new document, don't allow + updates of existing documents """ self.validate() doc = self.to_mongo() try: - object_id = self.__class__.objects._collection.save(doc, safe=safe) + collection = self.__class__.objects._collection + if force_insert: + object_id = collection.insert(doc, safe=safe) + else: + object_id = collection.save(doc, safe=safe) except pymongo.errors.OperationFailure, err: - raise OperationError('Tried to save duplicate unique keys (%s)' - % str(err)) + message = 'Could not save document (%s)' + if 'duplicate key' in str(err): + message = 'Tried to save duplicate unique keys (%s)' + raise OperationError(message % str(err)) id_field = self._meta['id_field'] self[id_field] = self._fields[id_field].to_python(object_id) - def delete(self): + def delete(self, safe=False): """Delete the :class:`~mongoengine.Document` from the database. This will only take effect if the document has been previously saved. + + :param safe: check if the operation succeeded before returning """ id_field = self._meta['id_field'] object_id = self._fields[id_field].to_mongo(self[id_field]) - self.__class__.objects(**{id_field: object_id}).delete() + try: + self.__class__.objects(**{id_field: object_id}).delete(safe=safe) + except pymongo.errors.OperationFailure, err: + raise OperationError('Could not delete document (%s)' % str(err)) def reload(self): """Reloads all attributes from the database. + + .. versionadded:: 0.1.2 """ id_field = self._meta['id_field'] obj = self.__class__.objects(**{id_field: self[id_field]}).first() diff --git a/mongoengine/fields.py b/mongoengine/fields.py index 61fb385a..e9521010 100644 --- a/mongoengine/fields.py +++ b/mongoengine/fields.py @@ -82,6 +82,8 @@ class FloatField(BaseField): class BooleanField(BaseField): """A boolean field type. + + .. versionadded:: 0.1.2 """ def to_python(self, value): diff --git a/mongoengine/queryset.py b/mongoengine/queryset.py index cee57f01..c80467a9 100644 --- a/mongoengine/queryset.py +++ b/mongoengine/queryset.py @@ -121,6 +121,10 @@ class QuerySet(object): def ensure_index(self, key_or_list): """Ensure that the given indexes are in place. + + :param key_or_list: a single index key or a list of index keys (to + construct a multi-field index); keys may be prefixed with a **+** + or a **-** to determine the index ordering """ if isinstance(key_or_list, basestring): key_or_list = [key_or_list] @@ -146,6 +150,9 @@ class QuerySet(object): def __call__(self, *q_objs, **query): """Filter the selected documents by calling the :class:`~mongoengine.QuerySet` with a query. + + :param q_objs: :class:`~mongoengine.Q` objects to be used in the query + :param query: Django-style query keyword arguments """ for q in q_objs: self._where_clauses.append(q.as_js(self._document)) @@ -269,6 +276,8 @@ class QuerySet(object): def with_id(self, object_id): """Retrieve the object matching the id provided. + + :param object_id: the value for the id of the document to look up """ id_field = self._document._meta['id_field'] object_id = self._document._fields[id_field].to_mongo(object_id) @@ -294,6 +303,8 @@ class QuerySet(object): def limit(self, n): """Limit the number of returned documents to `n`. This may also be achieved using array-slicing syntax (e.g. ``User.objects[:5]``). + + :param n: the maximum number of objects to return """ self._cursor.limit(n) # Return self to allow chaining @@ -302,6 +313,8 @@ class QuerySet(object): def skip(self, n): """Skip `n` documents before returning the results. This may also be achieved using array-slicing syntax (e.g. ``User.objects[5:]``). + + :param n: the number of objects to skip before returning results """ self._cursor.skip(n) return self @@ -322,6 +335,9 @@ class QuerySet(object): """Order the :class:`~mongoengine.queryset.QuerySet` by the keys. The order may be specified by prepending each of the keys by a + or a -. Ascending order is assumed. + + :param keys: fields to order the query results by; keys may be + prefixed with **+** or **-** to determine the ordering direction """ key_list = [] for key in keys: @@ -338,6 +354,8 @@ class QuerySet(object): def explain(self, format=False): """Return an explain plan record for the :class:`~mongoengine.queryset.QuerySet`\ 's cursor. + + :param format: format the plan before returning it """ plan = self._cursor.explain() @@ -346,10 +364,12 @@ class QuerySet(object): plan = pprint.pformat(plan) return plan - def delete(self): + def delete(self, safe=False): """Delete the documents matched by the query. + + :param safe: check if the operation succeeded before returning """ - self._collection.remove(self._query) + self._collection.remove(self._query, safe=safe) @classmethod def _transform_update(cls, _doc_cls=None, **update): @@ -402,6 +422,11 @@ class QuerySet(object): def update(self, safe_update=True, **update): """Perform an atomic update on the fields matched by the query. + + :param safe: check if the operation succeeded before returning + :param update: Django-style update keyword arguments + + .. versionadded:: 0.2 """ if pymongo.version < '1.1.1': raise OperationError('update() method requires PyMongo 1.1.1+') @@ -417,6 +442,11 @@ class QuerySet(object): def update_one(self, safe_update=True, **update): """Perform an atomic update on first field matched by the query. + + :param safe: check if the operation succeeded before returning + :param update: Django-style update keyword arguments + + .. versionadded:: 0.2 """ update = QuerySet._transform_update(self._document, **update) try: @@ -442,6 +472,12 @@ class QuerySet(object): collection in use; ``query``, which is an object representing the current query; and ``options``, which is an object containing any options specified as keyword arguments. + + :param code: a string of Javascript code to execute + :param fields: fields that you will be using in your function, which + will be passed in to your function as arguments + :param options: options that you want available to the function + (accessed in Javascript through the ``options`` object) """ fields = [QuerySet._translate_field_name(self._document, f) for f in fields] @@ -458,6 +494,9 @@ class QuerySet(object): def sum(self, field): """Sum over the values of the specified field. + + :param field: the field to sum over; use dot-notation to refer to + embedded document fields """ sum_func = """ function(sumField) { @@ -472,6 +511,9 @@ class QuerySet(object): def average(self, field): """Average over the values of the specified field. + + :param field: the field to average over; use dot-notation to refer to + embedded document fields """ average_func = """ function(averageField) { @@ -492,6 +534,9 @@ class QuerySet(object): """Returns a dictionary of all items present in a list field across the whole queried set of documents, and their corresponding frequency. This is useful for generating tag clouds, or searching documents. + + :param list_field: the list field to use + :param normalize: normalize the results so they add to 1.0 """ freq_func = """ function(listField) { diff --git a/tests/document.py b/tests/document.py index 41739482..91b09e75 100644 --- a/tests/document.py +++ b/tests/document.py @@ -245,6 +245,19 @@ class DocumentTest(unittest.TestCase): self.assertTrue([('_types', 1), ('category', 1), ('addDate', -1)] in info.values()) self.assertTrue([('_types', 1), ('addDate', -1)] in info.values()) + + class ExtendedBlogPost(BlogPost): + title = StringField() + meta = {'indexes': ['title']} + + BlogPost.drop_collection() + + list(ExtendedBlogPost.objects) + info = ExtendedBlogPost.objects._collection.index_information() + self.assertTrue([('_types', 1), ('category', 1), ('addDate', -1)] + in info.values()) + self.assertTrue([('_types', 1), ('addDate', -1)] in info.values()) + self.assertTrue([('_types', 1), ('title', 1)] in info.values()) BlogPost.drop_collection() From 484bc1e6f09aeff1f2628a6e5d259623eabf16ea Mon Sep 17 00:00:00 2001 From: Harry Marr Date: Mon, 11 Jan 2010 04:43:17 +0000 Subject: [PATCH 7/8] Added a MongoEngine backend for Django sessions --- docs/django.rst | 13 +++++++ mongoengine/django/sessions.py | 63 ++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 mongoengine/django/sessions.py diff --git a/docs/django.rst b/docs/django.rst index 57684365..d2c468a2 100644 --- a/docs/django.rst +++ b/docs/django.rst @@ -27,3 +27,16 @@ file:: The :mod:`~mongoengine.django.auth` module also contains a :func:`~mongoengine.django.auth.get_user` helper function, that takes a user's :attr:`id` and returns a :class:`~mongoengine.django.auth.User` object. + +Sessions +======== +Django allows the use of different backend stores for its sessions. MongoEngine +provides a MongoDB-based session backend for Django, which allows you to use +sessions in you Django application with just MongoDB. To enable the MongoEngine +session backend, ensure that your settings module has +``'django.contrib.sessions.middleware.SessionMiddleware'`` in the +``MIDDLEWARE_CLASSES`` field and ``'django.contrib.sessions'`` in your +``INSTALLED_APPS``. From there, all you need to do is add the following line +into you settings module:: + + SESSION_ENGINE = 'mongoengine.django.sessions' diff --git a/mongoengine/django/sessions.py b/mongoengine/django/sessions.py new file mode 100644 index 00000000..7405c856 --- /dev/null +++ b/mongoengine/django/sessions.py @@ -0,0 +1,63 @@ +from django.contrib.sessions.backends.base import SessionBase, CreateError +from django.core.exceptions import SuspiciousOperation +from django.utils.encoding import force_unicode + +from mongoengine.document import Document +from mongoengine import fields +from mongoengine.queryset import OperationError + +from datetime import datetime + + +class MongoSession(Document): + session_key = fields.StringField(primary_key=True, max_length=40) + session_data = fields.StringField() + expire_date = fields.DateTimeField() + + meta = {'collection': 'django_session', 'allow_inheritance': False} + + +class SessionStore(SessionBase): + """A MongoEngine-based session store for Django. + """ + + def load(self): + try: + s = MongoSession.objects(session_key=self.session_key, + expire_date__gt=datetime.now())[0] + return self.decode(force_unicode(s.session_data)) + except (IndexError, SuspiciousOperation): + self.create() + return {} + + def exists(self, session_key): + return bool(MongoSession.objects(session_key=session_key).first()) + + def create(self): + while True: + self.session_key = self._get_new_session_key() + try: + self.save(must_create=True) + except CreateError: + continue + self.modified = True + self._session_cache = {} + return + + def save(self, must_create=False): + s = MongoSession(session_key=self.session_key) + s.session_data = self.encode(self._get_session(no_load=must_create)) + s.expire_date = self.get_expiry_date() + try: + s.save(force_insert=must_create, safe=True) + except OperationError: + if must_create: + raise CreateError + raise + + def delete(self, session_key=None): + if session_key is None: + if self.session_key is None: + return + session_key = self.session_key + MongoSession.objects(session_key=session_key).delete() From e7380e3676e88bb64f1080732bb81fd739162430 Mon Sep 17 00:00:00 2001 From: Harry Marr Date: Mon, 11 Jan 2010 16:14:03 +0000 Subject: [PATCH 8/8] Bump to v0.2.1 --- docs/changelog.rst | 7 +++++++ mongoengine/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4f66bb70..d5f79585 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,13 @@ Changelog ========= +Changes in v0.2.1 +================= +- Added a MongoEngine backend for Django sessions +- Added force_insert to Document.save() +- Improved querying syntax for ListField and EmbeddedDocumentField +- Added support for user-defined primary keys (_ids in MongoDB) + Changes in v0.2 =============== - Added Q class for building advanced queries diff --git a/mongoengine/__init__.py b/mongoengine/__init__.py index b23a878a..ebb76b8e 100644 --- a/mongoengine/__init__.py +++ b/mongoengine/__init__.py @@ -12,7 +12,7 @@ __all__ = (document.__all__ + fields.__all__ + connection.__all__ + __author__ = 'Harry Marr' -VERSION = (0, 2, 0) +VERSION = (0, 2, 1) def get_version(): version = '%s.%s' % (VERSION[0], VERSION[1])