From af38a92ec9862a7cf18766acf8a63a7adbcb6d4b Mon Sep 17 00:00:00 2001 From: Harry Marr Date: Sun, 15 Nov 2009 15:53:42 +0000 Subject: [PATCH] Initial commit --- .gitignore | 2 + mongomap/__init__.py | 0 mongomap/document.py | 68 +++++++++++++++++++++++++++ mongomap/fields.py | 109 +++++++++++++++++++++++++++++++++++++++++++ tests/document.py | 54 +++++++++++++++++++++ tests/fields.py | 55 ++++++++++++++++++++++ 6 files changed, 288 insertions(+) create mode 100644 .gitignore create mode 100644 mongomap/__init__.py create mode 100644 mongomap/document.py create mode 100644 mongomap/fields.py create mode 100644 tests/document.py create mode 100644 tests/fields.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d18402de --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc +.*.swp diff --git a/mongomap/__init__.py b/mongomap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mongomap/document.py b/mongomap/document.py new file mode 100644 index 00000000..2fed2be6 --- /dev/null +++ b/mongomap/document.py @@ -0,0 +1,68 @@ +import pymongo + +import fields + +class DocumentMetaclass(type): + """Metaclass for all documents. + """ + + def __new__(cls, name, bases, attrs): + doc_fields = {} + + # Include all fields present in superclasses + for base in bases: + if hasattr(base, '_fields'): + doc_fields.update(base._fields) + + # Add the document's fields to the _fields attribute + for attr_name, attr_val in attrs.items(): + if issubclass(attr_val.__class__, fields.Field): + if not attr_val.name: + attr_val.name = attr_name + doc_fields[attr_name] = attr_val + attrs['_fields'] = doc_fields + + return type.__new__(cls, name, bases, attrs) + + +class TopLevelDocumentMetaclass(DocumentMetaclass): + """Metaclass for top-level documents (i.e. documents that have their own + collection in the database. + """ + + def __new__(cls, name, bases, attrs): + # Classes defined in this module are abstract and should not have + # their own metadata with DB collection, etc. + if attrs['__module__'] != __name__: + collection = name.lower() + # Subclassed documents inherit collection from superclass + for base in bases: + if hasattr(base, '_meta') and 'collection' in base._meta: + collection = base._meta['collection'] + + meta = { + 'collection': collection, + } + meta.update(attrs.get('meta', {})) + attrs['_meta'] = meta + return DocumentMetaclass.__new__(cls, name, bases, attrs) + + +class Document(object): + + __metaclass__ = TopLevelDocumentMetaclass + + def __init__(self, **values): + self._data = {} + # Assign initial values to instance + for attr_name, attr_value in self._fields.items(): + if attr_name in values: + setattr(self, attr_name, values.pop(attr_name)) + else: + # Use default value + setattr(self, attr_name, getattr(self, attr_name)) + + def __iter__(self): + # Use _data rather than _fields as iterator only looks at names so + # values don't need to be converted to Python types + return iter(self._data) diff --git a/mongomap/fields.py b/mongomap/fields.py new file mode 100644 index 00000000..928fe5ce --- /dev/null +++ b/mongomap/fields.py @@ -0,0 +1,109 @@ +import re + + +__all__ = ['StringField', 'IntField', 'ValidationError'] + + +class ValidationError(Exception): + pass + + +class Field(object): + """A base class for fields in a MongoDB document. Instances of this class + may be added to subclasses of `Document` to define a document's schema. + """ + + def __init__(self, name=None, default=None): + self.name = name + self.default = default + + def __get__(self, instance, owner): + """Descriptor for retrieving a value from a field in a document. Do + any necessary conversion between Python and MongoDB types. + """ + if instance is None: + # Document class being used rather than a document object + return self + + # Get value from document instance if available, if not use default + value = instance._data.get(self.name) + if value is not None: + value = self._to_python(value) + elif self.default is not None: + value = self.default + if callable(value): + value = value() + return value + + def __set__(self, instance, value): + """Descriptor for assigning a value to a field in a document. Do any + necessary conversion between Python and MongoDB types. + """ + if value is not None: + try: + value = self._to_python(value) + self._validate(value) + value = self._to_mongo(value) + except ValueError: + raise ValidationError('Invalid value for field of type "' + + self.__class__.__name__ + '"') + instance._data[self.name] = value + + def _to_python(self, value): + """Convert a MongoDB-compatible type to a Python type. + """ + return unicode(value) + + def _to_mongo(self, value): + """Convert a Python type to a MongoDB-compatible type. + """ + return self._to_python(value) + + def _validate(self, value): + """Perform validation on a value. + """ + return value + + +class NestedDocumentField(Field): + """A nested document field. Only valid values are subclasses of + NestedDocument. + """ + pass + + +class StringField(Field): + """A unicode string field. + """ + + def __init__(self, regex=None, max_length=None, **kwargs): + self.regex = re.compile(regex) if regex else None + self.max_length = max_length + Field.__init__(self, **kwargs) + + def _validate(self, value): + if self.max_length is not None and len(value) > self.max_length: + raise ValidationError('String value is too long') + + if self.regex is not None and self.regex.match(value) is None: + message = 'String value did not match validation regex' + raise ValidationError(message) + + +class IntField(Field): + """An integer field. + """ + + def __init__(self, min_value=None, max_value=None, **kwargs): + self.min_value, self.max_value = min_value, max_value + Field.__init__(self, **kwargs) + + def _to_python(self, value): + return int(value) + + def _validate(self, value): + if self.min_value is not None and value < self.min_value: + raise ValidationError('Integer value is too small') + + if self.max_value is not None and value > self.max_value: + raise ValidationError('Integer value is too large') diff --git a/tests/document.py b/tests/document.py new file mode 100644 index 00000000..dc0c759b --- /dev/null +++ b/tests/document.py @@ -0,0 +1,54 @@ +import unittest + +from mongomap.document import Document +from mongomap.fields import StringField, IntField + + +class DocumentTest(unittest.TestCase): + + def test_definition(self): + """Ensure that document may be defined using fields. + """ + name_field = StringField() + age_field = IntField() + + class Person(Document): + name = name_field + age = age_field + non_field = True + + self.assertEqual(Person._fields['name'], name_field) + self.assertEqual(Person._fields['age'], age_field) + self.assertFalse('non_field' in Person._fields) + # Test iteration over fields + fields = list(Person()) + self.assertTrue('name' in fields and 'age' in fields) + + def test_inheritance(self): + """Ensure that document may inherit fields from a superclass document. + """ + class Person(Document): + name = StringField() + + class Employee(Person): + salary = IntField() + + self.assertTrue('name' in Employee._fields) + self.assertTrue('salary' in Employee._fields) + self.assertEqual(Employee._meta['collection'], + Person._meta['collection']) + + def test_creation(self): + """Ensure that document may be created using keyword arguments. + """ + class Person(Document): + name = StringField() + age = IntField() + + person = Person(name="Test User", age=30) + self.assertEqual(person.name, "Test User") + self.assertEqual(person.age, 30) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/fields.py b/tests/fields.py new file mode 100644 index 00000000..dbb58492 --- /dev/null +++ b/tests/fields.py @@ -0,0 +1,55 @@ +import unittest + +from mongomap.document import Document +from mongomap.fields import * + + +class FieldTest(unittest.TestCase): + + def test_default_values(self): + """Ensure that default field values are used when creating a document. + """ + class Person(Document): + name = StringField() + age = IntField(default=30) + userid = StringField(default=lambda: 'test') + + person = Person(name='Test Person') + self.assertEqual(person._data['age'], 30) + self.assertEqual(person._data['userid'], 'test') + + def test_string_validation(self): + """Ensure that invalid values cannot be assigned to string fields. + """ + class Person(Document): + name = StringField(max_length=20) + userid = StringField(r'[0-9a-z_]+$') + + person = Person() + # Test regex validation on userid + self.assertRaises(ValidationError, person.__setattr__, 'userid', + 'test.User') + person.userid = 'test_user' + self.assertEqual(person.userid, 'test_user') + + # Test max length validation on name + self.assertRaises(ValidationError, person.__setattr__, 'name', + 'Name that is more than twenty characters') + person.name = 'Shorter name' + self.assertEqual(person.name, 'Shorter name') + + def test_int_validation(self): + """Ensure that invalid values cannot be assigned to int fields. + """ + class Person(Document): + age = IntField(min_value=0, max_value=110) + + person = Person() + person.age = 50 + self.assertRaises(ValidationError, person.__setattr__, 'age', -1) + self.assertRaises(ValidationError, person.__setattr__, 'age', 120) + self.assertRaises(ValidationError, person.__setattr__, 'age', 'ten') + + +if __name__ == '__main__': + unittest.main()