diff --git a/.gitignore b/.gitignore index e191be82..95ad521c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.pyc .*.swp docs/.build +docs/_build diff --git a/docs/Makefile b/docs/Makefile index 66cd648f..76eb910c 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -5,7 +5,7 @@ SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = -BUILDDIR = .build +BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 diff --git a/docs/.themes/nature/static/nature.css_t b/docs/_themes/nature/static/nature.css_t similarity index 100% rename from docs/.themes/nature/static/nature.css_t rename to docs/_themes/nature/static/nature.css_t diff --git a/docs/.themes/nature/static/pygments.css b/docs/_themes/nature/static/pygments.css similarity index 100% rename from docs/.themes/nature/static/pygments.css rename to docs/_themes/nature/static/pygments.css diff --git a/docs/.themes/nature/theme.conf b/docs/_themes/nature/theme.conf similarity index 100% rename from docs/.themes/nature/theme.conf rename to docs/_themes/nature/theme.conf diff --git a/docs/apireference.rst b/docs/apireference.rst new file mode 100644 index 00000000..9ec4321a --- /dev/null +++ b/docs/apireference.rst @@ -0,0 +1,46 @@ +API Reference +============= + +Connecting +---------- + +.. autofunction:: mongoengine.connect + +Documents +--------- + +.. autoclass:: mongoengine.Document + :members: + + .. attribute:: objects + + A :class:`~mongoengine.queryset.QuerySet` object that is created lazily + on access. + +.. autoclass:: mongoengine.EmbeddedDocument + :members: + +Querying +-------- + +.. autoclass:: mongoengine.queryset.QuerySet + :members: + +Fields +------ + +.. autoclass:: mongoengine.StringField + +.. autoclass:: mongoengine.IntField + +.. autoclass:: mongoengine.FloatField + +.. autoclass:: mongoengine.DateTimeField + +.. autoclass:: mongoengine.EmbeddedDocumentField + +.. autoclass:: mongoengine.ListField + +.. autoclass:: mongoengine.ObjectIdField + +.. autoclass:: mongoengine.ReferenceField diff --git a/docs/conf.py b/docs/conf.py index 5b29b371..be7d3f5d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,13 +16,13 @@ import sys, os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.append(os.path.abspath('.')) +sys.path.append(os.path.abspath('..')) # -- General configuration ----------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = [] +extensions = ['sphinx.ext.autodoc'] # Add any paths that contain templates here, relative to this directory. templates_path = ['.templates'] @@ -64,7 +64,7 @@ release = '0.1' # List of directories, relative to source directory, that shouldn't be searched # for source files. -exclude_trees = ['.build'] +exclude_trees = ['_build'] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None @@ -99,7 +99,7 @@ html_theme = 'nature' #html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -html_theme_path = ['.themes'] +html_theme_path = ['_themes'] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". @@ -120,7 +120,7 @@ html_theme_path = ['.themes'] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['.static'] +html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. diff --git a/docs/index.rst b/docs/index.rst index 5fab07a6..e6a2bde6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,7 +3,7 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to MongoEngine's documentation! +MongoEngine User Documentation ======================================= Contents: @@ -12,6 +12,7 @@ Contents: :maxdepth: 2 tutorial.rst + apireference.rst Indices and tables ================== diff --git a/mongoengine/base.py b/mongoengine/base.py index 6cb453c1..e098116b 100644 --- a/mongoengine/base.py +++ b/mongoengine/base.py @@ -136,20 +136,36 @@ class TopLevelDocumentMetaclass(DocumentMetaclass): if attrs.get('__metaclass__') == TopLevelDocumentMetaclass: return super_new(cls, name, bases, attrs) - collection = attrs.get('__collection__', name.lower()) + collection = name.lower() + simple_class = True + # Subclassed documents inherit collection from superclass for base in bases: if hasattr(base, '_meta') and 'collection' in base._meta: + # Ensure that the Document class may be subclassed - + # inheritance may be disabled to remove dependency on + # additional fields _cls and _types + if base._meta.get('allow_inheritance', True) == False: + raise ValueError('Document %s may not be subclassed' % + base.__name__) + else: + simple_class = False collection = base._meta['collection'] meta = { 'collection': collection, + 'allow_inheritance': True, } meta.update(attrs.get('meta', {})) + # Only simple classes - direct subclasses of Document - may set + # allow_inheritance to False + if not simple_class and not meta['allow_inheritance']: + raise ValueError('Only direct subclasses of Document may set ' + '"allow_inheritance" to False') attrs['_meta'] = meta - attrs['_id'] = ObjectIdField() + attrs['id'] = ObjectIdField(name='_id') # Set up collection manager, needs the class to have fields so use # DocumentMetaclass before instantiating CollectionManager object @@ -168,10 +184,11 @@ class BaseDocument(object): if attr_name in values: setattr(self, attr_name, values.pop(attr_name)) else: - if attr_value.required: + # Use default value if present + value = getattr(self, attr_name, None) + if value is None and attr_value.required: raise ValidationError('Field "%s" is required' % attr_name) - # Use default value - setattr(self, attr_name, getattr(self, attr_name, None)) + setattr(self, attr_name, value) @classmethod def _get_subclasses(cls): @@ -226,9 +243,12 @@ class BaseDocument(object): for field_name, field in self._fields.items(): value = getattr(self, field_name, None) if value is not None: - data[field_name] = field.to_mongo(value) - data['_cls'] = self._class_name - data['_types'] = self._superclasses.keys() + [self._class_name] + data[field.name] = field.to_mongo(value) + # Only add _cls and _types if allow_inheritance is not False + if not (hasattr(self, '_meta') and + self._meta.get('allow_inheritance', True) == False): + data['_cls'] = self._class_name + data['_types'] = self._superclasses.keys() + [self._class_name] return data @classmethod @@ -241,6 +261,9 @@ class BaseDocument(object): data = dict((str(key), value) for key, value in son.items()) + if '_types' in data: + del data['_types'] + if '_cls' in data: del data['_cls'] @@ -254,7 +277,7 @@ class BaseDocument(object): cls = subclasses[class_name] for field_name, field in cls._fields.items(): - if field_name in data: - data[field_name] = field.to_python(data[field_name]) + if field.name in data: + data[field_name] = field.to_python(data[field.name]) return cls(**data) diff --git a/mongoengine/document.py b/mongoengine/document.py index 359a1789..a4b78619 100644 --- a/mongoengine/document.py +++ b/mongoengine/document.py @@ -6,25 +6,58 @@ __all__ = ['Document', 'EmbeddedDocument'] class EmbeddedDocument(BaseDocument): + """A :class:`~mongoengine.Document` that isn't stored in its own + collection. :class:`~mongoengine.EmbeddedDocument`\ s should be used as + fields on :class:`~mongoengine.Document`\ s through the + :class:`~mongoengine.EmbeddedDocumentField` field type. + """ __metaclass__ = DocumentMetaclass class Document(BaseDocument): + """The base class used for defining the structure and properties of + collections of documents stored in MongoDB. Inherit from this class, and + add fields as class attributes to define a document's structure. + Individual documents may then be created by making instances of the + :class:`~mongoengine.Document` subclass. + + By default, the MongoDB collection used to store documents created using a + :class:`~mongoengine.Document` subclass will be the name of the subclass + converted to lowercase. A different collection may be specified by + providing :attr:`collection` to the :attr:`meta` dictionary in the class + definition. + + A :class:`~mongoengine.Document` subclass may be itself subclassed, to + create a specialised version of the document that will be stored in the + same collection. To facilitate this behaviour, `_cls` and `_types` + fields are added to documents (hidden though the MongoEngine interface + though). To disable this behaviour and remove the dependence on the + presence of `_cls` and `_types`, set :attr:`allow_inheritance` to + ``False`` in the :attr:`meta` dictionary. + """ __metaclass__ = TopLevelDocumentMetaclass def save(self): - """Save the document to the database. If the document already exists, - it will be updated, otherwise it will be created. + """Save the :class:`~mongoengine.Document` to the database. If the + document already exists, it will be updated, otherwise it will be + created. """ - _id = self.objects._collection.save(self.to_mongo()) - self._id = _id + object_id = self.objects._collection.save(self.to_mongo()) + self.id = 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() @classmethod def drop_collection(cls): - """Drops the entire collection associated with this Document type from - the database. + """Drops the entire collection associated with this + :class:`~mongoengine.Document` type from the database. """ db = _get_db() db.drop_collection(cls._meta['collection']) diff --git a/mongoengine/fields.py b/mongoengine/fields.py index c8a9086d..e97aadb2 100644 --- a/mongoengine/fields.py +++ b/mongoengine/fields.py @@ -87,7 +87,7 @@ class DateTimeField(BaseField): class EmbeddedDocumentField(BaseField): """An embedded document field. Only valid values are subclasses of - EmbeddedDocument. + :class:`~mongoengine.EmbeddedDocument`. """ def __init__(self, document, **kwargs): @@ -179,19 +179,19 @@ class ReferenceField(BaseField): def to_mongo(self, document): if isinstance(document, (str, unicode, pymongo.objectid.ObjectId)): - _id = document + id_ = document else: try: - _id = document._id + id_ = document.id except: raise ValidationError('You can only reference documents once ' 'they have been saved to the database') - if not isinstance(_id, pymongo.objectid.ObjectId): - _id = pymongo.objectid.ObjectId(_id) + if not isinstance(id_, pymongo.objectid.ObjectId): + id_ = pymongo.objectid.ObjectId(id_) collection = self.document_type._meta['collection'] - return pymongo.dbref.DBRef(collection, _id) + return pymongo.dbref.DBRef(collection, id_) def validate(self, value): assert(isinstance(value, (self.document_type, pymongo.dbref.DBRef))) diff --git a/mongoengine/queryset.py b/mongoengine/queryset.py index dd860524..e324c529 100644 --- a/mongoengine/queryset.py +++ b/mongoengine/queryset.py @@ -5,15 +5,19 @@ import pymongo class QuerySet(object): """A set of results returned from a query. Wraps a MongoDB cursor, - providing Document objects as the results. + providing :class:`~mongoengine.Document` objects as the results. """ def __init__(self, document, collection): self._document = document self._collection = collection self._query = {} + + # If inheritance is allowed, only return instances and instances of + # subclasses of the class being used + if document._meta.get('allow_inheritance'): + self._query = {'_types': self._document._class_name} self._cursor_obj = None - self._ordering = [] def ensure_index(self, key_or_list, direction=None): """Ensure that the given indexes are in place. @@ -29,7 +33,8 @@ class QuerySet(object): return self def __call__(self, **query): - """Filter the selected documents by calling the queryset with a query. + """Filter the selected documents by calling the + :class:`~mongoengine.QuerySet` with a query. """ self._query.update(QuerySet._transform_query(**query)) return self @@ -73,7 +78,7 @@ class QuerySet(object): return result def with_id(self, object_id): - """Retrieve the object matching the _id provided. + """Retrieve the object matching the id provided. """ if not isinstance(object_id, pymongo.objectid.ObjectId): object_id = pymongo.objectid.ObjectId(object_id) @@ -84,7 +89,7 @@ class QuerySet(object): return result def next(self): - """Wrap the result in a Document object. + """Wrap the result in a :class:`~mongoengine.Document` object. """ return self._document._from_son(self._cursor.next()) @@ -94,41 +99,54 @@ class QuerySet(object): return self._cursor.count() def limit(self, n): - """Limit the number of returned documents to. + """Limit the number of returned documents to `n`. This may also be + achieved using array-slicing syntax (e.g. ``User.objects[:5]``). """ self._cursor.limit(n) # Return self to allow chaining return self def skip(self, n): - """Skip n documents before returning the results. + """Skip `n` documents before returning the results. This may also be + achieved using array-slicing syntax (e.g. ``User.objects[5:]``). """ self._cursor.skip(n) return self - - def order_by(self, *params): - """Apply ordering conditions, Django-style. - - e.g., ``Model.objects.().order_by("-published_date", "ordering")`` - will order first by ``published_date DESC``, and then ``ordering ASC``. - - """ - if not params: - self._ordering = [] - for param in params: - if param.startswith("-"): - param = param[1:] - sort_dir = pymongo.DESCENDING - else: - sort_dir = pymongo.ASCENDING - sort_rule = (param, sort_dir) - if not sort_rule in self._ordering: - self._ordering.append(sort_rule) - self._cursor.sort(self._ordering) + def __getitem__(self, key): + """Support skip and limit using getitem and slicing syntax. + """ + # Slice provided + if isinstance(key, slice): + self._cursor_obj = self._cursor[key] + # Allow further QuerySet modifications to be performed + return self + # Integer index provided + elif isinstance(key, int): + return self._document._from_son(self._cursor[key]) + + def order_by(self, *keys): + """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. + """ + key_list = [] + for key in keys: + direction = pymongo.ASCENDING + if key[0] == '-': + direction = pymongo.DESCENDING + if key[0] in ('-', '+'): + key = key[1:] + key_list.append((key, direction)) + + self._cursor.sort(key_list) return self def explain(self, format=False): + """Return an explain plan record for the + :class:`~mongoengine.queryset.QuerySet`\ 's cursor. + """ + plan = self._cursor.explain() if format: import pprint diff --git a/tests/document.py b/tests/document.py index c20e9b33..bec66ee9 100644 --- a/tests/document.py +++ b/tests/document.py @@ -41,7 +41,7 @@ class DocumentTest(unittest.TestCase): self.assertEqual(Person._fields['name'], name_field) self.assertEqual(Person._fields['age'], age_field) self.assertFalse('non_field' in Person._fields) - self.assertTrue('_id' in Person._fields) + self.assertTrue('id' in Person._fields) # Test iteration over fields fields = list(Person()) self.assertTrue('name' in fields and 'age' in fields) @@ -126,6 +126,36 @@ class DocumentTest(unittest.TestCase): self.assertEqual(Employee._meta['collection'], self.Person._meta['collection']) + def test_allow_inheritance(self): + """Ensure that inheritance may be disabled on simple classes and that + _cls and _types will not be used. + """ + class Animal(Document): + meta = {'allow_inheritance': False} + name = StringField() + + Animal.drop_collection() + + def create_dog_class(): + class Dog(Animal): + pass + self.assertRaises(ValueError, create_dog_class) + + # Check that _cls etc aren't present on simple documents + dog = Animal(name='dog') + dog.save() + collection = self.db[Animal._meta['collection']] + obj = collection.find_one() + self.assertFalse('_cls' in obj) + self.assertFalse('_types' in obj) + + Animal.drop_collection() + + def create_employee_class(): + class Employee(self.Person): + meta = {'allow_inheritance': False} + self.assertRaises(ValueError, create_employee_class) + def test_creation(self): """Ensure that document may be created using keyword arguments. """ @@ -145,7 +175,7 @@ class DocumentTest(unittest.TestCase): person['name'] = 'Another User' self.assertEquals(person['name'], 'Another User') - # Length = length(assigned fields + _id) + # Length = length(assigned fields + id) self.assertEquals(len(person), 3) self.assertTrue('age' in person) @@ -160,7 +190,7 @@ class DocumentTest(unittest.TestCase): content = StringField() self.assertTrue('content' in Comment._fields) - self.assertFalse('_id' in Comment._fields) + self.assertFalse('id' in Comment._fields) self.assertFalse(hasattr(Comment, '_meta')) def test_save(self): @@ -174,14 +204,23 @@ class DocumentTest(unittest.TestCase): person_obj = collection.find_one({'name': 'Test User'}) self.assertEqual(person_obj['name'], 'Test User') self.assertEqual(person_obj['age'], 30) - self.assertEqual(person_obj['_id'], person._id) + self.assertEqual(person_obj['_id'], person.id) + + def test_delete(self): + """Ensure that document may be deleted using the delete method. + """ + person = self.Person(name="Test User", age=30) + person.save() + self.assertEqual(self.Person.objects.count(), 1) + person.delete() + self.assertEqual(self.Person.objects.count(), 0) def test_save_custom_id(self): """Ensure that a document may be saved with a custom _id. """ # Create person object and save it to the database person = self.Person(name='Test User', age=30, - _id='497ce96f395f2f052a494fd4') + id='497ce96f395f2f052a494fd4') person.save() # Ensure that the object is in the database with the correct _id collection = self.db[self.Person._meta['collection']] @@ -268,7 +307,7 @@ class DocumentTest(unittest.TestCase): post_obj.author.age = 25 post_obj.author.save() - author = self.Person.objects(name='Test User').first() + author = list(self.Person.objects(name='Test User'))[-1] self.assertEqual(author.age, 25) BlogPost.drop_collection() diff --git a/tests/fields.py b/tests/fields.py index e8e7a46b..49c3f70f 100644 --- a/tests/fields.py +++ b/tests/fields.py @@ -46,10 +46,10 @@ class FieldTest(unittest.TestCase): name = StringField() person = Person(name='Test User') - self.assertRaises(AttributeError, getattr, person, '_id') - self.assertRaises(ValidationError, person.__setattr__, '_id', 47) - self.assertRaises(ValidationError, person.__setattr__, '_id', 'abc') - person._id = '497ce96f395f2f052a494fd4' + self.assertRaises(AttributeError, getattr, person, 'id') + self.assertRaises(ValidationError, person.__setattr__, 'id', 47) + self.assertRaises(ValidationError, person.__setattr__, 'id', 'abc') + person.id = '497ce96f395f2f052a494fd4' def test_string_validation(self): """Ensure that invalid values cannot be assigned to string fields. diff --git a/tests/queryset.py b/tests/queryset.py index 344ee65d..b73bab17 100644 --- a/tests/queryset.py +++ b/tests/queryset.py @@ -53,7 +53,7 @@ class QuerySetTest(unittest.TestCase): self.assertEqual(people.count(), 2) results = list(people) self.assertTrue(isinstance(results[0], self.Person)) - self.assertTrue(isinstance(results[0]._id, (pymongo.objectid.ObjectId, + self.assertTrue(isinstance(results[0].id, (pymongo.objectid.ObjectId, str, unicode))) self.assertEqual(results[0].name, "User A") self.assertEqual(results[0].age, 20) @@ -77,6 +77,26 @@ class QuerySetTest(unittest.TestCase): self.assertEqual(len(people), 1) self.assertEqual(people[0].name, 'User B') + person3 = self.Person(name="User C", age=40) + person3.save() + + # Test slice limit + people = list(self.Person.objects[:2]) + self.assertEqual(len(people), 2) + self.assertEqual(people[0].name, 'User A') + self.assertEqual(people[1].name, 'User B') + + # Test slice skip + people = list(self.Person.objects[1:]) + self.assertEqual(len(people), 2) + self.assertEqual(people[0].name, 'User B') + self.assertEqual(people[1].name, 'User C') + + # Test slice limit and skip + people = list(self.Person.objects[1:2]) + self.assertEqual(len(people), 1) + self.assertEqual(people[0].name, 'User B') + def test_find_one(self): """Ensure that a query using find_one returns a valid result. """ @@ -97,9 +117,18 @@ class QuerySetTest(unittest.TestCase): person = self.Person.objects(age__lt=30).first() self.assertEqual(person.name, "User A") + + # Use array syntax + person = self.Person.objects[0] + self.assertEqual(person.name, "User A") + + person = self.Person.objects[1] + self.assertEqual(person.name, "User B") + + self.assertRaises(IndexError, self.Person.objects.__getitem__, 2) # Find a document using just the object id - person = self.Person.objects.with_id(person1._id) + person = self.Person.objects.with_id(person1.id) self.assertEqual(person.name, "User A") def test_find_embedded(self): @@ -137,6 +166,25 @@ class QuerySetTest(unittest.TestCase): self.Person.objects.delete() self.assertEqual(self.Person.objects.count(), 0) + def test_order_by(self): + """Ensure that QuerySets may be ordered. + """ + self.Person(name="User A", age=20).save() + self.Person(name="User B", age=40).save() + self.Person(name="User C", age=30).save() + + names = [p.name for p in self.Person.objects.order_by('-age')] + self.assertEqual(names, ['User B', 'User C', 'User A']) + + names = [p.name for p in self.Person.objects.order_by('+age')] + self.assertEqual(names, ['User A', 'User C', 'User B']) + + names = [p.name for p in self.Person.objects.order_by('age')] + self.assertEqual(names, ['User A', 'User C', 'User B']) + + ages = [p.age for p in self.Person.objects.order_by('-name')] + self.assertEqual(ages, [30, 40, 20]) + def tearDown(self): self.Person.drop_collection()