Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
hmarr committed Nov 15, 2009
0 parents commit af38a92
Show file tree
Hide file tree
Showing 6 changed files with 288 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
@@ -0,0 +1,2 @@
*.pyc
.*.swp
Empty file added mongomap/__init__.py
Empty file.
68 changes: 68 additions & 0 deletions 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)
109 changes: 109 additions & 0 deletions 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')
54 changes: 54 additions & 0 deletions 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()
55 changes: 55 additions & 0 deletions 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()

0 comments on commit af38a92

Please sign in to comment.