├── tests ├── __init__.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── base.py ├── models.py ├── test_settings.py ├── test_utils.py ├── test_models.py ├── test_query.py └── test_routers.py ├── docs ├── history.rst ├── contributing.rst ├── _static │ └── logo.jpg ├── modules.rst ├── index.rst ├── horizon.rst ├── Makefile ├── make.bat └── conf.py ├── requirements.txt ├── requirements-dev.txt ├── horizon ├── __init__.py ├── manager.py ├── settings.py ├── query.py ├── routers.py ├── utils.py └── models.py ├── .pyup.yml ├── AUTHORS.rst ├── .editorconfig ├── .github └── ISSUE_TEMPLATE.md ├── runmakemigrations.py ├── MANIFEST.in ├── runtests.py ├── HISTORY.rst ├── setup.cfg ├── LICENSE ├── tox.ini ├── .gitignore ├── setup.py ├── Makefile ├── .travis.yml ├── CONTRIBUTING.rst └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx==1.8.5 2 | tox==3.7.0 3 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | Django==2.1.7 3 | -------------------------------------------------------------------------------- /docs/_static/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harikitech/django-horizon/HEAD/docs/_static/logo.jpg -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | horizon 2 | ======= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | horizon 8 | -------------------------------------------------------------------------------- /horizon/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = """UNCOVER TRUTH Inc.""" 2 | __email__ = 'develop@uncovertruth.co.jp' 3 | __version__ = '1.1.2' 4 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | # autogenerated pyup.io config file 2 | # see https://pyup.io/docs/configuration/ for all available options 3 | 4 | schedule: every day 5 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | from django.test import TransactionTestCase 2 | 3 | 4 | class HorizontalBaseTestCase(TransactionTestCase): 5 | """Base test case for horizonta.""" 6 | 7 | multi_db = True 8 | -------------------------------------------------------------------------------- /horizon/manager.py: -------------------------------------------------------------------------------- 1 | from django.db.models.manager import Manager 2 | 3 | from .query import QuerySet 4 | 5 | 6 | class HorizontalManager(Manager.from_queryset(QuerySet)): 7 | use_for_related_fields = True 8 | use_in_migrations = True 9 | 10 | def __init__(self): 11 | super(HorizontalManager, self).__init__() 12 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * UNCOVER TRUTH Inc. 9 | 10 | Contributors 11 | ------------ 12 | 13 | * None yet. Why not be the first? 14 | * `We are hiring`_ 15 | 16 | .. _`We are hiring`: https://www.wantedly.com/companies/uncovertruth/ 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Django Horizon's documentation! 2 | ========================================== 3 | 4 | .. include:: ../README.rst 5 | .. include:: ../AUTHORS.rst 6 | 7 | Contents: 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | contributing 13 | history 14 | 15 | Indices and tables 16 | ================== 17 | 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * Django Horizon version: 2 | * Django version: 3 | * Python version: 4 | * Operating System: 5 | 6 | ### Description 7 | 8 | Describe what you were trying to get done. 9 | Tell us what happened, what went wrong, and what you expected to happen. 10 | 11 | ### What I Did 12 | 13 | ``` 14 | Paste the command(s) you ran and the output. 15 | If there was a crash, please include the traceback here. 16 | ``` 17 | -------------------------------------------------------------------------------- /runmakemigrations.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", 'tests.test_settings') 6 | 7 | 8 | def runmigrations(): 9 | import django 10 | from django.core.management import call_command 11 | 12 | if hasattr(django, 'setup'): 13 | django.setup() 14 | 15 | call_command('makemigrations', 'tests') 16 | 17 | 18 | if __name__ == "__main__": 19 | runmigrations() 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft horizon 2 | graft docs 3 | 4 | include AUTHORS.rst 5 | include CONTRIBUTING.rst 6 | include HISTORY.rst 7 | include LICENSE 8 | include README.rst 9 | include setup.cfg 10 | include setup.py 11 | include runtests.py 12 | include Makefile 13 | 14 | exclude .editorconfig 15 | exclude .pyup.yml 16 | exclude .travis.yml 17 | exclude requirements.txt 18 | exclude requirements-dev.txt 19 | exclude runmakemigrations.py 20 | exclude tox.ini 21 | 22 | global-exclude __pycache__ 23 | global-exclude *.py[co] 24 | global-exclude *.cfg 25 | 26 | prune .github 27 | prune docs/_build 28 | prune tests 29 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", 'tests.test_settings') 7 | 8 | 9 | def runtests(): 10 | import django 11 | from django.conf import settings 12 | from django.test.utils import get_runner 13 | 14 | if hasattr(django, 'setup'): 15 | django.setup() 16 | 17 | TestRunner = get_runner(settings) 18 | test_runner = TestRunner(verbosity=2, interactive=True) 19 | failures = test_runner.run_tests(['tests']) 20 | sys.exit(bool(failures)) 21 | 22 | 23 | if __name__ == "__main__": 24 | runtests() 25 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 1.1.2 (2018-11-15) 6 | ------------------ 7 | 8 | * Update ``_create_object_from_params`` to new interface 9 | * Add support for Django 2.1 10 | * Add support for Python 3.7 11 | 12 | 1.1.1 (2018-08-03) 13 | ------------------ 14 | 15 | * Migrate to ``QuerySet`` as a mixin 16 | 17 | 1.1.0 (2018-03-30) 18 | ------------------ 19 | 20 | * Drop support for Django 1.9, 1.10 21 | 22 | 1.0.0 (2018-02-02) 23 | ------------------ 24 | 25 | * Add support for Django 2.0 26 | * Drop support for Django 1.8 27 | 28 | 0.0.1 (2017-05-22) 29 | ------------------ 30 | 31 | * First release on PyPI. 32 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.0.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:horizon/__init__.py] 7 | search = __version__ = '{current_version}' 8 | replace = __version__ = '{new_version}' 9 | 10 | [bdist_wheel] 11 | universal = 1 12 | 13 | [flake8] 14 | max-line-length = 100 15 | exclude = 16 | .eggs, 17 | .cache, 18 | .git, 19 | .tox, 20 | docs, 21 | migrations, 22 | __pycache__ 23 | 24 | [isort] 25 | include_trailing_comma=True 26 | line_length=80 27 | multi_line_output=3 28 | skip=migrations 29 | not_skip=__init__.py 30 | known_first_party=horizon 31 | 32 | [check-manifest] 33 | ignore = 34 | *.swp 35 | -------------------------------------------------------------------------------- /horizon/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.lru_cache import lru_cache 3 | 4 | CONFIG_DEFAULTS = { 5 | 'GROUPS': {}, 6 | 'METADATA_MODEL': None, 7 | } 8 | 9 | 10 | @lru_cache() 11 | def get_config(): 12 | USER_CONFIG = getattr(settings, 'HORIZONTAL_CONFIG', {}) 13 | 14 | CONFIG = CONFIG_DEFAULTS.copy() 15 | CONFIG.update(USER_CONFIG) 16 | 17 | for name, horizontal_group in CONFIG['GROUPS'].items(): 18 | horizontal_group['DATABASE_SET'] = set() 19 | for key, member in horizontal_group['DATABASES'].items(): 20 | horizontal_group['DATABASE_SET'].add(member['write']) 21 | horizontal_group['DATABASE_SET'].update(member.get('read', [])) 22 | 23 | if 'read' not in member: 24 | member['read'] = [member['write']] 25 | 26 | if 'PICKABLES' not in horizontal_group: 27 | horizontal_group['PICKABLES'] = [int(i) for i in horizontal_group['DATABASES'].keys()] 28 | 29 | return CONFIG 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 UNCOVER TRUTH Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/horizon.rst: -------------------------------------------------------------------------------- 1 | horizon package 2 | =============== 3 | 4 | Submodules 5 | ---------- 6 | 7 | horizon\.manager module 8 | ----------------------- 9 | 10 | .. automodule:: horizon.manager 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | horizon\.models module 16 | ---------------------- 17 | 18 | .. automodule:: horizon.models 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | horizon\.query module 24 | --------------------- 25 | 26 | .. automodule:: horizon.query 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | horizon\.routers module 32 | ----------------------- 33 | 34 | .. automodule:: horizon.routers 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | horizon\.settings module 40 | ------------------------ 41 | 42 | .. automodule:: horizon.settings 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | horizon\.utils module 48 | --------------------- 49 | 50 | .. automodule:: horizon.utils 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | 56 | Module contents 57 | --------------- 58 | 59 | .. automodule:: horizon 60 | :members: 61 | :undoc-members: 62 | :show-inheritance: 63 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = true 3 | skip_missing_interpreters = true 4 | envlist = 5 | py{27,34,35,36}-dj111, 6 | py{34,35,36,37}-dj20, 7 | py{35,36,37}-dj21, 8 | py{35,36,37}-djmaster, 9 | py37-{flake8,isort,readme,check-manifest} 10 | 11 | [travis:env] 12 | DJANGO = 13 | 1.11: dj111 14 | 2.0: dj20 15 | 2.1: dj21 16 | master: djmaster 17 | 18 | [testenv] 19 | passenv = TRAVIS TRAVIS_* CODECOV_TOKEN 20 | basepython = 21 | py27: python2.7 22 | py34: python3.4 23 | py35: python3.5 24 | py36: python3.6 25 | py37: python3.7 26 | deps = 27 | dj111: django>=1.11,<2.0 28 | dj20: django>=2.0,<2.1 29 | dj21: django>=2.1,<2.2 30 | djmaster: https://github.com/django/django/archive/master.tar.gz 31 | py27: mock 32 | py37-dj21: codecov 33 | coverage 34 | commands = 35 | coverage run --source=horizon setup.py test 36 | py37-dj21: codecov 37 | 38 | [testenv:flake8] 39 | skip_install = true 40 | basepython = python3 41 | deps = flake8 42 | commands = flake8 43 | 44 | [testenv:isort] 45 | skip_install = true 46 | basepython = python3 47 | deps = isort 48 | commands = isort --recursive --verbose --check-only --diff horizon tests setup.py 49 | 50 | [testenv:readme] 51 | skip_install = true 52 | basepython = python3 53 | deps = readme_renderer 54 | commands = python setup.py check -r -s 55 | 56 | [testenv:check-manifest] 57 | skip_install = true 58 | basepython = python3 59 | deps = check-manifest 60 | commands = check-manifest {toxinidir} 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/ca0d1e9ddc307deb4ffdab057497b3e009635229/Python.gitignore 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | 108 | 109 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import find_packages, setup 4 | 5 | import horizon 6 | 7 | with open('README.rst') as readme_file: 8 | readme = readme_file.read() 9 | 10 | with open('HISTORY.rst') as history_file: 11 | history = history_file.read() 12 | 13 | requirements = [ 14 | 'Django>=1.11', 15 | ] 16 | 17 | test_requirements = [ 18 | # TODO: put package test requirements here 19 | ] 20 | 21 | setup( 22 | name='django-horizon', 23 | version=horizon.__version__, 24 | description=( 25 | "Simple database sharding (horizontal partitioning) library for Django applications." 26 | ), 27 | long_description=readme + '\n\n' + history, 28 | author=horizon.__author__, 29 | author_email=horizon.__email__, 30 | url='https://github.com/uncovertruth/django-horizon', 31 | packages=find_packages(exclude=('tests', 'docs')), 32 | include_package_data=True, 33 | install_requires=requirements, 34 | license="MIT license", 35 | zip_safe=False, 36 | keywords='django-horizon, django, sharding, horizontal partitioning, database', 37 | classifiers=[ 38 | 'Development Status :: 2 - Pre-Alpha', 39 | 'Environment :: Web Environment', 40 | 'Framework :: Django :: 1.11', 41 | 'Framework :: Django :: 2.0', 42 | 'Framework :: Django :: 2.1', 43 | 'Intended Audience :: Developers', 44 | 'License :: OSI Approved :: MIT License', 45 | 'Natural Language :: English', 46 | 'Operating System :: OS Independent', 47 | 'Programming Language :: Python :: 2', 48 | 'Programming Language :: Python :: 2.7', 49 | 'Programming Language :: Python :: 3', 50 | 'Programming Language :: Python :: 3.4', 51 | 'Programming Language :: Python :: 3.5', 52 | 'Programming Language :: Python :: 3.6', 53 | 'Programming Language :: Python :: 3.7', 54 | ], 55 | test_suite='runtests.runtests', 56 | tests_require=test_requirements 57 | ) 58 | -------------------------------------------------------------------------------- /horizon/query.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Model 2 | from django.db.models.query import QuerySet as DjangoQuerySet 3 | from django.db.utils import ProgrammingError 4 | 5 | from .utils import get_key_field_name_from_model 6 | 7 | 8 | class HorizontalQuerySetMixin(object): 9 | def __init__(self, model=None, **kwargs): 10 | super(HorizontalQuerySetMixin, self).__init__(model=model, **kwargs) 11 | self._horizontal_key = None 12 | 13 | @classmethod 14 | def _get_horizontal_key_from_lookup_value(cls, lookup_value): 15 | if not lookup_value: 16 | return 17 | if isinstance(lookup_value, Model): 18 | return lookup_value.pk 19 | return lookup_value 20 | 21 | def _set_horizontal_key_from_params(self, kwargs): 22 | if self._horizontal_key is not None: 23 | return 24 | 25 | key_field = self.model._meta.get_field(get_key_field_name_from_model(self.model)) 26 | lookup_value = kwargs.get(key_field.attname, None) or kwargs.get(key_field.name, None) 27 | self._horizontal_key = self._get_horizontal_key_from_lookup_value(lookup_value) 28 | 29 | def _create_object_from_params(self, lookup, *args, **kwargs): 30 | self._set_horizontal_key_from_params(lookup) 31 | return super(HorizontalQuerySetMixin, self)._create_object_from_params( 32 | lookup, *args, **kwargs) 33 | 34 | def _extract_model_params(self, defaults, **kwargs): 35 | self._set_horizontal_key_from_params(kwargs) 36 | return super(HorizontalQuerySetMixin, self)._extract_model_params(defaults, **kwargs) 37 | 38 | def _filter_or_exclude(self, negate, *args, **kwargs): 39 | self._set_horizontal_key_from_params(kwargs) 40 | return super(HorizontalQuerySetMixin, self)._filter_or_exclude(negate, *args, **kwargs) 41 | 42 | def create(self, **kwargs): 43 | self._set_horizontal_key_from_params(kwargs) 44 | return super(HorizontalQuerySetMixin, self).create(**kwargs) 45 | 46 | def _clone(self, **kwargs): 47 | clone = super(HorizontalQuerySetMixin, self)._clone(**kwargs) 48 | clone._horizontal_key = self._horizontal_key 49 | return clone 50 | 51 | @property 52 | def db(self): 53 | if self._db: 54 | return self._db 55 | 56 | if self._horizontal_key is None: 57 | raise ProgrammingError("Missing horizontal key field's filter") 58 | 59 | self._add_hints(horizontal_key=self._horizontal_key) 60 | return super(HorizontalQuerySetMixin, self).db 61 | 62 | 63 | class QuerySet(HorizontalQuerySetMixin, DjangoQuerySet): 64 | pass 65 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PYPI_SERVER = pypitest 2 | 3 | .PHONY: clean clean-test clean-pyc clean-build docs help 4 | .DEFAULT_GOAL := help 5 | define BROWSER_PYSCRIPT 6 | import os, webbrowser, sys 7 | try: 8 | from urllib import pathname2url 9 | except: 10 | from urllib.request import pathname2url 11 | 12 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 13 | endef 14 | export BROWSER_PYSCRIPT 15 | 16 | define PRINT_HELP_PYSCRIPT 17 | import re, sys 18 | 19 | for line in sys.stdin: 20 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 21 | if match: 22 | target, help = match.groups() 23 | print("%-20s %s" % (target, help)) 24 | endef 25 | export PRINT_HELP_PYSCRIPT 26 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 27 | 28 | help: 29 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 30 | 31 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 32 | 33 | 34 | clean-build: ## remove build artifacts 35 | rm -fr build/ 36 | rm -fr dist/ 37 | rm -fr .eggs/ 38 | find . -name '*.egg-info' -exec rm -fr {} + 39 | find . -name '*.egg' -exec rm -f {} + 40 | 41 | clean-pyc: ## remove Python file artifacts 42 | find . -name '*.pyc' -exec rm -f {} + 43 | find . -name '*.pyo' -exec rm -f {} + 44 | find . -name '*~' -exec rm -f {} + 45 | find . -name '__pycache__' -exec rm -fr {} + 46 | 47 | clean-test: ## remove test and coverage artifacts 48 | rm -fr .tox/ 49 | rm -f .coverage 50 | rm -fr htmlcov/ 51 | 52 | lint: ## check style with flake8 53 | flake8 horizon tests 54 | 55 | test: ## run tests quickly with the default Python 56 | 57 | python setup.py test 58 | 59 | test-all: ## run tests on every Python version with tox 60 | tox 61 | 62 | coverage: ## check code coverage quickly with the default Python 63 | 64 | coverage run --source horizon setup.py test 65 | 66 | coverage report -m 67 | coverage html 68 | $(BROWSER) htmlcov/index.html 69 | 70 | docs: ## generate Sphinx HTML documentation, including API docs 71 | rm -f docs/horizon.rst 72 | rm -f docs/modules.rst 73 | sphinx-apidoc -o docs/ horizon 74 | $(MAKE) -C docs clean 75 | $(MAKE) -C docs html 76 | $(BROWSER) docs/_build/html/index.html 77 | 78 | servedocs: docs ## compile the docs watching for changes 79 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 80 | 81 | release: clean ## package and upload a release 82 | python setup.py sdist bdist_wheel 83 | twine upload -r $(PYPI_SERVER) dist/* 84 | 85 | dist: clean ## builds source and wheel package 86 | python setup.py sdist bdist_wheel 87 | ls -l dist 88 | 89 | install: clean ## install the package to the active Python's site-packages 90 | python setup.py install 91 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | 4 | from horizon.manager import HorizontalManager 5 | from horizon.models import AbstractHorizontalMetadata, AbstractHorizontalModel 6 | 7 | 8 | class HorizontalMetadata(AbstractHorizontalMetadata): 9 | pass 10 | 11 | 12 | class OneModel(AbstractHorizontalModel): 13 | user = models.ForeignKey( 14 | settings.AUTH_USER_MODEL, 15 | on_delete=models.DO_NOTHING, 16 | db_constraint=False, 17 | ) 18 | spam = models.CharField(max_length=15) 19 | egg = models.CharField(max_length=15, null=True, default=None) 20 | 21 | objects = HorizontalManager() # For Django<1.10 22 | 23 | class Meta(object): 24 | horizontal_group = 'a' 25 | horizontal_key = 'user' 26 | 27 | 28 | class ManyModel(AbstractHorizontalModel): 29 | user = models.ForeignKey( 30 | settings.AUTH_USER_MODEL, 31 | on_delete=models.DO_NOTHING, 32 | db_constraint=False, 33 | ) 34 | one = models.ForeignKey(OneModel, on_delete=models.CASCADE) 35 | 36 | objects = HorizontalManager() # For Django<1.10 37 | 38 | class Meta(object): 39 | horizontal_group = 'a' 40 | horizontal_key = 'user' 41 | 42 | 43 | class ProxyBaseModel(AbstractHorizontalModel): 44 | user = models.ForeignKey( 45 | settings.AUTH_USER_MODEL, 46 | on_delete=models.DO_NOTHING, 47 | db_constraint=False, 48 | ) 49 | sushi = models.CharField(max_length=15, unique=True) 50 | 51 | class Meta(object): 52 | horizontal_group = 'b' 53 | horizontal_key = 'user' 54 | 55 | 56 | class ProxiedModel(ProxyBaseModel): 57 | tempura = models.CharField(max_length=15, unique=True) 58 | karaage = models.CharField(max_length=15, unique=True) 59 | 60 | class Meta(object): 61 | unique_together = ( 62 | ('tempura', 'karaage'), 63 | ) 64 | 65 | 66 | class AbstractModel(AbstractHorizontalModel): 67 | user = models.ForeignKey( 68 | settings.AUTH_USER_MODEL, 69 | on_delete=models.DO_NOTHING, 70 | db_constraint=False, 71 | ) 72 | pizza = models.CharField(max_length=15, unique=True) 73 | potate = models.CharField(max_length=15, unique=True) 74 | 75 | class Meta(object): 76 | abstract = True 77 | horizontal_group = 'b' 78 | horizontal_key = 'user' 79 | unique_together = ( 80 | ('pizza', 'potate'), 81 | ) 82 | 83 | 84 | class ConcreteModel(AbstractModel): 85 | coke = models.CharField(max_length=15, unique=True) 86 | 87 | class Meta(AbstractModel.Meta): 88 | unique_together = ( 89 | ('pizza', 'coke'), 90 | ) 91 | -------------------------------------------------------------------------------- /horizon/routers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.apps import apps 4 | from django.db.utils import IntegrityError 5 | 6 | from .utils import ( 7 | get_config_from_model, 8 | get_db_for_read_from_model_index, 9 | get_db_for_write_from_model_index, 10 | get_group_from_model, 11 | get_or_create_index, 12 | ) 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class HorizontalRouter(object): 18 | def _get_horizontal_index(self, model, hints): 19 | horizontal_group = get_group_from_model(model) 20 | if not horizontal_group: 21 | return 22 | 23 | horizontal_key = hints.get('horizontal_key', None) 24 | if not horizontal_key: 25 | instance = hints.get('instance', None) 26 | if instance and isinstance(instance, model): 27 | return instance._horizontal_database_index 28 | return None 29 | 30 | if not horizontal_key: 31 | raise IntegrityError("Missing 'horizontal_key'") 32 | return get_or_create_index(model, horizontal_key) 33 | 34 | def db_for_read(self, model, **hints): 35 | horizontal_index = self._get_horizontal_index(model, hints) 36 | if horizontal_index is None: 37 | return 38 | database = get_db_for_read_from_model_index(model, horizontal_index) 39 | logger.debug("'%s' read from '%s'", model.__name__, database) 40 | return database 41 | 42 | def db_for_write(self, model, **hints): 43 | horizontal_index = self._get_horizontal_index(model, hints) 44 | if horizontal_index is None: 45 | return 46 | database = get_db_for_write_from_model_index(model, horizontal_index) 47 | logger.debug("'%s' read from '%s'", model.__name__, database) 48 | return database 49 | 50 | def allow_relation(self, obj1, obj2, **hints): 51 | horizontal_group_1 = get_group_from_model(obj1._meta.model) 52 | horizontal_group_2 = get_group_from_model(obj2._meta.model) 53 | if not horizontal_group_1 or not horizontal_group_2: 54 | return 55 | 56 | if horizontal_group_1 != horizontal_group_2: 57 | return 58 | 59 | if obj1._state.db == obj2._state.db: 60 | return True 61 | 62 | if obj1._horizontal_database_index == obj2._horizontal_database_index: 63 | return True 64 | 65 | def allow_migrate(self, db, app_label, model_name=None, **hints): 66 | if 'model' in hints: 67 | model = hints['model'] 68 | elif model_name: 69 | model = apps.get_model(app_label, model_name) 70 | else: 71 | return 72 | 73 | if db in get_config_from_model(model).get('DATABASE_SET', []): 74 | return True 75 | -------------------------------------------------------------------------------- /horizon/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | 4 | from django.apps import apps 5 | from django.core.exceptions import ImproperlyConfigured 6 | 7 | from .settings import get_config 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def get_metadata_model(): 13 | try: 14 | return apps.get_model(get_config()['METADATA_MODEL'], require_ready=False) 15 | except ValueError: 16 | raise ImproperlyConfigured("METADATA_MODEL must be of the form 'app_label.model_name'") 17 | except LookupError: 18 | raise ImproperlyConfigured( 19 | "METADATA_MODEL refers to model '%s' that has not been installed" 20 | % get_config()['METADATA_MODEL'] 21 | ) 22 | 23 | 24 | def get_group_from_model(model): 25 | horizontal_group = getattr(model._meta, 'horizontal_group', None) 26 | if horizontal_group: 27 | return horizontal_group 28 | 29 | for parent_class in model._meta.get_parent_list(): 30 | horizontal_group = getattr(parent_class._meta, 'horizontal_group', None) 31 | if horizontal_group: 32 | return horizontal_group 33 | 34 | 35 | def get_key_field_name_from_model(model): 36 | horizontal_key = getattr(model._meta, 'horizontal_key', None) 37 | if horizontal_key: 38 | return horizontal_key 39 | 40 | for parent_class in model._meta.get_parent_list(): 41 | horizontal_key = getattr(parent_class._meta, 'horizontal_key', None) 42 | if horizontal_key: 43 | return horizontal_key 44 | 45 | 46 | def get_config_from_group(horizontal_group): 47 | if horizontal_group not in get_config()['GROUPS']: 48 | return {} 49 | return get_config()['GROUPS'][horizontal_group] 50 | 51 | 52 | def get_config_from_model(model): 53 | horizontal_group = get_group_from_model(model) 54 | if not horizontal_group: 55 | return {} 56 | return get_config_from_group(horizontal_group) 57 | 58 | 59 | def get_db_for_read_from_model_index(model, index): 60 | config = get_config_from_model(model) 61 | return random.choice(config['DATABASES'][index]['read']) 62 | 63 | 64 | def get_db_for_write_from_model_index(model, index): 65 | config = get_config_from_model(model) 66 | return config['DATABASES'][index]['write'] 67 | 68 | 69 | def get_or_create_index(model, horizontal_key): 70 | metadata_model = get_metadata_model() 71 | horizontal_group = get_group_from_model(model) 72 | metadata, created = metadata_model.objects.get_or_create( 73 | group=get_group_from_model(model), 74 | key=horizontal_key, 75 | defaults={ 76 | 'index': random.choice(get_config_from_group(horizontal_group)['PICKABLES']) 77 | }, 78 | ) 79 | if created: 80 | logger.info("Assign new index to '%s': %s", horizontal_group, metadata.index) 81 | return metadata.index 82 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | sudo: false 3 | 4 | language: python 5 | python: 6 | - 2.7 7 | - 3.4 8 | - 3.5 9 | - 3.6 10 | - 3.7 11 | 12 | cache: pip 13 | 14 | env: 15 | global: 16 | - secure: u0BkKcx3XkU+fi+Hf+Lmp+BMntW/QYme4GRrwKVQh1Blfunz6FIYxcNJDRwvrccGgj9BtUVg5O1Il7Hxo/k0OGb1glJni3G5C98qRroX5qG3jk+ptWLSYadE82CRUEB72UMKcuDq2lu+R6ACEQ/EQzLhVkX8GuFQ7/W6QRzbf6LN/OfOuz2OiZH/7WPxKk73EKQZtvslEqTI6zB0+RRjCt1ObHeZ4etYzRblO+gcVfeN1ySPeS5oyC8PwxjHMhpYMcMQLDjmqUU/ARQfDgAQVOyVKb7j/INmesWSRoBUWjkkeTiE9Wtx4MdAQ1bLnIhWOyDk5PXGDcK2SEOq2BMP0mScfqKzqUDQ2VGpVGR6ukBvkvN9OjZXR0TDx/ryrLgAG+z4pvzzN/XJbyVSGkH4tK2X8mua5kisb25OBEuMNDLRhubrHfWpTgWuYCNSPGQR2iE/LKRbDqWj5svMdbYmhBxV1/eLDga0KZo4PStyKfzJHYm/Qz7TfwgxMYV0WXofZmIi4LtX7j0LyEho62PwAPm8aiYfRFxXXtd5UFEEVZGSHXDUL5y4sILARzQmrIL4ZP9z52fxeBePpY6i8Otuov0GjtLifg0FjBGo5N7D1ANT00wKlbJCARstNtIElN/11dILdJE2y1KJeYiZI1SfoRUJk0TdJjgji5MIz6IHTQ0= 17 | matrix: 18 | - DJANGO=1.11 19 | - DJANGO=2.0 20 | - DJANGO=2.1 21 | - DJANGO=master 22 | 23 | matrix: 24 | fast_finish: true 25 | include: 26 | - env: TOXENV=flake8 27 | python: 3.7 28 | - env: TOXENV=isort 29 | python: 3.7 30 | - env: TOXENV=readme 31 | python: 3.7 32 | - env: TOXENV=check-manifest 33 | python: 3.7 34 | exclude: 35 | - python: 2.7 36 | env: DJANGO=2.0 37 | - python: 2.7 38 | env: DJANGO=2.1 39 | - python: 3.4 40 | env: DJANGO=2.1 41 | allow_failures: 42 | - env: DJANGO=master 43 | # Use old sqlite3 that does not support shared memory 44 | - env: DJANGO=1.11 45 | python: 2.7 46 | 47 | install: 48 | - travis_retry pip install -U pip tox-travis setuptools wheel 49 | before_script: 50 | - sqlite3 -version 51 | script: 52 | - tox 53 | deploy: 54 | provider: pypi 55 | user: uncovertruth 56 | password: 57 | secure: vm63jxiupnzZI1A9UwiE8JOJ02/7u8sIHUdx0K4Nb9teqZew+uk4DaSSY6MXtjFtatnBocBG/9xc7Ej90suFfQzx5ouYguWvhMDnBivzSvFQ+L68PlgNQ2od09r7NidCOKMkgBDjM0wPnSjzAfaPQatc3PCHcA04BOH10+KPZdDDNQtvjiqRPcFuIZiet0yA5ZECDy4vYeZInXREikbFneKW4NH35eigtZbxxXPX1QBTP9eWPbEM9Hjy+q79/njXHSkyTvdeCjfBHj2Uqul78FVwxdZI37Q4Bl7dgnJ96ccqmHVPEkdhW2wpIcu04hvF1Yo6xVmcx6xsSK77lNh2apLJh5eaYv0jA107z0XszamUZyhMrIIXhrMeXOFOBvsc6oT+VvV7AIfJTLulmsRT49fwAPxzyjobGwqTN0vEJAtfux07WptR2e1IQvOGIzl2JMBckEB+37ecB5H50fW0F9MjxzsE99nEkM0QkKRe64UEZ45Dtgltzdg91JdDzhJMD7qJAfaUpLsBc2dXeoi9Is/QgpPJ15vA7JWmMOuV0eT0gaIHuaRHGd5SHT75jAmu3v5ygulXVIesytasoFFfICWDXP4YsPro+PNmyTd8QW+tAJkigSFt85E5fYhdTToZ4jDMGFC2+ryoxUkk84XX1Ax3Fu22QRip8XjWRRz72Uc= 58 | distributions: sdist bdist_wheel 59 | on: 60 | tags: true 61 | repo: uncovertruth/django-horizon 62 | python: 3.7 63 | condition: $DJANGO = 2.1 64 | notifications: 65 | email: false 66 | slack: 67 | secure: HPa3L23uNVcVnXsNtkMkIuhOEOLTqvSbcXPy3ScjHEuZrRqKhHaX6DPx2/6PXR6azyKmywoqcVxDNfAiK5ditlvefxKyp8a1Csduwd4uGEqxAgxM72F/Qjfdb+QXCVp2M6ypjy8H9GSZ8pR888Bea1Vid49c8z1JEr3FbTw/acfY4WGeZTrbD2ZEsVYSEKJR/vS7/YxEY3jO4qxRoGul056LhZvabKACM0TseQkPIDLbY+/f/21LOz1d6TRdukRV9PdOp2lN4wJSloNja3j1x3Ox/auX2U1wAekH33HWyMi3au6X0zdLrEoNhCWdkfRy22qr/JSqeq+qo8xo4bzPrrMuUYzSBtMSp16BWQj47NSJAOhyIlMvtKbRgFdHuu+4XxRpGcmg+MjFdtyzOOjXldMr95adI9+RKzWLQUvBtxJFptDW48yf2t+VIP1WnNfHHoIjj1rROyGTGTE36FrbjAi3V5oRWYBkpKMEmlcVmN/1DKoQ8GECyDKlw0R84xYWUau/LpM20THFzEnPgqUY/DG7a9aqYR/Szo0FY4NuHcW3jzBKeH0xQEOps2OeVVfr7CHcq9NK1AAFYOMeEi5LR6yMfBpbzfVKSRU5EgvmcH8Sr5pBP77UMxiersCEwK3GvwLGrioV4KY598GTGti4tcRaoc1PU+7+EmeFdsGPi4w= 68 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every 8 | little bit helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/uncovertruth/django-horizon/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" 30 | and "help wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | Django Horizon could always use more documentation, whether as part of the 42 | official Django Horizon docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/uncovertruth/django-horizon/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `django-horizon` for local development. 61 | 62 | 1. Fork the `django-horizon` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/django-horizon.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv django-horizon 70 | $ cd django-horizon/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: 80 | 81 | $ flake8 django-horizon tests 82 | $ python setup.py test or py.test 83 | $ tox 84 | 85 | To get flake8 and tox, just pip install them into your virtualenv. 86 | 87 | 6. Commit your changes and push your branch to GitHub:: 88 | 89 | $ git add . 90 | $ git commit -m "Your detailed description of your changes." 91 | $ git push origin name-of-your-bugfix-or-feature 92 | 93 | 7. Submit a pull request through the GitHub website. 94 | 95 | Pull Request Guidelines 96 | ----------------------- 97 | 98 | Before you submit a pull request, check that it meets these guidelines: 99 | 100 | 1. The pull request should include tests. 101 | 2. If the pull request adds functionality, the docs should be updated. Put 102 | your new functionality into a function with a docstring, and add the 103 | feature to the list in README.rst. 104 | 3. The pull request should work for Python 2.6, 2.7, 3.3, 3.4 and 3.5, and for PyPy. Check 105 | https://travis-ci.org/uncovertruth/django-horizon/pull_requests 106 | and make sure that the tests pass for all supported Python versions. 107 | 108 | Tips 109 | ---- 110 | 111 | To run a subset of tests:: 112 | 113 | 114 | $ python -m unittest tests.test_orizon 115 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | """Django settings for tests.""" 2 | 3 | SECRET_KEY = 'fake-key' 4 | INSTALLED_APPS = [ 5 | 'django.contrib.auth', 6 | 'django.contrib.contenttypes', 7 | 'tests', 8 | ] 9 | 10 | 11 | DATABASES = { 12 | 'default': { 13 | 'NAME': 'default', 14 | 'ENGINE': 'django.db.backends.sqlite3', 15 | 'TEST': { 16 | 'NAME': 'file:memorydb_default?mode=memory&cache=shared', 17 | }, 18 | }, 19 | 'a1-primary': { 20 | 'NAME': 'a1-primary', 21 | 'ENGINE': 'django.db.backends.sqlite3', 22 | 'TEST': { 23 | 'NAME': 'file:memorydb_a1-primary?mode=memory&cache=shared', 24 | }, 25 | }, 26 | 'a1-replica-1': { 27 | 'NAME': 'a1-replica-1', 28 | 'ENGINE': 'django.db.backends.sqlite3', 29 | 'TEST': { 30 | 'MIRROR': 'a1-primary', 31 | }, 32 | }, 33 | 'a1-replica-2': { 34 | 'NAME': 'a1-replica-2', 35 | 'ENGINE': 'django.db.backends.sqlite3', 36 | 'TEST': { 37 | 'MIRROR': 'a1-primary', 38 | }, 39 | }, 40 | 'a2-primary': { 41 | 'NAME': 'a2-primary', 42 | 'ENGINE': 'django.db.backends.sqlite3', 43 | 'TEST': { 44 | 'NAME': 'file:memorydb_a2-primary?mode=memory&cache=shared', 45 | }, 46 | }, 47 | 'a2-replica': { 48 | 'NAME': 'a2-replica', 49 | 'ENGINE': 'django.db.backends.sqlite3', 50 | 'TEST': { 51 | 'MIRROR': 'a2-primary', 52 | }, 53 | }, 54 | 'a3': { 55 | 'NAME': 'a3', 56 | 'ENGINE': 'django.db.backends.sqlite3', 57 | 'TEST': { 58 | 'NAME': 'file:memorydb_a3?mode=memory&cache=shared', 59 | }, 60 | }, 61 | 'b1-primary': { 62 | 'NAME': 'b1-primary', 63 | 'ENGINE': 'django.db.backends.sqlite3', 64 | 'TEST': { 65 | 'NAME': 'file:memorydb_b1-primary?mode=memory&cache=shared', 66 | }, 67 | }, 68 | 'b1-replica-1': { 69 | 'NAME': 'b1-replica-1', 70 | 'ENGINE': 'django.db.backends.sqlite3', 71 | 'TEST': { 72 | 'MIRROR': 'b1-primary', 73 | }, 74 | }, 75 | 'b1-replica-2': { 76 | 'NAME': 'b1-replica-2', 77 | 'ENGINE': 'django.db.backends.sqlite3', 78 | 'TEST': { 79 | 'MIRROR': 'b1-primary', 80 | }, 81 | }, 82 | 'b2-primary': { 83 | 'NAME': 'b2-primary', 84 | 'ENGINE': 'django.db.backends.sqlite3', 85 | 'TEST': { 86 | 'NAME': 'file:memorydb_b2-primary?mode=memory&cache=shared', 87 | }, 88 | }, 89 | 'b2-replica': { 90 | 'NAME': 'b2-replica', 91 | 'ENGINE': 'django.db.backends.sqlite3', 92 | 'TEST': { 93 | 'MIRROR': 'b2-primary', 94 | }, 95 | }, 96 | 'b3': { 97 | 'NAME': 'b3', 98 | 'ENGINE': 'django.db.backends.sqlite3', 99 | 'TEST': { 100 | 'NAME': 'file:memorydb_b3?mode=memory&cache=shared', 101 | }, 102 | }, 103 | } 104 | DATABASE_ROUTERS = ( 105 | 'horizon.routers.HorizontalRouter', 106 | ) 107 | 108 | 109 | HORIZONTAL_CONFIG = { 110 | 'GROUPS': { 111 | 'a': { 112 | 'DATABASES': { 113 | 1: { 114 | 'write': 'a1-primary', 115 | 'read': ['a1-replica-1', 'a1-replica-2'], 116 | }, 117 | 2: { 118 | 'write': 'a2-primary', 119 | 'read': ['a2-replica'], 120 | }, 121 | 3: { 122 | 'write': 'a3', 123 | }, 124 | }, 125 | }, 126 | 'b': { 127 | 'DATABASES': { 128 | 1: { 129 | 'write': 'b1-primary', 130 | 'read': ['b1-replica-1', 'b1-replica-2'], 131 | }, 132 | 2: { 133 | 'write': 'b2-primary', 134 | 'read': ['b2-replica'], 135 | }, 136 | 3: { 137 | 'write': 'b3', 138 | }, 139 | }, 140 | 'PICKABLES': [2, 3], 141 | }, 142 | }, 143 | 'METADATA_MODEL': 'tests.HorizontalMetadata', 144 | } 145 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.core.exceptions import ImproperlyConfigured 3 | from django.test import TestCase, override_settings 4 | 5 | from horizon.utils import ( 6 | get_config, 7 | get_config_from_group, 8 | get_config_from_model, 9 | get_db_for_read_from_model_index, 10 | get_db_for_write_from_model_index, 11 | get_group_from_model, 12 | get_key_field_name_from_model, 13 | get_metadata_model, 14 | get_or_create_index, 15 | ) 16 | 17 | from .models import ( 18 | ConcreteModel, 19 | HorizontalMetadata, 20 | ManyModel, 21 | OneModel, 22 | ProxiedModel, 23 | ProxyBaseModel, 24 | ) 25 | 26 | user_model = get_user_model() 27 | 28 | 29 | class UtilsTestCase(TestCase): 30 | def get_metadata_model(self): 31 | self.assertEqual(get_metadata_model(), HorizontalMetadata) 32 | 33 | @override_settings(HORIZONTAL_CONFIG={'METADATA_MODEL': ':innocent:'}) 34 | def test_get_metadata_failed_when_wrong_format(self): 35 | with self.assertRaises(ImproperlyConfigured): 36 | get_config.cache_clear() 37 | get_metadata_model() 38 | get_config.cache_clear() 39 | 40 | @override_settings(HORIZONTAL_CONFIG={'METADATA_MODEL': 'where.IsMyModel'}) 41 | def test_get_metadata_failed_when_lookup_failed(self): 42 | with self.assertRaises(ImproperlyConfigured): 43 | get_config.cache_clear() 44 | get_metadata_model() 45 | get_config.cache_clear() 46 | 47 | def test_get_group_from_model(self): 48 | self.assertEqual('a', get_group_from_model(OneModel)) 49 | self.assertEqual('a', get_group_from_model(ManyModel)) 50 | self.assertEqual('b', get_group_from_model(ProxyBaseModel)) 51 | self.assertEqual('b', get_group_from_model(ProxiedModel)) 52 | self.assertEqual('b', get_group_from_model(ConcreteModel)) 53 | 54 | def test_get_group_from_model_for_none_horizontal_models(self): 55 | self.assertIsNone(get_group_from_model(user_model)) 56 | 57 | def test_get_key_field_name_from_model(self): 58 | self.assertEqual('user', get_key_field_name_from_model(OneModel)) 59 | self.assertEqual('user', get_key_field_name_from_model(ManyModel)) 60 | self.assertEqual('user', get_key_field_name_from_model(ProxyBaseModel)) 61 | self.assertEqual('user', get_key_field_name_from_model(ProxiedModel)) 62 | self.assertEqual('user', get_key_field_name_from_model(ConcreteModel)) 63 | 64 | def test_get_key_field_name_from_model_for_none_horizontal_models(self): 65 | self.assertIsNone(get_key_field_name_from_model(user_model)) 66 | 67 | def test_get_config_from_model(self): 68 | config_for_mqny = get_config_from_model(OneModel) 69 | self.assertDictEqual( 70 | { 71 | 'DATABASES': { 72 | 1: { 73 | 'write': 'a1-primary', 74 | 'read': ['a1-replica-1', 'a1-replica-2'], 75 | }, 76 | 2: { 77 | 'write': 'a2-primary', 78 | 'read': ['a2-replica'], 79 | }, 80 | 3: { 81 | 'write': 'a3', 82 | 'read': ['a3'], # Complete db for read 83 | }, 84 | }, 85 | 'PICKABLES': [1, 2, 3], # Complete pickables 86 | 'DATABASE_SET': { 87 | 'a1-primary', 88 | 'a1-replica-1', 89 | 'a1-replica-2', 90 | 'a2-primary', 91 | 'a2-replica', 92 | 'a3', 93 | }, 94 | }, 95 | config_for_mqny, 96 | ) 97 | 98 | config_for_one = get_config_from_model(OneModel) 99 | self.assertDictEqual(config_for_mqny, config_for_one) 100 | self.assertDictEqual(config_for_one, get_config_from_group('a')) 101 | 102 | def test_get_config_from_model_for_none_horizontal_models(self): 103 | config_for_user = get_config_from_model(user_model) 104 | self.assertDictEqual({}, config_for_user) 105 | 106 | def test_get_db_for_read_from_model_index(self): 107 | for model in (OneModel, ManyModel): 108 | self.assertIn( 109 | get_db_for_read_from_model_index(model, 1), 110 | ['a1-replica-1', 'a1-replica-2'], 111 | ) 112 | self.assertIn( 113 | get_db_for_read_from_model_index(model, 2), 114 | ['a2-replica'], 115 | ) 116 | self.assertIn( 117 | get_db_for_read_from_model_index(model, 3), 118 | ['a3'], 119 | ) 120 | 121 | def test_get_db_for_write_from_model_index(self): 122 | for model in (ProxyBaseModel, ProxiedModel, ConcreteModel, ): 123 | self.assertEqual('b1-primary', get_db_for_write_from_model_index(model, 1)) 124 | self.assertEqual('b2-primary', get_db_for_write_from_model_index(model, 2)) 125 | self.assertEqual('b3', get_db_for_write_from_model_index(model, 3)) 126 | 127 | def test_get_or_create_index(self): 128 | user = user_model.objects.create_user('spam') 129 | one_index = get_or_create_index(OneModel, user.id) 130 | self.assertTrue(HorizontalMetadata.objects.get(group='a', key=user.id)) 131 | 132 | many_index = get_or_create_index(ManyModel, user.id) 133 | self.assertEqual(one_index, many_index) 134 | -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | import django.db.models.deletion 4 | import horizon.manager 5 | import uuid 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='ConcreteModel', 19 | fields=[ 20 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 21 | ('pizza', models.CharField(max_length=15, unique=True)), 22 | ('potate', models.CharField(max_length=15, unique=True)), 23 | ('coke', models.CharField(max_length=15, unique=True)), 24 | ('user', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)), 25 | ], 26 | options={ 27 | 'abstract': False, 28 | 'horizontal_group': 'b', 29 | 'horizontal_key': 'user', 30 | }, 31 | managers=[ 32 | ('objects', horizon.manager.HorizontalManager()), 33 | ], 34 | ), 35 | migrations.CreateModel( 36 | name='HorizontalMetadata', 37 | fields=[ 38 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 39 | ('group', models.CharField(max_length=15)), 40 | ('key', models.CharField(max_length=32)), 41 | ('index', models.PositiveSmallIntegerField()), 42 | ], 43 | options={ 44 | 'abstract': False, 45 | }, 46 | ), 47 | migrations.CreateModel( 48 | name='ManyModel', 49 | fields=[ 50 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 51 | ], 52 | options={ 53 | 'horizontal_group': 'a', 54 | 'horizontal_key': 'user', 55 | }, 56 | managers=[ 57 | ('objects', horizon.manager.HorizontalManager()), 58 | ], 59 | ), 60 | migrations.CreateModel( 61 | name='OneModel', 62 | fields=[ 63 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 64 | ('spam', models.CharField(max_length=15)), 65 | ('egg', models.CharField(default=None, max_length=15, null=True)), 66 | ('user', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)), 67 | ], 68 | options={ 69 | 'horizontal_group': 'a', 70 | 'horizontal_key': 'user', 71 | }, 72 | managers=[ 73 | ('objects', horizon.manager.HorizontalManager()), 74 | ], 75 | ), 76 | migrations.CreateModel( 77 | name='ProxyBaseModel', 78 | fields=[ 79 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 80 | ('sushi', models.CharField(max_length=15, unique=True)), 81 | ], 82 | options={ 83 | 'horizontal_group': 'b', 84 | 'horizontal_key': 'user', 85 | }, 86 | managers=[ 87 | ('objects', horizon.manager.HorizontalManager()), 88 | ], 89 | ), 90 | migrations.CreateModel( 91 | name='ProxiedModel', 92 | fields=[ 93 | ('proxybasemodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.ProxyBaseModel')), 94 | ('tempura', models.CharField(max_length=15, unique=True)), 95 | ('karaage', models.CharField(max_length=15, unique=True)), 96 | ], 97 | bases=('tests.proxybasemodel',), 98 | managers=[ 99 | ('objects', horizon.manager.HorizontalManager()), 100 | ], 101 | ), 102 | migrations.AddField( 103 | model_name='proxybasemodel', 104 | name='user', 105 | field=models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL), 106 | ), 107 | migrations.AddField( 108 | model_name='manymodel', 109 | name='one', 110 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.OneModel'), 111 | ), 112 | migrations.AddField( 113 | model_name='manymodel', 114 | name='user', 115 | field=models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL), 116 | ), 117 | migrations.AlterUniqueTogether( 118 | name='horizontalmetadata', 119 | unique_together=set([('group', 'key')]), 120 | ), 121 | migrations.AlterUniqueTogether( 122 | name='proxiedmodel', 123 | unique_together=set([('tempura', 'karaage')]), 124 | ), 125 | migrations.AlterUniqueTogether( 126 | name='concretemodel', 127 | unique_together=set([('pizza', 'coke')]), 128 | ), 129 | ] 130 | -------------------------------------------------------------------------------- /horizon/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.core import checks 4 | from django.core.exceptions import FieldDoesNotExist 5 | from django.db import models 6 | from django.db.migrations import state 7 | from django.db.migrations.operations import models as migrate_models 8 | from django.db.models import options 9 | from django.utils.functional import cached_property 10 | 11 | from .manager import HorizontalManager 12 | from .utils import ( 13 | get_config_from_model, 14 | get_group_from_model, 15 | get_key_field_name_from_model, 16 | get_or_create_index, 17 | ) 18 | 19 | _HORIZON_OPTIONS = ( 20 | 'horizontal_group', 21 | 'horizontal_key', 22 | ) 23 | 24 | 25 | # Monkey patch to add horizontal options in to models and database migrations 26 | options.DEFAULT_NAMES += _HORIZON_OPTIONS 27 | state.DEFAULT_NAMES += _HORIZON_OPTIONS 28 | migrate_models.AlterModelOptions.ALTER_OPTION_KEYS += list(_HORIZON_OPTIONS) 29 | 30 | 31 | class AbstractHorizontalMetadata(models.Model): 32 | group = models.CharField(max_length=15) 33 | key = models.CharField(max_length=32) 34 | index = models.PositiveSmallIntegerField() 35 | 36 | class Meta(object): 37 | abstract = True 38 | unique_together = ( 39 | ('group', 'key'), 40 | ) 41 | 42 | 43 | class AbstractHorizontalModel(models.Model): 44 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 45 | 46 | objects = HorizontalManager() 47 | 48 | @classmethod 49 | def check(cls, **kwargs): 50 | errors = super(AbstractHorizontalModel, cls).check(**kwargs) 51 | clash_errors = cls._check_horizontal_meta(**kwargs) 52 | if clash_errors: 53 | errors.extend(clash_errors) 54 | return errors 55 | 56 | errors.extend(cls._check_horizontal_group(**kwargs)) 57 | errors.extend(cls._check_horizontal_key(**kwargs)) 58 | return errors 59 | 60 | @classmethod 61 | def _check_horizontal_meta(cls, **kwargs): 62 | errors = [] 63 | if not get_group_from_model(cls): 64 | errors.append( 65 | checks.Error( 66 | "'horizontal_group' not configured.", 67 | obj=cls, 68 | id='horizon.E001', 69 | ) 70 | ) 71 | if not get_key_field_name_from_model(cls): 72 | errors.append( 73 | checks.Error( 74 | "'horizontal_key' not configured.", 75 | obj=cls, 76 | id='horizon.E001', 77 | ) 78 | ) 79 | return errors 80 | 81 | @classmethod 82 | def _check_horizontal_group(cls, **kwargs): 83 | if get_config_from_model(cls): 84 | return [] 85 | return [ 86 | checks.Error( 87 | "'horizontal_group' '%s' does not defined in settings." 88 | % get_group_from_model(cls), 89 | obj=cls, 90 | id='horizon.E002', 91 | ), 92 | ] 93 | 94 | @classmethod 95 | def _check_horizontal_key(cls, **kwargs): 96 | try: 97 | cls._meta.get_field(get_key_field_name_from_model(cls)) 98 | return [] 99 | except FieldDoesNotExist: 100 | return [ 101 | checks.Error( 102 | "'horizontal_key' refers to the non-existent field '%s'." 103 | % get_key_field_name_from_model(cls), 104 | obj=cls, 105 | id='horizon.E003', 106 | ), 107 | ] 108 | 109 | def _get_unique_checks(self, exclude=None): 110 | if exclude is None: 111 | exclude = [] 112 | 113 | fields_with_class = [(self.__class__, self._meta.local_fields)] 114 | for parent_class in self._meta.get_parent_list(): 115 | fields_with_class.append((parent_class, parent_class._meta.local_fields)) 116 | 117 | # Exclude default field's unique check and add field's unique check with horizontal key 118 | unique_fields_for_exclude = [] 119 | unique_fields_with_horizontal_key = [] 120 | for model_class, fields in fields_with_class: 121 | for f in fields: 122 | name = f.name 123 | if name in exclude: 124 | continue 125 | if f.unique: 126 | unique_fields_for_exclude.append( 127 | (model_class, (name, )), 128 | ) 129 | unique_fields_with_horizontal_key.append( 130 | (model_class, (get_key_field_name_from_model(self), name)), 131 | ) 132 | unique_checks, date_checks = super(AbstractHorizontalModel, self) \ 133 | ._get_unique_checks(exclude=exclude) 134 | for unique_check in unique_checks: 135 | if unique_check in unique_fields_for_exclude: 136 | continue 137 | unique_fields_with_horizontal_key.append(unique_check) 138 | return unique_fields_with_horizontal_key, date_checks 139 | 140 | @cached_property 141 | def _horizontal_key(self): 142 | key_field = self._meta.get_field(get_key_field_name_from_model(self)) 143 | return getattr(self, key_field.attname) 144 | 145 | @cached_property 146 | def _horizontal_database_index(self): 147 | return get_or_create_index(self, self._horizontal_key) 148 | 149 | class Meta(object): 150 | abstract = True 151 | horizontal_group = None 152 | horizontal_key = None 153 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Django Horizon 3 | ============== 4 | 5 | .. image:: https://travis-ci.org/uncovertruth/django-horizon.svg?branch=master 6 | :target: https://travis-ci.org/uncovertruth/django-horizon 7 | 8 | .. image:: https://api.codacy.com/project/badge/Grade/6f4ba73576904beaa41d68f40970bda9 9 | :target: https://www.codacy.com/app/develop_2/django-horizon?utm_source=github.com&utm_medium=referral&utm_content=uncovertruth/django-horizon&utm_campaign=Badge_Grade 10 | 11 | .. image:: https://codebeat.co/badges/74f07702-68ed-47e7-91e6-9088b0532342 12 | :target: https://codebeat.co/projects/github-com-uncovertruth-django-horizon-master 13 | 14 | .. image:: https://www.codefactor.io/repository/github/uncovertruth/django-horizon/badge 15 | :target: https://www.codefactor.io/repository/github/uncovertruth/django-horizon 16 | 17 | .. image:: https://codecov.io/gh/uncovertruth/django-horizon/branch/master/graph/badge.svg 18 | :target: https://codecov.io/gh/uncovertruth/django-horizon 19 | 20 | .. image:: https://readthedocs.org/projects/django-horizon/badge/?version=latest 21 | :target: http://django-horizon.readthedocs.io/en/latest/?badge=latest 22 | :alt: Documentation Status 23 | 24 | .. image:: https://pyup.io/repos/github/uncovertruth/django-horizon/shield.svg 25 | :target: https://pyup.io/repos/github/uncovertruth/django-horizon/ 26 | :alt: Updates 27 | 28 | .. image:: https://pyup.io/repos/github/uncovertruth/django-horizon/python-3-shield.svg 29 | :target: https://pyup.io/repos/github/uncovertruth/django-horizon/ 30 | :alt: Python 3 31 | 32 | .. image:: https://img.shields.io/pypi/v/django-horizon.svg 33 | :target: https://pypi.python.org/pypi/django-horizon 34 | 35 | 36 | Purpose 37 | ------- 38 | 39 | Simple database sharding (horizontal partitioning) library for Django applications. 40 | 41 | 42 | * Free software: MIT license 43 | * Documentation: https://django-horizon.readthedocs.io. 44 | * Inspired by django-sharding_. Thank you so much for your cool solution :) 45 | 46 | .. _django-sharding: https://github.com/JBKahn/django-sharding 47 | 48 | 49 | .. image:: https://raw.githubusercontent.com/uncovertruth/django-horizon/master/docs/_static/logo.jpg 50 | :alt: Logo 51 | 52 | 53 | Features 54 | -------- 55 | 56 | * Shard (horizontal partitioning) by some ForeignKey_ field like user account. 57 | 58 | .. _ForeignKey: https://docs.djangoproject.com/en/dev/ref/models/fields/#django.db.models.ForeignKey 59 | 60 | Installation 61 | ------------ 62 | 63 | To install Django Horizon, run this command in your terminal: 64 | 65 | .. code-block:: console 66 | 67 | $ pip install django-horizon 68 | 69 | This is the preferred method to install Django Horizon, as it will always install the most recent stable release. 70 | 71 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 72 | you through the process. 73 | 74 | .. _pip: https://pip.pypa.io 75 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 76 | 77 | Usage 78 | ----- 79 | 80 | Setup 81 | ^^^^^ 82 | 83 | Add database router configuration in your ``settings.py``: 84 | 85 | Horizontal database groups and a metadata store 86 | """"""""""""""""""""""""""""""""""""""""""""""" 87 | 88 | .. code-block:: python 89 | 90 | HORIZONTAL_CONFIG = { 91 | 'GROUPS': { 92 | 'group1': { # The name of database horizontal partitioning group 93 | 'DATABASES': { 94 | 1: { 95 | 'write': 'member1-primary', 96 | 'read': ['member1-replica-1', 'member1-replica-2'], # Pick randomly by router 97 | }, 98 | 2: { 99 | 'write': 'member2-primary', 100 | 'read': ['member2-replica'], 101 | }, 102 | 3: { 103 | 'write': 'a3', # Used by 'read' too 104 | }, 105 | }, 106 | 'PICKABLES': [2, 3], # Group member keys to pick new database 107 | }, 108 | }, 109 | 'METADATA_MODEL': 'app.HorizontalMetadata', # Metadata store for horizontal partition key and there database 110 | } 111 | 112 | Database router 113 | """"""""""""""" 114 | 115 | .. code-block:: python 116 | 117 | DATABASE_ROUTERS = ( 118 | 'horizon.routers.HorizontalRouter', 119 | ... 120 | ) 121 | 122 | Example models 123 | ^^^^^^^^^^^^^^ 124 | 125 | Horizontal partitioning by user 126 | 127 | Metadata store 128 | """""""""""""" 129 | 130 | .. code-block:: python 131 | 132 | from horizon.models import AbstractHorizontalMetadata 133 | 134 | class HorizontalMetadata(AbstractHorizontalMetadata): 135 | pass 136 | 137 | In the example, metadata store save followings. 138 | 139 | - ``group``: Group name for horizontal partitioning. 140 | - ``key``: Determines the distribution of the table's records among the horizontal partitioning group. 141 | - ``index``: Choosed database index in horizontal partitioning groups. 142 | 143 | Sharded model 144 | """"""""""""" 145 | 146 | .. code-block:: python 147 | 148 | from django.conf import settings 149 | 150 | from horizon.manager import HorizontalManager # For Django<1.10 151 | from horizon.models import AbstractHorizontalModel 152 | 153 | 154 | class SomeLargeModel(AbstractHorizontalModel): 155 | user = models.ForeignKey( 156 | settings.AUTH_USER_MODEL, 157 | on_delete=models.DO_NOTHING, 158 | db_constraint=False, # May be using anothor database 159 | ) 160 | ... 161 | 162 | objects = HorizontalManager() # For Django<1.10 163 | 164 | class Meta(object): 165 | horizontal_group = 'group1' # Group name 166 | horizontal_key = 'user' # Field name to use group key 167 | 168 | In many cases use UUIDField_ field for ``id``. 169 | The ``AbstractHorizontalModel`` uses UUIDField_ as a them id field in default. 170 | 171 | .. _UUIDField: https://docs.djangoproject.com/en/dev/ref/models/fields/#uuidfield 172 | 173 | Using a model 174 | """"""""""""" 175 | 176 | .. code-block:: python 177 | 178 | from django.contrib.auth import get_user_model 179 | 180 | 181 | user_model = get_user_model() 182 | user = user_model.objects.get(pk=1) 183 | 184 | # Get by foreign instance 185 | SomeLargeModel.objects.filter(uses=user) 186 | 187 | # Get by foreign id 188 | SomeLargeModel.objects.filter(uses_id=user.id) 189 | 190 | Model limitations 191 | """"""""""""""""" 192 | 193 | * ``django.db.utils.IntegrityError`` occured when not specify horizontal key field to filter 194 | 195 | .. code-block:: python 196 | 197 | SomeLargeModel.objects.all() 198 | 199 | * Cannot lookup by foreign key field, cause there are other (like ``default``) database 200 | 201 | .. code-block:: python 202 | 203 | list(user.somelargemodel_set.all()) 204 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django_horizon.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django_horizon.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django_horizon" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django_horizon" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django_horizon.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django_horizon.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import get_user_model 3 | from django.db import models 4 | from django.test import TestCase 5 | 6 | from horizon.models import AbstractHorizontalModel 7 | 8 | from .base import HorizontalBaseTestCase 9 | from .models import ( 10 | ConcreteModel, 11 | HorizontalMetadata, 12 | ManyModel, 13 | OneModel, 14 | ProxiedModel, 15 | ProxyBaseModel, 16 | ) 17 | 18 | user_model = get_user_model() 19 | 20 | 21 | class HorizontalModelTestCase(HorizontalBaseTestCase): 22 | def setUp(self): 23 | super(HorizontalModelTestCase, self).setUp() 24 | self.user_a = user_model.objects.create_user('spam') 25 | self.user_b = user_model.objects.create_user('egg') 26 | 27 | def test_get_unique_checks_for_proxied_model(self): 28 | proxied = ProxiedModel.objects.create( 29 | user=self.user_a, 30 | sushi='tsuna', 31 | tempura='momiji', 32 | karaage='chicken', 33 | ) 34 | unique_checks, date_checks = proxied._get_unique_checks() 35 | self.assertSetEqual( 36 | { 37 | (ProxyBaseModel, ('user', 'id')), # Horizontal key added 38 | (ProxyBaseModel, ('user', 'sushi')), # Horizontal key added 39 | (ProxiedModel, ('user', 'proxybasemodel_ptr')), # Horizontal key added 40 | (ProxiedModel, ('user', 'tempura')), # Horizontal key added 41 | (ProxiedModel, ('user', 'karaage')), # Horizontal key added 42 | (ProxiedModel, ('tempura', 'karaage')), # Horizontal key added 43 | }, 44 | set(unique_checks), 45 | ) 46 | 47 | def test_get_unique_checks_for_abstract_model(self): 48 | concrete = ConcreteModel.objects.create( 49 | user=self.user_a, 50 | pizza='pepperoni', 51 | potate='head', 52 | coke='pe*si' 53 | ) 54 | unique_checks, date_checks = concrete._get_unique_checks() 55 | self.assertSetEqual( 56 | { 57 | (ConcreteModel, ('user', 'id')), # Horizontal key added 58 | (ConcreteModel, ('user', 'pizza')), # Horizontal key added 59 | (ConcreteModel, ('user', 'potate')), # Horizontal key added 60 | (ConcreteModel, ('user', 'coke')), # Horizontal key added 61 | (ConcreteModel, ('pizza', 'coke')), 62 | }, 63 | set(unique_checks), 64 | ) 65 | 66 | def test_get_unique_checks_with_exclude(self): 67 | concrete = ConcreteModel.objects.create( 68 | user=self.user_a, 69 | pizza='pepperoni', 70 | potate='head', 71 | coke='pe*si' 72 | ) 73 | unique_checks, date_checks = concrete._get_unique_checks(exclude='coke') 74 | self.assertSetEqual( 75 | { 76 | (ConcreteModel, ('user', 'id')), # Horizontal key added 77 | (ConcreteModel, ('user', 'pizza')), # Horizontal key added 78 | (ConcreteModel, ('user', 'potate')), # Horizontal key added 79 | }, 80 | set(unique_checks), 81 | ) 82 | 83 | def test_horizontal_key(self): 84 | one = OneModel.objects.create(user=self.user_a, spam='1st') 85 | self.assertEqual(self.user_a.id, one._horizontal_key) 86 | 87 | def test_horizontal_database_index(self): 88 | one = OneModel.objects.create(user=self.user_a, spam='1st') 89 | many = ManyModel.objects.create(user=self.user_a, one=one) 90 | self.assertEqual(one._horizontal_database_index, many._horizontal_database_index) 91 | 92 | def test_filter(self): 93 | OneModel.objects.create(user=self.user_a, spam='1st') 94 | self.assertEqual(1, OneModel.objects.filter(user=self.user_a).count()) 95 | 96 | def test_get_or_create_expected_database(self): 97 | HorizontalMetadata.objects.create(group='a', key=self.user_a.id, index=1) 98 | one = OneModel.objects.create(user=self.user_a, spam='1st') 99 | 100 | self.assertTrue(OneModel.objects.using('a1-primary').get(pk=one.pk)) 101 | self.assertTrue(OneModel.objects.using('a1-replica-1').get(pk=one.pk)) 102 | self.assertTrue(OneModel.objects.using('a1-replica-2').get(pk=one.pk)) 103 | 104 | with self.assertRaises(OneModel.DoesNotExist): 105 | self.assertTrue(OneModel.objects.using('a2-primary').get(pk=one.pk)) 106 | 107 | with self.assertRaises(OneModel.DoesNotExist): 108 | self.assertTrue(OneModel.objects.using('a3').get(pk=one.pk)) 109 | 110 | def test_get_or_create_expected_database_for_inherited_model(self): 111 | HorizontalMetadata.objects.create(group='b', key=self.user_a.id, index=1) 112 | concrete = ConcreteModel.objects.create(user=self.user_a, pizza='pepperoni', potate='head') 113 | self.assertTrue(ConcreteModel.objects.using('b1-primary').get(pk=concrete.pk)) 114 | self.assertTrue(ConcreteModel.objects.using('b1-replica-1').get(pk=concrete.pk)) 115 | self.assertTrue(ConcreteModel.objects.using('b1-replica-2').get(pk=concrete.pk)) 116 | 117 | def test_get_or_create_expected_database_for_proxied_model(self): 118 | HorizontalMetadata.objects.create(group='b', key=self.user_a.id, index=2) 119 | proxied = ProxiedModel.objects.create( 120 | user=self.user_a, 121 | sushi='tsuna', 122 | tempura='momiji', 123 | karaage='chicken', 124 | ) 125 | self.assertTrue(ProxiedModel.objects.using('b2-primary').get(pk=proxied.pk)) 126 | self.assertTrue(ProxiedModel.objects.using('b2-replica').get(pk=proxied.pk)) 127 | self.assertTrue(ProxyBaseModel.objects.using('b2-primary').get(pk=proxied.pk)) 128 | self.assertTrue(ProxyBaseModel.objects.using('b2-replica').get(pk=proxied.pk)) 129 | 130 | 131 | class AbstractHorizontalModelTestCase(TestCase): 132 | def test_check_horizontal_meta_without_horitontal_group(self): 133 | class WithoutHorizontalGroupModel(AbstractHorizontalModel): 134 | user = models.ForeignKey( 135 | settings.AUTH_USER_MODEL, 136 | on_delete=models.DO_NOTHING, 137 | db_constraint=False, 138 | ) 139 | 140 | class Meta(object): 141 | horizontal_key = 'user' 142 | errors = WithoutHorizontalGroupModel.check() 143 | self.assertEqual(1, len(errors)) 144 | self.assertEqual('horizon.E001', errors[0].id) 145 | 146 | def test_check_horizontal_meta_without_horitontal_key(self): 147 | class WithoutHorizontalKeyModel(AbstractHorizontalModel): 148 | user = models.ForeignKey( 149 | settings.AUTH_USER_MODEL, 150 | on_delete=models.DO_NOTHING, 151 | db_constraint=False, 152 | ) 153 | 154 | class Meta(object): 155 | horizontal_group = 'a' 156 | errors = WithoutHorizontalKeyModel.check() 157 | self.assertEqual(1, len(errors)) 158 | self.assertEqual('horizon.E001', errors[0].id) 159 | 160 | def test_check_horizontal_meta_wrong_horizontal_group(self): 161 | class WrongGroupModel(AbstractHorizontalModel): 162 | user = models.ForeignKey( 163 | settings.AUTH_USER_MODEL, 164 | on_delete=models.DO_NOTHING, 165 | db_constraint=False, 166 | ) 167 | 168 | class Meta(object): 169 | horizontal_group = 'wrong' 170 | horizontal_key = 'user' 171 | errors = WrongGroupModel.check() 172 | self.assertEqual(1, len(errors)) 173 | self.assertEqual('horizon.E002', errors[0].id) 174 | 175 | def test_check_horizontal_meta_wrong_horizontal_key(self): 176 | class WrongKeyModel(AbstractHorizontalModel): 177 | user = models.ForeignKey( 178 | settings.AUTH_USER_MODEL, 179 | on_delete=models.DO_NOTHING, 180 | db_constraint=False, 181 | ) 182 | 183 | class Meta(object): 184 | horizontal_group = 'a' 185 | horizontal_key = 'wrong' 186 | errors = WrongKeyModel.check() 187 | self.assertEqual(1, len(errors)) 188 | self.assertEqual('horizon.E003', errors[0].id) 189 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # django-horizon documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import os 16 | import sys 17 | 18 | import django 19 | 20 | import horizon 21 | 22 | # If extensions (or modules to document with autodoc) are in another 23 | # directory, add these directories to sys.path here. If the directory is 24 | # relative to the documentation root, use os.path.abspath to make it 25 | # absolute, like shown here. 26 | #sys.path.insert(0, os.path.abspath('.')) 27 | 28 | # Get the project root dir, which is the parent dir of this 29 | cwd = os.getcwd() 30 | project_root = os.path.dirname(cwd) 31 | 32 | # Insert the project root dir as the first element in the PYTHONPATH. 33 | # This lets us ensure that the source package is imported, and that its 34 | # version is used. 35 | sys.path.insert(0, project_root) 36 | 37 | # Setup Django project 38 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", 'tests.test_settings') 39 | if hasattr(django, 'setup'): 40 | django.setup() 41 | 42 | # -- General configuration --------------------------------------------- 43 | 44 | # If your documentation needs a minimal Sphinx version, state it here. 45 | #needs_sphinx = '1.0' 46 | 47 | # Add any Sphinx extension module names here, as strings. They can be 48 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 49 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 50 | 51 | # Add any paths that contain templates here, relative to this directory. 52 | templates_path = ['_templates'] 53 | 54 | # The suffix of source filenames. 55 | source_suffix = '.rst' 56 | 57 | # The encoding of source files. 58 | #source_encoding = 'utf-8-sig' 59 | 60 | # The master toctree document. 61 | master_doc = 'index' 62 | 63 | # General information about the project. 64 | project = u'Django Horizon' 65 | copyright = u"2017, UNCOVER TRUTH Inc." 66 | 67 | # The version info for the project you're documenting, acts as replacement 68 | # for |version| and |release|, also used in various other places throughout 69 | # the built documents. 70 | # 71 | # The short X.Y version. 72 | version = horizon.__version__ 73 | # The full version, including alpha/beta/rc tags. 74 | release = horizon.__version__ 75 | 76 | # The language for content autogenerated by Sphinx. Refer to documentation 77 | # for a list of supported languages. 78 | #language = None 79 | 80 | # There are two options for replacing |today|: either, you set today to 81 | # some non-false value, then it is used: 82 | #today = '' 83 | # Else, today_fmt is used as the format for a strftime call. 84 | #today_fmt = '%B %d, %Y' 85 | 86 | # List of patterns, relative to source directory, that match files and 87 | # directories to ignore when looking for source files. 88 | exclude_patterns = ['_build'] 89 | 90 | # The reST default role (used for this markup: `text`) to use for all 91 | # documents. 92 | #default_role = None 93 | 94 | # If true, '()' will be appended to :func: etc. cross-reference text. 95 | #add_function_parentheses = True 96 | 97 | # If true, the current module name will be prepended to all description 98 | # unit titles (such as .. function::). 99 | #add_module_names = True 100 | 101 | # If true, sectionauthor and moduleauthor directives will be shown in the 102 | # output. They are ignored by default. 103 | #show_authors = False 104 | 105 | # The name of the Pygments (syntax highlighting) style to use. 106 | pygments_style = 'sphinx' 107 | 108 | # A list of ignored prefixes for module index sorting. 109 | #modindex_common_prefix = [] 110 | 111 | # If true, keep warnings as "system message" paragraphs in the built 112 | # documents. 113 | #keep_warnings = False 114 | 115 | 116 | # -- Options for HTML output ------------------------------------------- 117 | 118 | # The theme to use for HTML and HTML Help pages. See the documentation for 119 | # a list of builtin themes. 120 | html_theme = 'bizstyle' 121 | 122 | # Theme options are theme-specific and customize the look and feel of a 123 | # theme further. For a list of options available for each theme, see the 124 | # documentation. 125 | html_theme_options = {'maincolor' : "#696969"} 126 | 127 | # Add any paths that contain custom themes here, relative to this directory. 128 | #html_theme_path = [] 129 | 130 | # The name for this set of Sphinx documents. If None, it defaults to 131 | # " v documentation". 132 | #html_title = None 133 | 134 | # A shorter title for the navigation bar. Default is the same as 135 | # html_title. 136 | #html_short_title = None 137 | 138 | # The name of an image file (relative to this directory) to place at the 139 | # top of the sidebar. 140 | #html_logo = None 141 | 142 | # The name of an image file (within the static path) to use as favicon 143 | # of the docs. This file should be a Windows icon file (.ico) being 144 | # 16x16 or 32x32 pixels large. 145 | #html_favicon = None 146 | 147 | # Add any paths that contain custom static files (such as style sheets) 148 | # here, relative to this directory. They are copied after the builtin 149 | # static files, so a file named "default.css" will overwrite the builtin 150 | # "default.css". 151 | html_static_path = ['_static'] 152 | 153 | # If not '', a 'Last updated on:' timestamp is inserted at every page 154 | # bottom, using the given strftime format. 155 | #html_last_updated_fmt = '%b %d, %Y' 156 | 157 | # If true, SmartyPants will be used to convert quotes and dashes to 158 | # typographically correct entities. 159 | #html_use_smartypants = True 160 | 161 | # Custom sidebar templates, maps document names to template names. 162 | #html_sidebars = {} 163 | 164 | # Additional templates that should be rendered to pages, maps page names 165 | # to template names. 166 | #html_additional_pages = {} 167 | 168 | # If false, no module index is generated. 169 | #html_domain_indices = True 170 | 171 | # If false, no index is generated. 172 | #html_use_index = True 173 | 174 | # If true, the index is split into individual pages for each letter. 175 | #html_split_index = False 176 | 177 | # If true, links to the reST sources are added to the pages. 178 | #html_show_sourcelink = True 179 | 180 | # If true, "Created using Sphinx" is shown in the HTML footer. 181 | # Default is True. 182 | #html_show_sphinx = True 183 | 184 | # If true, "(C) Copyright ..." is shown in the HTML footer. 185 | # Default is True. 186 | #html_show_copyright = True 187 | 188 | # If true, an OpenSearch description file will be output, and all pages 189 | # will contain a tag referring to it. The value of this option 190 | # must be the base URL from which the finished HTML is served. 191 | #html_use_opensearch = '' 192 | 193 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 194 | #html_file_suffix = None 195 | 196 | # Output file base name for HTML help builder. 197 | htmlhelp_basename = 'django_horizondoc' 198 | 199 | 200 | # -- Options for LaTeX output ------------------------------------------ 201 | 202 | latex_elements = { 203 | # The paper size ('letterpaper' or 'a4paper'). 204 | #'papersize': 'letterpaper', 205 | 206 | # The font size ('10pt', '11pt' or '12pt'). 207 | #'pointsize': '10pt', 208 | 209 | # Additional stuff for the LaTeX preamble. 210 | #'preamble': '', 211 | } 212 | 213 | # Grouping the document tree into LaTeX files. List of tuples 214 | # (source start file, target name, title, author, documentclass 215 | # [howto/manual]). 216 | latex_documents = [ 217 | ('index', 'django_horizon.tex', 218 | u'Django Horizon Documentation', 219 | u'UNCOVER TRUTH Inc.', 'manual'), 220 | ] 221 | 222 | # The name of an image file (relative to this directory) to place at 223 | # the top of the title page. 224 | #latex_logo = None 225 | 226 | # For "manual" documents, if this is true, then toplevel headings 227 | # are parts, not chapters. 228 | #latex_use_parts = False 229 | 230 | # If true, show page references after internal links. 231 | #latex_show_pagerefs = False 232 | 233 | # If true, show URL addresses after external links. 234 | #latex_show_urls = False 235 | 236 | # Documents to append as an appendix to all manuals. 237 | #latex_appendices = [] 238 | 239 | # If false, no module index is generated. 240 | #latex_domain_indices = True 241 | 242 | 243 | # -- Options for manual page output ------------------------------------ 244 | 245 | # One entry per manual page. List of tuples 246 | # (source start file, name, description, authors, manual section). 247 | man_pages = [ 248 | ('index', 'django_horizon', 249 | u'Django Horizon Documentation', 250 | [u'UNCOVER TRUTH Inc.'], 1) 251 | ] 252 | 253 | # If true, show URL addresses after external links. 254 | #man_show_urls = False 255 | 256 | 257 | # -- Options for Texinfo output ---------------------------------------- 258 | 259 | # Grouping the document tree into Texinfo files. List of tuples 260 | # (source start file, target name, title, author, 261 | # dir menu entry, description, category) 262 | texinfo_documents = [ 263 | ('index', 'django_horizon', 264 | u'Django Horizon Documentation', 265 | u'UNCOVER TRUTH Inc.', 266 | 'django_horizon', 267 | 'One line description of project.', 268 | 'Miscellaneous'), 269 | ] 270 | 271 | # Documents to append as an appendix to all manuals. 272 | #texinfo_appendices = [] 273 | 274 | # If false, no module index is generated. 275 | #texinfo_domain_indices = True 276 | 277 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 278 | #texinfo_show_urls = 'footnote' 279 | 280 | # If true, do not generate a @detailmenu in the "Top" node's menu. 281 | #texinfo_no_detailmenu = False 282 | -------------------------------------------------------------------------------- /tests/test_query.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.db.utils import ProgrammingError 5 | 6 | from horizon.query import QuerySet 7 | 8 | from .base import HorizontalBaseTestCase 9 | from .models import OneModel 10 | 11 | user_model = get_user_model() 12 | 13 | 14 | class QuerySetTestCase(HorizontalBaseTestCase): 15 | def setUp(self): 16 | super(QuerySetTestCase, self).setUp() 17 | self.user = user_model.objects.create_user('spam') 18 | self.queryset = QuerySet() 19 | 20 | def test_get(self): 21 | OneModel.objects.create(user=self.user, spam='1st') 22 | with patch.object( 23 | QuerySet, 24 | '_get_horizontal_key_from_lookup_value', 25 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 26 | ) as mock_get_horizontal_key_from_lookup_value: 27 | OneModel.objects.get(user=self.user, spam='1st') 28 | mock_get_horizontal_key_from_lookup_value.assert_any_call(self.user) 29 | self.assertEqual( 30 | self.user.id, 31 | self.queryset._get_horizontal_key_from_lookup_value(self.user), 32 | ) 33 | 34 | def test_get_by_id(self): 35 | OneModel.objects.create(user=self.user, spam='1st') 36 | with patch.object( 37 | QuerySet, 38 | '_get_horizontal_key_from_lookup_value', 39 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 40 | ) as mock_get_horizontal_key_from_lookup_value: 41 | OneModel.objects.get(user_id=self.user.id, spam='1st') 42 | mock_get_horizontal_key_from_lookup_value.assert_any_call(self.user.id) 43 | self.assertEqual( 44 | self.user.id, 45 | self.queryset._get_horizontal_key_from_lookup_value(self.user.id), 46 | ) 47 | 48 | def test_create(self): 49 | with patch.object( 50 | QuerySet, 51 | '_get_horizontal_key_from_lookup_value', 52 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 53 | ) as mock_get_horizontal_key_from_lookup_value: 54 | OneModel.objects.create(user=self.user, spam='1st') 55 | mock_get_horizontal_key_from_lookup_value.assert_called_once_with(self.user) 56 | self.assertEqual( 57 | self.user.id, 58 | self.queryset._get_horizontal_key_from_lookup_value(self.user), 59 | ) 60 | 61 | def test_create_by_id(self): 62 | with patch.object( 63 | QuerySet, 64 | '_get_horizontal_key_from_lookup_value', 65 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 66 | ) as mock_get_horizontal_key_from_lookup_value: 67 | OneModel.objects.create(user_id=self.user.id, spam='1st') 68 | mock_get_horizontal_key_from_lookup_value.assert_called_once_with(self.user.id) 69 | self.assertEqual( 70 | self.user.id, 71 | self.queryset._get_horizontal_key_from_lookup_value(self.user.id), 72 | ) 73 | 74 | def test_get_or_create(self): 75 | # Create 76 | with patch.object( 77 | QuerySet, 78 | '_get_horizontal_key_from_lookup_value', 79 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 80 | ) as mock_get_horizontal_key_from_lookup_value: 81 | one1, created = OneModel.objects.get_or_create(user=self.user, spam='1st') 82 | mock_get_horizontal_key_from_lookup_value.assert_any_call(self.user) 83 | self.assertEqual( 84 | self.user.id, 85 | self.queryset._get_horizontal_key_from_lookup_value(self.user), 86 | ) 87 | self.assertTrue(created) 88 | 89 | # Get 90 | with patch.object( 91 | QuerySet, 92 | '_get_horizontal_key_from_lookup_value', 93 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 94 | ) as mock_get_horizontal_key_from_lookup_value: 95 | one2, created = OneModel.objects.get_or_create(user=self.user, spam='1st') 96 | mock_get_horizontal_key_from_lookup_value.assert_any_call(self.user) 97 | self.assertEqual( 98 | self.user.id, 99 | self.queryset._get_horizontal_key_from_lookup_value(self.user), 100 | ) 101 | self.assertFalse(created) 102 | self.assertEqual(one1.pk, one2.pk) 103 | 104 | def test_get_or_create_by_id(self): 105 | # Create 106 | with patch.object( 107 | QuerySet, 108 | '_get_horizontal_key_from_lookup_value', 109 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 110 | ) as mock_get_horizontal_key_from_lookup_value: 111 | one1, created = OneModel.objects.get_or_create(user_id=self.user.id, spam='1st') 112 | mock_get_horizontal_key_from_lookup_value.assert_any_call(self.user.id) 113 | self.assertEqual( 114 | self.user.id, 115 | self.queryset._get_horizontal_key_from_lookup_value(self.user.id), 116 | ) 117 | self.assertTrue(created) 118 | 119 | # Get 120 | with patch.object( 121 | QuerySet, 122 | '_get_horizontal_key_from_lookup_value', 123 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 124 | ) as mock_get_horizontal_key_from_lookup_value: 125 | one2, created = OneModel.objects.get_or_create(user_id=self.user.id, spam='1st') 126 | mock_get_horizontal_key_from_lookup_value.assert_any_call(self.user.id) 127 | self.assertEqual( 128 | self.user.id, 129 | self.queryset._get_horizontal_key_from_lookup_value(self.user.id), 130 | ) 131 | self.assertFalse(created) 132 | self.assertEqual(one1.pk, one2.pk) 133 | 134 | def test_update_or_create(self): 135 | # Create 136 | with patch.object( 137 | QuerySet, 138 | '_get_horizontal_key_from_lookup_value', 139 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 140 | ) as mock_get_horizontal_key_from_lookup_value: 141 | one1, created = OneModel.objects.update_or_create(user=self.user, spam='1st', 142 | defaults={'egg': 'scrambled'}) 143 | mock_get_horizontal_key_from_lookup_value.assert_any_call(self.user) 144 | self.assertEqual( 145 | self.user.id, 146 | self.queryset._get_horizontal_key_from_lookup_value(self.user), 147 | ) 148 | self.assertTrue(created) 149 | self.assertEqual('scrambled', one1.egg) 150 | 151 | # Update 152 | with patch.object( 153 | QuerySet, 154 | '_get_horizontal_key_from_lookup_value', 155 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 156 | ) as mock_get_horizontal_key_from_lookup_value: 157 | one2, created = OneModel.objects.update_or_create(user=self.user, spam='1st', 158 | defaults={'egg': 'fried'}) 159 | mock_get_horizontal_key_from_lookup_value.assert_any_call(self.user) 160 | self.assertEqual( 161 | self.user.id, 162 | self.queryset._get_horizontal_key_from_lookup_value(self.user), 163 | ) 164 | self.assertFalse(created) 165 | self.assertEqual('scrambled', one1.egg) 166 | self.assertEqual(one1.pk, one2.pk) 167 | 168 | def test_update_or_create_by_id(self): 169 | # Create 170 | with patch.object( 171 | QuerySet, 172 | '_get_horizontal_key_from_lookup_value', 173 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 174 | ) as mock_get_horizontal_key_from_lookup_value: 175 | one1, created = OneModel.objects.update_or_create(user_id=self.user.id, spam='1st', 176 | defaults={'egg': 'scrambled'}) 177 | mock_get_horizontal_key_from_lookup_value.assert_any_call(self.user.id) 178 | self.assertEqual( 179 | self.user.id, 180 | self.queryset._get_horizontal_key_from_lookup_value(self.user.id), 181 | ) 182 | self.assertTrue(created) 183 | self.assertEqual('scrambled', one1.egg) 184 | 185 | # Update 186 | with patch.object( 187 | QuerySet, 188 | '_get_horizontal_key_from_lookup_value', 189 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 190 | ) as mock_get_horizontal_key_from_lookup_value: 191 | one2, created = OneModel.objects.update_or_create(user_id=self.user.id, spam='1st', 192 | defaults={'egg': 'fried'}) 193 | mock_get_horizontal_key_from_lookup_value.assert_any_call(self.user.id) 194 | self.assertEqual( 195 | self.user.id, 196 | self.queryset._get_horizontal_key_from_lookup_value(self.user.id), 197 | ) 198 | self.assertFalse(created) 199 | self.assertEqual('scrambled', one1.egg) 200 | self.assertEqual(one1.pk, one2.pk) 201 | 202 | def test_filter(self): 203 | OneModel.objects.create(user=self.user, spam='1st') 204 | OneModel.objects.create(user=self.user, spam='2nd') 205 | with patch.object( 206 | QuerySet, 207 | '_get_horizontal_key_from_lookup_value', 208 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 209 | ) as mock_get_horizontal_key_from_lookup_value: 210 | q = OneModel.objects.filter(user=self.user) 211 | mock_get_horizontal_key_from_lookup_value.assert_called_once_with(self.user) 212 | self.assertEqual( 213 | self.user.id, 214 | self.queryset._get_horizontal_key_from_lookup_value(self.user), 215 | ) 216 | self.assertEqual(2, q.count()) 217 | 218 | def test_filter_by_id(self): 219 | OneModel.objects.create(user=self.user, spam='1st') 220 | OneModel.objects.create(user=self.user, spam='2nd') 221 | with patch.object( 222 | QuerySet, 223 | '_get_horizontal_key_from_lookup_value', 224 | wraps=self.queryset._get_horizontal_key_from_lookup_value, 225 | ) as mock_get_horizontal_key_from_lookup_value: 226 | q = OneModel.objects.filter(user_id=self.user.id) 227 | mock_get_horizontal_key_from_lookup_value.assert_called_once_with(self.user.id) 228 | self.assertEqual( 229 | self.user.id, 230 | self.queryset._get_horizontal_key_from_lookup_value(self.user.id), 231 | ) 232 | self.assertEqual(2, q.count()) 233 | 234 | def test_raise_value_error_when_lookup_from_foreign(self): 235 | OneModel.objects.create(user=self.user, spam='1st') 236 | with self.assertRaises(ValueError): 237 | list(self.user.onemodel_set.all()) 238 | 239 | def test_filter_without_shard_key(self): 240 | with self.assertRaises(ProgrammingError): 241 | list(OneModel.objects.all()) 242 | 243 | def test_clone(self): 244 | qs = OneModel.objects.filter(user=self.user) 245 | qs = qs.exclude(spam='1st') 246 | list(qs) 247 | -------------------------------------------------------------------------------- /tests/test_routers.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.contrib.auth import get_user_model 4 | 5 | from horizon.routers import HorizontalRouter 6 | 7 | from .base import HorizontalBaseTestCase 8 | from .models import ( 9 | ConcreteModel, 10 | HorizontalMetadata, 11 | ManyModel, 12 | OneModel, 13 | ProxiedModel, 14 | ProxyBaseModel, 15 | ) 16 | 17 | user_model = get_user_model() 18 | 19 | 20 | class HorizontalRouterMigrationTestCase(HorizontalBaseTestCase): 21 | def setUp(self): 22 | super(HorizontalRouterMigrationTestCase, self).setUp() 23 | self.router = HorizontalRouter() 24 | 25 | def test_allow_migrate(self): 26 | for expected_database in ('a1-primary', 'a1-replica-1', 'a1-replica-2', 'a2-primary', 27 | 'a2-replica', 'a3'): 28 | self.assertTrue( 29 | self.router.allow_migrate(expected_database, 'tests', 'OneModel', 30 | model=OneModel)) 31 | self.assertTrue( 32 | self.router.allow_migrate(expected_database, 'tests', 'ManyModel', 33 | model=OneModel)) 34 | 35 | for expected_database in ('b1-primary', 'b1-replica-1', 'b1-replica-2', 'b2-primary', 36 | 'b2-replica', 'b3'): 37 | self.assertTrue( 38 | self.router.allow_migrate(expected_database, 'tests', 'ProxyBaseModel', 39 | model=ProxyBaseModel)) 40 | self.assertTrue( 41 | self.router.allow_migrate(expected_database, 'tests', 'ProxiedModel', 42 | model=ProxiedModel)) 43 | self.assertTrue( 44 | self.router.allow_migrate(expected_database, 'tests', 'ConcreteModel', 45 | model=ConcreteModel)) 46 | 47 | def test_allow_migrate_without_hints(self): 48 | for expected_database in ('a1-primary', 'a1-replica-1', 'a1-replica-2', 'a2-primary', 49 | 'a2-replica', 'a3'): 50 | self.assertTrue( 51 | self.router.allow_migrate(expected_database, 'tests', 'OneModel')) 52 | self.assertTrue( 53 | self.router.allow_migrate(expected_database, 'tests', 'ManyModel')) 54 | 55 | for expected_database in ('b1-primary', 'b1-replica-1', 'b1-replica-2', 'b2-primary', 56 | 'b2-replica', 'b3'): 57 | self.assertTrue( 58 | self.router.allow_migrate(expected_database, 'tests', 'ProxyBaseModel')) 59 | self.assertTrue( 60 | self.router.allow_migrate(expected_database, 'tests', 'ProxiedModel')) 61 | self.assertTrue( 62 | self.router.allow_migrate(expected_database, 'tests', 'ConcreteModel')) 63 | 64 | def test_not_allow_migrate(self): 65 | for horizontal_model in (OneModel, ManyModel, ProxyBaseModel, ProxiedModel, ConcreteModel): 66 | self.assertIsNone( 67 | self.router.allow_migrate('default', 'tests', horizontal_model.__name__, 68 | model=horizontal_model), 69 | "Other database", 70 | ) 71 | 72 | self.assertIsNone( 73 | self.router.allow_migrate('a1-primary', user_model._meta.app_label, user_model.__name__, 74 | model=user_model), 75 | "Not configured for horizontal database groups", 76 | ) 77 | self.assertIsNone( 78 | self.router.allow_migrate('a1-primary', 'tests', ProxyBaseModel.__name__, 79 | model=ProxyBaseModel), 80 | "Another horizontal group", 81 | ) 82 | self.assertIsNone( 83 | self.router.allow_migrate('a1-primary', 'tests', ProxiedModel.__name__, 84 | model=ProxiedModel), 85 | "Another horizontal group", 86 | ) 87 | self.assertIsNone( 88 | self.router.allow_migrate('a1-primary', 'tests', ConcreteModel.__name__, 89 | model=ConcreteModel), 90 | "Another horizontal group", 91 | ) 92 | 93 | def test_not_allow_migrate_without_hints(self): 94 | for horizontal_model in ( 95 | 'OneModel', 'ManyModel', 'ProxyBaseModel', 'ProxiedModel', 'ConcreteModel' 96 | ): 97 | self.assertIsNone( 98 | self.router.allow_migrate('default', 'tests', horizontal_model), 99 | "Other database", 100 | ) 101 | 102 | self.assertIsNone( 103 | self.router.allow_migrate( 104 | 'a1-primary', user_model._meta.app_label, 'User', model=user_model), 105 | "Not configured for horizontal database groups", 106 | ) 107 | self.assertIsNone( 108 | self.router.allow_migrate( 109 | 'a1-primary', 'tests', 'ProxyBaseModel', model=ProxyBaseModel), 110 | "Another horizontal group", 111 | ) 112 | self.assertIsNone( 113 | self.router.allow_migrate( 114 | 'a1-primary', 'tests', 'ProxiedModel', model=ProxiedModel), 115 | "Another horizontal group", 116 | ) 117 | self.assertIsNone( 118 | self.router.allow_migrate( 119 | 'a1-primary', 'tests', 'ConcreteModel', model=ConcreteModel), 120 | "Another horizontal group", 121 | ) 122 | 123 | 124 | class HorizontalRouterRelationTestCase(HorizontalBaseTestCase): 125 | def setUp(self): 126 | super(HorizontalRouterRelationTestCase, self).setUp() 127 | self.router = HorizontalRouter() 128 | self.user_a = user_model.objects.create_user('spam') 129 | self.user_b = user_model.objects.create_user('egg') 130 | 131 | def test_allow_relation_in_same_database(self): 132 | HorizontalMetadata.objects.create(group='a', key=self.user_a.id, index=1) 133 | HorizontalMetadata.objects.create(group='a', key=self.user_b.id, index=1) 134 | one1 = OneModel.objects.create(user=self.user_a, spam='1st') 135 | one2 = OneModel.objects.create(user=self.user_b, spam='2nd') 136 | self.assertTrue(self.router.allow_relation(one1, one2)) 137 | 138 | def test_disallow_other_database(self): 139 | HorizontalMetadata.objects.create(group='a', key=self.user_a.id, index=1) 140 | HorizontalMetadata.objects.create(group='a', key=self.user_b.id, index=2) 141 | one1 = OneModel.objects.create(user=self.user_a, spam='1st') 142 | one2 = OneModel.objects.create(user=self.user_b, spam='2nd') 143 | self.assertIsNone(self.router.allow_relation(one1, one2), "Other shard") 144 | 145 | def test_disallow_other_group(self): 146 | one = OneModel.objects.create(user=self.user_a, spam='meat?') 147 | concrete = ConcreteModel.objects.create( 148 | user=self.user_a, 149 | pizza='pepperoni', 150 | potate='head', 151 | coke='pe*si' 152 | ) 153 | self.assertIsNone(self.router.allow_relation(one, concrete), "Other group") 154 | 155 | def test_allow_relation_to_forein(self): 156 | one = OneModel.objects.create(user=self.user_a, spam='meat?') 157 | many = ManyModel.objects.create(user=self.user_a, one=one) 158 | self.assertTrue(self.router.allow_relation(one, many)) 159 | 160 | def test_disallow_default_database(self): 161 | one = OneModel.objects.create(user=self.user_a, spam='meat?') 162 | self.assertFalse(self.router.allow_relation(one, self.user_a)) 163 | 164 | 165 | class HorizontalRouterReadWriteTestCase(HorizontalBaseTestCase): 166 | def setUp(self): 167 | super(HorizontalRouterReadWriteTestCase, self).setUp() 168 | self.router = HorizontalRouter() 169 | self.user_a = user_model.objects.create_user('spam') 170 | self.user_b = user_model.objects.create_user('egg') 171 | self.user_c = user_model.objects.create_user('musubi') 172 | HorizontalMetadata.objects.create(group='a', key=self.user_a.id, index=1) 173 | HorizontalMetadata.objects.create(group='a', key=self.user_b.id, index=2) 174 | HorizontalMetadata.objects.create(group='a', key=self.user_c.id, index=3) 175 | 176 | def test_db_for_write(self): 177 | with patch.object( 178 | HorizontalRouter, 'db_for_write', wraps=self.router.db_for_write, 179 | ) as mock_db_for_write: 180 | OneModel.objects.create(user=self.user_a, spam='1st') 181 | mock_db_for_write.assert_any_call(OneModel, horizontal_key=self.user_a.id) 182 | self.assertEqual( 183 | 'a1-primary', 184 | self.router.db_for_write(OneModel, horizontal_key=self.user_a.id), 185 | ) 186 | 187 | with patch.object( 188 | HorizontalRouter, 'db_for_write', wraps=self.router.db_for_write, 189 | ) as mock_db_for_write: 190 | OneModel.objects.create(user=self.user_b, spam='2nd') 191 | mock_db_for_write.assert_any_call(OneModel, horizontal_key=self.user_b.id) 192 | self.assertEqual( 193 | 'a2-primary', 194 | self.router.db_for_write(OneModel, horizontal_key=self.user_b.id), 195 | ) 196 | 197 | with patch.object( 198 | HorizontalRouter, 'db_for_write', wraps=self.router.db_for_write, 199 | ) as mock_db_for_write: 200 | OneModel.objects.create(user=self.user_c, spam='1st') 201 | mock_db_for_write.assert_any_call(OneModel, horizontal_key=self.user_c.id) 202 | self.assertEqual( 203 | 'a3', 204 | self.router.db_for_write(OneModel, horizontal_key=self.user_c.id), 205 | ) 206 | 207 | def test_db_for_write_by_id(self): 208 | with patch.object( 209 | HorizontalRouter, 'db_for_write', wraps=self.router.db_for_write, 210 | ) as mock_db_for_write: 211 | OneModel.objects.create(user_id=self.user_a.id, spam='1st') 212 | mock_db_for_write.assert_any_call(OneModel, horizontal_key=self.user_a.id) 213 | self.assertEqual( 214 | 'a1-primary', 215 | self.router.db_for_write(OneModel, horizontal_key=self.user_a.id), 216 | ) 217 | 218 | def test_db_for_write_other_databases(self): 219 | with patch.object( 220 | HorizontalRouter, 'db_for_write', wraps=self.router.db_for_write, 221 | ) as mock_db_for_write: 222 | new_user = user_model.objects.create_user('pizza') 223 | mock_db_for_write.assert_any_call(user_model, instance=new_user) 224 | self.assertIsNone(self.router.db_for_write(user_model, instance=new_user)) 225 | 226 | def test_db_for_read(self): 227 | with patch.object( 228 | HorizontalRouter, 'db_for_read', wraps=self.router.db_for_read, 229 | ) as mock_db_for_read: 230 | list(OneModel.objects.filter(user=self.user_a)) 231 | mock_db_for_read.assert_any_call(OneModel, horizontal_key=self.user_a.id) 232 | self.assertIn( 233 | self.router.db_for_read(OneModel, horizontal_key=self.user_a.id), 234 | ['a1-replica-1', 'a1-replica-2'], 235 | ) 236 | 237 | with patch.object( 238 | HorizontalRouter, 'db_for_read', wraps=self.router.db_for_read, 239 | ) as mock_db_for_read: 240 | list(OneModel.objects.filter(user=self.user_b)) 241 | mock_db_for_read.assert_any_call(OneModel, horizontal_key=self.user_b.id) 242 | self.assertIn( 243 | self.router.db_for_read(OneModel, horizontal_key=self.user_b.id), 244 | ['a2-replica'], 245 | ) 246 | 247 | with patch.object( 248 | HorizontalRouter, 'db_for_read', wraps=self.router.db_for_read, 249 | ) as mock_db_for_read: 250 | list(OneModel.objects.filter(user=self.user_c)) 251 | mock_db_for_read.assert_any_call(OneModel, horizontal_key=self.user_c.id) 252 | self.assertIn( 253 | self.router.db_for_read(OneModel, horizontal_key=self.user_c.id), 254 | ['a3'], 255 | ) 256 | 257 | def test_db_for_read_by_id(self): 258 | with patch.object( 259 | HorizontalRouter, 'db_for_read', wraps=self.router.db_for_read, 260 | ) as mock_db_for_read: 261 | list(OneModel.objects.filter(user_id=self.user_a.id)) 262 | mock_db_for_read.assert_any_call(OneModel, horizontal_key=self.user_a.id) 263 | self.assertIn( 264 | self.router.db_for_read(OneModel, horizontal_key=self.user_a.id), 265 | ['a1-replica-1', 'a1-replica-2'], 266 | ) 267 | 268 | def test_db_for_read_other_databases(self): 269 | with patch.object( 270 | HorizontalRouter, 'db_for_read', wraps=self.router.db_for_read, 271 | ) as mock_db_for_read: 272 | list(user_model.objects.filter()) 273 | mock_db_for_read.assert_any_call(user_model) 274 | self.assertIsNone(self.router.db_for_read(user_model)) 275 | --------------------------------------------------------------------------------