├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── docs └── HISTORY.rst ├── setup.py └── simpledb ├── __init__.py ├── base.py ├── compiler.py ├── models.py ├── query.py ├── tests.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | *egg-info* 2 | *.pyc 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011, Fez Consulting Limited 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | * Neither the name of Fez Consulting Limited nor the names 15 | of its contributors may be used to endorse or promote products 16 | derived from this software without specific prior written 17 | permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 22 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 23 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 24 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 25 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 26 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 28 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 29 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE.txt 3 | recursive-include docs * 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | django-simpledb 3 | =============== 4 | 5 | *This project is unfinished - contributions welcomed!* 6 | 7 | ``django-simpledb`` is a database backend for `django-nonrel`_. 8 | 9 | .. _django-nonrel: http://www.allbuttonspressed.com/projects/django-nonrel 10 | 11 | It's unfinished. Patches are welcome, with tests. -------------------------------------------------------------------------------- /docs/HISTORY.rst: -------------------------------------------------------------------------------- 1 | 0.0.1 2 | ----- 3 | 4 | Unreleased version. -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import os 3 | 4 | version = '0.0.1' 5 | 6 | setup(name='django-simpledb', 7 | version=version, 8 | description="Lazy signup for Django", 9 | long_description=open("README.rst").read() + "\n" + 10 | open(os.path.join("docs", "HISTORY.rst")).read(), 11 | # Get more strings from http://www.python.org/pypi?%3Aaction=list_classifiers 12 | classifiers=[ 13 | "Programming Language :: Python", 14 | "Topic :: Software Development :: Libraries :: Python Modules", 15 | "Framework :: Django", 16 | "Development Status :: 3 - Alpha", 17 | "License :: OSI Approved :: BSD License" 18 | ], 19 | keywords='django nonrel nosql simpledb amazon', 20 | author='Dan Fairs', 21 | author_email='dan@fezconsulting.com', 22 | url='http://github.com/danfairs/django-simpledb', 23 | license='BSD', 24 | packages=find_packages(exclude=['ez_setup']), 25 | namespace_packages=[], 26 | include_package_data=True, 27 | zip_safe=False, 28 | install_requires=[ 29 | 'setuptools', 30 | 'djangotoolbox>=0.9.2', 31 | #'Django', nonrel 32 | ], 33 | test_requires=[ 34 | 'mock>=0.7.0' 35 | ], 36 | entry_points=""" 37 | # -*- Entry points: -*- 38 | """, 39 | ) 40 | -------------------------------------------------------------------------------- /simpledb/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danfairs/django-simpledb/5c5037257a678a00ed62d20265baa8f4399dd73e/simpledb/__init__.py -------------------------------------------------------------------------------- /simpledb/base.py: -------------------------------------------------------------------------------- 1 | from djangotoolbox.db.base import NonrelDatabaseFeatures, \ 2 | NonrelDatabaseOperations, NonrelDatabaseWrapper, NonrelDatabaseClient, \ 3 | NonrelDatabaseValidation, NonrelDatabaseIntrospection, \ 4 | NonrelDatabaseCreation 5 | 6 | # We don't use this, but `model` needs to be imported first due to a 7 | # relative import in boto.sdb.db.manager.get_manager, which is called in 8 | # a metaclass. This would otherwise be called during our next import line, 9 | # pulling in SDBManager, thus causing an ImportError due to a cyclic import. 10 | from boto.sdb.db import model 11 | from boto.sdb.db.manager.sdbmanager import SDBManager 12 | import boto 13 | 14 | from simpledb.utils import domain_for_model 15 | 16 | class HasConnection(object): 17 | 18 | @property 19 | def sdb(self): 20 | if not hasattr(self, '_sdb'): 21 | settings = self.connection.settings_dict 22 | self._sdb = boto.connect_sdb( 23 | aws_access_key_id=settings['AWS_ACCESS_KEY_ID'], 24 | aws_secret_access_key=settings['AWS_SECRET_ACCESS_KEY']) 25 | return self._sdb 26 | 27 | # TODO: You can either use the type mapping defined in NonrelDatabaseCreation 28 | # or you can override the mapping, here: 29 | class DatabaseCreation(NonrelDatabaseCreation, HasConnection): 30 | data_types = dict(NonrelDatabaseCreation.data_types, **{ 31 | 'TextField': 'text', 32 | 'XMLField': 'text', 33 | 'IntegerField': 'long', 34 | 'SmallIntegerField': 'int', 35 | 'PositiveIntegerField': 'long', 36 | 'PositiveSmallIntegerField': 'int', 37 | 'BigIntegerField': 'long', 38 | 'AutoField': 'long', 39 | 'DecimalField': 'unicode', 40 | 'ForeignKey': 'long', 41 | 'DateField': 'date', 42 | 'DateTimeField': 'datetime', 43 | }) 44 | 45 | def sql_create_model(self, model, style, known_models=set()): 46 | """ We don't actually return any SQL here, but we do go right ahead 47 | and create a domain for the model. 48 | """ 49 | domain_name = domain_for_model(model) 50 | self.sdb.create_domain(domain_name) 51 | return [], {} 52 | 53 | def create_test_db(self, verbosity=1, autoclobber=False): 54 | """ No test database for us """ 55 | return '' 56 | 57 | def destroy_test_db(self, old_database_name, verbosity=1): 58 | """ No-op """ 59 | pass 60 | 61 | 62 | class DatabaseFeatures(NonrelDatabaseFeatures): 63 | pass 64 | 65 | class DatabaseOperations(NonrelDatabaseOperations): 66 | compiler_module = __name__.rsplit('.', 1)[0] + '.compiler' 67 | 68 | class DatabaseClient(NonrelDatabaseClient): 69 | pass 70 | 71 | class DatabaseValidation(NonrelDatabaseValidation): 72 | pass 73 | 74 | class DatabaseIntrospection(NonrelDatabaseIntrospection, HasConnection): 75 | 76 | def table_names(self): 77 | """ We map tables onto AWS domains. 78 | """ 79 | rs = self.sdb.get_all_domains() 80 | return [d.name for d in rs] 81 | 82 | 83 | class DatabaseWrapper(NonrelDatabaseWrapper): 84 | def __init__(self, *args, **kwds): 85 | super(DatabaseWrapper, self).__init__(*args, **kwds) 86 | self.features = DatabaseFeatures(self) 87 | self.ops = DatabaseOperations(self) 88 | self.client = DatabaseClient(self) 89 | self.creation = DatabaseCreation(self) 90 | self.validation = DatabaseValidation(self) 91 | self.introspection = DatabaseIntrospection(self) 92 | 93 | def create_manager(self, domain_name): 94 | return SDBManager(cls=None, db_name=domain_name, 95 | db_user=self.settings_dict['AWS_ACCESS_KEY_ID'], 96 | db_passwd=self.settings_dict['AWS_SECRET_ACCESS_KEY'], 97 | db_host=None, db_port=None, db_table=None, ddl_dir=None, 98 | enable_ssl=True) 99 | -------------------------------------------------------------------------------- /simpledb/compiler.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import sys 4 | import uuid 5 | 6 | from django.db.models.sql.constants import LOOKUP_SEP, MULTI, SINGLE 7 | from django.db.models.sql.where import AND, OR 8 | from django.db.utils import DatabaseError, IntegrityError 9 | from django.utils.tree import Node 10 | 11 | from functools import wraps 12 | 13 | from boto.exception import (BotoClientError, SDBPersistenceError, 14 | BotoServerError) 15 | from boto.sdb.domain import Domain 16 | 17 | from djangotoolbox.db.basecompiler import NonrelQuery, NonrelCompiler, \ 18 | NonrelInsertCompiler, NonrelUpdateCompiler, NonrelDeleteCompiler 19 | 20 | from simpledb.query import SimpleDBQuery 21 | from simpledb.utils import domain_for_model 22 | 23 | logger = logging.getLogger('simpledb') 24 | AWS_MAX_RESULT_SIZE = 2500 25 | 26 | # TODO: Change this to match your DB 27 | # Valid query types (a dictionary is used for speedy lookups). 28 | OPERATORS_MAP = { 29 | 'exact': '=', 30 | 'gt': '>', 31 | 'gte': '>=', 32 | 'lt': '<', 33 | 'lte': '<=', 34 | 'isnull': lambda lookup_type, value: ('=' if value else '!=', None), 35 | 36 | #'startswith': lambda lookup_type, value: ..., 37 | #'range': lambda lookup_type, value: ..., 38 | #'year': lambda lookup_type, value: ..., 39 | } 40 | 41 | NEGATION_MAP = { 42 | 'exact': '!=', 43 | 'gt': '<=', 44 | 'gte': '<', 45 | 'lt': '>=', 46 | 'lte': '>', 47 | 'isnull': lambda lookup_type, value: ('!=' if value else '=', None), 48 | 49 | #'startswith': lambda lookup_type, value: ..., 50 | #'range': lambda lookup_type, value: ..., 51 | #'year': lambda lookup_type, value: ..., 52 | } 53 | 54 | DATETIME_ISO8601 = '%Y-%m-%dT%H:%M:%S.%f' 55 | DATE_ISO8601 = '%Y-%m-%d' 56 | 57 | def safe_call(func): 58 | @wraps(func) 59 | def _func(*args, **kwargs): 60 | try: 61 | return func(*args, **kwargs) 62 | except (BotoClientError, SDBPersistenceError, 63 | BotoServerError), e: 64 | raise DatabaseError, DatabaseError(*tuple(e)), sys.exc_info()[2] 65 | return _func 66 | 67 | def save_entity(connection, model, data): 68 | domain_name = domain_for_model(model) 69 | manager = connection.create_manager(domain_name) 70 | attrs = { 71 | '__type__': domain_name, 72 | } 73 | attrs.update(data) 74 | if not attrs.has_key('_id'): 75 | # New item. Generate an ID. 76 | attrs['_id'] = uuid.uuid4().int 77 | domain = Domain(name=domain_name, connection=manager.sdb) 78 | domain.put_attributes(attrs['_id'], attrs, replace=True) 79 | return attrs['_id'] 80 | 81 | 82 | class BackendQuery(NonrelQuery): 83 | 84 | def __init__(self, compiler, fields): 85 | super(BackendQuery, self).__init__(compiler, fields) 86 | # TODO: add your initialization code here 87 | domain = domain_for_model(self.query.model) 88 | self.db_query = SimpleDBQuery( 89 | self.connection.create_manager(domain), self.query.model) 90 | 91 | # This is needed for debugging 92 | def __repr__(self): 93 | # TODO: add some meaningful query string for debugging 94 | return '' % self.query.model._meta.db_table 95 | 96 | @safe_call 97 | def fetch(self, low_mark=None, high_mark=None): 98 | reslice = 0 99 | if high_mark > AWS_MAX_RESULT_SIZE: 100 | logger.warn('Requested result size %s, assuming infinite' % ( 101 | high_mark)) 102 | reslice = high_mark 103 | high_mark = None 104 | # TODO: run your low-level query here 105 | #low_mark, high_mark = self.limits 106 | if high_mark is None: 107 | # Infinite fetching 108 | results = self.db_query.fetch_infinite(offset=low_mark) 109 | if reslice: 110 | results = list(results)[:reslice] 111 | elif high_mark > low_mark: 112 | # Range fetching 113 | results = self.db_query.fetch_range(high_mark - low_mark, low_mark) 114 | else: 115 | results = () 116 | 117 | for entity in results: 118 | entity[self.query.get_meta().pk.column] = entity['_id'] 119 | del entity['_id'] 120 | yield entity 121 | 122 | @safe_call 123 | def count(self, limit=None): 124 | # TODO: implement this 125 | return self.db_query.count(limit) 126 | 127 | @safe_call 128 | def delete(self): 129 | self.db_query.delete() 130 | 131 | @safe_call 132 | def order_by(self, ordering): 133 | # TODO: implement this 134 | for order in ordering: 135 | if order.startswith('-'): 136 | column, direction = order[1:], 'DESC' 137 | else: 138 | column, direction = order, 'ASC' 139 | if column == self.query.get_meta().pk.column: 140 | column = '_id' 141 | self.db_query.add_ordering(column, direction) 142 | 143 | # This function is used by the default add_filters() implementation which 144 | # only supports ANDed filter rules and simple negation handling for 145 | # transforming OR filters to AND filters: 146 | # NOT (a OR b) => (NOT a) AND (NOT b) 147 | @safe_call 148 | def add_filter(self, column, lookup_type, negated, db_type, value): 149 | # TODO: implement this or the add_filters() function (see the base 150 | # class for a sample implementation) 151 | 152 | # Emulated/converted lookups 153 | if column == self.query.get_meta().pk.column: 154 | column = '_id' 155 | 156 | # Special-case IN 157 | if lookup_type == 'in': 158 | if negated: 159 | # XXX needs testing, error for now. 160 | raise NotImplementedError 161 | #op = '!=' 162 | else: 163 | op = '=' 164 | # boto needs IN queries' values to be lists of lists. 165 | db_value = [[self.convert_value_for_db(db_type, v)] for v in value] 166 | else: 167 | if negated: 168 | try: 169 | op = NEGATION_MAP[lookup_type] 170 | except KeyError: 171 | raise DatabaseError("Lookup type %r can't be negated" % lookup_type) 172 | else: 173 | try: 174 | op = OPERATORS_MAP[lookup_type] 175 | except KeyError: 176 | raise DatabaseError("Lookup type %r isn't supported" % lookup_type) 177 | 178 | # Handle special-case lookup types 179 | if callable(op): 180 | op, value = op(lookup_type, value) 181 | 182 | db_value = self.convert_value_for_db(db_type, value) 183 | 184 | # XXX check this is right 185 | self.db_query.filter('%s %s' % (column, op), db_value) 186 | #self.db_query.filter(column, op, db_value) 187 | 188 | class SQLCompiler(NonrelCompiler): 189 | query_class = BackendQuery 190 | 191 | # This gets called for each field type when you fetch() an entity. 192 | # db_type is the string that you used in the DatabaseCreation mapping 193 | def convert_value_from_db(self, db_type, value): 194 | # Handle list types 195 | if isinstance(value, (list, tuple)) and len(value) and \ 196 | db_type.startswith('ListField:'): 197 | db_sub_type = db_type.split(':', 1)[1] 198 | value = [self.convert_value_from_db(db_sub_type, subvalue) 199 | for subvalue in value] 200 | elif db_type == 'long': 201 | value = long(value) 202 | elif db_type == 'int': 203 | value = int(value) 204 | # Dates and datetimes are encoded as ISO 8601 205 | elif db_type == 'date': 206 | value = datetime.datetime.strptime(value, DATE_ISO8601).date() 207 | elif db_type == 'datetime': 208 | value = datetime.datetime.strptime(value, DATETIME_ISO8601) 209 | elif db_type == 'bool': 210 | if value == '0': 211 | value = False 212 | else: 213 | value = True 214 | elif isinstance(value, str): 215 | # Always retrieve strings as unicode 216 | value = value.decode('utf-8') 217 | return value 218 | 219 | # This gets called for each field type when you insert() an entity. 220 | # db_type is the string that you used in the DatabaseCreation mapping 221 | def convert_value_for_db(self, db_type, value): 222 | # TODO: implement this 223 | if db_type == 'bool': 224 | if value: 225 | value = u'1' 226 | else: 227 | value = u'0' 228 | elif isinstance(value, str): 229 | # Always store strings as unicode 230 | value = value.decode('utf-8') 231 | elif isinstance(value, (list, tuple)) and len(value) and \ 232 | db_type.startswith('ListField:'): 233 | db_sub_type = db_type.split(':', 1)[1] 234 | value = [self.convert_value_for_db(db_sub_type, subvalue) 235 | for subvalue in value] 236 | elif isinstance(value, datetime.datetime): 237 | value = value.strftime(DATETIME_ISO8601) 238 | elif isinstance(value, datetime.date): 239 | value = value.strftime(DATE_ISO8601) 240 | elif isinstance(value, (int, long)): 241 | value = str(value).decode('ascii') 242 | 243 | # XXX string quoting rules 244 | return value 245 | 246 | # This handles both inserts and updates of individual entities 247 | class SQLInsertCompiler(NonrelInsertCompiler, SQLCompiler): 248 | @safe_call 249 | def insert(self, data, return_id=False): 250 | pk_column = self.query.get_meta().pk.column 251 | if pk_column in data: 252 | data['_id'] = data[pk_column] 253 | del data[pk_column] 254 | pk = save_entity(self.connection, self.query.model, data) 255 | return pk 256 | 257 | class SQLUpdateCompiler(NonrelUpdateCompiler, SQLCompiler): 258 | pass 259 | 260 | class SQLDeleteCompiler(NonrelDeleteCompiler, SQLCompiler): 261 | pass 262 | -------------------------------------------------------------------------------- /simpledb/models.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------------- /simpledb/query.py: -------------------------------------------------------------------------------- 1 | from boto.sdb.db.query import Query as BotoQuery 2 | from boto.sdb.db.property import Property 3 | from boto.sdb.domain import Domain 4 | from boto.sdb.item import Item 5 | from simpledb.utils import domain_for_model 6 | 7 | def property_from_field(field): 8 | default = field.default 9 | if callable(default): 10 | default = default() 11 | choices = [c[0] for c in getattr(field, 'choices', ())] 12 | return Property( 13 | verbose_name=field.verbose_name, 14 | name=field.column, 15 | default=default, 16 | required=not field.null, 17 | choices=choices, 18 | unique=field.unique 19 | ) 20 | 21 | 22 | def model_adapter(django_model, manager): 23 | """ Return a generated subclass of django_model that conforms to the 24 | API that boto expects of its own models. 25 | """ 26 | class ModelAdapter(object): 27 | """ Adapter to provide the API that boto expects its models to have for 28 | normal Django models 29 | """ 30 | def __new__(self, id, **params): 31 | domain_name = domain_for_model(self.model_class) 32 | domain = Domain(name=domain_name, connection=manager.sdb) 33 | item = Item(domain, id) 34 | params['_id'] = id 35 | item.update(params) 36 | return item 37 | #return self.model_class(**attrs) 38 | 39 | # Used by SDBManager._get_all_descendents. Might need to implement 40 | # this for model inheritance... 41 | __sub_classes__ = () 42 | 43 | # Used by simpledb.base.SDBManager to track the real Django model 44 | # class this represents, and for us to know what kind of model to 45 | # actually instantiate. 46 | model_class = None 47 | 48 | @classmethod 49 | def find_property(cls, prop_name): 50 | """ Find the named property. Returns None if the property can't 51 | be found 52 | """ 53 | # Special-case - _id always maps to the primary key 54 | if prop_name == '_id': 55 | return property_from_field(django_model._meta.pk) 56 | 57 | # Otherwise, look through the Django model fields for a field 58 | # of the correct name. XXX should this be name or column? 59 | result = None 60 | for field in django_model._meta.fields: 61 | if field.name == prop_name: 62 | result = property_from_field(field) 63 | break 64 | return result 65 | 66 | @classmethod 67 | def properties(cls, hidden=True): 68 | return [property_from_field(f) for f in django_model._meta.fields] 69 | 70 | ModelAdapter.model_class = django_model 71 | ModelAdapter.__name__ = domain_for_model(django_model) 72 | return ModelAdapter 73 | 74 | 75 | class SimpleDBQuery(BotoQuery): 76 | 77 | def __init__(self, manager, model, limit=None, next_token=None): 78 | self.manager = manager 79 | self.model_class = model_adapter(model, manager) 80 | self.model = model 81 | self.limit = limit 82 | self.offset = 0 83 | self.filters = [] 84 | self.select = None 85 | self.sort_by = None 86 | self.rs = None 87 | self.next_token = next_token 88 | 89 | def fetch_infinite(self, offset): 90 | # XXX todo self.offset = offset 91 | if offset: 92 | raise NotImplementedError 93 | return self.manager.query(self) 94 | 95 | def fetch_range(self, count, low_mark): 96 | self.fetch(offset=low_mark, limit=low_mark+count) 97 | return self.manager.query(self) 98 | 99 | def add_ordering(self, column, direction): 100 | if direction.lower() == 'desc': 101 | sort_by = '-%s' % column 102 | else: 103 | sort_by = column 104 | 105 | if self.sort_by and self.sort_by != sort_by: 106 | # XXX What should we do here? Order in software? 107 | raise NotImplementedError 108 | 109 | self.sort_by = sort_by 110 | 111 | def delete(self): 112 | items = dict([(e['_id'], None) for e in self.fetch_infinite(0)]) 113 | domain = Domain(name=domain_for_model(self.model), 114 | connection=self.manager.sdb) 115 | return domain.batch_delete_attributes(items) 116 | -------------------------------------------------------------------------------- /simpledb/tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import mock 3 | import unittest 4 | from django.db import models 5 | 6 | class M(models.Model): 7 | name = models.CharField( 8 | 'long name', 9 | max_length=20, 10 | default='hi', 11 | unique=True) 12 | 13 | class X(models.Model): 14 | fk = models.ForeignKey('M') 15 | 16 | class ModelAdapterTests(unittest.TestCase): 17 | 18 | def adapt(self, model): 19 | from simpledb.query import model_adapter 20 | from boto.sdb.db import model as boto_model 21 | from boto.sdb.db.manager.sdbmanager import SDBManager 22 | 23 | manager = mock.Mock(spec=SDBManager) 24 | manager.sdb = self.sdb = mock.Mock(name='sdb') 25 | return model_adapter(model, manager) 26 | 27 | def test_find_property_ok(self): 28 | """ find_property should return a boto Property object for fields 29 | present on the model 30 | """ 31 | m = self.adapt(M) 32 | prop = m.find_property('name') 33 | self.assertEqual('long name', prop.verbose_name) 34 | self.assertEqual(True, prop.unique) 35 | self.assertEqual('hi', prop.default) 36 | 37 | def test_find_property_fk(self): 38 | """ The name of the property should be the database column, else the 39 | foreign key values won't be populated. 40 | """ 41 | m = self.adapt(X) 42 | prop = m.find_property('fk') 43 | self.assertEqual('fk_id', prop.name) 44 | 45 | def test_find_property_id(self): 46 | """ The _id property is special-cased to return the primary key 47 | """ 48 | m = self.adapt(X) 49 | prop = m.find_property('_id') 50 | self.assertEqual('id', prop.name) 51 | 52 | def test_find_property_callable_default(self): 53 | """ If the default is callable, then accessing the default should 54 | call. 55 | """ 56 | r = range(0, 3) 57 | def count(): 58 | return r.pop(0) 59 | 60 | class N(models.Model): 61 | counter = models.PositiveIntegerField(default=count) 62 | m = self.adapt(N) 63 | self.assertEqual(0, m.find_property('counter').default) 64 | self.assertEqual(1, m.find_property('counter').default) 65 | self.assertEqual(2, m.find_property('counter').default) 66 | 67 | def test_missing_property_none(self): 68 | """ If the property is missing, we should get None back. 69 | """ 70 | m = self.adapt(M) 71 | self.assertEqual(None, m.find_property('foo')) 72 | 73 | 74 | class SaveEntityTests(unittest.TestCase): 75 | 76 | def setUp(self): 77 | from boto.sdb.db import model 78 | from boto.sdb.db.manager.sdbmanager import SDBManager 79 | self.manager = mock.Mock(spec=SDBManager) 80 | self.manager.sdb = self.sdb = mock.Mock(name='sdb') 81 | self.connection = mock.Mock() 82 | self.connection.create_manager.return_value = self.manager 83 | 84 | def save_entity(self, *args, **kwargs): 85 | from simpledb.compiler import save_entity 86 | return save_entity(*args, **kwargs) 87 | 88 | def test_save_entity_no_id(self): 89 | """ Check that the appropriate methods are invoked on the boto 90 | manager when no id is present """ 91 | r = self.save_entity(self.connection, M, {'name': u'foo'}) 92 | 93 | # Since our data didn't have an _id, we should get a new uuid4 ID back 94 | self.assertTrue(bool(r)) 95 | args, kwargs = self.sdb.put_attributes.call_args 96 | self.assertEqual({}, kwargs) 97 | domain, id, data, replace, expected = args 98 | self.assertEqual('simpledb_m', domain.name) 99 | self.assertEqual(r, id) 100 | self.assertEqual({ 101 | '_id': r, 102 | '__type__': 103 | 'simpledb_m', 104 | 'name': 'foo', 105 | }, data) 106 | self.assertTrue(replace) 107 | self.assertEqual(None, expected) 108 | 109 | def test_save_entity_with_id(self): 110 | """ Check that the appropriate methods are invoked on the boto 111 | manager when an id is present """ 112 | my_id = u'x' * 32 113 | r = self.save_entity(self.connection, M, { 114 | 'name': u'foo', 115 | '_id': my_id 116 | }) 117 | 118 | # Shoudl get the same ID back 119 | self.assertEqual(my_id, r) 120 | args, kwargs = self.sdb.put_attributes.call_args 121 | self.assertEqual({}, kwargs) 122 | domain, id, data, replace, expected = args 123 | self.assertEqual('simpledb_m', domain.name) 124 | self.assertEqual(r, id) 125 | self.assertEqual({ 126 | '_id': r, 127 | '__type__': 128 | 'simpledb_m', 129 | 'name': 'foo', 130 | }, data) 131 | self.assertTrue(replace) 132 | self.assertEqual(None, expected) 133 | 134 | 135 | class ConnectionTests(unittest.TestCase): 136 | 137 | def setUp(self): 138 | self.query = mock.Mock() 139 | self.model = self.query.model = M 140 | meta = mock.Mock() 141 | self.query.get_meta.return_value = meta 142 | meta.pk.column = 'id_col' 143 | self.connection = mock.Mock() 144 | 145 | 146 | class InsertConnectionTests(ConnectionTests): 147 | 148 | def compiler(self): 149 | from simpledb.compiler import SQLInsertCompiler 150 | return SQLInsertCompiler(self.query, self.connection, None) 151 | 152 | @mock.patch('simpledb.compiler.save_entity') 153 | def test_insert_compiler_no_id(self, mock_save): 154 | """ Check that the insert compiler invokes save_entity correctly, 155 | when there's no ID column present in the data 156 | """ 157 | compiler = self.compiler() 158 | compiler.insert({'name': 'foo'}) 159 | args, kwargs = mock_save.call_args 160 | conn, m, data = args 161 | self.assertEqual(self.connection, conn) 162 | self.assertEqual(self.model, m) 163 | self.assertEqual({'name': 'foo'}, data) 164 | 165 | @mock.patch('simpledb.compiler.save_entity') 166 | def test_insert_compiler_id(self, mock_save): 167 | """ Check that the insert compiler invokes save_entity correctly, 168 | when there's an ID column present in the data - it should get renamed 169 | to _id. 170 | """ 171 | compiler = self.compiler() 172 | compiler.insert({ 173 | 'name': 'foo', 174 | 'id_col': 'fizz', 175 | }) 176 | args, kwargs = mock_save.call_args 177 | conn, m, data = args 178 | self.assertEqual(self.connection, conn) 179 | self.assertEqual(self.model, m) 180 | self.assertEqual({ 181 | 'name': 'foo', 182 | '_id': 'fizz' 183 | }, data) 184 | 185 | 186 | class SQLConnectionTests(ConnectionTests): 187 | 188 | def compiler(self): 189 | from simpledb.compiler import SQLCompiler 190 | return SQLCompiler(self.query, self.connection, None) 191 | 192 | def test_convert_date_from_db(self): 193 | """ Check we can convert from an ISO 8601 format back to a 194 | datetime.date 195 | """ 196 | dt = '2008-06-10' 197 | converted = self.compiler().convert_value_from_db('date', dt) 198 | self.assertEqual(2008, converted.year) 199 | self.assertEqual(6, converted.month) 200 | self.assertEqual(10, converted.day) 201 | 202 | def test_convert_date_to_db(self): 203 | """ Check that a date gets encoded to an ISO 8601 string 204 | correctly 205 | """ 206 | dt = datetime.date(2008, 6, 10) 207 | actual = self.compiler().convert_value_for_db('date', dt) 208 | self.assertEqual('2008-06-10', actual) 209 | 210 | def test_convert_datetime_from_db(self): 211 | """ Check that datetimes get created properly from encoded strings 212 | """ 213 | dt = '2008-06-10T14:02:36.25' 214 | converted = self.compiler().convert_value_from_db('datetime', dt) 215 | self.assertEqual(2008, converted.year) 216 | self.assertEqual(6, converted.month) 217 | self.assertEqual(10, converted.day) 218 | self.assertEqual(14, converted.hour) 219 | self.assertEqual(2, converted.minute) 220 | self.assertEqual(36, converted.second) 221 | self.assertEqual(250000, converted.microsecond) 222 | 223 | def test_convert_datetime_to_db(self): 224 | dt = datetime.datetime(2008, 6, 10, 14, 2, 36, 250000) 225 | actual = self.compiler().convert_value_for_db('datetime', dt) 226 | self.assertEqual('2008-06-10T14:02:36.250000', actual) 227 | 228 | def test_convert_long_to_db(self): 229 | actual = self.compiler().convert_value_for_db('long', 1L) 230 | self.assertEqual('1', actual) 231 | 232 | def test_convert_long_from_db(self): 233 | actual = self.compiler().convert_value_from_db('long', '1') 234 | self.assertEqual(1L, actual) 235 | 236 | def test_convert_bool_to_db(self): 237 | actual = self.compiler().convert_value_for_db('bool', True) 238 | self.assertEqual('1', actual) 239 | actual = self.compiler().convert_value_for_db('bool', False) 240 | self.assertEqual('0', actual) 241 | 242 | def test_convert_bool_from_db(self): 243 | actual = self.compiler().convert_value_from_db('bool', '1') 244 | self.assertTrue(actual) 245 | actual = self.compiler().convert_value_from_db('bool', '0') 246 | self.assertFalse(actual) 247 | 248 | class SimpleDBQueryTests(unittest.TestCase): 249 | 250 | def query(self): 251 | from simpledb.query import SimpleDBQuery 252 | manager = mock.Mock() 253 | return SimpleDBQuery(manager, M, None) 254 | 255 | def test_ordering_asc(self): 256 | query = self.query() 257 | query.add_ordering('foo', 'ASC') 258 | self.assertEqual('foo', query.sort_by) 259 | 260 | def test_ordering_desc(self): 261 | query = self.query() 262 | query.add_ordering('foo', 'DESC') 263 | self.assertEqual('-foo', query.sort_by) 264 | 265 | def test_ordering_reset(self): 266 | query = self.query() 267 | query.add_ordering('foo', 'DESC') 268 | 269 | # Change ordering isn't implemented 270 | self.assertRaises( 271 | NotImplementedError, 272 | query.add_ordering, 273 | 'foo', 274 | 'ASC' 275 | ) 276 | 277 | # Not changing, should be OK 278 | query.add_ordering('foo', 'DESC') 279 | 280 | # Change order field also not allowed 281 | self.assertRaises( 282 | NotImplementedError, 283 | query.add_ordering, 284 | 'bar', 285 | 'DESC' 286 | ) 287 | 288 | @mock.patch('simpledb.query.SimpleDBQuery.fetch_infinite') 289 | @mock.patch('boto.sdb.domain.Domain.batch_delete_attributes') 290 | def test_delete(self, mock_boto_delete, mock_fetch): 291 | """ Delete should fetch all items in the current query, and end up 292 | calling boto's Domain.batch_delete_attributes with a dict - all item 293 | names as keys, and None as values. This will cause SimpleDB to delete 294 | the item completely. 295 | """ 296 | fake_results = [{'_id': 1}, {'_id': 2}] 297 | mock_fetch.return_value = iter(fake_results) 298 | query = self.query() 299 | query.delete() 300 | mock_fetch.assert_called_with(0) 301 | mock_boto_delete.assert_called_with({1: None, 2: None}) 302 | 303 | 304 | class BackendQueryTests(unittest.TestCase): 305 | 306 | @mock.patch('simpledb.query.domain_for_model') 307 | def backend_query(self, mock_domain): 308 | from simpledb.compiler import BackendQuery 309 | mock_domain.return_value = 'some_name' 310 | compiler = mock.Mock() 311 | def f(db_type, value): 312 | return value 313 | compiler.convert_value_for_db.side_effect = f 314 | return BackendQuery(compiler, None) 315 | 316 | @mock.patch('simpledb.query.SimpleDBQuery.delete') 317 | def test_delete(self, mock_delete): 318 | """ delete() on the backend query simply proxies directly to the 319 | delete() method on SimpleDBQuery 320 | """ 321 | self.backend_query().delete() 322 | mock_delete.assert_called_with() 323 | 324 | def test_add_filter_in(self): 325 | """ 'in' queries are passed to boto like regular equals queries, but 326 | with a list of values rather than a single value. boto will OR them. 327 | """ 328 | query = self.backend_query() 329 | query.add_filter('name', 'in', False, 'unicode', ['x', 'y']) 330 | self.assertEqual([('name =', [['x'], ['y']])], query.db_query.filters) 331 | 332 | class IntegrationTests(unittest.TestCase): 333 | 334 | @mock.patch('simpledb.query.SimpleDBQuery.fetch_infinite') 335 | def test_fetch(self, mock_fetch): 336 | """ 337 | """ 338 | # List of values for the mock fetch to return, as it'll be called 339 | # for both the fetch of X, and the traverse to the related M model. 340 | values = [ 341 | [{ 342 | '_id': u'999', 343 | 'fk_id': u'123456' 344 | }], 345 | [{ 346 | '_id': u'123456', 347 | 'name': u'name for m' 348 | }] 349 | ] 350 | def r(*args, **kwargs): 351 | return values.pop(0) 352 | mock_fetch.side_effect = r 353 | xs = X.objects.all() 354 | self.assertEqual(1, len(xs)) 355 | x = xs[0] 356 | self.assertEqual(123456, x.fk_id) 357 | self.assertEqual(u'name for m', x.fk.name) 358 | -------------------------------------------------------------------------------- /simpledb/utils.py: -------------------------------------------------------------------------------- 1 | def domain_for_model(model): 2 | return model._meta.db_table --------------------------------------------------------------------------------