├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .isort.cfg ├── .travis.yml ├── LICENSE ├── README.md ├── django_hana ├── __init__.py ├── base.py ├── client.py ├── compat.py ├── compiler.py ├── creation.py ├── introspection.py ├── models.py ├── operations.py └── schema.py ├── requirements-testing.txt ├── runtests.py ├── setup.py ├── tests ├── __init__.py ├── mock_db.py ├── models.py ├── test_queries.py └── test_settings.py └── tox.ini /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### What is the expected behavior? 2 | 3 | 4 | #### What is the current behavior? 5 | 6 | 7 | #### What are the steps to reproduce? 8 | 9 | 10 | #### Which versions of Python and Django are affected? 11 | 12 | 13 | #### Is there anything else we should know? 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | 45 | # Translations 46 | *.mo 47 | *.pot 48 | 49 | # Django stuff: 50 | *.log 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | # PyBuilder 56 | target/ 57 | 58 | # Visual Studio Code settings 59 | .vscode 60 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | skip=.tox 3 | atomic=true 4 | multi_line_output=5 5 | known_third_party=django,mock 6 | known_first_party=django_hana 7 | line_length=120 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | 8 | sudo: false 9 | 10 | env: 11 | - DJANGO=1.8 12 | - DJANGO=1.9 13 | - DJANGO=1.10 14 | - DJANGO=1.11 15 | - DJANGO=master 16 | 17 | matrix: 18 | fast_finish: true 19 | include: 20 | - python: "3.6" 21 | env: DJANGO=master 22 | - python: "3.6" 23 | env: DJANGO=1.11 24 | - python: "3.3" 25 | env: DJANGO=1.8 26 | - python: "2.7" 27 | env: TOXENV="isort" 28 | - python: "2.7" 29 | env: TOXENV="lint" 30 | - python: "3.5" 31 | env: TOXENV="lint" 32 | exclude: 33 | - python: "2.7" 34 | env: DJANGO=master 35 | - python: "3.4" 36 | env: DJANGO=master 37 | 38 | allow_failures: 39 | - env: DJANGO=master 40 | - env: DJANGO=1.11 41 | 42 | install: 43 | - pip install tox tox-travis 44 | 45 | script: 46 | - tox 47 | 48 | after_success: 49 | - pip install codecov 50 | - codecov -e TOXENV,DJANGO 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) django_hana developers. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the django_hana nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django DB Backend for SAP HANA 2 | 3 | [![Build Status](https://travis-ci.org/mathebox/django_hana_pyhdb.svg?branch=master)](https://travis-ci.org/mathebox/django_hana_pyhdb) 4 | [![codecov](https://codecov.io/gh/mathebox/django_hana_pyhdb/branch/master/graph/badge.svg)](https://codecov.io/gh/mathebox/django_hana_pyhdb) 5 | 6 | - build on top of [PyHDB](https://github.com/SAP/PyHDB) 7 | - original work done by [@kapilratnani](https://github.com/kapilratnani) (https://github.com/kapilratnani/django_hana) 8 | 9 | ## Installation 10 | 1. Install [PyHDB](https://github.com/SAP/PyHDB) 11 | 12 | 1. Install the python package via setup.py 13 | 14 | ```bash 15 | python setup.py install 16 | ``` 17 | 18 | 1. The config in the Django project is as follows 19 | 20 | ```python 21 | DATABASES = { 22 | 'default': { 23 | 'ENGINE': 'django_hana', # or as per your python path 24 | 'NAME': '', # The schema to use. It will be created if doesn't exist 25 | 'USER': '', 26 | 'PASSWORD': '', 27 | 'HOST': '', 28 | 'PORT': '315', 29 | } 30 | } 31 | ``` 32 | 1. HANA doesn't support Timezone. Set USE_TZ=False in settings.py. 33 | 34 | ## Config 35 | ### Column/Row store 36 | Use the column/row-store class decorators to make sure that your models are using the correct HANA engine. If the models are not using any decorators the default behaviour will be a ROW-store column. 37 | ```python 38 | from django.db import models 39 | from django_hana import column_store, row_store 40 | 41 | @column_store 42 | class ColumnStoreModel(models.Model): 43 | some_field = models.CharField() 44 | 45 | @row_store 46 | class RowStoreModel(models.Model): 47 | some_field = models.CharField() 48 | ``` 49 | 50 | ### Support of spatial column types 51 | Add `django.contrib.gis` to your `INSTALLED_APPS`. 52 | 53 | In your `models.py` files use 54 | ``` 55 | from django.contrib.gis.db.models import ... 56 | ``` 57 | instead of 58 | ``` 59 | from django.db.models import ... 60 | ``` 61 | Make use of the following fields: 62 | - PointField 63 | - LineStringField 64 | - PolygonField 65 | - MultiPointField 66 | - MulitLineString 67 | - MultiPolygon 68 | 69 | ## Contributing 70 | 71 | 1. Fork repo 72 | 1. Create your feature branch (e.g. `git checkout -b my-new-feature`) 73 | 1. Implement your feature 74 | 1. Commit your changes (e.g. `git commit -am 'Add some feature'` | See [here](https://git-scm.com/book/ch5-2.html#_commit_guidelines)) 75 | 1. Push to the branch (e.g. `git push -u origin my-new-feature`) 76 | 1. Create new pull request 77 | 78 | ## Setting up for developement / Implement a feature 79 | 80 | 1. (Optional) Create virtualenv 81 | 1. Install development dependencies (`pip install -r requirements-testing.txt`) 82 | 1. Add test case 83 | 1. Run tests 84 | 1. For all supported python and django version: `tox` 85 | 1. For a single env: `tox -e ` (e.g. `tox -e py35django110`) 86 | 1. Tests failing? 87 | 1. Hack, hack, hack 88 | 1. Run tests again 89 | 1. Tests should pass 90 | 1. Run isort (`isort -rc .` or `tox -e isort`) 91 | 1. run flake8 (`flake8 .` or `tox -e lint`) 92 | 93 | 94 | ## Disclaimer 95 | This project is not a part of standard SAP HANA delivery, hence SAP support is not responsible for any queries related to 96 | this software. All queries/issues should be reported here. 97 | -------------------------------------------------------------------------------- /django_hana/__init__.py: -------------------------------------------------------------------------------- 1 | # REGISTER 2 | MODEL_STORE = {} 3 | 4 | 5 | # Model class decorators 6 | def column_store(klass): 7 | """Register model use HANA's column store""" 8 | MODEL_STORE[klass.__name__] = 'COLUMN' 9 | return klass 10 | 11 | 12 | def row_store(klass): 13 | """Register model use HANA's column store""" 14 | MODEL_STORE[klass.__name__] = 'ROW' 15 | return klass 16 | -------------------------------------------------------------------------------- /django_hana/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | SAP HANA database backend for Django. 3 | """ 4 | import logging 5 | import sys 6 | from time import time 7 | 8 | from django.contrib.gis.db.backends.base.features import BaseSpatialFeatures 9 | from django.db import utils 10 | from django.db.backends.base.base import BaseDatabaseWrapper 11 | from django.db.backends.base.features import BaseDatabaseFeatures 12 | from django.db.backends.base.validation import BaseDatabaseValidation 13 | from django.db.transaction import TransactionManagementError 14 | from django.utils import six 15 | 16 | try: 17 | import pyhdb as Database 18 | setattr(Database, 'Binary', Database.Blob) # add mapping form Binary to BLOB 19 | except ImportError as e: 20 | from django.core.exceptions import ImproperlyConfigured 21 | raise ImproperlyConfigured('Error loading PyHDB module: %s' % e) 22 | 23 | from django_hana.client import DatabaseClient # NOQA isort:skip 24 | from django_hana.creation import DatabaseCreation # NOQA isort:skip 25 | from django_hana.introspection import DatabaseIntrospection # NOQA isort:skip 26 | from django_hana.operations import DatabaseOperations # NOQA isort:skip 27 | from django_hana.schema import DatabaseSchemaEditor # NOQA isort:skip 28 | 29 | logger = logging.getLogger('django.db.backends') 30 | 31 | 32 | class DatabaseFeatures(BaseDatabaseFeatures, BaseSpatialFeatures): 33 | needs_datetime_string_cast = True 34 | can_return_id_from_insert = False 35 | requires_rollback_on_dirty_transaction = True 36 | has_real_datatype = True 37 | can_defer_constraint_checks = True 38 | has_select_for_update = True 39 | has_select_for_update_nowait = True 40 | has_bulk_insert = True 41 | supports_tablespaces = False 42 | supports_transactions = True 43 | can_distinct_on_fields = False 44 | uses_autocommit = True 45 | uses_savepoints = False 46 | can_introspect_foreign_keys = False 47 | supports_timezones = False 48 | requires_literal_defaults = True 49 | 50 | 51 | class CursorWrapper(object): 52 | """ 53 | Hana doesn't support %s placeholders 54 | Wrapper to convert all %s placeholders to qmark(?) placeholders 55 | """ 56 | codes_for_integrityerror = (301,) 57 | 58 | def __init__(self, cursor, db): 59 | self.cursor = cursor 60 | self.db = db 61 | self.is_hana = True 62 | 63 | def set_dirty(self): 64 | if not self.db.get_autocommit(): 65 | self.db.set_dirty() 66 | 67 | def __getattr__(self, attr): 68 | self.set_dirty() 69 | if attr in self.__dict__: 70 | return self.__dict__[attr] 71 | else: 72 | return getattr(self.cursor, attr) 73 | 74 | def __iter__(self): 75 | return iter(self.cursor) 76 | 77 | def __enter__(self): 78 | return self 79 | 80 | def __exit__(self, type, value, traceback): 81 | # self.cursor.close() 82 | pass 83 | 84 | def execute(self, sql, params=()): 85 | """ 86 | execute with replaced placeholders 87 | """ 88 | try: 89 | self.cursor.execute(self._replace_params(sql), params) 90 | except Database.IntegrityError as e: 91 | six.reraise(utils.IntegrityError, utils.IntegrityError(*tuple(e.args)), sys.exc_info()[2]) 92 | except Database.Error as e: 93 | # Map some error codes to IntegrityError, since they seem to be 94 | # misclassified and Django would prefer the more logical place. 95 | if e[0] in self.codes_for_integrityerror: 96 | six.reraise(utils.IntegrityError, utils.IntegrityError(*tuple(e.args)), sys.exc_info()[2]) 97 | six.reraise(utils.DatabaseError, utils.DatabaseError(*tuple(e.args)), sys.exc_info()[2]) 98 | 99 | def executemany(self, sql, param_list): 100 | try: 101 | self.cursor.executemany(self._replace_params(sql), param_list) 102 | except Database.IntegrityError as e: 103 | six.reraise(utils.IntegrityError, utils.IntegrityError(*tuple(e.args)), sys.exc_info()[2]) 104 | except Database.Error as e: 105 | # Map some error codes to IntegrityError, since they seem to be 106 | # misclassified and Django would prefer the more logical place. 107 | if e[0] in self.codes_for_integrityerror: 108 | six.reraise(utils.IntegrityError, utils.IntegrityError(*tuple(e.args)), sys.exc_info()[2]) 109 | six.reraise(utils.DatabaseError, utils.DatabaseError(*tuple(e.args)), sys.exc_info()[2]) 110 | 111 | def _replace_params(self, sql): 112 | """ 113 | converts %s style placeholders to ? 114 | """ 115 | return sql.replace('%s', '?') 116 | 117 | 118 | class CursorDebugWrapper(CursorWrapper): 119 | def execute(self, sql, params=()): 120 | self.set_dirty() 121 | start = time() 122 | try: 123 | return CursorWrapper.execute(self, sql, params) 124 | finally: 125 | stop = time() 126 | duration = stop - start 127 | 128 | def sanitize_blob(value): 129 | if isinstance(value, Database.Blob): 130 | value = value.encode() 131 | return value 132 | 133 | params = [sanitize_blob(p) for p in params] if isinstance(params, (list, tuple)) else params 134 | params = sanitize_blob(params) 135 | 136 | sql = self.db.ops.last_executed_query(self.cursor, sql, params) 137 | self.db.queries.append({ 138 | 'sql': sql, 139 | 'time': '%.3f' % duration, 140 | }) 141 | logger.debug('(%.3f) %s; args=%s' % (duration, sql, params), extra={ 142 | 'duration': duration, 143 | 'sql': sql, 144 | 'params': params, 145 | }) 146 | 147 | def executemany(self, sql, param_list): 148 | self.set_dirty() 149 | start = time() 150 | try: 151 | return CursorWrapper.executemany(self, sql, param_list) 152 | finally: 153 | stop = time() 154 | duration = stop - start 155 | try: 156 | times = len(param_list) 157 | except TypeError: # param_list could be an iterator 158 | times = '?' 159 | self.db.queries.append({ 160 | 'sql': '%s times: %s' % (times, sql), 161 | 'time': '%.3f' % duration, 162 | }) 163 | logger.debug('(%.3f) %s; args=%s' % (duration, sql, param_list), extra={ 164 | 'duration': duration, 165 | 'sql': sql, 166 | 'params': param_list 167 | }) 168 | 169 | 170 | class DatabaseWrapper(BaseDatabaseWrapper): 171 | vendor = 'hana' 172 | 173 | data_types = { 174 | 'AutoField': 'INTEGER', 175 | 'BigIntegerField': 'BIGINT', 176 | 'BinaryField': 'BLOB', 177 | 'BooleanField': 'TINYINT', 178 | 'CharField': 'NVARCHAR(%(max_length)s)', 179 | 'DateField': 'DATE', 180 | 'DateTimeField': 'TIMESTAMP', 181 | 'DecimalField': 'DECIMAL(%(max_digits)s, %(decimal_places)s)', 182 | 'DurationField': 'BIGINT', 183 | 'FileField': 'NVARCHAR(%(max_length)s)', 184 | 'FilePathField': 'NVARCHAR(%(max_length)s)', 185 | 'FloatField': 'FLOAT', 186 | 'GenericIPAddressField': 'NVARCHAR(39)', 187 | 'ImageField': 'NVARCHAR(%(max_length)s)', 188 | 'IntegerField': 'INTEGER', 189 | 'NullBooleanField': 'TINYINT', 190 | 'OneToOneField': 'INTEGER', 191 | 'PositiveIntegerField': 'INTEGER', 192 | 'PositiveSmallIntegerField': 'SMALLINT', 193 | 'SlugField': 'NVARCHAR(%(max_length)s)', 194 | 'SmallIntegerField': 'SMALLINT', 195 | 'TextField': 'NCLOB', 196 | 'TimeField': 'TIME', 197 | 'URLField': 'NVARCHAR(%(max_length)s)', 198 | 'UUIDField': 'NVARCHAR(32)', 199 | } 200 | 201 | operators = { 202 | 'exact': '= %s', 203 | 'iexact': '= UPPER(%s)', 204 | 'contains': 'LIKE %s', 205 | 'icontains': 'LIKE UPPER(%s)', 206 | 'regex': '~ %s', 207 | 'iregex': '~* %s', 208 | 'gt': '> %s', 209 | 'gte': '>= %s', 210 | 'lt': '< %s', 211 | 'lte': '<= %s', 212 | 'startswith': 'LIKE %s', 213 | 'endswith': 'LIKE %s', 214 | 'istartswith': 'LIKE UPPER(%s)', 215 | 'iendswith': 'LIKE UPPER(%s)', 216 | } 217 | 218 | Database = Database 219 | 220 | def __init__(self, *args, **kwargs): 221 | super(DatabaseWrapper, self).__init__(*args, **kwargs) 222 | 223 | self.features = DatabaseFeatures(self) 224 | 225 | self.ops = DatabaseOperations(self) 226 | self.client = DatabaseClient(self) 227 | self.creation = DatabaseCreation(self) 228 | self.introspection = DatabaseIntrospection(self) 229 | self.validation = BaseDatabaseValidation(self) 230 | 231 | def close(self): 232 | self.validate_thread_sharing() 233 | if self.connection is None: 234 | return 235 | self.connection.close() 236 | self.connection = None 237 | # try: 238 | # self.connection.close() 239 | # self.connection = None 240 | # except Database.Error: 241 | # # In some cases (database restart, network connection lost etc...) 242 | # # the connection to the database is lost without giving Django a 243 | # # notification. If we don't set self.connection to None, the error 244 | # # will occur a every request. 245 | # self.connection = None 246 | # logger.warning('saphana error while closing the connection.', 247 | # exc_info=sys.exc_info() 248 | # ) 249 | # raise 250 | 251 | def connect(self): 252 | if not self.settings_dict['NAME']: 253 | from django.core.exceptions import ImproperlyConfigured 254 | raise ImproperlyConfigured( 255 | 'settings.DATABASES is improperly configured. ' 256 | 'Please supply the NAME value.' 257 | ) 258 | conn_params = {} 259 | if self.settings_dict['USER']: 260 | conn_params['user'] = self.settings_dict['USER'] 261 | if self.settings_dict['PASSWORD']: 262 | conn_params['password'] = self.settings_dict['PASSWORD'] 263 | if self.settings_dict['HOST']: 264 | conn_params['host'] = self.settings_dict['HOST'] 265 | if self.settings_dict['PORT']: 266 | conn_params['port'] = self.settings_dict['PORT'] 267 | self.connection = Database.connect( 268 | host=conn_params['host'], 269 | port=int(conn_params['port']), 270 | user=conn_params['user'], 271 | password=conn_params['password'] 272 | ) 273 | # set autocommit on by default 274 | self.set_autocommit(True) 275 | self.default_schema = self.settings_dict['NAME'] 276 | # make it upper case 277 | self.default_schema = self.default_schema.upper() 278 | self.create_or_set_default_schema() 279 | 280 | def _cursor(self): 281 | self.ensure_connection() 282 | return self.connection.cursor() 283 | 284 | def _set_autocommit(self, autocommit): 285 | self.connection.setautocommit(autocommit) 286 | 287 | def cursor(self): 288 | # Call parent, in order to support cursor overriding from apps like Django Debug Toolbar 289 | # self.BaseDatabaseWrapper API is very asymetrical here - uses make_debug_cursor() for the 290 | # debug cursor, but directly instantiates urils.CursorWrapper for the regular one 291 | result = super(DatabaseWrapper, self).cursor() 292 | if getattr(result, 'is_hana', False): 293 | cursor = result 294 | else: 295 | cursor = CursorWrapper(self._cursor(), self) 296 | return cursor 297 | 298 | def make_debug_cursor(self, cursor): 299 | return CursorDebugWrapper(cursor, self) 300 | 301 | def set_dirty(self): 302 | pass 303 | 304 | def create_or_set_default_schema(self): 305 | """ 306 | Create if doesn't exist and then make it default 307 | """ 308 | cursor = self.cursor() 309 | cursor.execute('select (1) as a from schemas where schema_name=\'%s\'' % self.default_schema) 310 | res = cursor.fetchone() 311 | if not res: 312 | cursor.execute('create schema %s' % self.default_schema) 313 | cursor.execute('set schema ' + self.default_schema) 314 | 315 | def _enter_transaction_management(self, managed): 316 | """ 317 | Disables autocommit on entering a transaction 318 | """ 319 | self.ensure_connection() 320 | if self.features.uses_autocommit and managed: 321 | self.connection.setautocommit(auto=False) 322 | 323 | def leave_transaction_management(self): 324 | """ 325 | On leaving a transaction restore autocommit behavior 326 | """ 327 | try: 328 | if self.transaction_state: 329 | del self.transaction_state[-1] 330 | else: 331 | raise TransactionManagementError('This code isn\'t under transaction management') 332 | if self._dirty: 333 | self.rollback() 334 | raise TransactionManagementError('Transaction managed block ended with pending COMMIT/ROLLBACK') 335 | except: 336 | raise 337 | finally: 338 | # restore autocommit behavior 339 | self.connection.setautocommit(auto=True) 340 | self._dirty = False 341 | 342 | def _commit(self): 343 | if self.connection is not None: 344 | return self.connection.commit() 345 | # try: 346 | # return self.connection.commit() 347 | # except Database.IntegrityError as e: 348 | # ### TODO: reraise instead of raise - six.reraise was deleted due to incompability with django 1.4 349 | # raise 350 | 351 | def schema_editor(self, *args, **kwargs): 352 | return DatabaseSchemaEditor(self, **kwargs) 353 | 354 | def is_usable(self): 355 | return not self.connection.closed 356 | -------------------------------------------------------------------------------- /django_hana/client.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from django.db.backends.base.client import BaseDatabaseClient 5 | 6 | 7 | class DatabaseClient(BaseDatabaseClient): 8 | executable_name = 'hdbsql' 9 | 10 | def runshell(self): 11 | settings_dict = self.connection.settings_dict 12 | args = [self.executable_name] 13 | if settings_dict['USER']: 14 | args += ['-u', settings_dict['USER']] 15 | if settings_dict['HOST']: 16 | args.extend(['-n', settings_dict['HOST'] + ':' + settings_dict['PORT']]) 17 | if settings_dict['PASSWORD']: 18 | args.extend(['-p', str(settings_dict['PASSWORD'])]) 19 | args.extend(['-S', settings_dict['NAME']]) 20 | if os.name == 'nt': 21 | sys.exit(os.system(' '.join(args))) 22 | else: 23 | os.execvp(self.executable_name, args) 24 | -------------------------------------------------------------------------------- /django_hana/compat.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import django 4 | 5 | 6 | def createPlaceholder(compiler, field, val): 7 | if django.VERSION >= (1, 9): 8 | return compiler.field_as_sql(field, val)[0] 9 | 10 | return compiler.placeholder(field, val) 11 | -------------------------------------------------------------------------------- /django_hana/compiler.py: -------------------------------------------------------------------------------- 1 | from django.db.models.sql import compiler 2 | 3 | from django_hana import compat 4 | 5 | 6 | class SQLCompiler(compiler.SQLCompiler): 7 | def resolve_columns(self, row, fields=()): 8 | """ 9 | Taken from fox: 10 | https://github.com/django/django/commit/9f6859e1ea 11 | 12 | Basically a hook, where we call convert_values() which would turn 0/1 to Booleans. 13 | """ 14 | values = [] 15 | index_extra_select = len(self.query.extra_select.keys()) 16 | for value, field in map(None, row[index_extra_select:], fields): 17 | values.append(self.query.convert_values(value, field, connection=self.connection)) 18 | return row[:index_extra_select] + tuple(values) 19 | 20 | def as_sql(self, *args, **kwargs): 21 | result, params = super(SQLCompiler, self).as_sql(*args, **kwargs) 22 | update_params = self.connection.ops.modify_params(params) 23 | return result, update_params 24 | 25 | 26 | class SQLInsertCompiler(compiler.SQLInsertCompiler, SQLCompiler): 27 | def as_sql(self): 28 | qn = self.connection.ops.quote_name 29 | opts = self.query.model._meta 30 | result = ['INSERT INTO %s' % qn(opts.db_table)] 31 | 32 | has_fields = bool(self.query.fields) 33 | fields = self.query.fields if has_fields else [opts.pk] 34 | 35 | pkinfields = False # when explicit pk value is provided 36 | if opts.pk in fields: 37 | pkinfields = True 38 | 39 | if opts.has_auto_field and not pkinfields: 40 | # get auto field name 41 | auto_field_column = opts.auto_field.db_column or opts.auto_field.column 42 | result.append('('+auto_field_column+',%s)' % ', '.join([qn(f.column) for f in fields])) 43 | else: 44 | result.append('(%s)' % ', '.join([qn(f.column) for f in fields])) 45 | 46 | if has_fields: 47 | params = values = [ 48 | [ 49 | f.get_db_prep_save( 50 | getattr(obj, f.attname) if self.query.raw else f.pre_save(obj, True), 51 | connection=self.connection 52 | ) for f in fields 53 | ] 54 | for obj in self.query.objs 55 | ] 56 | else: 57 | values = [[self.connection.ops.pk_default_value()] for obj in self.query.objs] 58 | params = [[]] 59 | fields = [None] 60 | 61 | placeholders = [ 62 | [compat.createPlaceholder(self, field, v) for field, v in zip(fields, val)] 63 | for val in values 64 | ] 65 | 66 | seq_func = '' 67 | # don't insert call to seq function if explicit pk field value is provided 68 | if opts.has_auto_field and not pkinfields: 69 | auto_field_column = opts.auto_field.db_column or opts.auto_field.column 70 | seq_func = self.connection.ops.get_seq_name(opts.db_table, auto_field_column) + '.nextval, ' 71 | 72 | params = self.connection.ops.modify_insert_params(placeholders, params) 73 | params = self.connection.ops.modify_params(params) 74 | 75 | can_bulk = ( 76 | not any(hasattr(field, 'get_placeholder') for field in fields) 77 | and self.connection.features.has_bulk_insert 78 | ) 79 | 80 | if can_bulk and len(params) > 1: 81 | placeholders = ['%s'] * len(fields) 82 | return [ 83 | (' '.join(result + ['VALUES (' + seq_func + '%s)' % ', '.join(placeholders)]), params) 84 | ] 85 | 86 | return [ 87 | (' '.join(result + ['VALUES (' + seq_func + '%s)' % ', '.join(p)]), vals) 88 | for p, vals in zip(placeholders, params) 89 | ] 90 | 91 | def execute_sql(self, return_id=False): 92 | assert not (return_id and len(self.query.objs) != 1) 93 | self.return_id = return_id 94 | with self.connection.cursor() as cursor: 95 | for sql, params in self.as_sql(): 96 | if isinstance(params, (list, tuple)) and isinstance(params[0], (list, tuple)): 97 | cursor.executemany(sql, params) 98 | else: 99 | cursor.execute(sql, params) 100 | if not (return_id and cursor): 101 | return 102 | if self.connection.features.can_return_id_from_insert: 103 | return self.connection.ops.fetch_returned_insert_id(cursor) 104 | return self.connection.ops.last_insert_id( 105 | cursor, 106 | self.query.get_meta().db_table, 107 | self.query.get_meta().pk.column, 108 | ) 109 | 110 | 111 | class SQLDeleteCompiler(compiler.SQLDeleteCompiler, SQLCompiler): 112 | pass 113 | 114 | 115 | class SQLUpdateCompiler(compiler.SQLUpdateCompiler, SQLCompiler): 116 | def as_sql(self, *args, **kwargs): 117 | result, params = super(SQLUpdateCompiler, self).as_sql(*args, **kwargs) 118 | update_params = self.connection.ops.modify_update_params(params) 119 | return result, update_params 120 | 121 | 122 | class SQLAggregateCompiler(compiler.SQLAggregateCompiler, SQLCompiler): 123 | pass 124 | -------------------------------------------------------------------------------- /django_hana/creation.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | 4 | from django.db.backends.base.creation import BaseDatabaseCreation 5 | 6 | import django_hana 7 | 8 | 9 | class DatabaseCreation(BaseDatabaseCreation): 10 | 11 | def sql_create_model(self, model, style, known_models=set()): 12 | """ 13 | Returns the SQL required to create a single model, as a tuple of: 14 | (list_of_sql, pending_references_dict) 15 | """ 16 | opts = model._meta 17 | if not opts.managed or opts.proxy: 18 | return [], {} 19 | final_output = [] 20 | table_output = [] 21 | pending_references = {} 22 | qn = self.connection.ops.quote_name 23 | for f in opts.local_fields: 24 | col_type = f.db_type(connection=self.connection) 25 | tablespace = f.db_tablespace or opts.db_tablespace 26 | if col_type is None: 27 | # Skip ManyToManyFields, because they're not represented as 28 | # database columns in this table. 29 | continue 30 | # Make the definition (e.g. 'foo VARCHAR(30)') for this field. 31 | field_output = [style.SQL_FIELD(qn(f.column)), style.SQL_COLTYPE(col_type)] 32 | if not f.null: 33 | field_output.append(style.SQL_KEYWORD('NOT NULL')) 34 | if f.primary_key: 35 | field_output.append(style.SQL_KEYWORD('PRIMARY KEY')) 36 | elif f.unique: 37 | field_output.append(style.SQL_KEYWORD('UNIQUE')) 38 | if tablespace and f.unique: 39 | # We must specify the index tablespace inline, because we 40 | # won't be generating a CREATE INDEX statement for this field. 41 | tablespace_sql = self.connection.ops.tablespace_sql(tablespace, inline=True) 42 | if tablespace_sql: 43 | field_output.append(tablespace_sql) 44 | if f.rel: 45 | ref_output, pending = self.sql_for_inline_foreign_key_references(f, known_models, style) 46 | if pending: 47 | pending_references.setdefault(f.rel.to, []).append((model, f)) 48 | else: 49 | field_output.extend(ref_output) 50 | table_output.append(' '.join(field_output)) 51 | for field_constraints in opts.unique_together: 52 | contraints = ', '.join([style.SQL_FIELD(qn(opts.get_field(f).column)) for f in field_constraints]) 53 | table_output.append(style.SQL_KEYWORD('UNIQUE') + ' (%s)' % contraints) 54 | 55 | # check which column type 56 | store_type = self.connection.settings_dict.get('DEFAULT_MODEL_STORE', 'COLUMN') 57 | table_type = django_hana.MODEL_STORE.get(model.__name__, store_type) 58 | 59 | full_statement = [style.SQL_KEYWORD('CREATE ' + table_type + ' TABLE') + ' ' + 60 | style.SQL_TABLE(qn(opts.db_table)) + ' ('] 61 | for i, line in enumerate(table_output): # Combine and add commas. 62 | full_statement.append( 63 | ' %s%s' % (line, i < len(table_output)-1 and ',' or '')) 64 | full_statement.append(')') 65 | if opts.db_tablespace: 66 | tablespace_sql = self.connection.ops.tablespace_sql( 67 | opts.db_tablespace) 68 | if tablespace_sql: 69 | full_statement.append(tablespace_sql) 70 | final_output.append('\n'.join(full_statement)) 71 | 72 | if opts.has_auto_field: 73 | # Add any extra SQL needed to support auto-incrementing primary 74 | # keys. 75 | auto_column = opts.auto_field.db_column or opts.auto_field.name 76 | autoinc_sql = self.connection.ops.autoinc_sql(opts.db_table, 77 | auto_column) 78 | if autoinc_sql: 79 | for stmt in autoinc_sql: 80 | final_output.append(stmt) 81 | 82 | return final_output, pending_references 83 | 84 | def sql_for_inline_foreign_key_references(self, field, known_models, style): 85 | """ 86 | Return the SQL snippet defining the foreign key reference for a field. 87 | Foreign key not supported 88 | """ 89 | return [], False 90 | 91 | def sql_destroy_model(self, model, references_to_delete, style): 92 | """ 93 | Return the DROP TABLE and restraint dropping statements for a single 94 | model. 95 | """ 96 | if not model._meta.managed or model._meta.proxy or model._meta.swapped: 97 | return [] 98 | # Drop the table now 99 | qn = self.connection.ops.quote_name 100 | output = ['%s %s;' % (style.SQL_KEYWORD('DROP TABLE'), style.SQL_TABLE(qn(model._meta.db_table)))] 101 | 102 | if model._meta.has_auto_field: 103 | ds = self.connection.ops.drop_sequence_sql(model._meta.db_table) 104 | if ds: 105 | output.append(ds) 106 | return output 107 | 108 | def _create_test_db(self, verbosity, autoclobber, keepdb=False): 109 | """ 110 | Internal implementation - creates the test db tables. 111 | Modified because SAP HANA has schema to group related tables 112 | """ 113 | suffix = self.sql_table_creation_suffix() 114 | 115 | test_database_name = self._get_test_db_name() 116 | 117 | qn = self.connection.ops.quote_name 118 | 119 | cursor = self.connection.cursor() 120 | try: 121 | cursor.execute( 122 | 'CREATE SCHEMA %s %s' % (qn(test_database_name), suffix)) 123 | except Exception as e: 124 | # if we want to keep the db, then no need to do any of the below, 125 | # just return and skip it all. 126 | if keepdb: 127 | return test_database_name 128 | 129 | sys.stderr.write('Got an error creating the test database: %s\n' % e) 130 | if not autoclobber: 131 | input_template = ( 132 | 'Type "yes" if you would like to try deleting the test database "%s", ' 133 | 'or "no" to cancel: ' 134 | ) 135 | confirm = input(input_template % test_database_name) 136 | if autoclobber or confirm == 'yes': 137 | try: 138 | if verbosity >= 1: 139 | print('Destroying old test database "%s"...' % self.connection.alias) 140 | cursor.execute('DROP SCHEMA %s CASCADE' % qn(test_database_name)) 141 | cursor.execute('CREATE SCHEMA %s %s' % (qn(test_database_name), suffix)) 142 | except Exception as e: 143 | sys.stderr.write('Got an error recreating the test database: %s\n' % e) 144 | sys.exit(2) 145 | else: 146 | print('Tests cancelled.') 147 | sys.exit(1) 148 | 149 | return test_database_name 150 | 151 | def _destroy_test_db(self, test_database_name, verbosity): 152 | """ 153 | Internal implementation - remove the test db tables. 154 | """ 155 | # Remove the test database to clean up after 156 | # ourselves. Connect to the previous database (not the test database) 157 | # to do so, because it's not allowed to delete a database while being 158 | # connected to it. 159 | cursor = self.connection.cursor() 160 | # Wait to avoid "database is being accessed by other users" errors. 161 | time.sleep(1) 162 | cursor.execute('DROP SCHEMA %s CASCADE' % self.connection.ops.quote_name(test_database_name)) 163 | self.connection.close() 164 | 165 | def sql_indexes_for_field(self, model, f, style): 166 | """ 167 | Return the CREATE INDEX SQL statements for a single model field. 168 | """ 169 | from django.db.backends.utils import truncate_name 170 | 171 | if f.db_index and not f.unique: 172 | qn = self.connection.ops.quote_name 173 | tablespace = f.db_tablespace or model._meta.db_tablespace 174 | if tablespace: 175 | tablespace_sql = self.connection.ops.tablespace_sql(tablespace) 176 | if tablespace_sql: 177 | tablespace_sql = ' ' + tablespace_sql 178 | else: 179 | tablespace_sql = '' 180 | i_name = '%s_%s' % (model._meta.db_table, self._digest(f.column)) 181 | output = [ 182 | style.SQL_KEYWORD('CREATE INDEX') + ' ' + 183 | style.SQL_TABLE(qn(truncate_name(i_name, self.connection.ops.max_name_length()))) + ' ' + 184 | style.SQL_KEYWORD('ON') + ' ' + 185 | style.SQL_TABLE(qn(model._meta.db_table)) + ' ' + 186 | '(%s)' % style.SQL_FIELD(qn(f.column)) + 187 | '%s' % tablespace_sql 188 | ] 189 | else: 190 | output = [] 191 | return output 192 | -------------------------------------------------------------------------------- /django_hana/introspection.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db.backends.base.introspection import BaseDatabaseIntrospection, TableInfo 4 | from django.utils import six 5 | 6 | 7 | class DatabaseIntrospection(BaseDatabaseIntrospection): 8 | # Maps type codes to Django Field types. 9 | data_types_reverse = { 10 | # 16: 'BooleanField', 11 | 20: 'BigIntegerField', 12 | 21: 'SmallIntegerField', 13 | 3: 'IntegerField', 14 | 25: 'TextField', 15 | 700: 'FloatField', 16 | 701: 'FloatField', 17 | 869: 'GenericIPAddressField', 18 | 9: 'CharField', 19 | 1082: 'DateField', 20 | 1083: 'TimeField', 21 | 16: 'DateTimeField', 22 | 1266: 'TimeField', 23 | 1700: 'DecimalField', 24 | } 25 | 26 | def get_table_list(self, cursor): 27 | """ 28 | Returns a list of table names in the current database. 29 | """ 30 | sql = ( 31 | 'select table_name, \'t\' from tables where schema_name=\'{0}\' ' 32 | 'UNION select view_name, \'v\' from views where schema_name=\'{0}\'' 33 | ) 34 | cursor.execute(sql.format(self.connection.default_schema)) 35 | result = [TableInfo(row[0], row[1]) for row in cursor.fetchall()] 36 | result = result + [TableInfo(t.name.lower(), t.type) for t in result] 37 | return result 38 | 39 | def table_name_converter(self, name): 40 | return six.text_type(name.upper()) 41 | 42 | def get_table_description(self, cursor, table_name): 43 | """ 44 | Returns a description of the table, with the DB-API cursor.description interface. 45 | """ 46 | cursor.execute('SELECT * FROM %s LIMIT 1' % self.connection.ops.quote_name(table_name)) 47 | return cursor.description 48 | 49 | def get_relations(self, cursor, table_name): 50 | """ 51 | Returns a dictionary of {field_name: (field_name_other_table, other_table)} 52 | representing all relationships to the given table. 53 | """ 54 | constraints = self.get_key_columns(cursor, table_name) 55 | relations = {} 56 | for my_fieldname, other_table, other_field in constraints: 57 | relations[my_fieldname] = (other_field, other_table) 58 | return relations 59 | 60 | def get_key_columns(self, cursor, table_name): 61 | """ 62 | Returns a list of (column_name, referenced_table_name, referenced_column_name) for all 63 | key columns in given table. 64 | """ 65 | key_columns = [] 66 | table_name = self.connection.ops.quote_name(table_name).replace('"', '') 67 | schema_name = self.connection.ops.quote_name(self.connection.default_schema).replace('"', '') 68 | sql = ( 69 | 'SELECT column_name, referenced_table_name, referenced_column_name ' 70 | 'FROM REFERENTIAL_CONSTRAINTS ' 71 | 'WHERE table_name = %s ' 72 | 'AND schema_name = %s ' 73 | 'AND referenced_schema_name = %s ' 74 | 'AND referenced_table_name IS NOT NULL ' 75 | 'AND referenced_column_name IS NOT NULL' 76 | ) 77 | cursor.execute(sql, [table_name, schema_name, schema_name]) 78 | key_columns.extend(cursor.fetchall()) 79 | return key_columns 80 | 81 | def get_constraints(self, cursor, table_name): 82 | constraints = {} 83 | table_name = self.connection.ops.quote_name(table_name).replace('"', '') 84 | schema_name = self.connection.ops.quote_name(self.connection.default_schema).replace('"', '') 85 | # Fetch pk and unique constraints 86 | sql = ( 87 | 'SELECT constraint_name, column_name, is_primary_key, is_unique_key ' 88 | 'FROM CONSTRAINTS ' 89 | 'WHERE schema_name = %s ' 90 | 'AND table_name = %s' 91 | ) 92 | cursor.execute(sql, [schema_name, table_name]) 93 | for constraint, column, pk, unique in cursor.fetchall(): 94 | # If we're the first column, make the record 95 | if constraint not in constraints: 96 | constraints[constraint] = { 97 | 'columns': set(), 98 | 'primary_key': bool(pk), 99 | 'unique': bool(unique), 100 | 'foreign_key': None, 101 | 'check': False, # check constraints are not supported in SAP HANA 102 | 'index': True, # All P and U come with index 103 | } 104 | # Record the details 105 | constraints[constraint]['columns'].add(column) 106 | # Fetch fk constraints 107 | sql = ( 108 | 'SELECT constraint_name, column_name, referenced_table_name, referenced_column_name ' 109 | 'FROM REFERENTIAL_CONSTRAINTS ' 110 | 'WHERE table_name = %s ' 111 | 'AND schema_name = %s ' 112 | 'AND referenced_schema_name = %s ' 113 | 'AND referenced_table_name IS NOT NULL ' 114 | 'AND referenced_column_name IS NOT NULL' 115 | ) 116 | cursor.execute(sql, [table_name, schema_name, schema_name]) 117 | for constraint, column, ref_table, ref_column in cursor.fetchall(): 118 | if constraint not in constraints: 119 | # If we're the first column, make the record 120 | constraints[constraint] = { 121 | 'columns': set(), 122 | 'primary_key': False, 123 | 'unique': False, 124 | 'index': False, 125 | 'check': False, 126 | 'foreign_key': (ref_table, ref_column) if ref_column else None, 127 | } 128 | # Record the details 129 | constraints[constraint]['columns'].add(column) 130 | # Fetch indexes 131 | sql = ( 132 | 'SELECT index_name, column_name ' 133 | 'FROM index_columns ' 134 | 'WHERE schema_name = %s ' 135 | 'AND table_name = %s' 136 | ) 137 | cursor.execute(sql, [schema_name, table_name]) 138 | for constraint, column in cursor.fetchall(): 139 | # If we're the first column, make the record 140 | if constraint not in constraints: 141 | constraints[constraint] = { 142 | 'columns': set(), 143 | 'primary_key': False, 144 | 'unique': False, 145 | 'foreign_key': None, 146 | 'check': False, 147 | 'index': True, 148 | } 149 | # Record the details 150 | constraints[constraint]['columns'].add(column) 151 | return constraints 152 | 153 | def get_indexes(self, cursor, table_name): 154 | sql = ( 155 | 'SELECT ' 156 | 'idx_col.column_name as column_name, ' 157 | 'CASE WHEN indexes.constraint = "PRIMARY KEY" THEN 1 ELSE 0 END as is_primary_key, ' 158 | 'SIGN(LOCATE(indexes.index_type, "UNIQUE")) as is_unique ' 159 | 'FROM index_columns idx_col ' 160 | 'JOIN (SELECT index_oid ' 161 | 'FROM index_columns ' 162 | 'WHERE schema_name = %s ' 163 | 'AND table_name = %s ' 164 | 'GROUP BY index_oid ' 165 | 'HAVING count(*) = 1) single_idx_col ' 166 | 'ON idx_col.index_oid = single_idx_col.index_oid ' 167 | 'JOIN indexes indexes ' 168 | 'ON idx_col.index_oid = indexes.index_oid' 169 | ) 170 | table_name = self.connection.ops.quote_name(table_name).replace('"', '') 171 | schema_name = self.connection.ops.quote_name(self.connection.default_schema).replace('"', '') 172 | cursor.execute(sql, [schema_name, table_name]) 173 | indexes = {} 174 | for row in cursor.fetchall(): 175 | indexes[row[0]] = { 176 | 'primary_key': bool(row[1]), 177 | 'unique': bool(row[2]), 178 | } 179 | return indexes 180 | -------------------------------------------------------------------------------- /django_hana/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.gis.db import models 2 | from django.contrib.gis.db.backends.base.models import SpatialRefSysMixin 3 | from django.utils.encoding import python_2_unicode_compatible 4 | 5 | 6 | @python_2_unicode_compatible 7 | class HanaGeometryColumns(models.Model): 8 | """ 9 | Maps to the HANA ST_GEOMETRY_COLUMNS view. 10 | """ 11 | schema_name = models.CharField(max_length=256, null=False) 12 | table_name = models.CharField(max_length=256, null=False) 13 | column_name = models.CharField(max_length=256, null=False) 14 | srs_id = models.IntegerField(null=False) 15 | srs_name = models.CharField(max_length=256) 16 | data_type_name = models.CharField(max_length=16) 17 | 18 | class Meta: 19 | app_label = 'gis' 20 | db_table = 'ST_GEOMETRY_COLUMNS' 21 | managed = False 22 | 23 | @classmethod 24 | def table_name_col(cls): 25 | """ 26 | Returns the name of the metadata column used to store the feature table 27 | name. 28 | """ 29 | return 'table_name' 30 | 31 | @classmethod 32 | def geom_col_name(cls): 33 | """ 34 | Returns the name of the metadata column used to store the feature 35 | geometry column. 36 | """ 37 | return 'column_name' 38 | 39 | def __str__(self): 40 | return '%s - %s (SRID: %s)' % (self.table_name, self.column_name, self.srid) 41 | 42 | 43 | class HanaSpatialRefSys(models.Model, SpatialRefSysMixin): 44 | """ 45 | Maps to the SAP HANA SYS.ST_SPATIAL_REFERENCE_SYSTEMS view. 46 | """ 47 | owner_name = models.CharField(max_length=256) 48 | srs_id = models.IntegerField(null=False) 49 | srs_name = models.CharField(max_length=256, null=False) 50 | round_earth = models.CharField(max_length=7, null=False) 51 | axis_order = models.CharField(max_length=12, null=False) 52 | snap_to_grid = models.FloatField() 53 | tolerance = models.FloatField() 54 | semi_major_axis = models.FloatField() 55 | semi_minor_axis = models.FloatField() 56 | inv_flattening = models.FloatField() 57 | min_x = models.FloatField() 58 | max_x = models.FloatField() 59 | min_y = models.FloatField() 60 | max_y = models.FloatField() 61 | min_z = models.FloatField() 62 | max_z = models.FloatField() 63 | organization = models.CharField(max_length=256) 64 | organization_coordsys_id = models.IntegerField(null=False) 65 | srs_type = models.CharField(max_length=11, null=False) 66 | linear_unit_of_measure = models.CharField(max_length=256, null=False) 67 | angular_unit_of_measure = models.CharField(max_length=256) 68 | polygon_format = models.CharField(max_length=16, null=False) 69 | storage_format = models.CharField(max_length=8, null=False) 70 | definition = models.CharField(max_length=5000) 71 | transform_definition = models.CharField(max_length=5000) 72 | objects = models.GeoManager() 73 | 74 | class Meta: 75 | app_label = 'gis' 76 | db_table = 'ST_SPATIAL_REFERENCE_SYSTEMS' 77 | managed = False 78 | 79 | @property 80 | def wkt(self): 81 | return self.definition 82 | 83 | @classmethod 84 | def wkt_col(cls): 85 | return 'definition' 86 | -------------------------------------------------------------------------------- /django_hana/operations.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import uuid 4 | 5 | from django.contrib.gis.db.backends.base.adapter import WKTAdapter 6 | from django.contrib.gis.db.backends.base.operations import BaseSpatialOperations 7 | from django.contrib.gis.db.backends.utils import SpatialOperator 8 | from django.contrib.gis.geometry.backend import Geometry 9 | from django.contrib.gis.measure import Distance 10 | from django.db.backends.base.operations import BaseDatabaseOperations 11 | from django.utils import six 12 | from django.utils.encoding import force_text 13 | 14 | from .base import Database 15 | 16 | 17 | class HanaSpatialOperator(SpatialOperator): 18 | sql_template = '%(lhs)s.%(func)s(%(rhs)s)' 19 | 20 | 21 | class HanaIsOneSpatialOperator(SpatialOperator): 22 | sql_template = '%(lhs)s.%(func)s(%(rhs)s) = 1' 23 | 24 | 25 | class HanaIsValueSpatialOperator(SpatialOperator): 26 | sql_template = '%(lhs)s.%(func)s(%(rhs)s) %(op)s %%s' 27 | 28 | 29 | class DatabaseOperations(BaseDatabaseOperations, BaseSpatialOperations): 30 | compiler_module = 'django_hana.compiler' 31 | 32 | Adapter = WKTAdapter 33 | Adaptor = Adapter # Backwards-compatibility alias. 34 | 35 | gis_operators = { 36 | 'contains': HanaIsOneSpatialOperator(func='ST_CONTAINS'), 37 | 'coveredby': HanaIsOneSpatialOperator(func='ST_COVEREDBY'), 38 | 'covers': HanaIsOneSpatialOperator(func='ST_COVERS'), 39 | 'crosses': HanaIsOneSpatialOperator(func='ST_CROSSES'), 40 | 'disjoint': HanaIsOneSpatialOperator(func='ST_DISJOINT'), 41 | 'distance': HanaIsValueSpatialOperator(func='ST_DISTANCE', op='='), 42 | 'distance_gt': HanaIsValueSpatialOperator(func='ST_DISTANCE', op='>'), 43 | 'distance_gte': HanaIsValueSpatialOperator(func='ST_DISTANCE', op='>='), 44 | 'distance_lt': HanaIsValueSpatialOperator(func='ST_DISTANCE', op='<'), 45 | 'distance_lte': HanaIsValueSpatialOperator(func='ST_DISTANCE', op='<='), 46 | 'equals': HanaIsOneSpatialOperator(func='ST_EQUALS'), 47 | 'exact': HanaIsOneSpatialOperator(func='ST_EQUALS'), 48 | 'intersects': HanaIsOneSpatialOperator(func='ST_INTERSECTS'), 49 | 'overlaps': HanaIsOneSpatialOperator(func='ST_OVERLAPS'), 50 | 'same_as': HanaIsOneSpatialOperator(func='ST_EQUALS'), 51 | 'relate': HanaIsOneSpatialOperator(func='ST_RELATE'), 52 | 'touches': HanaIsOneSpatialOperator(func='ST_TOUCHES'), 53 | 'within': HanaIsValueSpatialOperator(func='ST_WITHINDISTANCE', op='<='), 54 | } 55 | 56 | def __init__(self, connection): 57 | super(DatabaseOperations, self).__init__(connection) 58 | 59 | def get_seq_name(self, table, column): 60 | return '%s_%s_seq' % (table, column) 61 | 62 | def autoinc_sql(self, table, column): 63 | seq_name = self.quote_name(self.get_seq_name(table, column)) 64 | column = self.quote_name(column) 65 | table = self.quote_name(table) 66 | seq_sql = 'CREATE SEQUENCE %(seq_name)s RESET BY SELECT IFNULL(MAX(%(column)s),0) + 1 FROM %(table)s' % locals() 67 | return [seq_sql] 68 | 69 | def date_extract_sql(self, lookup_type, field_name): 70 | if lookup_type == 'week_day': 71 | # For consistency across backends, we return Sunday=1, Saturday=7. 72 | return 'MOD(WEEKDAY (%s) + 2,7)' % field_name 73 | else: 74 | return 'EXTRACT(%s FROM %s)' % (lookup_type, field_name) 75 | 76 | def date_trunc_sql(self, lookup_type, field_name): 77 | # very low tech, code should be optimized 78 | ltypes = { 79 | 'year': 'YYYY', 80 | 'month': 'YYYY-MM', 81 | 'day': 'YYYY-MM-DD', 82 | } 83 | cur_type = ltypes.get(lookup_type) 84 | if not cur_type: 85 | return field_name 86 | sql = 'TO_DATE(TO_VARCHAR(%s, "%s"))' % (field_name, cur_type) 87 | return sql 88 | 89 | def no_limit_value(self): 90 | return None 91 | 92 | def quote_name(self, name): 93 | return '"%s"' % name.replace('"', '""').upper() 94 | 95 | def bulk_batch_size(self, fields, objs): 96 | return 2500 97 | 98 | def sql_flush(self, style, tables, sequences, allow_cascades=False): 99 | if tables: 100 | sql = [ 101 | ' '.join([ 102 | style.SQL_KEYWORD('DELETE'), 103 | style.SQL_KEYWORD('FROM'), 104 | style.SQL_FIELD(self.quote_name(table)), 105 | ]) 106 | for table in tables 107 | ] 108 | sql.extend(self.sequence_reset_by_name_sql(style, sequences)) 109 | return sql 110 | else: 111 | return [] 112 | 113 | def sequence_reset_by_name_sql(self, style, sequences): 114 | sql = [] 115 | for sequence_info in sequences: 116 | table_name = sequence_info['table'] 117 | column_name = sequence_info['column'] 118 | seq_name = self.get_seq_name(table_name, column_name) 119 | sql.append(' '.join([ 120 | 'ALTER SEQUENCE', 121 | seq_name, 122 | 'RESET BY SELECT IFNULL(MAX(', 123 | column_name, 124 | '),0) + 1 from', 125 | table_name, 126 | ])) 127 | return sql 128 | 129 | def sequence_reset_sql(self, style, model_list): 130 | from django.db import models 131 | output = [] 132 | for model in model_list: 133 | for f in model._meta.local_fields: 134 | if isinstance(f, models.AutoField): 135 | output.append(' '.join([ 136 | style.SQL_KEYWORD('ALTER SEQUENCE'), 137 | style.SQL_TABLE(self.get_seq_name(model._meta.db_table, f.column)), 138 | style.SQL_KEYWORD('RESET BY SELECT'), 139 | style.SQL_FIELD('IFNULL(MAX('+f.column+'),0) + 1'), 140 | style.SQL_KEYWORD('FROM'), 141 | style.SQL_TABLE(model._meta.db_table), 142 | ])) 143 | break # Only one AutoField is allowed per model, so don't bother continuing. 144 | for f in model._meta.many_to_many: 145 | if not f.rel.through: 146 | output.append(' '.join([ 147 | style.SQL_KEYWORD('ALTER SEQUENCE'), 148 | style.SQL_TABLE(self.get_seq_name(f.m2m_db_table(), 'id')), 149 | style.SQL_KEYWORD('RESET BY SELECT'), 150 | style.SQL_FIELD('IFNULL(MAX(id),0) + 1'), 151 | style.SQL_KEYWORD('FROM'), 152 | style.SQL_TABLE(f.m2m_db_table()) 153 | ])) 154 | return output 155 | 156 | def prep_for_iexact_query(self, x): 157 | return x 158 | 159 | def check_aggregate_support(self, aggregate): 160 | """ 161 | Check that the backend supports the provided aggregate. 162 | 163 | This is used on specific backends to rule out known aggregates 164 | that are known to have faulty implementations. If the named 165 | aggregate function has a known problem, the backend should 166 | raise NotImplementedError. 167 | """ 168 | if aggregate.sql_function in ('STDDEV_POP', 'VAR_POP'): 169 | raise NotImplementedError() 170 | 171 | def max_name_length(self): 172 | """ 173 | Returns the maximum length of table and column names, or None if there 174 | is no limit. 175 | """ 176 | return 127 177 | 178 | def start_transaction_sql(self): 179 | return '' 180 | 181 | def last_insert_id(self, cursor, table_name, pk_name): 182 | """ 183 | Given a cursor object that has just performed an INSERT statement into 184 | a table that has an auto-incrementing ID, returns the newly created ID. 185 | 186 | This method also receives the table name and the name of the primary-key 187 | column. 188 | """ 189 | seq_name = self.connection.ops.get_seq_name(table_name, pk_name) 190 | sql = 'select {}.currval from dummy'.format(seq_name) 191 | cursor.execute(sql) 192 | return cursor.fetchone()[0] 193 | 194 | def value_to_db_datetime(self, value): 195 | """ 196 | Transform a datetime value to an object compatible with what is expected 197 | by the backend driver for datetime columns. 198 | """ 199 | if value is None: 200 | return None 201 | if value.tzinfo: 202 | # HANA doesn't support timezone. If tzinfo is present truncate it. 203 | # Better set USE_TZ=False in settings.py 204 | import datetime 205 | return six.text_type( 206 | datetime.datetime( 207 | value.year, value.month, value.day, value.hour, value.minute, value.second, value.microsecond 208 | ) 209 | ) 210 | return six.text_type(value) 211 | 212 | def lookup_cast(self, lookup_type, internal_type=None): 213 | if lookup_type in ('iexact', 'icontains', 'istartswith', 'iendswith'): 214 | return 'UPPER(%s)' 215 | return '%s' 216 | 217 | def convert_values(self, value, field): 218 | """ 219 | Type conversion for boolean field. Keping values as 0/1 confuses 220 | the modelforms. 221 | """ 222 | if (field and field.get_internal_type() in ('BooleanField', 'NullBooleanField') and value in (0, 1)): 223 | value = bool(value) 224 | return value 225 | 226 | # Decimal to Database. Django == 1.8 227 | def value_to_db_decimal(self, value, max_digits, decimal_places): 228 | return value or None 229 | 230 | # Decimal to Database. Django >= 1.9 231 | def adapt_decimalfield_value(self, value, max_digits=None, decimal_places=None): 232 | return value or None 233 | 234 | def modify_insert_params(self, placeholder, params): 235 | insert_param_groups = [] 236 | for p in params: 237 | if isinstance(p, list): 238 | insert_param_groups.append([self.sanitize_bool(value) for value in p]) 239 | else: 240 | # As of Django 1.9, modify_insert_params is also called in SQLInsertCompiler.field_as_sql. 241 | # When it's called from there, params is not a list inside a list, but only a list. 242 | insert_param_groups.append(self.sanitize_bool(p)) 243 | return insert_param_groups 244 | 245 | def modify_update_params(self, params): 246 | return tuple(self.sanitize_bool(param) for param in params) 247 | 248 | def modify_params(self, params): 249 | return tuple(self.sanitize_geometry(param) for param in params) 250 | 251 | def sanitize_bool(self, param): 252 | if type(param) is bool: 253 | return 1 if param else 0 254 | return param 255 | 256 | def sanitize_geometry(self, param): 257 | if type(param) is WKTAdapter: 258 | return str(param) 259 | return param 260 | 261 | def get_db_converters(self, expression): 262 | converters = super(DatabaseOperations, self).get_db_converters(expression) 263 | internal_type = expression.output_field.get_internal_type() 264 | geometry_fields = ( 265 | 'PointField', 'LineStringField', 'PolygonField', 266 | 'MultiPointField', 'MultiLineStringField', 'MultiPolygonField', 267 | ) 268 | if internal_type == 'TextField': 269 | converters.append(self.convert_textfield_value) 270 | elif internal_type == 'BinaryField': 271 | converters.append(self.convert_binaryfield_value) 272 | elif internal_type in ['BooleanField', 'NullBooleanField']: 273 | converters.append(self.convert_booleanfield_value) 274 | elif internal_type == 'UUIDField': 275 | converters.append(self.convert_uuidfield_value) 276 | elif internal_type in geometry_fields: 277 | converters.append(self.convert_geometry_value) 278 | if hasattr(expression.output_field, 'geom_type'): 279 | converters.append(self.convert_geometry) 280 | return converters 281 | 282 | def convert_textfield_value(self, value, expression, connection, context): 283 | if isinstance(value, Database.NClob): 284 | value = force_text(value.read()) 285 | return value 286 | 287 | def convert_binaryfield_value(self, value, expression, connection, context): 288 | if isinstance(value, Database.Blob): 289 | value = value.read() 290 | return value 291 | 292 | def convert_booleanfield_value(self, value, expression, connection, context): 293 | if value in (0, 1): 294 | value = bool(value) 295 | return value 296 | 297 | def convert_uuidfield_value(self, value, expression, connection, context): 298 | if value is not None: 299 | value = uuid.UUID(value) 300 | return value 301 | 302 | def convert_geometry_value(self, value, expression, connection, context): 303 | if value is not None: 304 | value = ''.join('{:02x}'.format(x) for x in value) 305 | return value 306 | 307 | def convert_geometry(self, value, expression, connection, context): 308 | if value: 309 | value = Geometry(value) 310 | if 'transformed_srid' in context: 311 | value.srid = context['transformed_srid'] 312 | return value 313 | 314 | def _geo_db_type(self, f): 315 | return 'ST_%s' % f.geom_type 316 | 317 | def geo_db_type(self, f): 318 | internal_type = self._geo_db_type(f) 319 | return internal_type if f.geom_type == 'POINT' else 'ST_GEOMETRY' 320 | 321 | def get_distance(self, f, value, lookup_type): 322 | if not value: 323 | return [] 324 | value = value[0] 325 | if isinstance(value, Distance): 326 | if f.geodetic(self.connection): 327 | raise ValueError('SAP HANA does not support distance queries on ' 328 | 'geometry fields with a geodetic coordinate system. ' 329 | 'Distance objects; use a numeric value of your ' 330 | 'distance in degrees instead.') 331 | else: 332 | dist_param = getattr(value, Distance.unit_attname(f.units_name(self.connection))) 333 | else: 334 | dist_param = value 335 | return [dist_param] 336 | 337 | def get_geom_placeholder(self, f, value, compiler): 338 | if value is None: 339 | placeholder = '%s' 340 | else: 341 | db_type = self._geo_db_type(f) 342 | placeholder = 'NEW %s(%%s, %s)' % (db_type, f.srid) 343 | 344 | if hasattr(value, 'as_sql'): 345 | sql, _ = compiler.compile(value) 346 | placeholder = placeholder % sql 347 | 348 | return placeholder 349 | 350 | def geometry_columns(self): 351 | from django_hana.models import HanaGeometryColumns 352 | return HanaGeometryColumns 353 | 354 | def spatial_ref_sys(self): 355 | from django_hana.models import HanaSpatialRefSys 356 | return HanaSpatialRefSys 357 | -------------------------------------------------------------------------------- /django_hana/schema.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.base.schema import BaseDatabaseSchemaEditor 2 | 3 | import django_hana 4 | 5 | 6 | class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): 7 | # sql templates to configure (override) 8 | 9 | sql_create_table_template = 'CREATE %(table_type)s TABLE %%(table)s (%%(definition)s)' 10 | sql_create_table = 'CREATE COLUMN TABLE %(table)s (%(definition)s)' 11 | sql_create_table_unique = 'UNIQUE (%(columns)s)' 12 | sql_rename_table = 'RENAME TABLE %(old_table)s TO %(new_table)s' 13 | sql_retablespace_table = 'ALTER TABLE %(table)s MOVE TO %(new_tablespace)s' 14 | # sql_delete_table = 'DROP TABLE %(table)s CASCADE' 15 | 16 | sql_create_column = 'ALTER TABLE %(table)s ADD (%(column)s %(definition)s)' 17 | sql_alter_column = 'ALTER TABLE %(table)s %(changes)s' 18 | sql_alter_column_type = 'ALTER (%(column)s %(type)s)' 19 | sql_alter_column_null = 'ALTER (%(column)s %(type)s NULL)' 20 | sql_alter_column_not_null = 'ALTER (%(column)s %(type)s NOT NULL)' 21 | sql_alter_column_default = 'ALTER (%(column)s %(definition)s DEFAULT %(default)s)' 22 | sql_alter_column_no_default = 'ALTER (%(column)s %(definition)s)' 23 | sql_delete_column = 'ALTER TABLE %(table)s DROP (%(column)s)' 24 | sql_rename_column = 'RENAME COLUMN %(table)s.%(old_column)s TO %(new_column)s' 25 | sql_update_with_default = 'UPDATE %(table)s SET %(column)s = %(default)s WHERE %(column)s IS NULL' 26 | 27 | sql_create_check = 'ALTER TABLE %(table)s ADD CONSTRAINT %(name)s CHECK (%(check)s)' 28 | sql_delete_check = 'ALTER TABLE %(table)s DROP CONSTRAINT %(name)s' 29 | 30 | sql_create_unique = 'ALTER TABLE %(table)s ADD CONSTRAINT %(name)s UNIQUE (%(columns)s)' 31 | sql_delete_unique = 'ALTER TABLE %(table)s DROP CONSTRAINT %(name)s' 32 | 33 | sql_create_fk = ( 34 | 'ALTER TABLE %(table)s ADD CONSTRAINT %(name)s FOREIGN KEY (%(column)s) ' 35 | 'REFERENCES %(to_table)s (%(to_column)s) ON DELETE CASCADE' 36 | ) 37 | sql_create_inline_fk = None 38 | sql_delete_fk = 'ALTER TABLE %(table)s DROP CONSTRAINT %(name)s' 39 | 40 | sql_create_index = 'CREATE INDEX %(name)s ON %(table)s (%(columns)s)%(extra)s' 41 | sql_delete_index = 'DROP INDEX %(name)s' 42 | 43 | # sql_create_pk = 'ALTER TABLE %(table)s ADD CONSTRAINT %(name)s PRIMARY KEY (%(columns)s)' 44 | # sql_delete_pk = 'ALTER TABLE %(table)s DROP CONSTRAINT %(name)s' 45 | 46 | def skip_default(self, field): 47 | # When altering a column, SAP HANA requires the column definition. This is not the case for other databases. 48 | # So Django does not pass the column definition to the sql format strings. Since Django does not use database 49 | # default. (It creates a column with default values and drop the contraint immediately.) In order to avoid 50 | # entire methods of Django to support this behavior, we will skip creating default constraints entirely. 51 | return True 52 | 53 | def create_model(self, model): 54 | # To support creating column and row table, we have to use this workaround. It sets the sql format string 55 | # according to the table type of the model. 56 | store_type = self.connection.settings_dict.get('DEFAULT_MODEL_STORE', 'COLUMN') 57 | table_type = django_hana.MODEL_STORE.get(model.__name__, store_type) 58 | self.sql_create_table = self.sql_create_table_template % {'table_type': table_type} 59 | super(DatabaseSchemaEditor, self).create_model(model) 60 | -------------------------------------------------------------------------------- /requirements-testing.txt: -------------------------------------------------------------------------------- 1 | coverage==4.3.4 2 | flake8==3.2.1 3 | flake8-quotes==0.9.0 4 | isort==4.2.5 5 | mock=2.0.0 6 | tox==2.6.0 7 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | 5 | import django 6 | 7 | 8 | def runtests(): 9 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 10 | django.setup() 11 | setup_file = sys.modules['__main__'].__file__ 12 | setup_dir = os.path.abspath(os.path.dirname(setup_file)) 13 | return unittest.defaultTestLoader.discover(setup_dir) 14 | 15 | 16 | if __name__ == '__main__': 17 | runtests() 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup( 6 | name='django_hana', 7 | version='1.1', 8 | description='SAP HANA backend for Django 1.8', 9 | author='Max Bothe, Kapil Ratnani', 10 | author_email='mathebox@gmail.com, kapil.ratnani@iiitb.net', 11 | url='https://github.com/mathebox/django_hana', 12 | packages=['django_hana'], 13 | test_suite='runtests.runtests', 14 | ) 15 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathebox/django_hana_pyhdb/30baac0d2e753795a8a70b47693c6ef82ae6e509/tests/__init__.py -------------------------------------------------------------------------------- /tests/mock_db.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | 4 | class MockCursor(object): 5 | def execute(self, sql, params=()): 6 | raise NotImplementedError('Unexpected call to "execute". You need to use "patch_db_execute".') 7 | 8 | def executemany(self, sql, param_list): 9 | raise NotImplementedError('Unexpected call to "executemany". You need to use "patch_db_executemany".') 10 | 11 | def fetchone(self): 12 | raise NotImplementedError('Unexpected call to "fetchone". You need to use "patch_db_fetchone".') 13 | 14 | def fetchmany(self, count): 15 | raise NotImplementedError('Unexpected call to "fetchmany". You need to use "patch_db_fetchmany".') 16 | 17 | def fetchall(self): 18 | raise NotImplementedError('Unexpected call to "fetchall". You need to use "patch_db_fetchall".') 19 | 20 | def close(self): 21 | pass 22 | 23 | 24 | class MockConnection(object): 25 | autocommit = False 26 | closed = False 27 | 28 | def setautocommit(self, autocommit): 29 | self.autocommit = autocommit 30 | 31 | def commit(self): 32 | pass 33 | 34 | def close(self): 35 | pass 36 | 37 | def cursor(self): 38 | return MockCursor() 39 | 40 | def rollback(self): 41 | return 42 | 43 | 44 | def mock_connect(*args, **kwargs): 45 | return MockConnection() 46 | 47 | 48 | mock_hana = mock.patch('pyhdb.connect', mock_connect) 49 | patch_db_execute = mock.patch.object(MockCursor, 'execute') 50 | patch_db_executemany = mock.patch.object(MockCursor, 'executemany') 51 | patch_db_fetchone = mock.patch.object(MockCursor, 'fetchone') 52 | patch_db_fetchmany = mock.patch.object(MockCursor, 'fetchmany') 53 | patch_db_fetchall = mock.patch.object(MockCursor, 'fetchall') 54 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_hana import column_store, row_store 4 | 5 | 6 | class SimpleModel(models.Model): 7 | char_field = models.CharField(max_length=100) 8 | 9 | class Meta: 10 | app_label = 'test_dhp' 11 | 12 | 13 | class ComplexModel(models.Model): 14 | big_integer_field = models.BigIntegerField() 15 | binary_field = models.BinaryField() 16 | boolean_field = models.BooleanField() 17 | char_field = models.CharField(max_length=100) 18 | date_field = models.DateField() 19 | date_time_field = models.DateTimeField() 20 | decimal_field = models.DecimalField(max_digits=5, decimal_places=2) 21 | duration_field = models.DurationField() 22 | email_field = models.EmailField() 23 | file_field = models.FileField() 24 | file_path_field = models.FilePathField(path='/home/foobar/') 25 | float_field = models.FloatField() 26 | image_field = models.ImageField() 27 | integer_field = models.IntegerField() 28 | generic_ip_address_field = models.GenericIPAddressField() 29 | null_boolean_field = models.NullBooleanField() 30 | positive_integer_field = models.PositiveIntegerField() 31 | positive_small_integer_field = models.PositiveSmallIntegerField() 32 | slug_field = models.SlugField() 33 | small_integer_field = models.SmallIntegerField() 34 | text_field = models.TextField() 35 | time_field = models.TimeField() 36 | url_field = models.URLField() 37 | uuid_field = models.UUIDField() 38 | 39 | class Meta: 40 | app_label = 'test_dhp' 41 | 42 | 43 | class RelationModel(models.Model): 44 | to_one_field = models.ForeignKey(ComplexModel) 45 | to_many_field = models.ManyToManyField(ComplexModel) 46 | 47 | class Meta: 48 | app_label = 'test_dhp' 49 | 50 | 51 | @column_store 52 | class SimpleColumnModel(models.Model): 53 | char_field = models.CharField(max_length=50) 54 | 55 | class Meta: 56 | app_label = 'test_dhp' 57 | 58 | 59 | @row_store 60 | class SimpleRowModel(models.Model): 61 | char_field = models.CharField(max_length=50) 62 | 63 | class Meta: 64 | app_label = 'test_dhp' 65 | -------------------------------------------------------------------------------- /tests/test_queries.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import decimal 3 | import unittest 4 | import uuid 5 | 6 | import django 7 | import mock 8 | from django.db import connection, models 9 | from django.db.models.fields.files import FieldFile 10 | from django.utils import six 11 | from mock import call 12 | 13 | from django_hana.base import Database 14 | 15 | from .mock_db import mock_hana, patch_db_execute, patch_db_executemany, patch_db_fetchmany, patch_db_fetchone 16 | from .models import ComplexModel, RelationModel, SimpleColumnModel, SimpleModel, SimpleRowModel 17 | 18 | 19 | class DatabaseConnectionMixin(object): 20 | maxDiff = None 21 | 22 | @mock_hana 23 | @patch_db_execute 24 | @patch_db_fetchone 25 | def setUp(self, mock_fetchone, mock_execute): 26 | connection.ensure_connection() 27 | 28 | 29 | class TestSetup(DatabaseConnectionMixin, unittest.TestCase): 30 | @mock_hana 31 | @patch_db_execute 32 | def test_create_table(self, mock_execute): 33 | expected_statements = [ 34 | call( 35 | 'CREATE COLUMN TABLE "TEST_DHP_COMPLEXMODEL" ' 36 | '("ID" INTEGER NOT NULL PRIMARY KEY, ' 37 | '"BIG_INTEGER_FIELD" BIGINT NOT NULL, ' 38 | '"BINARY_FIELD" BLOB NOT NULL, ' 39 | '"BOOLEAN_FIELD" TINYINT NOT NULL, ' 40 | '"CHAR_FIELD" NVARCHAR(100) NOT NULL, ' 41 | '"DATE_FIELD" DATE NOT NULL, ' 42 | '"DATE_TIME_FIELD" TIMESTAMP NOT NULL, ' 43 | '"DECIMAL_FIELD" DECIMAL(5, 2) NOT NULL, ' 44 | '"DURATION_FIELD" BIGINT NOT NULL, ' 45 | '"EMAIL_FIELD" NVARCHAR(254) NOT NULL, ' 46 | '"FILE_FIELD" NVARCHAR(100) NOT NULL, ' 47 | '"FILE_PATH_FIELD" NVARCHAR(100) NOT NULL, ' 48 | '"FLOAT_FIELD" FLOAT NOT NULL, ' 49 | '"IMAGE_FIELD" NVARCHAR(100) NOT NULL, ' 50 | '"INTEGER_FIELD" INTEGER NOT NULL, ' 51 | '"GENERIC_IP_ADDRESS_FIELD" NVARCHAR(39) NOT NULL, ' 52 | '"NULL_BOOLEAN_FIELD" TINYINT NULL, ' 53 | '"POSITIVE_INTEGER_FIELD" INTEGER NOT NULL, ' 54 | '"POSITIVE_SMALL_INTEGER_FIELD" SMALLINT NOT NULL, ' 55 | '"SLUG_FIELD" NVARCHAR(50) NOT NULL, ' 56 | '"SMALL_INTEGER_FIELD" SMALLINT NOT NULL, ' 57 | '"TEXT_FIELD" NCLOB NOT NULL, ' 58 | '"TIME_FIELD" TIME NOT NULL, ' 59 | '"URL_FIELD" NVARCHAR(200) NOT NULL, ' 60 | '"UUID_FIELD" NVARCHAR(32) NOT NULL' 61 | ')', 62 | None 63 | ), 64 | call( 65 | 'CREATE SEQUENCE "TEST_DHP_COMPLEXMODEL_ID_SEQ" ' 66 | 'RESET BY SELECT IFNULL(MAX("ID"),0) + 1 FROM "TEST_DHP_COMPLEXMODEL"', 67 | [] 68 | ), 69 | call('CREATE INDEX "TEST_DHP_COMPLEXMODEL_D7C9D0CA" ON "TEST_DHP_COMPLEXMODEL" ("SLUG_FIELD")', []), 70 | ] 71 | 72 | with connection.schema_editor() as editor: 73 | editor.create_model(ComplexModel) 74 | self.assertSequenceEqual(mock_execute.call_args_list, expected_statements) 75 | 76 | @mock_hana 77 | @patch_db_execute 78 | @mock.patch('__builtin__.hash' if six.PY2 else 'builtins.hash', mock.Mock(side_effect=[ # only Django 1.8 79 | 3107422457, 80 | 1315547209, 81 | 1095174084, 82 | ])) 83 | def test_create_relationship_table(self, mock_execute): 84 | expected_statements = [ 85 | call( 86 | 'CREATE COLUMN TABLE "TEST_DHP_RELATIONMODEL" ' 87 | '("ID" INTEGER NOT NULL PRIMARY KEY, "TO_ONE_FIELD_ID" INTEGER NOT NULL)', 88 | None 89 | ), 90 | call( 91 | 'CREATE COLUMN TABLE "TEST_DHP_RELATIONMODEL_TO_MANY_FIELD" ' 92 | '("ID" INTEGER NOT NULL PRIMARY KEY, ' 93 | '"RELATIONMODEL_ID" INTEGER NOT NULL, ' 94 | '"COMPLEXMODEL_ID" INTEGER NOT NULL)' 95 | if django.VERSION >= (1, 9) else 96 | 'CREATE COLUMN TABLE "TEST_DHP_RELATIONMODEL_TO_MANY_FIELD" ' 97 | '("ID" INTEGER NOT NULL PRIMARY KEY, ' 98 | '"RELATIONMODEL_ID" INTEGER NOT NULL, ' 99 | '"COMPLEXMODEL_ID" INTEGER NOT NULL, ' 100 | 'UNIQUE ("RELATIONMODEL_ID", "COMPLEXMODEL_ID"))', 101 | None 102 | ), 103 | call( 104 | 'CREATE SEQUENCE "TEST_DHP_RELATIONMODEL_ID_SEQ" ' 105 | 'RESET BY SELECT IFNULL(MAX("ID"),0) + 1 FROM "TEST_DHP_RELATIONMODEL"', 106 | [] 107 | ), 108 | call( 109 | 'ALTER TABLE "TEST_DHP_RELATIONMODEL" ' 110 | 'ADD CONSTRAINT "TEST_DHP_RELATIONMODEL_TO_ONE_FIELD_ID_B93780F9_FK_TEST_DHP_COMPLEXMODEL_ID" ' 111 | 'FOREIGN KEY ("TO_ONE_FIELD_ID") REFERENCES "TEST_DHP_COMPLEXMODEL" ("ID") ON DELETE CASCADE', 112 | [] 113 | ), 114 | call( 115 | 'CREATE INDEX "TEST_DHP_RELATIONMODEL_2E33486B" ON "TEST_DHP_RELATIONMODEL" ("TO_ONE_FIELD_ID")', 116 | [] 117 | ), 118 | call( 119 | 'CREATE SEQUENCE "TEST_DHP_RELATIONMODEL_TO_MANY_FIELD_ID_SEQ" ' 120 | 'RESET BY SELECT IFNULL(MAX("ID"),0) + 1 FROM "TEST_DHP_RELATIONMODEL_TO_MANY_FIELD"', 121 | [] 122 | ), 123 | call( 124 | 'ALTER TABLE "TEST_DHP_RELATIONMODEL_TO_MANY_FIELD" ADD CONSTRAINT ' 125 | '"TEST_DHP_RELATIONMODEL_TO_MANY_FIELD_RELATIONMODEL_ID_4E69A849_FK_TEST_DHP_RELATIONMODEL_ID" ' 126 | 'FOREIGN KEY ("RELATIONMODEL_ID") REFERENCES "TEST_DHP_RELATIONMODEL" ("ID") ON DELETE CASCADE', 127 | [] 128 | ), 129 | call( 130 | 'ALTER TABLE "TEST_DHP_RELATIONMODEL_TO_MANY_FIELD" ADD CONSTRAINT ' 131 | '"TEST_DHP_RELATIONMODEL_TO_MANY_FIELD_COMPLEXMODEL_ID_414707C4_FK_TEST_DHP_COMPLEXMODEL_ID" ' 132 | 'FOREIGN KEY ("COMPLEXMODEL_ID") REFERENCES "TEST_DHP_COMPLEXMODEL" ("ID") ON DELETE CASCADE', 133 | [] 134 | ), 135 | call( 136 | 'CREATE INDEX "TEST_DHP_RELATIONMODEL_TO_MANY_FIELD_87DCF9A5" ' 137 | 'ON "TEST_DHP_RELATIONMODEL_TO_MANY_FIELD" ("RELATIONMODEL_ID")', 138 | [] 139 | ), 140 | call( 141 | 'CREATE INDEX "TEST_DHP_RELATIONMODEL_TO_MANY_FIELD_370D6EEB" ' 142 | 'ON "TEST_DHP_RELATIONMODEL_TO_MANY_FIELD" ("COMPLEXMODEL_ID")', 143 | [] 144 | ), 145 | ] 146 | if django.VERSION >= (1, 9): 147 | expected_statements.insert(8, call( 148 | 'ALTER TABLE "TEST_DHP_RELATIONMODEL_TO_MANY_FIELD" ADD CONSTRAINT ' 149 | '"TEST_DHP_RELATIONMODEL_TO_MANY_FIELD_RELATIONMODEL_ID_7FEAA1CD_UNIQ" ' 150 | 'UNIQUE ("RELATIONMODEL_ID", "COMPLEXMODEL_ID")', 151 | [] 152 | )) 153 | 154 | with connection.schema_editor() as editor: 155 | editor.create_model(RelationModel) 156 | self.assertSequenceEqual(mock_execute.call_args_list, expected_statements) 157 | 158 | @mock_hana 159 | @patch_db_execute 160 | def test_create_column_table(self, mock_execute): 161 | expected_statements = [ 162 | call( 163 | 'CREATE COLUMN TABLE "TEST_DHP_SIMPLECOLUMNMODEL" ' 164 | '("ID" INTEGER NOT NULL PRIMARY KEY, "CHAR_FIELD" NVARCHAR(50) NOT NULL)', 165 | None 166 | ), 167 | call( 168 | 'CREATE SEQUENCE "TEST_DHP_SIMPLECOLUMNMODEL_ID_SEQ" ' 169 | 'RESET BY SELECT IFNULL(MAX("ID"),0) + 1 FROM "TEST_DHP_SIMPLECOLUMNMODEL"', 170 | [] 171 | ), 172 | ] 173 | 174 | with connection.schema_editor() as editor: 175 | editor.create_model(SimpleColumnModel) 176 | self.assertSequenceEqual(mock_execute.call_args_list, expected_statements) 177 | 178 | @mock_hana 179 | @patch_db_execute 180 | def test_create_row_table(self, mock_execute): 181 | expected_statements = [ 182 | call( 183 | 'CREATE ROW TABLE "TEST_DHP_SIMPLEROWMODEL" ' 184 | '("ID" INTEGER NOT NULL PRIMARY KEY, "CHAR_FIELD" NVARCHAR(50) NOT NULL)', 185 | None 186 | ), 187 | call( 188 | 'CREATE SEQUENCE "TEST_DHP_SIMPLEROWMODEL_ID_SEQ" ' 189 | 'RESET BY SELECT IFNULL(MAX("ID"),0) + 1 FROM "TEST_DHP_SIMPLEROWMODEL"', 190 | [] 191 | ), 192 | ] 193 | 194 | with connection.schema_editor() as editor: 195 | editor.create_model(SimpleRowModel) 196 | self.assertSequenceEqual(mock_execute.call_args_list, expected_statements) 197 | 198 | @mock_hana 199 | @patch_db_execute 200 | def test_add_column_default_value(self, mock_execute): 201 | expected_statements = [ 202 | call( 203 | 'ALTER TABLE "TEST_DHP_SIMPLEMODEL" ' 204 | 'ADD ("NEW_CHAR_FIELD" NVARCHAR(50) NOT NULL)', 205 | [] 206 | ), 207 | ] 208 | 209 | with connection.schema_editor() as editor: 210 | field = models.CharField(max_length=50, default='default_value') 211 | field.set_attributes_from_name('new_char_field') 212 | editor.add_field(SimpleModel, field) 213 | self.assertSequenceEqual(mock_execute.call_args_list, expected_statements) 214 | 215 | 216 | class TestCreation(DatabaseConnectionMixin, unittest.TestCase): 217 | @mock_hana 218 | @patch_db_execute 219 | @patch_db_fetchone 220 | def test_insert_object(self, mock_fetchone, mock_execute): 221 | expected_field_names = [ 222 | '"BIG_INTEGER_FIELD"', 223 | '"BINARY_FIELD"', 224 | '"BOOLEAN_FIELD"', 225 | '"CHAR_FIELD"', 226 | '"DATE_FIELD"', 227 | '"DATE_TIME_FIELD"', 228 | '"DECIMAL_FIELD"', 229 | '"DURATION_FIELD"', 230 | '"EMAIL_FIELD"', 231 | '"FILE_FIELD"', 232 | '"FILE_PATH_FIELD"', 233 | '"FLOAT_FIELD"', 234 | '"IMAGE_FIELD"', 235 | '"INTEGER_FIELD"', 236 | '"GENERIC_IP_ADDRESS_FIELD"', 237 | '"NULL_BOOLEAN_FIELD"', 238 | '"POSITIVE_INTEGER_FIELD"', 239 | '"POSITIVE_SMALL_INTEGER_FIELD"', 240 | '"SLUG_FIELD"', 241 | '"SMALL_INTEGER_FIELD"', 242 | '"TEXT_FIELD"', 243 | '"TIME_FIELD"', 244 | '"URL_FIELD"', 245 | '"UUID_FIELD"', 246 | ] 247 | expected_statements = [ 248 | call( 249 | 'INSERT INTO "TEST_DHP_COMPLEXMODEL" (id,%(field_names)s) ' 250 | 'VALUES (test_dhp_complexmodel_id_seq.nextval, %(param_placeholders)s)' % { 251 | 'field_names': ', '.join(expected_field_names), 252 | 'param_placeholders': ', '.join('?' * len(expected_field_names)), 253 | }, 254 | [ 255 | 9223372036854775807, 256 | Database.Blob(b'foobar'), 257 | 0, 258 | 'foobar', 259 | '2017-01-01', 260 | '2017-01-01 13:45:21', 261 | 123.45, 262 | 1234567890, 263 | 'foo@foobar.com', 264 | 'uploads/foobar.txt', 265 | 'uploads/barbaz.txt', 266 | 12.34567, 267 | 'uploads/image.png', 268 | -2147483648, 269 | '192.0.2.30', 270 | None, 271 | 2147483647, 272 | 32767, 273 | 'something-foobar-1234', 274 | -32768, 275 | 'some long text', 276 | '13:45:21', 277 | 'https://foo.bar.com/baz/', 278 | '12345678123456781234567812345678', 279 | ] 280 | ), 281 | call('select test_dhp_complexmodel_id_seq.currval from dummy', ()), 282 | ] 283 | mock_fetchone.side_effect = [[1]] 284 | 285 | ComplexModel.objects.create( 286 | big_integer_field=9223372036854775807, 287 | binary_field=b'foobar', 288 | boolean_field=False, 289 | char_field='foobar', 290 | date_field=datetime.date(2017, 1, 1), 291 | date_time_field=datetime.datetime(2017, 1, 1, 13, 45, 21), 292 | decimal_field=123.45, 293 | duration_field=datetime.timedelta(microseconds=1234567890), 294 | email_field='foo@foobar.com', 295 | file_field='uploads/foobar.txt', 296 | file_path_field='uploads/barbaz.txt', 297 | float_field=12.34567, 298 | image_field='uploads/image.png', 299 | integer_field=-2147483648, 300 | generic_ip_address_field='192.0.2.30', 301 | null_boolean_field=None, 302 | positive_integer_field=2147483647, 303 | positive_small_integer_field=32767, 304 | slug_field='something-foobar-1234', 305 | small_integer_field=-32768, 306 | text_field='some long text', 307 | time_field=datetime.time(13, 45, 21), 308 | url_field='https://foo.bar.com/baz/', 309 | uuid_field='12345678-12345678-12345678-12345678', 310 | ) 311 | 312 | # Blob of pyhdb is not comparable. Therefore comparing `mock_execute.call_args_list` and `expected_statements` 313 | # will fail. We check the value of the binary file manually and patch the expected statement afterwards. 314 | first_call = mock_execute.call_args_list[0] 315 | (_, first_call_args), _ = first_call 316 | self.assertIsInstance(first_call_args[1], Database.Blob) 317 | self.assertEqual(first_call_args[1].read(), b'foobar') 318 | 319 | # Patch expected_statement 320 | first_expected_call = expected_statements[0] 321 | _, (_, first_expected_call_args), _ = first_expected_call 322 | first_expected_call_args[1] = first_call_args[1] 323 | 324 | self.assertSequenceEqual(mock_execute.call_args_list, expected_statements) 325 | 326 | @mock_hana 327 | @patch_db_executemany 328 | @patch_db_fetchone 329 | def test_insert_objects(self, mock_fetchone, mock_execute): 330 | expected_statements = [ 331 | call( 332 | 'INSERT INTO "TEST_DHP_SIMPLEMODEL" (id,"CHAR_FIELD") ' 333 | 'VALUES (test_dhp_simplemodel_id_seq.nextval, ?)', 334 | (['foobar'], ['barbaz']) 335 | ), 336 | ] 337 | mock_fetchone.side_effect = [[1]] 338 | 339 | SimpleModel.objects.bulk_create([ 340 | SimpleModel(char_field='foobar'), 341 | SimpleModel(char_field='barbaz'), 342 | ]) 343 | 344 | self.assertSequenceEqual(mock_execute.call_args_list, expected_statements) 345 | 346 | 347 | class TestSelection(DatabaseConnectionMixin, unittest.TestCase): 348 | valid_db_values = [ 349 | 1234, # id 350 | 9223372036854775807, # big integer 351 | Database.Blob(b'foobar'), # binary 352 | 0, # boolean 353 | 'foobar', # char 354 | datetime.date(2017, 1, 1), # date 355 | datetime.datetime(2017, 1, 1, 13, 45, 21), # date time 356 | decimal.Decimal(123.45), # decimal 357 | 1234567890, # duration 358 | 'foo@foobar.com', # email 359 | 'uploads/foobar.txt', # file 360 | 'uploads/barbaz.txt', # file path 361 | 12.34567, # float 362 | 'uploads/image.png', # image 363 | -2147483648, # integer 364 | '192.0.2.30', # generic ip address 365 | None, # null boolean 366 | 2147483647, # postive integer 367 | 32767, # positive small integer 368 | 'something-foobar-1234', # slug 369 | -32768, # small integer 370 | Database.NClob('some long text'), # text 371 | datetime.time(13, 45, 21), # time 372 | 'https://foo.bar.com/baz/', # url 373 | '12345678-12345678-12345678-12345678', # uuid 374 | ] 375 | 376 | @mock_hana 377 | @patch_db_execute 378 | @patch_db_fetchmany 379 | def test_select_model(self, mock_fetchmany, mock_execute): 380 | expected_field_names = [ 381 | '"TEST_DHP_COMPLEXMODEL"."BIG_INTEGER_FIELD"', 382 | '"TEST_DHP_COMPLEXMODEL"."BINARY_FIELD"', 383 | '"TEST_DHP_COMPLEXMODEL"."BOOLEAN_FIELD"', 384 | '"TEST_DHP_COMPLEXMODEL"."CHAR_FIELD"', 385 | '"TEST_DHP_COMPLEXMODEL"."DATE_FIELD"', 386 | '"TEST_DHP_COMPLEXMODEL"."DATE_TIME_FIELD"', 387 | '"TEST_DHP_COMPLEXMODEL"."DECIMAL_FIELD"', 388 | '"TEST_DHP_COMPLEXMODEL"."DURATION_FIELD"', 389 | '"TEST_DHP_COMPLEXMODEL"."EMAIL_FIELD"', 390 | '"TEST_DHP_COMPLEXMODEL"."FILE_FIELD"', 391 | '"TEST_DHP_COMPLEXMODEL"."FILE_PATH_FIELD"', 392 | '"TEST_DHP_COMPLEXMODEL"."FLOAT_FIELD"', 393 | '"TEST_DHP_COMPLEXMODEL"."IMAGE_FIELD"', 394 | '"TEST_DHP_COMPLEXMODEL"."INTEGER_FIELD"', 395 | '"TEST_DHP_COMPLEXMODEL"."GENERIC_IP_ADDRESS_FIELD"', 396 | '"TEST_DHP_COMPLEXMODEL"."NULL_BOOLEAN_FIELD"', 397 | '"TEST_DHP_COMPLEXMODEL"."POSITIVE_INTEGER_FIELD"', 398 | '"TEST_DHP_COMPLEXMODEL"."POSITIVE_SMALL_INTEGER_FIELD"', 399 | '"TEST_DHP_COMPLEXMODEL"."SLUG_FIELD"', 400 | '"TEST_DHP_COMPLEXMODEL"."SMALL_INTEGER_FIELD"', 401 | '"TEST_DHP_COMPLEXMODEL"."TEXT_FIELD"', 402 | '"TEST_DHP_COMPLEXMODEL"."TIME_FIELD"', 403 | '"TEST_DHP_COMPLEXMODEL"."URL_FIELD"', 404 | '"TEST_DHP_COMPLEXMODEL"."UUID_FIELD"', 405 | ] 406 | expected_statements = [ 407 | call( 408 | 'SELECT "TEST_DHP_COMPLEXMODEL"."ID", %(field_names)s FROM "TEST_DHP_COMPLEXMODEL"' % { 409 | 'field_names': ', '.join(expected_field_names), 410 | }, ()), 411 | ] 412 | mock_fetchmany.side_effect = [ 413 | [ 414 | tuple(self.valid_db_values), 415 | ], 416 | ] 417 | 418 | objects = list(ComplexModel.objects.all()) # trigger database query with list() 419 | self.assertEqual(len(objects), 1) 420 | self.assertEqual(objects[0].id, 1234) 421 | self.assertEqual(objects[0].big_integer_field, 9223372036854775807) 422 | self.assertEqual(objects[0].binary_field, b'foobar') 423 | self.assertEqual(objects[0].boolean_field, False) 424 | self.assertEqual(objects[0].char_field, 'foobar') 425 | self.assertEqual(objects[0].date_field, datetime.date(2017, 1, 1)) 426 | self.assertEqual(objects[0].date_time_field, datetime.datetime(2017, 1, 1, 13, 45, 21)) 427 | self.assertEqual(objects[0].decimal_field, 123.45) 428 | self.assertEqual(objects[0].duration_field, datetime.timedelta(0, 1234, 567890)) 429 | self.assertEqual(objects[0].email_field, 'foo@foobar.com') 430 | self.assertEqual(objects[0].file_field, FieldFile(objects[0], objects[0].file_field, 'uploads/foobar.txt')) 431 | self.assertEqual(objects[0].file_path_field, 'uploads/barbaz.txt') 432 | self.assertEqual(objects[0].float_field, 12.34567) 433 | self.assertEqual(objects[0].image_field, 'uploads/image.png') 434 | self.assertEqual(objects[0].integer_field, -2147483648) 435 | self.assertEqual(objects[0].generic_ip_address_field, '192.0.2.30') 436 | self.assertEqual(objects[0].null_boolean_field, None) 437 | self.assertEqual(objects[0].positive_integer_field, 2147483647) 438 | self.assertEqual(objects[0].positive_small_integer_field, 32767) 439 | self.assertEqual(objects[0].slug_field, 'something-foobar-1234') 440 | self.assertEqual(objects[0].small_integer_field, -32768) 441 | self.assertEqual(objects[0].text_field, 'some long text') 442 | self.assertEqual(objects[0].time_field, datetime.time(13, 45, 21)) 443 | self.assertEqual(objects[0].url_field, 'https://foo.bar.com/baz/') 444 | self.assertEqual(objects[0].uuid_field, uuid.UUID('12345678-12345678-12345678-12345678')) 445 | 446 | self.assertSequenceEqual(mock_execute.call_args_list, expected_statements) 447 | 448 | 449 | class TestAggregation(DatabaseConnectionMixin, unittest.TestCase): 450 | @mock_hana 451 | @patch_db_execute 452 | @patch_db_fetchone 453 | def test_aggregate(self, mock_fetchone, mock_execute): 454 | expected_statements = [ 455 | call( 456 | 'SELECT COUNT("TEST_DHP_SIMPLEMODEL"."CHAR_FIELD") AS "CHAR_FIELD__COUNT" ' 457 | 'FROM "TEST_DHP_SIMPLEMODEL"', 458 | () 459 | ) 460 | ] 461 | field_count = 1 462 | mock_fetchone.side_effect = [[field_count]] 463 | 464 | data = SimpleModel.objects.all().aggregate(models.Count('char_field')) 465 | 466 | self.assertIn('char_field__count', data) 467 | self.assertEqual(data['char_field__count'], field_count) 468 | self.assertSequenceEqual(mock_execute.call_args_list, expected_statements) 469 | 470 | @mock_hana 471 | @patch_db_execute 472 | @patch_db_fetchmany 473 | def test_annotate(self, mock_fetchmany, mock_execute): 474 | expected_statements = [ 475 | call( 476 | 'SELECT "TEST_DHP_SIMPLEMODEL"."ID", "TEST_DHP_SIMPLEMODEL"."CHAR_FIELD", ' 477 | 'COUNT("TEST_DHP_SIMPLEMODEL"."CHAR_FIELD") AS "NUM_FIELDS" ' 478 | 'FROM "TEST_DHP_SIMPLEMODEL" ' 479 | 'GROUP BY "TEST_DHP_SIMPLEMODEL"."ID", "TEST_DHP_SIMPLEMODEL"."CHAR_FIELD"', 480 | () 481 | ), 482 | ] 483 | mock_fetchmany.side_effect = [[]] # return empty list 484 | 485 | qs = SimpleModel.objects.annotate(num_fields=models.Count('char_field')) 486 | list(qs) # trigger database query with list() 487 | self.assertSequenceEqual(mock_execute.call_args_list, expected_statements) 488 | 489 | @mock_hana 490 | @patch_db_execute 491 | @patch_db_fetchone 492 | def test_annotate_aggreate(self, mock_fetchone, mock_execute): 493 | expected_statements = [ 494 | call( 495 | 'SELECT SUM("NUM_FIELDS") ' 496 | 'FROM (SELECT "TEST_DHP_SIMPLEMODEL"."ID" AS Col1, ' 497 | 'COUNT("TEST_DHP_SIMPLEMODEL"."CHAR_FIELD") AS "NUM_FIELDS" ' 498 | 'FROM "TEST_DHP_SIMPLEMODEL" ' 499 | 'GROUP BY "TEST_DHP_SIMPLEMODEL"."ID") subquery', 500 | () 501 | ), 502 | ] 503 | num_fields = 1 504 | mock_fetchone.side_effect = [[num_fields]] 505 | 506 | data = SimpleModel.objects.annotate(num_fields=models.Count('char_field')).aggregate(models.Sum('num_fields')) 507 | 508 | self.assertIn('num_fields__sum', data) 509 | self.assertEqual(data['num_fields__sum'], num_fields) 510 | self.assertSequenceEqual(mock_execute.call_args_list, expected_statements) 511 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | 6 | SECRET_KEY = 'fake-key' 7 | 8 | INSTALLED_APPS = [ 9 | 'django.contrib.auth', 10 | 'django.contrib.contenttypes', 11 | 'django.contrib.sessions', 12 | 'django.contrib.messages', 13 | 'django.contrib.staticfiles', 14 | ] 15 | 16 | ROOT_URLCONF = 'tests.urls' 17 | 18 | TEMPLATES = [ 19 | { 20 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 21 | 'APP_DIRS': True, 22 | }, 23 | ] 24 | 25 | DATABASES = { 26 | 'default': { 27 | 'ENGINE': 'django_hana', 28 | 'NAME': 'testing_django_hana', 29 | 'USER': 'foo', 30 | 'PASSWORD': 'foo', 31 | 'HOST': '0.0.0.0', 32 | 'PORT': '30015', 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | downloadcache = {toxworkdir}/cache/ 3 | envlist = 4 | py{27,33,34,35}-django18 5 | py{27,34,35}-django{19,110} 6 | {py27,py34,py35,py36}-django111 7 | py{35,36}-djangomaster 8 | isort, lint 9 | 10 | [travis:env] 11 | DJANGO = 12 | 1.8: django18 13 | 1.9: django19 14 | 1.10: django110 15 | 1.11: django111 16 | master: djangomaster 17 | 18 | [testenv] 19 | commands = coverage run --source=django_hana setup.py test 20 | deps = 21 | coverage >= 4.2, < 4.3 22 | mock >= 2.0, < 2.1 23 | https://github.com/SAP/PyHDB/zipball/master 24 | django18: Django >= 1.8, < 1.9 25 | django19: Django >= 1.9, < 1.10 26 | django110: Django >= 1.10, < 1.11 27 | django111: Django >=1.11a1, <2.0 28 | djangomaster: https://github.com/django/django/zipball/master 29 | 30 | [testenv:isort] 31 | commands = isort --check-only 32 | deps = isort >= 4.2, < 4.3 33 | 34 | [testenv:lint] 35 | commands = flake8 . 36 | deps = 37 | flake8 38 | flake8-quotes 39 | 40 | [flake8] 41 | exclude = .tox, __pycache__, build, dist 42 | max-line-length = 120 43 | --------------------------------------------------------------------------------