├── tests ├── __init__.py ├── testapp │ ├── __init__.py │ ├── forms.py │ ├── serializers.py │ └── models.py ├── testmigrationsapp │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── .gitignore │ └── models.py ├── makemigrationsrunner.py ├── runtests.py ├── runner.py ├── migraterunner.py ├── test_query.py ├── test_migrations.py ├── test_forms.py ├── test_serializers.py ├── test_models.py ├── test_sql.py └── test_partial_index.py ├── setup.cfg ├── making-a-release.md ├── partial_index ├── __init__.py ├── mixins.py ├── query.py └── index.py ├── .travis.yml ├── setup.py ├── LICENSE.txt ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testmigrationsapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testmigrationsapp/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testmigrationsapp/migrations/.gitignore: -------------------------------------------------------------------------------- 1 | 0*.py 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [bdist_wheel] 5 | universal=1 6 | -------------------------------------------------------------------------------- /making-a-release.md: -------------------------------------------------------------------------------- 1 | ## Making a new release for PyPI 2 | 3 | 1. Update version number in `partial_index/__init__.py` 4 | 1. Update version number in `setup.py` 5 | 1. Update download link version number in `setup.py` 6 | 1. If added or removed support for some Python/Django versions, update classifiers in `setup.py` 7 | 1. Update version history at the end of `README.md` 8 | 1. Push to release branch on github, review that tests pass on Travis. 9 | 1. Make sure you are in a Python3 environment. 10 | 1. `python3 setup.py sdist bdist_wheel upload` 11 | 1. Go to https://github.com/mattiaslinnap/django-partial-index/releases and click New Release, fill details: 12 | 1. New tag name should be just the numeric version ("1.2.3" not "v1.2.3") 13 | 1. From branch "release" 14 | 1. Release title should be "v1.2.3" 15 | -------------------------------------------------------------------------------- /partial_index/__init__.py: -------------------------------------------------------------------------------- 1 | # Provide a nicer error message than failing to import models.Index. 2 | 3 | VERSION = (0, 6, 0) 4 | __version__ = '.'.join(str(v) for v in VERSION) 5 | 6 | 7 | __all__ = ['PartialIndex', 'PQ', 'PF', 'ValidatePartialUniqueMixin', 'PartialUniqueValidationError'] 8 | 9 | 10 | MIN_DJANGO_VERSION = (1, 11) 11 | DJANGO_VERSION_ERROR = 'Django version %s or later is required for django-partial-index.' % '.'.join(str(v) for v in MIN_DJANGO_VERSION) 12 | 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError(DJANGO_VERSION_ERROR) 17 | 18 | if tuple(django.VERSION[:2]) < MIN_DJANGO_VERSION: 19 | raise ImportError(DJANGO_VERSION_ERROR) 20 | 21 | 22 | from .index import PartialIndex 23 | from .query import PQ, PF 24 | from .mixins import ValidatePartialUniqueMixin, PartialUniqueValidationError 25 | -------------------------------------------------------------------------------- /tests/makemigrationsrunner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | from django.core import management 4 | from os.path import abspath, dirname, exists, join 5 | import sys 6 | 7 | 8 | REPO_DIR = dirname(dirname(abspath(__file__))) 9 | TESTS_DIR = join(REPO_DIR, 'tests') 10 | 11 | 12 | sys.path.append(REPO_DIR) 13 | sys.path.append(TESTS_DIR) 14 | 15 | 16 | DATABASES = { 17 | 'default': { 18 | 'ENGINE': 'django.db.backends.sqlite3', 19 | 'NAME': ':memory:', 20 | } 21 | } 22 | 23 | 24 | def main(args): 25 | # Since this test suite is designed to be ran outside of ./manage.py test, we need to do some setup first. 26 | import django 27 | from django.conf import settings 28 | settings.configure(INSTALLED_APPS=['testmigrationsapp'], DATABASES=DATABASES) 29 | django.setup() 30 | management.call_command('makemigrations', 'testmigrationsapp', verbosity=0) 31 | 32 | 33 | if __name__ == '__main__': 34 | parser = argparse.ArgumentParser() 35 | args = parser.parse_args() 36 | main(args) 37 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | services: 4 | - postgresql 5 | 6 | matrix: 7 | include: 8 | - python: 2.7 9 | env: DJANGO_VERSION=1.11.22 10 | - python: 3.4 11 | env: DJANGO_VERSION=1.11.22 12 | - python: 3.5 13 | env: DJANGO_VERSION=1.11.22 14 | - python: 3.6 15 | env: DJANGO_VERSION=1.11.22 16 | - python: 3.7 17 | env: DJANGO_VERSION=1.11.22 18 | 19 | - python: 3.4 20 | env: DJANGO_VERSION=2.0.13 21 | - python: 3.5 22 | env: DJANGO_VERSION=2.0.13 23 | - python: 3.6 24 | env: DJANGO_VERSION=2.0.13 25 | - python: 3.7 26 | env: DJANGO_VERSION=2.0.13 27 | 28 | - python: 3.5 29 | env: DJANGO_VERSION=2.1.10 30 | - python: 3.6 31 | env: DJANGO_VERSION=2.1.10 32 | - python: 3.7 33 | env: DJANGO_VERSION=2.1.10 34 | 35 | - python: 3.5 36 | env: DJANGO_VERSION=2.2.3 37 | - python: 3.6 38 | env: DJANGO_VERSION=2.2.3 39 | - python: 3.7 40 | env: DJANGO_VERSION=2.2.3 41 | 42 | install: "pip install --upgrade pip && pip install -q psycopg2 django==$DJANGO_VERSION djangorestframework==3.8.2" 43 | script: "./tests/runtests.py" 44 | -------------------------------------------------------------------------------- /tests/testapp/forms.py: -------------------------------------------------------------------------------- 1 | """ModelForms for testing ValidatePartialUniqueMixin.""" 2 | from django import forms 3 | 4 | from testapp.models import RoomBookingQ, RoomBookingText 5 | 6 | 7 | class RoomBookingTextForm(forms.ModelForm): 8 | """Always fails with ImproperlyConfigured error, because mixin cannot be used with text-based conditions.""" 9 | class Meta: 10 | model = RoomBookingText 11 | fields = ('user', 'room', 'deleted_at') 12 | 13 | 14 | class RoomBookingAllFieldsForm(forms.ModelForm): 15 | """All fields are present on the form.""" 16 | class Meta: 17 | model = RoomBookingQ 18 | fields = ('user', 'room', 'deleted_at') 19 | 20 | 21 | class RoomBookingNoConditionFieldForm(forms.ModelForm): 22 | """Index fields are present on the form, but the condition field is not.""" 23 | class Meta: 24 | model = RoomBookingQ 25 | fields = ('user', 'room') 26 | 27 | 28 | class RoomBookingJustRoomForm(forms.ModelForm): 29 | """Only one out of the two indexed fields is present on the form.""" 30 | class Meta: 31 | model = RoomBookingQ 32 | fields = ('room', ) 33 | -------------------------------------------------------------------------------- /tests/testapp/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from testapp.models import RoomBookingText, RoomBookingQ 4 | 5 | 6 | class RoomBookingTextSerializer(serializers.ModelSerializer): 7 | """Always fails with ImproperlyConfigured error, because mixin cannot be used with text-based conditions.""" 8 | class Meta: 9 | model = RoomBookingText 10 | fields = ('user', 'room', 'deleted_at') 11 | 12 | 13 | class RoomBookingAllFieldsSerializer(serializers.ModelSerializer): 14 | """All fields are present on the form.""" 15 | class Meta: 16 | model = RoomBookingQ 17 | fields = ('user', 'room', 'deleted_at') 18 | 19 | 20 | class RoomBookingNoConditionFieldSerializer(serializers.ModelSerializer): 21 | """Index fields are present on the form, but the condition field is not.""" 22 | class Meta: 23 | model = RoomBookingQ 24 | fields = ('user', 'room') 25 | 26 | 27 | class RoomBookingJustRoomSerializer(serializers.ModelSerializer): 28 | """Only one out of the two indexed fields is present on the form.""" 29 | class Meta: 30 | model = RoomBookingQ 31 | fields = ('room', ) 32 | -------------------------------------------------------------------------------- /tests/runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | import argparse 5 | from os.path import abspath, dirname, join 6 | import subprocess 7 | import sys 8 | 9 | 10 | REPO_DIR = dirname(dirname(abspath(__file__))) 11 | TESTS_DIR = join(REPO_DIR, 'tests') 12 | RUNNER_PY = join(TESTS_DIR, 'runner.py') 13 | 14 | 15 | def main(args): 16 | exitcodes = [] 17 | if not args.skip_postgresql: 18 | print('Testing with PostgreSQL...', file=sys.stderr) 19 | exitcodes.append(subprocess.call([RUNNER_PY, '--db', 'postgresql'] + args.testpaths)) 20 | print('Done testing with PostgreSQL.', file=sys.stderr) 21 | print('', file=sys.stderr) 22 | if not args.skip_sqlite: 23 | print('Testing with SQLite...', file=sys.stderr) 24 | exitcodes.append(subprocess.call([RUNNER_PY, '--db', 'sqlite'] + args.testpaths)) 25 | print('Done testing with SQLite.', file=sys.stderr) 26 | # Exit with 0 if all non-skipped tests did the same. 27 | sys.exit(max(exitcodes) or 0) 28 | 29 | 30 | if __name__ == '__main__': 31 | parser = argparse.ArgumentParser(description='Runs tests.') 32 | parser.add_argument('--skip-postgresql', default=False, action='store_true') 33 | parser.add_argument('--skip-sqlite', default=False, action='store_true') 34 | parser.add_argument('testpaths', nargs='*') 35 | args = parser.parse_args() 36 | main(args) 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | 6 | setup( 7 | name='django-partial-index', 8 | packages=['partial_index'], 9 | version='0.6.0', 10 | description='PostgreSQL and SQLite partial indexes for Django models', 11 | long_description=open('README.md').read(), 12 | long_description_content_type='text/markdown', 13 | author='Mattias Linnap', 14 | author_email='mattias@linnap.com', 15 | url='https://github.com/mattiaslinnap/django-partial-index', 16 | download_url='https://github.com/mattiaslinnap/django-partial-index/archive/0.6.0.tar.gz', 17 | license='BSD', 18 | install_requires=[], 19 | classifiers=[ 20 | 'Development Status :: 4 - Beta', 21 | 'Environment :: Web Environment', 22 | 'Framework :: Django', 23 | 'Framework :: Django :: 1.11', 24 | 'Framework :: Django :: 2.0', 25 | 'Framework :: Django :: 2.1', 26 | 'Framework :: Django :: 2.2', 27 | 'Intended Audience :: Developers', 28 | 'License :: OSI Approved :: BSD License', 29 | 'Operating System :: OS Independent', 30 | 'Programming Language :: Python', 31 | 'Programming Language :: Python :: 2', 32 | 'Programming Language :: Python :: 2.7', 33 | 'Programming Language :: Python :: 3', 34 | 'Programming Language :: Python :: 3.4', 35 | 'Programming Language :: Python :: 3.5', 36 | 'Programming Language :: Python :: 3.6', 37 | 'Topic :: Database', 38 | 'Topic :: Internet :: WWW/HTTP', 39 | ] 40 | ) 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Mattias Linnap 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /tests/testmigrationsapp/models.py: -------------------------------------------------------------------------------- 1 | """Models for tests.""" 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models 5 | 6 | 7 | from partial_index import PartialIndex, PQ, PF 8 | 9 | 10 | class User(models.Model): 11 | name = models.CharField(max_length=50) 12 | 13 | def __str__(self): 14 | return self.name 15 | 16 | 17 | class Room(models.Model): 18 | name = models.CharField(max_length=50) 19 | 20 | def __str__(self): 21 | return self.name 22 | 23 | 24 | class RoomBookingQ(models.Model): 25 | user = models.ForeignKey(User, on_delete=models.CASCADE) 26 | room = models.ForeignKey(Room, on_delete=models.CASCADE) 27 | deleted_at = models.DateTimeField(null=True, blank=True) 28 | 29 | class Meta: 30 | indexes = [PartialIndex(fields=['user', 'room'], unique=True, where=PQ(deleted_at__isnull=True))] 31 | 32 | 33 | class JobQ(models.Model): 34 | order = models.IntegerField() 35 | group = models.IntegerField() 36 | is_complete = models.BooleanField(default=False) 37 | 38 | class Meta: 39 | indexes = [ 40 | PartialIndex(fields=['-order'], unique=False, where=PQ(is_complete=False)), 41 | PartialIndex(fields=['group'], unique=True, where=PQ(is_complete=False)), 42 | ] 43 | 44 | 45 | class ComparisonQ(models.Model): 46 | """Partial index that references multiple fields on the model.""" 47 | a = models.IntegerField() 48 | b = models.IntegerField() 49 | 50 | class Meta: 51 | indexes = [ 52 | PartialIndex(fields=['a', 'b'], unique=True, where=PQ(a=PF('b'))), 53 | ] 54 | -------------------------------------------------------------------------------- /tests/runner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | from os.path import abspath, dirname, join 4 | import sys 5 | 6 | REPO_DIR = dirname(dirname(abspath(__file__))) 7 | TESTS_DIR = join(REPO_DIR, 'tests') 8 | 9 | sys.path.append(REPO_DIR) 10 | sys.path.append(TESTS_DIR) 11 | 12 | 13 | DATABASES_FOR_DB = { 14 | 'postgresql': { 15 | 'default': { 16 | 'ENGINE': 'django.db.backends.postgresql', 17 | 'NAME': 'partial_index', 18 | } 19 | }, 20 | 'sqlite': { 21 | 'default': { 22 | 'ENGINE': 'django.db.backends.sqlite3', 23 | 'NAME': join(REPO_DIR, 'partial_index.sqlite3'), 24 | } 25 | }, 26 | } 27 | 28 | 29 | def main(args): 30 | # Since this test suite is designed to be ran outside of ./manage.py test, we need to do some setup first. 31 | import django 32 | from django.conf import settings 33 | settings.configure(INSTALLED_APPS=['testapp'], DATABASES=DATABASES_FOR_DB[args.db], DB_NAME=args.db) 34 | django.setup() 35 | 36 | from django.test.runner import DiscoverRunner 37 | test_runner = DiscoverRunner(top_level=TESTS_DIR, interactive=False, keepdb=False) 38 | if args.testpaths: 39 | paths = ['tests.' + p for p in args.testpaths] 40 | failures = test_runner.run_tests(paths) 41 | else: 42 | failures = test_runner.run_tests(['tests']) 43 | if failures: 44 | sys.exit(1) 45 | 46 | 47 | if __name__ == '__main__': 48 | parser = argparse.ArgumentParser() 49 | parser.add_argument('--db', required=True) 50 | parser.add_argument('testpaths', nargs='*') 51 | args = parser.parse_args() 52 | main(args) 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | .idea/ 104 | -------------------------------------------------------------------------------- /tests/migraterunner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import subprocess 4 | 5 | from django.core import management 6 | import os 7 | from os.path import abspath, dirname, exists, join 8 | import sys 9 | 10 | try: 11 | from io import StringIO 12 | except ImportError: 13 | from cStringIO import StringIO 14 | 15 | REPO_DIR = dirname(dirname(abspath(__file__))) 16 | TESTS_DIR = join(REPO_DIR, 'tests') 17 | SQLITE_PATH = join(REPO_DIR, 'test_migrations_partial_index.sqlite3') 18 | POSTGRESQL_TABLE = 'test_migrations_partial_index' 19 | 20 | sys.path.append(REPO_DIR) 21 | sys.path.append(TESTS_DIR) 22 | 23 | 24 | DATABASES_FOR_DB = { 25 | 'postgresql': { 26 | 'default': { 27 | 'ENGINE': 'django.db.backends.postgresql', 28 | 'NAME': POSTGRESQL_TABLE, 29 | } 30 | }, 31 | 'sqlite': { 32 | 'default': { 33 | 'ENGINE': 'django.db.backends.sqlite3', 34 | 'NAME': SQLITE_PATH, 35 | } 36 | }, 37 | } 38 | 39 | 40 | def create_database(args): 41 | if args.db == 'postgresql': 42 | # subprocess.check_call(['dropdb', '--if-exists', POSTGRESQL_TABLE]) 43 | subprocess.check_call(['createdb', '--encoding', 'utf-8', POSTGRESQL_TABLE]) 44 | else: 45 | pass 46 | 47 | 48 | def destroy_database(args): 49 | if args.db == 'postgresql': 50 | subprocess.check_call(['dropdb', '--if-exists', POSTGRESQL_TABLE]) 51 | else: 52 | if exists(SQLITE_PATH): 53 | os.remove(SQLITE_PATH) 54 | 55 | 56 | def main(args): 57 | try: 58 | create_database(args) 59 | 60 | # Since this test suite is designed to be ran outside of ./manage.py test, we need to do some setup first. 61 | import django 62 | from django.conf import settings 63 | settings.configure(INSTALLED_APPS=['testmigrationsapp'], DATABASES=DATABASES_FOR_DB[args.db]) 64 | django.setup() 65 | 66 | management.call_command('migrate', 'testmigrationsapp', verbosity=1) 67 | 68 | import django.db 69 | django.db.connections.close_all() 70 | finally: 71 | destroy_database(args) 72 | 73 | 74 | if __name__ == '__main__': 75 | parser = argparse.ArgumentParser() 76 | parser.add_argument('--db', required=True) 77 | args = parser.parse_args() 78 | main(args) 79 | -------------------------------------------------------------------------------- /tests/test_query.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for SQL CREATE INDEX statements. 3 | """ 4 | 5 | from django.db import connection 6 | from django.test import TransactionTestCase 7 | 8 | from partial_index import query, PQ, PF 9 | from testapp.models import AB, ABC 10 | 11 | 12 | class QueryToSqlTest(TransactionTestCase): 13 | """Check that Q object to SQL transformation is valid.""" 14 | 15 | def schema_editor(self): 16 | # collect_sql=True -> do not actually execute. 17 | return connection.schema_editor(collect_sql=True) 18 | 19 | def assertQSql(self, q, expect_sql): 20 | with self.schema_editor() as editor: 21 | sql = query.q_to_sql(q, AB, editor) 22 | self.assertEqual(expect_sql, sql) 23 | 24 | def test_isnull(self): 25 | self.assertQSql(PQ(a__isnull=True), '"testapp_ab"."a" IS NULL') 26 | 27 | def test_not_null(self): 28 | self.assertQSql(PQ(a__isnull=False), '"testapp_ab"."a" IS NOT NULL') 29 | 30 | def test_a_equals_const(self): 31 | self.assertQSql(PQ(a='Hello'), '"testapp_ab"."a" = \'Hello\'') 32 | 33 | def test_a_equals_const_exact(self): 34 | self.assertQSql(PQ(a__exact='Hello'), '"testapp_ab"."a" = \'Hello\'') 35 | 36 | def test_a_equals_b(self): 37 | self.assertQSql(PQ(a=PF('b')), '"testapp_ab"."a" = ("testapp_ab"."b")') 38 | 39 | 40 | class QueryMentionedFieldsTest(TransactionTestCase): 41 | def assertMentioned(self, q, fields): 42 | self.assertEqual(set(query.q_mentioned_fields(q, ABC)), set(fields)) 43 | 44 | def test_empty(self): 45 | self.assertMentioned(PQ(), []) 46 | 47 | def test_single_const(self): 48 | self.assertMentioned(PQ(a=123), ['a']) 49 | 50 | def test_single_const_exact(self): 51 | self.assertMentioned(PQ(a__exact=123), ['a']) 52 | 53 | def test_single_null(self): 54 | self.assertMentioned(PQ(a__isnull=True), ['a']) 55 | self.assertMentioned(PQ(a__isnull=False), ['a']) 56 | 57 | def test_two_const(self): 58 | self.assertMentioned(PQ(a=12, b=34), ['a', 'b']) 59 | 60 | def test_two_null(self): 61 | self.assertMentioned(PQ(a=12, b__isnull=True), ['a', 'b']) 62 | 63 | def test_f_equal(self): 64 | self.assertMentioned(PQ(a=PF('b')), ['a', 'b']) 65 | 66 | def test_f_add(self): 67 | self.assertMentioned(PQ(a=PF('b') + PF('c') + 1), ['a', 'b', 'c']) 68 | 69 | def test_contains_f(self): 70 | self.assertMentioned(PQ(a__contains='Hello', b=PF('c')), ['a', 'b', 'c']) 71 | 72 | def test_or(self): 73 | self.assertMentioned(PQ(a=12) | PQ(b=34), ['a', 'b']) 74 | 75 | def test_or_duplicate(self): 76 | self.assertMentioned(PQ(a=12, b=34) | PQ(b=56), ['a', 'b']) 77 | 78 | def test_or_extra(self): 79 | self.assertMentioned(PQ(a=12, b=34) | PQ(c=56), ['a', 'b', 'c']) 80 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for actual use of the indexes after creating models with them. 3 | """ 4 | import subprocess 5 | import re 6 | 7 | from django.conf import settings 8 | from django.test import TransactionTestCase 9 | import os 10 | from os.path import abspath, dirname, exists, join 11 | 12 | 13 | TESTS = dirname(abspath(__file__)) 14 | IGNORE = ['__init__.py', '__pycache__', '.gitignore'] 15 | MIGS = join(TESTS, 'testmigrationsapp', 'migrations') 16 | 17 | 18 | def listmigs(): 19 | return list(sorted(fname for fname in os.listdir(MIGS) if fname not in IGNORE and not fname.endswith('.pyc'))) 20 | 21 | 22 | class MigrationsTestCase(TransactionTestCase): 23 | 24 | def delete_migrations_files(self): 25 | for fname in listmigs(): 26 | os.remove(join(MIGS, fname)) 27 | 28 | def makemigrations(self): 29 | return subprocess.check_output([join(TESTS, 'makemigrationsrunner.py')]) 30 | 31 | def migrate(self): 32 | return subprocess.check_output([join(TESTS, 'migraterunner.py'), '--db', settings.DB_NAME]) 33 | 34 | def test_makemigrations_first_file_made(self): 35 | self.delete_migrations_files() 36 | self.assertEqual(listmigs(), []) 37 | self.makemigrations() 38 | self.assertEqual(listmigs(), ['0001_initial.py']) 39 | self.delete_migrations_files() 40 | 41 | def test_makemigrations_contents(self): 42 | self.delete_migrations_files() 43 | self.assertEqual(listmigs(), []) 44 | self.makemigrations() 45 | content = open(join(MIGS, '0001_initial.py')).read() 46 | content = re.sub(r'\s', '', content) 47 | self.assertEqual(len(re.findall(r"migrations\.AddIndex\(model_name='[a-z]+',index=partial_index\.PartialIndex\(fields=\[", content)), 4) 48 | self.assertEqual(len(re.findall(r"where=partial_index\.PQ\(", content)), 4) 49 | self.assertEqual(len(re.findall(r"where=partial_index\.PQ\(a=partial_index\.PF\('b'\)\)\)", content)), 1) 50 | self.assertEqual(len(re.findall(r"where_postgresql", content)), 0) 51 | self.assertEqual(len(re.findall(r"where_sqlite", content)), 0) 52 | self.delete_migrations_files() 53 | 54 | def test_makemigrations_second_time_file_not_changed(self): 55 | self.delete_migrations_files() 56 | self.assertEqual(listmigs(), []) 57 | self.makemigrations() 58 | self.assertEqual(listmigs(), ['0001_initial.py']) 59 | before = open(join(MIGS, '0001_initial.py')).read() 60 | self.makemigrations() 61 | self.assertEqual(listmigs(), ['0001_initial.py']) 62 | after = open(join(MIGS, '0001_initial.py')).read() 63 | self.assertEqual(before, after) 64 | self.delete_migrations_files() 65 | 66 | def test_migrate_succeeds(self): 67 | self.delete_migrations_files() 68 | self.makemigrations() 69 | migrateoutput = self.migrate() 70 | self.assertIn(b'Applying testmigrationsapp.0001_initial... OK', migrateoutput) 71 | self.delete_migrations_files() 72 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | """Models for tests.""" 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models 5 | 6 | from partial_index import PartialIndex, PQ, PF, ValidatePartialUniqueMixin 7 | 8 | 9 | class AB(models.Model): 10 | a = models.CharField(max_length=50) 11 | b = models.CharField(max_length=50) 12 | 13 | 14 | class ABC(models.Model): 15 | a = models.CharField(max_length=50) 16 | b = models.CharField(max_length=50) 17 | c = models.CharField(max_length=50) 18 | 19 | 20 | class User(models.Model): 21 | name = models.CharField(max_length=50) 22 | 23 | def __str__(self): 24 | return self.name 25 | 26 | 27 | class Room(models.Model): 28 | name = models.CharField(max_length=50) 29 | 30 | def __str__(self): 31 | return self.name 32 | 33 | 34 | class RoomBookingText(ValidatePartialUniqueMixin, models.Model): 35 | """Note that ValidatePartialUniqueMixin cannot actually be used on this model, as it uses text-based index conditions. 36 | 37 | Any ModelForm or DRF Serializer validation will fail. 38 | """ 39 | user = models.ForeignKey(User, on_delete=models.CASCADE) 40 | room = models.ForeignKey(Room, on_delete=models.CASCADE) 41 | deleted_at = models.DateTimeField(null=True, blank=True) 42 | 43 | class Meta: 44 | indexes = [PartialIndex(fields=['user', 'room'], unique=True, where='deleted_at IS NULL')] 45 | 46 | 47 | class RoomBookingQ(ValidatePartialUniqueMixin, models.Model): 48 | user = models.ForeignKey(User, on_delete=models.CASCADE) 49 | room = models.ForeignKey(Room, on_delete=models.CASCADE) 50 | deleted_at = models.DateTimeField(null=True, blank=True) 51 | 52 | class Meta: 53 | indexes = [PartialIndex(fields=['user', 'room'], unique=True, where=PQ(deleted_at__isnull=True))] 54 | 55 | 56 | class JobText(models.Model): 57 | order = models.IntegerField() 58 | group = models.IntegerField() 59 | is_complete = models.BooleanField(default=False) 60 | 61 | class Meta: 62 | indexes = [ 63 | PartialIndex(fields=['-order'], unique=False, where_postgresql='is_complete = false', where_sqlite='is_complete = 0'), 64 | PartialIndex(fields=['group'], unique=True, where_postgresql='is_complete = false', where_sqlite='is_complete = 0'), 65 | ] 66 | 67 | 68 | class JobQ(models.Model): 69 | order = models.IntegerField() 70 | group = models.IntegerField() 71 | is_complete = models.BooleanField(default=False) 72 | 73 | class Meta: 74 | indexes = [ 75 | PartialIndex(fields=['-order'], unique=False, where=PQ(is_complete=False)), 76 | PartialIndex(fields=['group'], unique=True, where=PQ(is_complete=False)), 77 | ] 78 | 79 | 80 | class ComparisonText(models.Model): 81 | """Partial index that references multiple fields on the model.""" 82 | a = models.IntegerField() 83 | b = models.IntegerField() 84 | 85 | class Meta: 86 | indexes = [ 87 | PartialIndex(fields=['a', 'b'], unique=True, where='a = b'), 88 | ] 89 | 90 | 91 | class ComparisonQ(models.Model): 92 | """Partial index that references multiple fields on the model.""" 93 | a = models.IntegerField() 94 | b = models.IntegerField() 95 | 96 | class Meta: 97 | indexes = [ 98 | PartialIndex(fields=['a', 'b'], unique=True, where=PQ(a=PF('b'))), 99 | ] 100 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for actual use of the indexes after creating models with them. 3 | """ 4 | import datetime 5 | 6 | from django.core.exceptions import ImproperlyConfigured 7 | from django.test import TransactionTestCase 8 | 9 | from testapp.forms import RoomBookingAllFieldsForm, RoomBookingNoConditionFieldForm, RoomBookingJustRoomForm, RoomBookingTextForm 10 | from testapp.models import User, Room, RoomBookingQ 11 | 12 | 13 | class OnlyQTestCase(TransactionTestCase): 14 | def test_text_condition_improperlyconfigured(self): 15 | form = RoomBookingTextForm(data={'user': 1, 'room': 1}) 16 | with self.assertRaises(ImproperlyConfigured): 17 | form.is_valid() 18 | 19 | 20 | class FormTestCase(object): 21 | """Base class for form tests. 22 | """ 23 | formclass = None 24 | conflict_error = 'RoomBookingQ with the same values for room, user already exists.' 25 | 26 | def setUp(self): 27 | self.user1 = User.objects.create(name='User1') 28 | self.user2 = User.objects.create(name='User2') 29 | self.room1 = Room.objects.create(name='Room1') 30 | self.room2 = Room.objects.create(name='Room2') 31 | self.booking1 = RoomBookingQ.objects.create(user=self.user1, room=self.room1) 32 | self.booking2 = RoomBookingQ.objects.create(user=self.user1, room=self.room2) 33 | 34 | def test_add_duplicate_invalid(self): 35 | if self.formclass != RoomBookingJustRoomForm: 36 | form = self.formclass(data={'user': self.user1.id, 'room': self.room1.id}) 37 | self.assertFalse(form.is_valid(), 'Form errors: %s' % form.errors) 38 | self.assertIn(self.conflict_error, form.errors['__all__']) 39 | else: 40 | pass # Skipped - JustRoomForm only works for modifications. 41 | 42 | def test_add_duplicate_when_deleted_valid(self): 43 | if self.formclass != RoomBookingJustRoomForm: 44 | self.booking1.deleted_at = datetime.datetime.utcnow() 45 | self.booking1.save() 46 | 47 | form = self.formclass(data={'user': self.user1.id, 'room': self.room1.id}) 48 | self.assertTrue(form.is_valid(), 'Form errors: %s' % form.errors) 49 | self.assertFalse(form.errors) 50 | else: 51 | pass # Skipped - JustRoomForm only works for modifications. 52 | 53 | def test_add_non_duplicate_valid(self): 54 | if self.formclass != RoomBookingJustRoomForm: 55 | form = self.formclass(data={'user': self.user2.id, 'room': self.room1.id}) 56 | self.assertTrue(form.is_valid(), 'Form errors: %s' % form.errors) 57 | self.assertFalse(form.errors) 58 | else: 59 | pass # Skipped - JustRoomForm only works for modifications. 60 | 61 | def test_modify_existing_valid(self): 62 | form = self.formclass(data={'user': self.user1.id, 'room': self.room1.id}, instance=self.booking1) 63 | self.assertTrue(form.is_valid(), 'Form errors: %s' % form.errors) 64 | self.assertFalse(form.errors) 65 | 66 | def test_modify_another_to_be_duplicate_invalid(self): 67 | form = self.formclass(data={'user': self.user1.id, 'room': self.room1.id}, instance=self.booking2) 68 | self.assertFalse(form.is_valid(), 'Form errors: %s' % form.errors) 69 | self.assertIn(self.conflict_error, form.errors['__all__']) 70 | 71 | def test_modify_another_to_be_duplicate_when_deleted_valid(self): 72 | self.booking1.deleted_at = datetime.datetime.utcnow() 73 | self.booking1.save() 74 | 75 | form = self.formclass(data={'user': self.user1.id, 'room': self.room1.id}, instance=self.booking2) 76 | self.assertTrue(form.is_valid(), 'Form errors: %s' % form.errors) 77 | self.assertFalse(form.errors) 78 | 79 | 80 | class AllFieldsFormTest(FormTestCase, TransactionTestCase): 81 | """Test that partial unique validation on a ModelForm works when all fields are present on the form.""" 82 | formclass = RoomBookingAllFieldsForm 83 | 84 | 85 | class NoConditionFieldFormTest(FormTestCase, TransactionTestCase): 86 | """Test that partial unique validation on a ModelForm works when all index fields, but not the condition field are present on the form.""" 87 | formclass = RoomBookingNoConditionFieldForm 88 | 89 | 90 | class SingleFieldFormTest(FormTestCase, TransactionTestCase): 91 | """Test that partial unique validation on a ModelForm works when not all unique fields are present on the form. 92 | 93 | These have to be provided from an existing instance. 94 | """ 95 | formclass = RoomBookingJustRoomForm 96 | -------------------------------------------------------------------------------- /partial_index/mixins.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured, ValidationError 2 | from django.db.models import Q 3 | 4 | from .index import PartialIndex 5 | from . import query 6 | 7 | 8 | class PartialUniqueValidationError(ValidationError): 9 | pass 10 | 11 | 12 | class ValidatePartialUniqueMixin(object): 13 | """PartialIndex with unique=True validation to ModelForms and Django Rest Framework Serializers. 14 | 15 | Mixin should be added before the parent model class, for example: 16 | 17 | class MyModel(ValidatePartialUniqueMixin, models.Model): 18 | ... 19 | 20 | indexes = [ 21 | PartialIndex(...) 22 | ] 23 | 24 | The mixin is usable only for PartialIndexes with a Q-object where-condition. If applied to a model 25 | with a text-based where-condition, an error is raised. 26 | 27 | Important Note: 28 | Django's standard ModelForm validation for unique constraints is sub-optimal. If a field belonging to the 29 | unique index is not present on the form, then it the constraint is not validated. This requires adding 30 | hidden fields on the form, checking them against tampering, etc. 31 | 32 | ValidatePartialUniqueMixin does not follow that example: 33 | It always validates with all fields, even if they are not on the form. 34 | """ 35 | 36 | def validate_unique(self, exclude=None): 37 | # Standard unique validation first. 38 | super(ValidatePartialUniqueMixin, self).validate_unique(exclude=exclude) 39 | self.validate_partial_unique() 40 | 41 | def validate_partial_unique(self): 42 | """Check partial unique constraints on the model and raise ValidationError if any failed. 43 | 44 | We want to check if another instance already exists with the fields mentioned in idx.fields, but only if idx.where matches. 45 | But can't just check for the fields in idx.fields - idx.where may refer to other fields on the current (or other) models. 46 | Also can't check for all fields on the current model - should not include irrelevant fields which may hide duplicates. 47 | 48 | To find potential conflicts, we need to build a queryset which: 49 | 1. Filters by idx.fields with their current values on this instance, 50 | 2. Filters on idx.where 51 | 3. Filters by fields mentioned in idx.where, with their current values on this instance, 52 | 4. Excludes current object if it does not match the where condition. 53 | 54 | Note that step 2 ensures the lookup only looks for conflicts among rows covered by the PartialIndes, 55 | and steps 2+3 ensures that the QuerySet is empty if the PartialIndex does not cover the current object. 56 | """ 57 | # Find PartialIndexes with unique=True defined on model. 58 | unique_idxs = [idx for idx in self._meta.indexes if isinstance(idx, PartialIndex) and idx.unique] 59 | 60 | if unique_idxs: 61 | model_fields = set(f.name for f in self._meta.get_fields(include_parents=True, include_hidden=True)) 62 | 63 | for idx in unique_idxs: 64 | where = idx.where 65 | if not isinstance(where, Q): 66 | raise ImproperlyConfigured( 67 | 'ValidatePartialUniqueMixin is not supported for PartialIndexes with a text-based where condition. ' + 68 | 'Please upgrade to Q-object based where conditions.' 69 | ) 70 | 71 | mentioned_fields = set(idx.fields) | set(query.q_mentioned_fields(where, self.__class__)) 72 | 73 | missing_fields = mentioned_fields - model_fields 74 | if missing_fields: 75 | raise RuntimeError('Unable to use ValidatePartialUniqueMixin: expecting to find fields %s on model. ' + 76 | 'This is a bug in the PartialIndex definition or the django-partial-index library itself.') 77 | 78 | values = {field_name: getattr(self, field_name) for field_name in mentioned_fields} 79 | 80 | conflict = self.__class__.objects.filter(**values) # Step 1 and 3 81 | conflict = conflict.filter(where) # Step 2 82 | if self.pk: 83 | conflict = conflict.exclude(pk=self.pk) # Step 4 84 | 85 | if conflict.exists(): 86 | raise PartialUniqueValidationError('%s with the same values for %s already exists.' % ( 87 | self.__class__.__name__, 88 | ', '.join(sorted(idx.fields)), 89 | )) 90 | -------------------------------------------------------------------------------- /partial_index/query.py: -------------------------------------------------------------------------------- 1 | """Django Q object to SQL string conversion.""" 2 | from django.db.models import expressions, Q, F 3 | from django.db.models.sql import Query 4 | 5 | 6 | class Vendor(object): 7 | POSTGRESQL = 'postgresql' 8 | SQLITE = 'sqlite' 9 | 10 | 11 | class PQ(Q): 12 | """Compatibility class for Q-objects. 13 | 14 | Django 2.0 Q-objects are suitable on their own, but Django 1.11 needs a better deep equality comparison and a deconstruct() method. 15 | 16 | PartialIndex definitions in model classes should use PQ to avoid problems when upgrading projects. 17 | """ 18 | 19 | def __eq__(self, other): 20 | """Copied from Django 2.0 django.utils.tree.Node.__eq__()""" 21 | if self.__class__ != other.__class__: 22 | return False 23 | if (self.connector, self.negated) == (other.connector, other.negated): 24 | return self.children == other.children 25 | return False 26 | 27 | def deconstruct(self): 28 | """Copied from Django 2.0 django.db.models.query_utils.Q.deconstruct()""" 29 | path = '%s.%s' % (self.__class__.__module__, self.__class__.__name__) 30 | # Keep imports clean in migrations 31 | if path.startswith('partial_index.query.'): 32 | path = path.replace('partial_index.query.', 'partial_index.') 33 | 34 | args, kwargs = (), {} 35 | if len(self.children) == 1 and not isinstance(self.children[0], Q): 36 | child = self.children[0] 37 | kwargs = {child[0]: child[1]} 38 | else: 39 | args = tuple(self.children) 40 | if self.connector != self.default: 41 | kwargs = {'_connector': self.connector} 42 | if self.negated: 43 | kwargs['_negated'] = True 44 | return path, args, kwargs 45 | 46 | 47 | class PF(F): 48 | """Compatibility class for F-expressions. 49 | 50 | Django 2.0 F-expressions are suitable on their own, but Django 1.11 a deconstruct() method. 51 | 52 | PartialIndex definitions in model classes should use PF to avoid problems when upgrading projects. 53 | """ 54 | 55 | def __eq__(self, other): 56 | if self.__class__ != other.__class__: 57 | return False 58 | return self.name == other.name 59 | 60 | def deconstruct(self): 61 | path = '%s.%s' % (self.__class__.__module__, self.__class__.__name__) 62 | # Keep imports clean in migrations 63 | if path.startswith('partial_index.query.'): 64 | path = path.replace('partial_index.query.', 'partial_index.') 65 | 66 | args = (self.name, ) 67 | kwargs = {} 68 | return path, args, kwargs 69 | 70 | 71 | 72 | def get_valid_vendor(schema_editor): 73 | vendor = schema_editor.connection.vendor 74 | if vendor not in [Vendor.POSTGRESQL, Vendor.SQLITE]: 75 | raise ValueError('Database vendor %s is not supported by django-partial-index.' % vendor) 76 | return vendor 77 | 78 | 79 | def q_to_sql(q, model, schema_editor): 80 | # Q -> SQL conversion based on code from Ian Foote's Check Constraints pull request: 81 | # https://github.com/django/django/pull/7615/ 82 | 83 | query = Query(model) 84 | where = query._add_q(q, used_aliases=set(), allow_joins=False)[0] 85 | connection = schema_editor.connection 86 | compiler = connection.ops.compiler('SQLCompiler')(query, connection, 'default') 87 | sql, params = where.as_sql(compiler, connection) 88 | params = tuple(map(schema_editor.quote_value, params)) 89 | where_sql = sql % params 90 | return where_sql 91 | 92 | 93 | def expression_mentioned_fields(exp): 94 | if isinstance(exp, expressions.Col): 95 | field = exp.output_field or exp.field # TODO: which one makes sense to use here? 96 | if field and field.name: 97 | return [field.name] 98 | elif hasattr(exp, 'get_source_expressions'): 99 | child_fields = [] 100 | for source in exp.get_source_expressions(): 101 | child_fields.extend(expression_mentioned_fields(source)) 102 | return child_fields 103 | else: 104 | raise NotImplementedError('Unexpected expression class %s=%s when looking up mentioned fields.' % (exp.__class__.__name__, exp)) 105 | 106 | 107 | def q_mentioned_fields(q, model): 108 | """Returns list of field names mentioned in Q object. 109 | 110 | Q(a__isnull=True, b=F('c')) -> ['a', 'b', 'c'] 111 | """ 112 | query = Query(model) 113 | where = query._add_q(q, used_aliases=set(), allow_joins=False)[0] 114 | return list(sorted(set(expression_mentioned_fields(where)))) 115 | -------------------------------------------------------------------------------- /tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for actual use of the indexes after creating models with them. 3 | """ 4 | # import datetime 5 | # from unittest import skip 6 | # 7 | # from django.core.exceptions import ImproperlyConfigured 8 | # from django.test import TestCase 9 | # 10 | # from testapp.serializers import RoomBookingAllFieldsSerializer, RoomBookingNoConditionFieldSerializer, RoomBookingJustRoomSerializer, RoomBookingTextSerializer 11 | # from testapp.models import User, Room, RoomBookingQ 12 | # 13 | # 14 | # @skip 15 | # class OnlyQTestCase(TestCase): 16 | # def test_text_condition_improperlyconfigured(self): 17 | # ser = RoomBookingTextSerializer(data={'user': 1, 'room': 1}) 18 | # with self.assertRaises(ImproperlyConfigured): 19 | # ser.is_valid() 20 | # 21 | # 22 | # class SerializerTestCase(object): 23 | # """Base class for serializer tests. Does not inherit from TestCase so that it would not run by itself. 24 | # Subclasses must inherit from TestCase, and set serializerclass property. 25 | # """ 26 | # serializerclass = None 27 | # conflict_error = 'RoomBookingQ with the same values for room, user already exists.' 28 | # 29 | # def setUp(self): 30 | # self.user1 = User.objects.create(name='User1') 31 | # self.user2 = User.objects.create(name='User2') 32 | # self.room1 = Room.objects.create(name='Room1') 33 | # self.room2 = Room.objects.create(name='Room2') 34 | # self.booking1 = RoomBookingQ.objects.create(user=self.user1, room=self.room1) 35 | # self.booking2 = RoomBookingQ.objects.create(user=self.user1, room=self.room2) 36 | # 37 | # def test_add_duplicate_invalid(self): 38 | # if self.serializerclass != RoomBookingJustRoomSerializer: 39 | # ser = self.serializerclass(data={'user': self.user1.id, 'room': self.room1.id}) 40 | # self.assertFalse(ser.is_valid(), 'Serializer errors: %s' % ser.errors) 41 | # self.assertIn(self.conflict_error, ser.errors['__all__']) 42 | # else: 43 | # pass # Skipped - JustRoomSerializer only works for modifications. 44 | # 45 | # def test_add_duplicate_when_deleted_valid(self): 46 | # if self.serializerclass != RoomBookingJustRoomSerializer: 47 | # self.booking1.deleted_at = datetime.datetime.utcnow() 48 | # self.booking1.save() 49 | # 50 | # ser= self.serializerclass(data={'user': self.user1.id, 'room': self.room1.id}) 51 | # self.assertTrue(ser.is_valid(), 'Serializer errors: %s' % ser.errors) 52 | # self.assertFalse(ser.errors) 53 | # else: 54 | # pass # Skipped - JustRoomSerializer only works for modifications. 55 | # 56 | # def test_add_non_duplicate_valid(self): 57 | # if self.serializerclass != RoomBookingJustRoomSerializer: 58 | # ser= self.serializerclass(data={'user': self.user2.id, 'room': self.room1.id}) 59 | # self.assertTrue(ser.is_valid(), 'Serializer errors: %s' % ser.errors) 60 | # self.assertFalse(ser.errors) 61 | # else: 62 | # pass # Skipped - JustRoomSerializer only works for modifications. 63 | # 64 | # def test_modify_existing_valid(self): 65 | # ser= self.serializerclass(data={'user': self.user1.id, 'room': self.room1.id}, instance=self.booking1) 66 | # self.assertTrue(ser.is_valid(), 'Serializer errors: %s' % ser.errors) 67 | # self.assertFalse(ser.errors) 68 | # 69 | # def test_modify_another_to_be_duplicate_invalid(self): 70 | # ser= self.serializerclass(data={'user': self.user1.id, 'room': self.room1.id}, instance=self.booking2) 71 | # self.assertFalse(ser.is_valid(), 'Serializer errors: %s' % ser.errors) 72 | # self.assertIn(self.conflict_error, ser.errors['__all__']) 73 | # 74 | # def test_modify_another_to_be_duplicate_when_deleted_valid(self): 75 | # self.booking1.deleted_at = datetime.datetime.utcnow() 76 | # self.booking1.save() 77 | # 78 | # ser= self.serializerclass(data={'user': self.user1.id, 'room': self.room1.id}, instance=self.booking2) 79 | # self.assertTrue(ser.is_valid(), 'Serializer errors: %s' % ser.errors) 80 | # self.assertFalse(ser.errors) 81 | # 82 | # 83 | # @skip 84 | # class AllFieldsSerializerTest(SerializerTestCase, TestCase): 85 | # """Test that partial unique validation on a ModelSerializer works when all fields are present on the serializer.""" 86 | # serializerclass = RoomBookingAllFieldsSerializer 87 | # 88 | # 89 | # @skip 90 | # class NoConditionFieldSerializerTest(SerializerTestCase, TestCase): 91 | # """Test that partial unique validation on a ModelSerializer works when all index fields, but not the condition field are present on the serializer.""" 92 | # serializerclass = RoomBookingNoConditionFieldSerializer 93 | # 94 | # 95 | # @skip 96 | # class SingleFieldSerializerTest(SerializerTestCase, TestCase): 97 | # """Test that partial unique validation on a ModelSerializer works when not all unique fields are present on the serializer. 98 | # 99 | # These have to be provided from an existing instance. 100 | # """ 101 | # serializerclass = RoomBookingJustRoomSerializer 102 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for actual use of the indexes after creating models with them. 3 | """ 4 | from django.db import IntegrityError 5 | from django.test import TransactionTestCase 6 | from django.utils import timezone 7 | 8 | from testapp.models import User, Room, RoomBookingText, JobText, ComparisonText, RoomBookingQ, JobQ, ComparisonQ 9 | 10 | 11 | class PartialIndexRoomBookingTest(TransactionTestCase): 12 | """Test that partial unique constraints work as expected when inserting data to the db. 13 | 14 | Models and indexes are created when django creates the test db, they do not need to be set up. 15 | """ 16 | 17 | def setUp(self): 18 | self.user1 = User.objects.create(name='User1') 19 | self.user2 = User.objects.create(name='User2') 20 | self.room1 = Room.objects.create(name='Room1') 21 | self.room2 = Room.objects.create(name='Room2') 22 | 23 | def test_roombooking_text_different_rooms(self): 24 | RoomBookingText.objects.create(user=self.user1, room=self.room1) 25 | RoomBookingText.objects.create(user=self.user1, room=self.room2) 26 | 27 | def test_roombooking_q_different_rooms(self): 28 | RoomBookingQ.objects.create(user=self.user1, room=self.room1) 29 | RoomBookingQ.objects.create(user=self.user1, room=self.room2) 30 | 31 | def test_roombooking_text_different_users(self): 32 | RoomBookingText.objects.create(user=self.user1, room=self.room1) 33 | RoomBookingText.objects.create(user=self.user2, room=self.room1) 34 | 35 | def test_roombooking_q_different_users(self): 36 | RoomBookingQ.objects.create(user=self.user1, room=self.room1) 37 | RoomBookingQ.objects.create(user=self.user2, room=self.room1) 38 | 39 | def test_roombooking_text_same_mark_first_deleted(self): 40 | for i in range(3): 41 | book = RoomBookingText.objects.create(user=self.user1, room=self.room1) 42 | book.deleted_at = timezone.now() 43 | book.save() 44 | RoomBookingText.objects.create(user=self.user1, room=self.room1) 45 | 46 | def test_roombooking_q_same_mark_first_deleted(self): 47 | for i in range(3): 48 | book = RoomBookingQ.objects.create(user=self.user1, room=self.room1) 49 | book.deleted_at = timezone.now() 50 | book.save() 51 | RoomBookingQ.objects.create(user=self.user1, room=self.room1) 52 | 53 | def test_roombooking_text_same_conflict(self): 54 | RoomBookingText.objects.create(user=self.user1, room=self.room1) 55 | with self.assertRaises(IntegrityError): 56 | RoomBookingText.objects.create(user=self.user1, room=self.room1) 57 | 58 | def test_roombooking_q_same_conflict(self): 59 | RoomBookingQ.objects.create(user=self.user1, room=self.room1) 60 | with self.assertRaises(IntegrityError): 61 | RoomBookingQ.objects.create(user=self.user1, room=self.room1) 62 | 63 | 64 | class PartialIndexJobTest(TransactionTestCase): 65 | """Test that partial unique constraints work as expected when inserting data to the db. 66 | 67 | Models and indexes are created when django creates the test db, they do not need to be set up. 68 | """ 69 | def test_job_text_same_id(self): 70 | job1 = JobText.objects.create(order=1, group=1) 71 | job2 = JobText.objects.create(order=1, group=2) 72 | self.assertEqual(job1.order, job2.order) 73 | 74 | def test_job_q_same_id(self): 75 | job1 = JobQ.objects.create(order=1, group=1) 76 | job2 = JobQ.objects.create(order=1, group=2) 77 | self.assertEqual(job1.order, job2.order) 78 | 79 | def test_job_text_same_group(self): 80 | JobText.objects.create(order=1, group=1) 81 | with self.assertRaises(IntegrityError): 82 | JobText.objects.create(order=2, group=1) 83 | 84 | def test_job_q_same_group(self): 85 | JobQ.objects.create(order=1, group=1) 86 | with self.assertRaises(IntegrityError): 87 | JobQ.objects.create(order=2, group=1) 88 | 89 | def test_job_text_complete_same_group(self): 90 | job1 = JobText.objects.create(order=1, group=1, is_complete=True) 91 | job2 = JobText.objects.create(order=1, group=1) 92 | self.assertEqual(job1.order, job2.order) 93 | 94 | def test_job_q_complete_same_group(self): 95 | job1 = JobQ.objects.create(order=1, group=1, is_complete=True) 96 | job2 = JobQ.objects.create(order=1, group=1) 97 | self.assertEqual(job1.order, job2.order) 98 | 99 | def test_job_text_complete_later_same_group(self): 100 | job1 = JobText.objects.create(order=1, group=1) 101 | job2 = JobText.objects.create(order=1, group=1, is_complete=True) 102 | self.assertEqual(job1.order, job2.order) 103 | 104 | def test_job_q_complete_later_same_group(self): 105 | job1 = JobQ.objects.create(order=1, group=1) 106 | job2 = JobQ.objects.create(order=1, group=1, is_complete=True) 107 | self.assertEqual(job1.order, job2.order) 108 | 109 | 110 | class PartialIndexComparisonTest(TransactionTestCase): 111 | """Test that partial unique constraints work as expected when inserting data to the db. 112 | 113 | Models and indexes are created when django creates the test db, they do not need to be set up. 114 | """ 115 | def test_comparison_text_duplicate_same_number(self): 116 | ComparisonText.objects.create(a=1, b=1) 117 | with self.assertRaises(IntegrityError): 118 | ComparisonText.objects.create(a=1, b=1) 119 | 120 | def test_comparison_q_duplicate_same_number(self): 121 | ComparisonQ.objects.create(a=1, b=1) 122 | with self.assertRaises(IntegrityError): 123 | ComparisonQ.objects.create(a=1, b=1) 124 | 125 | def test_comparison_text_different_same_number(self): 126 | ComparisonText.objects.create(a=1, b=1) 127 | ComparisonText.objects.create(a=2, b=2) 128 | 129 | def test_comparison_q_different_same_number(self): 130 | ComparisonQ.objects.create(a=1, b=1) 131 | ComparisonQ.objects.create(a=2, b=2) 132 | 133 | def test_comparison_text_duplicate_different_numbers(self): 134 | ComparisonText.objects.create(a=1, b=2) 135 | ComparisonText.objects.create(a=1, b=2) 136 | 137 | def test_comparison_q_duplicate_different_numbers(self): 138 | ComparisonQ.objects.create(a=1, b=2) 139 | ComparisonQ.objects.create(a=1, b=2) 140 | -------------------------------------------------------------------------------- /partial_index/index.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Index, Q 2 | from django.utils import six 3 | from django.utils.encoding import force_bytes 4 | import hashlib 5 | import warnings 6 | 7 | 8 | from . import query 9 | 10 | 11 | def validate_where(where='', where_postgresql='', where_sqlite=''): 12 | if where: 13 | if where_postgresql or where_sqlite: 14 | raise ValueError('If providing a common where predicate, must not provide where_postgresql or where_sqlite.') 15 | if isinstance(where, six.string_types): 16 | warnings.warn( 17 | 'Text-based where predicates are deprecated, will be removed in a future release. ' + 18 | 'Please upgrade to where=PQ().', 19 | DeprecationWarning) 20 | elif isinstance(where, query.PQ): 21 | pass 22 | else: 23 | raise ValueError('Where predicate must be a string or a partial_index.PQ object.') 24 | else: 25 | if not where_postgresql and not where_sqlite: 26 | raise ValueError('A where predicate must be provided.') 27 | if where_postgresql == where_sqlite: 28 | raise ValueError('If providing a separate where_postgresql and where_sqlite, then they must be different.' + 29 | 'If the same expression works for both, just use single where.') 30 | if not isinstance(where_postgresql, six.string_types) or not isinstance(where_sqlite, six.string_types): 31 | raise ValueError('where_postgresql and where_sqlite must be strings.') 32 | warnings.warn( 33 | 'Text-based where predicates are deprecated, will be removed in a future release. ' + 34 | 'Please upgrade to where=PQ().', 35 | DeprecationWarning) 36 | return where, where_postgresql, where_sqlite 37 | 38 | 39 | class PartialIndex(Index): 40 | suffix = 'partial' 41 | # Allow an index name longer than 30 characters since this index can only be used on PostgreSQL and SQLite, 42 | # and the Django default 30 character limit for cross-database compatibility isn't applicable. 43 | # The "partial" suffix is 4 letters longer than the default "idx". 44 | max_name_length = 34 45 | sql_create_index = { 46 | 'postgresql': 'CREATE%(unique)s INDEX %(name)s ON %(table)s%(using)s (%(columns)s)%(extra)s WHERE %(where)s', 47 | 'sqlite': 'CREATE%(unique)s INDEX %(name)s ON %(table)s%(using)s (%(columns)s) WHERE %(where)s', 48 | } 49 | 50 | # Mutable default fields=[] looks wrong, but it's copied from super class. 51 | def __init__(self, fields=[], name=None, unique=None, where='', where_postgresql='', where_sqlite=''): 52 | if unique not in [True, False]: 53 | raise ValueError('Unique must be True or False') 54 | self.unique = unique 55 | self.where, self.where_postgresql, self.where_sqlite = \ 56 | validate_where(where=where, where_postgresql=where_postgresql, where_sqlite=where_sqlite) 57 | super(PartialIndex, self).__init__(fields=fields, name=name) 58 | 59 | def __repr__(self): 60 | if self.where: 61 | if isinstance(self.where, query.PQ): 62 | anywhere = "where=%s" % repr(self.where) 63 | else: 64 | anywhere = "where='%s'" % self.where 65 | else: 66 | anywhere = "where_postgresql='%s', where_sqlite='%s'" % (self.where_postgresql, self.where_sqlite) 67 | 68 | return "<%(name)s: fields=%(fields)s, unique=%(unique)s, %(anywhere)s>" % { 69 | 'name': self.__class__.__name__, 70 | 'fields': "'{}'".format(', '.join(self.fields)), 71 | 'unique': self.unique, 72 | 'anywhere': anywhere 73 | } 74 | 75 | def deconstruct(self): 76 | path, args, kwargs = super(PartialIndex, self).deconstruct() 77 | if path.startswith('partial_index.index'): 78 | path = path.replace('partial_index.index', 'partial_index') 79 | kwargs['unique'] = self.unique 80 | if self.where: 81 | kwargs['where'] = self.where 82 | else: 83 | kwargs['where_postgresql'] = self.where_postgresql 84 | kwargs['where_sqlite'] = self.where_sqlite 85 | return path, args, kwargs 86 | 87 | def get_sql_create_template_values(self, model, schema_editor, using): 88 | # This method exists on Django 1.11 Index class, but has been moved to the SchemaEditor on Django 2.0. 89 | # This makes it complex to call superclass methods and avoid duplicating code. 90 | # Can be simplified if Django 1.11 support is dropped one day. 91 | 92 | # Copied from Django 1.11 Index.get_sql_create_template_values(), which does not exist in Django 2.0: 93 | fields = [model._meta.get_field(field_name) for field_name, order in self.fields_orders] 94 | tablespace_sql = schema_editor._get_index_tablespace_sql(model, fields) 95 | quote_name = schema_editor.quote_name 96 | columns = [ 97 | ('%s %s' % (quote_name(field.column), order)).strip() 98 | for field, (field_name, order) in zip(fields, self.fields_orders) 99 | ] 100 | parameters = { 101 | 'table': quote_name(model._meta.db_table), 102 | 'name': quote_name(self.name), 103 | 'columns': ', '.join(columns), 104 | 'using': using, 105 | 'extra': tablespace_sql, 106 | } 107 | 108 | # PartialIndex updates: 109 | parameters['unique'] = ' UNIQUE' if self.unique else '' 110 | # Note: the WHERE predicate is not yet checked for syntax or field names, and is inserted into the CREATE INDEX query unescaped. 111 | # This is bad for usability, but is not a security risk, as the string cannot come from user input. 112 | vendor = query.get_valid_vendor(schema_editor) 113 | if isinstance(self.where, query.PQ): 114 | parameters['where'] = query.q_to_sql(self.where, model, schema_editor) 115 | elif vendor == 'postgresql': 116 | parameters['where'] = self.where_postgresql or self.where 117 | elif vendor == 'sqlite': 118 | parameters['where'] = self.where_sqlite or self.where 119 | else: 120 | raise ValueError('Should never happen') 121 | return parameters 122 | 123 | def create_sql(self, model, schema_editor, using=''): 124 | vendor = query.get_valid_vendor(schema_editor) 125 | sql_template = self.sql_create_index[vendor] 126 | sql_parameters = self.get_sql_create_template_values(model, schema_editor, using) 127 | return sql_template % sql_parameters 128 | 129 | def name_hash_extra_data(self): 130 | return [str(self.unique), self.where, self.where_postgresql, self.where_sqlite] 131 | 132 | def set_name_with_model(self, model): 133 | """Sets an unique generated name for the index. 134 | 135 | PartialIndex would like to only override "hash_data = ...", but the entire method must be duplicated for that. 136 | """ 137 | table_name = model._meta.db_table 138 | column_names = [model._meta.get_field(field_name).column for field_name, order in self.fields_orders] 139 | column_names_with_order = [ 140 | (('-%s' if order else '%s') % column_name) 141 | for column_name, (field_name, order) in zip(column_names, self.fields_orders) 142 | ] 143 | # The length of the parts of the name is based on the default max 144 | # length of 30 characters. 145 | hash_data = [table_name] + column_names_with_order + [self.suffix] + self.name_hash_extra_data() 146 | self.name = '%s_%s_%s' % ( 147 | table_name[:11], 148 | column_names[0][:7], 149 | '%s_%s' % (self._hash_generator(*hash_data), self.suffix), 150 | ) 151 | assert len(self.name) <= self.max_name_length, ( 152 | 'Index too long for multiple database support. Is self.suffix ' 153 | 'longer than 3 characters?' 154 | ) 155 | self.check_name() 156 | 157 | @staticmethod 158 | def _hash_generator(*args): 159 | """Copied from Django 2.1 for compatibility. In Django 2.2 this has been moved into django.db.backends.utils.names_digest(). 160 | 161 | Note that even if Django changes the hash calculation in the future, we should not - that would cause index renames on Django version upgrade. 162 | """ 163 | h = hashlib.md5() 164 | for arg in args: 165 | h.update(force_bytes(arg)) 166 | return h.hexdigest()[:6] 167 | -------------------------------------------------------------------------------- /tests/test_sql.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for SQL CREATE INDEX statements. 3 | """ 4 | from django.db import connection 5 | from django.test import TransactionTestCase 6 | import re 7 | 8 | from partial_index import PartialIndex, PQ 9 | from testapp.models import RoomBookingText, JobText, ComparisonText, RoomBookingQ, JobQ, ComparisonQ 10 | 11 | 12 | ROOMBOOKING_TEXT_SQL = r'^CREATE UNIQUE INDEX "testapp_[a-zA-Z0-9_]+_partial" ' + \ 13 | r'ON "testapp_roombookingtext" \("user_id", "room_id"\) ' + \ 14 | r'WHERE deleted_at IS NULL;?$' 15 | ROOMBOOKING_Q_SQL = r'^CREATE UNIQUE INDEX "testapp_[a-zA-Z0-9_]+_partial" ' + \ 16 | r'ON "testapp_roombookingq" \("user_id", "room_id"\) ' + \ 17 | r'WHERE "testapp_roombookingq"."deleted_at" IS NULL;?$' 18 | 19 | JOB_TEXT_NONUNIQUE_SQL = r'^CREATE INDEX "testapp_[a-zA-Z0-9_]+_partial" ' + \ 20 | r'ON "testapp_jobtext" \("order" DESC\) ' + \ 21 | r'WHERE is_complete = %s;?$' 22 | JOB_Q_NONUNIQUE_SQL = r'^CREATE INDEX "testapp_[a-zA-Z0-9_]+_partial" ' + \ 23 | r'ON "testapp_jobq" \("order" DESC\) ' + \ 24 | r'WHERE "testapp_jobq"."is_complete" = %s;?$' 25 | 26 | JOB_TEXT_UNIQUE_SQL = r'^CREATE UNIQUE INDEX "testapp_[a-zA-Z0-9_]+_partial" ' + \ 27 | r'ON "testapp_jobtext" \("group"\) ' + \ 28 | r'WHERE is_complete = %s;?$' 29 | JOB_Q_UNIQUE_SQL = r'^CREATE UNIQUE INDEX "testapp_[a-zA-Z0-9_]+_partial" ' + \ 30 | r'ON "testapp_jobq" \("group"\) ' + \ 31 | r'WHERE "testapp_jobq"."is_complete" = %s;?$' 32 | 33 | COMPARISON_TEXT_SQL = r'^CREATE UNIQUE INDEX "testapp_[a-zA-Z0-9_]+_partial" ' + \ 34 | r'ON "testapp_comparisontext" \("a", "b"\) ' + \ 35 | r'WHERE a = b;?$' 36 | COMPARISON_Q_SQL = r'^CREATE UNIQUE INDEX "testapp_[a-zA-Z0-9_]+_partial" ' + \ 37 | r'ON "testapp_comparisonq" \("a", "b"\) ' + \ 38 | r'WHERE "testapp_comparisonq"."a" = \("testapp_comparisonq"."b"\);?$' 39 | 40 | 41 | class PartialIndexSqlTest(TransactionTestCase): 42 | """Check that the schema editor generates valid SQL for the index.""" 43 | 44 | def schema_editor(self): 45 | # collect_sql=True -> do not actually execute. 46 | return connection.schema_editor(collect_sql=True) 47 | 48 | def assertContainsMatch(self, texts, pattern): 49 | found = False 50 | for text in texts: 51 | if re.match(pattern, text): 52 | found = True 53 | break 54 | self.assertTrue(found, 'Pattern matching \"%s\" not found in %s' % (pattern, texts)) 55 | 56 | def false(self, editor): 57 | return 'false' if editor.connection.vendor == 'postgresql' else '0' 58 | 59 | def test_roombooking_text_createsql(self): 60 | with self.schema_editor() as editor: 61 | sql = RoomBookingText._meta.indexes[0].create_sql(RoomBookingText, editor) 62 | self.assertRegex(sql, ROOMBOOKING_TEXT_SQL) 63 | 64 | def test_roombooking_q_createsql(self): 65 | with self.schema_editor() as editor: 66 | sql = RoomBookingQ._meta.indexes[0].create_sql(RoomBookingQ, editor) 67 | self.assertRegex(sql, ROOMBOOKING_Q_SQL) 68 | 69 | def test_roombooking_text_create_model(self): 70 | with self.schema_editor() as editor: 71 | editor.create_model(RoomBookingText) 72 | self.assertContainsMatch(editor.collected_sql, ROOMBOOKING_TEXT_SQL) 73 | 74 | def test_roombooking_q_create_model(self): 75 | with self.schema_editor() as editor: 76 | editor.create_model(RoomBookingQ) 77 | self.assertContainsMatch(editor.collected_sql, ROOMBOOKING_Q_SQL) 78 | 79 | def test_job_text_createsql(self): 80 | with self.schema_editor() as editor: 81 | sql = JobText._meta.indexes[0].create_sql(JobText, editor) 82 | self.assertRegex(sql, JOB_TEXT_NONUNIQUE_SQL % self.false(editor)) 83 | 84 | def test_job_q_createsql(self): 85 | with self.schema_editor() as editor: 86 | sql = JobQ._meta.indexes[0].create_sql(JobQ, editor) 87 | self.assertRegex(sql, JOB_Q_NONUNIQUE_SQL % self.false(editor)) 88 | 89 | def test_job_text_create_model(self): 90 | with self.schema_editor() as editor: 91 | editor.create_model(JobText) 92 | f = self.false(editor) 93 | self.assertContainsMatch(editor.collected_sql, JOB_TEXT_NONUNIQUE_SQL % f) 94 | self.assertContainsMatch(editor.collected_sql, JOB_TEXT_UNIQUE_SQL % f) 95 | 96 | def test_job_q_create_model(self): 97 | with self.schema_editor() as editor: 98 | editor.create_model(JobQ) 99 | f = self.false(editor) 100 | self.assertContainsMatch(editor.collected_sql, JOB_Q_NONUNIQUE_SQL % f) 101 | self.assertContainsMatch(editor.collected_sql, JOB_Q_UNIQUE_SQL % f) 102 | 103 | def test_comparison_text_createsql(self): 104 | with self.schema_editor() as editor: 105 | sql = ComparisonText._meta.indexes[0].create_sql(ComparisonText, editor) 106 | self.assertRegex(sql, COMPARISON_TEXT_SQL) 107 | 108 | def test_comparison_q_createsql(self): 109 | with self.schema_editor() as editor: 110 | sql = ComparisonQ._meta.indexes[0].create_sql(ComparisonQ, editor) 111 | self.assertRegex(sql, COMPARISON_Q_SQL) 112 | 113 | def test_comparison_text_create_model(self): 114 | with self.schema_editor() as editor: 115 | editor.create_model(ComparisonText) 116 | self.assertContainsMatch(editor.collected_sql, COMPARISON_TEXT_SQL) 117 | 118 | def test_comparison_q_create_model(self): 119 | with self.schema_editor() as editor: 120 | editor.create_model(ComparisonQ) 121 | self.assertContainsMatch(editor.collected_sql, COMPARISON_Q_SQL) 122 | 123 | 124 | class PartialIndexCreateTest(TransactionTestCase): 125 | """Check that the index really can be added to and removed from the model in the DB.""" 126 | 127 | def schema_editor(self): 128 | # Actually execute statements. 129 | return connection.schema_editor() 130 | 131 | @staticmethod 132 | def get_constraints(model): 133 | """Get the indexes on the table using a new cursor.""" 134 | with connection.cursor() as cursor: 135 | return connection.introspection.get_constraints(cursor, model._meta.db_table) 136 | 137 | def assertAddRemoveConstraint(self, model, index_name, index, expect_attrs): 138 | num_constraints_before = len(self.get_constraints(model)) 139 | 140 | # Add the index 141 | with self.schema_editor() as editor: 142 | editor.add_index(model, index) 143 | constraints = self.get_constraints(model) 144 | self.assertEqual(len(constraints), num_constraints_before + 1) 145 | for k, v in expect_attrs.items(): 146 | self.assertEqual(constraints[index_name][k], v) 147 | 148 | # Drop the index 149 | with self.schema_editor() as editor: 150 | editor.remove_index(model, index) 151 | constraints = self.get_constraints(model) 152 | self.assertEqual(len(constraints), num_constraints_before) 153 | self.assertNotIn(index_name, constraints) 154 | 155 | def test_unique_text(self): 156 | index_name = 'roombookingtext_test_idx' 157 | index = PartialIndex(fields=['user', 'room'], name=index_name, unique=True, where='deleted_at IS NULL') 158 | self.assertAddRemoveConstraint(RoomBookingText, index_name, index, { 159 | 'columns': ['user_id', 'room_id'], 160 | 'primary_key': False, 161 | 'check': False, 162 | 'index': True, 163 | 'unique': True, 164 | }) 165 | 166 | def test_not_unique_text(self): 167 | index_name = 'jobtext_test_idx' 168 | index = PartialIndex(fields=['-group'], name=index_name, unique=False, where_postgresql='is_complete = false', where_sqlite='is_complete = 0') 169 | self.assertAddRemoveConstraint(JobText, index_name, index, { 170 | 'columns': ['group'], 171 | 'orders': ['DESC'], 172 | 'primary_key': False, 173 | 'check': False, 174 | 'index': True, 175 | 'unique': False, 176 | }) 177 | 178 | def test_unique_q(self): 179 | index_name = 'roombookingq_test_idx' 180 | index = PartialIndex(fields=['user', 'room'], name=index_name, unique=True, where=PQ(deleted_at__isnull=True)) 181 | self.assertAddRemoveConstraint(RoomBookingQ, index_name, index, { 182 | 'columns': ['user_id', 'room_id'], 183 | 'primary_key': False, 184 | 'check': False, 185 | 'index': True, 186 | 'unique': True, 187 | }) 188 | 189 | def test_not_unique_q(self): 190 | index_name = 'jobq_test_idx' 191 | index = PartialIndex(fields=['-group'], name=index_name, unique=False, where=PQ(is_complete=False)) 192 | self.assertAddRemoveConstraint(JobQ, index_name, index, { 193 | 'columns': ['group'], 194 | 'orders': ['DESC'], 195 | 'primary_key': False, 196 | 'check': False, 197 | 'index': True, 198 | 'unique': False, 199 | }) 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-partial-index 2 | 3 | [![Build Status](https://api.travis-ci.org/mattiaslinnap/django-partial-index.svg?branch=master)](https://travis-ci.org/mattiaslinnap/django-partial-index) 4 | [![PyPI version](https://badge.fury.io/py/django-partial-index.svg)](https://pypi.python.org/pypi/django-partial-index/) 5 | 6 | Partial (sometimes also called filtered or conditional) index support for Django. 7 | 8 | With partial indexes, only some subset of the rows in the table have corresponding index entries. 9 | This can be useful for optimizing index size and query speed, and to add unique constraints for only selected rows. 10 | 11 | More info on partial indexes: 12 | 13 | * https://www.postgresql.org/docs/current/static/indexes-partial.html 14 | * https://sqlite.org/partialindex.html 15 | 16 | 17 | ## Partial indexes now included in Django 18 | 19 | Since the release of [Django 2.2 LTS](https://docs.djangoproject.com/en/2.2/releases/2.2/) in April 2019, 20 | partial indexes are now supported by standard Django. 21 | 22 | These are called [index conditions](https://docs.djangoproject.com/en/2.2/ref/models/indexes/#condition) there. 23 | 24 | The django-partial-index package will live on in maintenance mode. 25 | 26 | It can be useful if you are maintaining a project on and older version of Django, or wish to migrate django-partial-index indexes to Django 2.2 style on your own schedule. 27 | 28 | ## Install 29 | 30 | `pip install django-partial-index` 31 | 32 | Requirements: 33 | 34 | * Django 1.11, 2.0, 2.1 or 2.2, 35 | * Python 2.7, 3.4, 3.5, 3.6 or 3.7 (as supported by the Django version), 36 | * PostgreSQL or SQLite database backend. (Partial indexes are not supported on MySQL, and require major hackery on Oracle.) 37 | 38 | All Python versions which Django supports are also supported by this package. These are: 39 | 40 | * Django 1.11 - Python 2.7 and 3.4 - 3.7, 41 | * Django 2.0 - Python 3.4 - 3.7, 42 | * Django 2.1 - Python 3.5 - 3.7, 43 | * Django 2.2 - Python 3.5 - 3.7. 44 | 45 | 46 | ## Usage 47 | 48 | Set up a PartialIndex and insert it into your model's class-based Meta.indexes list: 49 | 50 | ```python 51 | from partial_index import PartialIndex, PQ 52 | 53 | class MyModel(models.Model): 54 | class Meta: 55 | indexes = [ 56 | PartialIndex(fields=['user', 'room'], unique=True, where=PQ(deleted_at__isnull=True)), 57 | PartialIndex(fields=['created_at'], unique=False, where=PQ(is_complete=False)), 58 | ] 59 | ``` 60 | 61 | The `PQ` uses the exact same syntax and supports all the same features as Django's `Q` objects ([see Django docs for a full tutorial](https://docs.djangoproject.com/en/1.11/topics/db/queries/#complex-lookups-with-q-objects)). It is provided for compatibility with Django 1.11. 62 | 63 | Of course, these (unique) indexes could be created by a handwritten [RunSQL migration](https://docs.djangoproject.com/en/1.11/ref/migration-operations/#runsql). 64 | But the constraints are part of the business logic, and best kept close to the model definitions. 65 | 66 | ### Partial unique constraints 67 | 68 | With `unique=True`, this can be used to create unique constraints for a subset of the rows. 69 | 70 | For example, you might have a model that has a deleted_at field to mark rows as archived instead of deleting them forever. 71 | You wish to add unique constraints on "alive" rows, but allow multiple copies in the archive. 72 | [Django's unique_together](https://docs.djangoproject.com/en/1.11/ref/models/options/#unique-together) is not sufficient here, as that cannot 73 | distinguish between the archived and alive rows. 74 | 75 | ```python 76 | from partial_index import PartialIndex, PQ 77 | 78 | class RoomBooking(models.Model): 79 | user = models.ForeignKey(User) 80 | room = models.ForeignKey(Room) 81 | deleted_at = models.DateTimeField(null=True, blank=True) 82 | 83 | class Meta: 84 | # unique_together = [('user', 'room')] - Does not allow multiple deleted rows. Instead use: 85 | indexes = [ 86 | PartialIndex(fields=['user', 'room'], unique=True, where=PQ(deleted_at__isnull=True)) 87 | ] 88 | ``` 89 | 90 | ### Partial non-unique indexes 91 | 92 | With `unique=False`, partial indexes can be used to optimise lookups that return only a small subset of the rows. 93 | 94 | For example, you might have a job queue table which keeps an archive of millions of completed jobs. Among these are a few pending jobs, 95 | which you want to find with a `.filter(is_complete=0)` query. 96 | 97 | ```python 98 | from partial_index import PartialIndex, PQ 99 | 100 | class Job(models.Model): 101 | created_at = models.DateTimeField(auto_now_add=True) 102 | is_complete = models.IntegerField(default=0) 103 | 104 | class Meta: 105 | indexes = [ 106 | PartialIndex(fields=['created_at'], unique=False, where=PQ(is_complete=0)) 107 | ] 108 | ``` 109 | 110 | Compared to an usual full index on the `is_complete` field, this can be significantly smaller in disk and memory use, and faster to update. 111 | 112 | ### Referencing multiple fields in the condition 113 | 114 | With `F`-expressions, you can create conditions that reference multiple fields: 115 | 116 | ```python 117 | from partial_index import PartialIndex, PQ, PF 118 | 119 | class NotTheSameAgain(models.Model): 120 | a = models.IntegerField() 121 | b = models.IntegerField() 122 | 123 | class Meta: 124 | indexes = [ 125 | PartialIndex(fields=['a', 'b'], unique=True, where=PQ(a=PF('b'))), 126 | ] 127 | ``` 128 | 129 | This PartialIndex allows multiple copies of `(2, 3)`, but only a single copy of `(2, 2)` to exist in the database. 130 | 131 | The `PF` uses the exact same syntax and supports all the same features as Django's `F` expressions ([see Django docs for a full tutorial](https://docs.djangoproject.com/en/1.11/ref/models/expressions/#f-expressions)). It is provided for compatibility with Django 1.11. 132 | 133 | ### Unique validation on ModelForms 134 | 135 | Unique partial indexes are validated by the PostgreSQL and SQLite databases. When they reject an INSERT or UPDATE, Django raises a `IntegrityError` exception. This results in a `500 Server Error` status page in the browser if not handled before the database query is run. 136 | 137 | ModelForms perform unique validation before saving an object, and present the user with a descriptive error message. 138 | 139 | Adding an index does not modify the parent model's unique validation, so partial index validations are not handled by them by default. To add that to your model, include the `ValidatePartialUniqueMixin` in your model definition: 140 | 141 | ```python 142 | from partial_index import PartialIndex, PQ, ValidatePartialUniqueMixin 143 | 144 | class MyModel(ValidatePartialUniqueMixin, models.Model): 145 | class Meta: 146 | indexes = [ 147 | PartialIndex(fields=['user', 'room'], unique=True, where=PQ(deleted_at__isnull=True)), 148 | ] 149 | ``` 150 | 151 | Note that it should be added on the model itself, not the ModelForm class. 152 | 153 | Adding the mixin for non-unique partial indexes is unnecessary, as they cannot cause database IntegrityErrors. 154 | 155 | ### Text-based where-conditions (deprecated) 156 | 157 | Text-based where-conditions are deprecated and will be removed in the next release (0.6.0) of django-partial-index. 158 | 159 | They are still supported in version 0.5.0 to simplify upgrading existing projects to the `PQ`-based indexes. New projects should not use them. 160 | 161 | 162 | ```python 163 | from partial_index import PartialIndex 164 | 165 | class TextExample(models.Model): 166 | class Meta: 167 | indexes = [ 168 | PartialIndex(fields=['user', 'room'], unique=True, where='deleted_at IS NULL'), 169 | PartialIndex(fields=['created_at'], unique=False, where_postgresql='is_complete = false', where_sqlite='is_complete = 0') 170 | ] 171 | ``` 172 | 173 | 174 | ## Version History 175 | 176 | ### 0.6.0 (latest) 177 | * Add support for Django 2.2. 178 | * Document (already existing) support for Django 2.1 and Python 3.7. 179 | 180 | ### 0.5.2 181 | * Fix makemigrations for Django 1.11. 182 | * Make sure PQ and PF are imported directly from partial_index in migration files. 183 | 184 | ### 0.5.1 185 | * Fix README formatting in PyPI. 186 | 187 | ### 0.5.0 188 | * Add support for Q-object based where-expressions. 189 | * Deprecate support for text-based where-expressions. These will be removed in version 0.6.0. 190 | * Add ValidatePartialUniqueMixin for model classes. This adds partial unique index validation for ModelForms, avoiding an IntegrityError and instead showing an error message as with usual unique_together constraints. 191 | 192 | ### 0.4.0 193 | * Add support for Django 2.0. 194 | 195 | ### 0.3.0 196 | * Add support for separate `where_postgresql=''` and `where_sqlite=''` predicates, when the expression has different syntax on the two 197 | database backends and you wish to support both. 198 | 199 | ### 0.2.1 200 | * Ensure that automatically generated index names depend on the "unique" and "where" parameters. Otherwise two indexes with the same fields would be considered identical by Django. 201 | 202 | ### 0.2.0 203 | * Fully tested SQLite and PostgreSQL support. 204 | * Tests for generated SQL statements, adding and removing indexes, and that unique constraints work when inserting rows into the db tables. 205 | * Python 2.7, 3.4-3.6 support. 206 | 207 | ### 0.1.1 208 | * Experimental SQLite support. 209 | 210 | ### 0.1.0 211 | * First release, working but untested PostgreSQL support. 212 | 213 | ## Future plans 214 | 215 | * Add a validation mixin for DRF Serializers. 216 | * Remove support for text-based where conditions. 217 | -------------------------------------------------------------------------------- /tests/test_partial_index.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for basic fields and functions in the PartialIndex class. 3 | 4 | Tests interacting with a real PostgreSQL database are elsewhere. 5 | """ 6 | 7 | from django.test import SimpleTestCase 8 | 9 | from partial_index import PartialIndex, PQ 10 | from testapp.models import AB 11 | 12 | 13 | class PartialIndexTextBasedWhereRulesTest(SimpleTestCase): 14 | """Test the rules for providing text-based where arguments.""" 15 | 16 | def test_where_not_provided(self): 17 | with self.assertRaisesRegexp(ValueError, 'must be provided'): 18 | PartialIndex(fields=['a', 'b'], unique=True) 19 | 20 | def test_single_and_pg_where_same(self): 21 | with self.assertRaisesRegexp(ValueError, 'must not provide'): 22 | PartialIndex(fields=['a', 'b'], unique=True, where='a IS NULL', where_postgresql='a IS NULL') 23 | 24 | def test_single_and_pg_where_different(self): 25 | with self.assertRaisesRegexp(ValueError, 'must not provide'): 26 | PartialIndex(fields=['a', 'b'], unique=True, where='a IS NULL', where_postgresql='a IS NOT NULL') 27 | 28 | def test_single_and_sqlite_where_same(self): 29 | with self.assertRaisesRegexp(ValueError, 'must not provide'): 30 | PartialIndex(fields=['a', 'b'], unique=True, where='a IS NULL', where_sqlite='a IS NULL') 31 | 32 | def test_single_and_sqlite_where_different(self): 33 | with self.assertRaisesRegexp(ValueError, 'must not provide'): 34 | PartialIndex(fields=['a', 'b'], unique=True, where='a IS NULL', where_sqlite='a IS NOT NULL') 35 | 36 | def test_all_where_same(self): 37 | with self.assertRaisesRegexp(ValueError, 'must not provide'): 38 | PartialIndex(fields=['a', 'b'], unique=True, where='a IS NULL', where_postgresql='a IS NULL', where_sqlite='a IS NULL') 39 | 40 | def test_all_where_different(self): 41 | with self.assertRaisesRegexp(ValueError, 'must not provide'): 42 | PartialIndex(fields=['a', 'b'], unique=True, where='a IS NULL', where_postgresql='a IS NOT NULL', where_sqlite='a = 3') 43 | 44 | def test_pg_and_sqlite_where_same(self): 45 | with self.assertRaisesRegexp(ValueError, 'must be different'): 46 | PartialIndex(fields=['a', 'b'], unique=True, where_postgresql='a IS NULL', where_sqlite='a IS NULL') 47 | 48 | 49 | class PartialIndexPQBasedWhereRulesTest(SimpleTestCase): 50 | """Test the rules for providing PQ-based where arguments.""" 51 | 52 | def test_where_not_provided(self): 53 | # Same as text based test - keep a copy here for the future when text-based are removed entirely. 54 | with self.assertRaisesRegexp(ValueError, 'must be provided'): 55 | PartialIndex(fields=['a', 'b'], unique=True) 56 | 57 | def test_single_q_and_pg(self): 58 | with self.assertRaisesRegexp(ValueError, 'must not provide'): 59 | PartialIndex(fields=['a', 'b'], unique=True, where=PQ(a__isnull=True), where_postgresql='a IS NULL') 60 | 61 | def test_single_q_and_sqlite(self): 62 | with self.assertRaisesRegexp(ValueError, 'must not provide'): 63 | PartialIndex(fields=['a', 'b'], unique=True, where=PQ(a__isnull=True), where_sqlite='a IS NULL') 64 | 65 | def test_single_q_and_pg_and_sqlite(self): 66 | with self.assertRaisesRegexp(ValueError, 'must not provide'): 67 | PartialIndex(fields=['a', 'b'], unique=True, where=PQ(a__isnull=True), where_postgresql='a IS NULL', where_sqlite='a IS NULL') 68 | 69 | 70 | class PartialIndexSingleTextWhereTest(SimpleTestCase): 71 | """Test simple fields and methods on the PartialIndex class with a single text-based where predicate.""" 72 | 73 | def setUp(self): 74 | self.idx = PartialIndex(fields=['a', 'b'], unique=True, where='a IS NULL') 75 | 76 | def test_no_unique(self): 77 | with self.assertRaisesMessage(ValueError, 'Unique must be True or False'): 78 | PartialIndex(fields=['a', 'b'], where='a is null') 79 | 80 | def test_fields(self): 81 | self.assertEqual(self.idx.unique, True) 82 | self.assertEqual(self.idx.where, 'a IS NULL') 83 | self.assertEqual(self.idx.where_postgresql, '') 84 | self.assertEqual(self.idx.where_sqlite, '') 85 | 86 | def test_repr(self): 87 | self.assertEqual(repr(self.idx), "") 88 | 89 | def test_deconstruct(self): 90 | path, args, kwargs = self.idx.deconstruct() 91 | self.assertEqual(path, 'partial_index.PartialIndex') 92 | self.assertEqual((), args) 93 | self.assertEqual(kwargs['fields'], ['a', 'b']) 94 | self.assertEqual(kwargs['unique'], True) 95 | self.assertEqual(kwargs['where'], 'a IS NULL') 96 | self.assertNotIn('where_postgresql', kwargs) 97 | self.assertNotIn('where_sqlite', kwargs) 98 | self.assertIn('name', kwargs) # Exact value of name is not tested. 99 | 100 | def test_suffix(self): 101 | self.assertEqual(self.idx.suffix, 'partial') 102 | 103 | def test_generated_name_ends_with_partial(self): 104 | idx = PartialIndex(fields=['a', 'b'], unique=False, where='a IS NULL') 105 | idx.set_name_with_model(AB) 106 | self.assertEqual(idx.name[-8:], '_partial') 107 | 108 | def test_field_sort_changes_generated_name(self): 109 | idx1 = PartialIndex(fields=['a', 'b'], unique=False, where='a IS NULL') 110 | idx1.set_name_with_model(AB) 111 | idx2 = PartialIndex(fields=['a', '-b'], unique=False, where='a IS NULL') 112 | idx2.set_name_with_model(AB) 113 | self.assertNotEqual(idx1.name, idx2.name) 114 | 115 | def test_field_order_changes_generated_name(self): 116 | idx1 = PartialIndex(fields=['a', 'b'], unique=False, where='a IS NULL') 117 | idx1.set_name_with_model(AB) 118 | idx2 = PartialIndex(fields=['b', 'a'], unique=False, where='a IS NULL') 119 | idx2.set_name_with_model(AB) 120 | self.assertNotEqual(idx1.name, idx2.name) 121 | 122 | def test_unique_changes_generated_name(self): 123 | idx1 = PartialIndex(fields=['a', 'b'], unique=False, where='a IS NULL') 124 | idx1.set_name_with_model(AB) 125 | idx2 = PartialIndex(fields=['a', 'b'], unique=True, where='a IS NULL') 126 | idx2.set_name_with_model(AB) 127 | self.assertNotEqual(idx1.name, idx2.name) 128 | 129 | def test_where_changes_generated_name(self): 130 | idx1 = PartialIndex(fields=['a', 'b'], unique=False, where='a IS NULL') 131 | idx1.set_name_with_model(AB) 132 | idx2 = PartialIndex(fields=['a', 'b'], unique=False, where='a IS NOT NULL') 133 | idx2.set_name_with_model(AB) 134 | self.assertNotEqual(idx1.name, idx2.name) 135 | 136 | 137 | class PartialIndexMultiTextWhereTest(SimpleTestCase): 138 | """Test simple fields and methods on the PartialIndex class with separate where_vendor='' arguments.""" 139 | 140 | def setUp(self): 141 | self.idx = PartialIndex(fields=['a', 'b'], unique=True, where_postgresql='a = false', where_sqlite='a = 0') 142 | 143 | def test_no_unique(self): 144 | with self.assertRaisesMessage(ValueError, 'Unique must be True or False'): 145 | PartialIndex(fields=['a', 'b'], where_postgresql='a = false', where_sqlite='a = 0') 146 | 147 | def test_fields(self): 148 | self.assertEqual(self.idx.unique, True) 149 | self.assertEqual(self.idx.where, '') 150 | self.assertEqual(self.idx.where_postgresql, 'a = false') 151 | self.assertEqual(self.idx.where_sqlite, 'a = 0') 152 | 153 | def test_repr(self): 154 | self.assertEqual(repr(self.idx), "") 155 | 156 | def test_deconstruct(self): 157 | path, args, kwargs = self.idx.deconstruct() 158 | self.assertEqual(path, 'partial_index.PartialIndex') 159 | self.assertEqual((), args) 160 | self.assertEqual(kwargs['fields'], ['a', 'b']) 161 | self.assertEqual(kwargs['unique'], True) 162 | self.assertNotIn('where', kwargs) 163 | self.assertEqual(kwargs['where_postgresql'], 'a = false') 164 | self.assertEqual(kwargs['where_sqlite'], 'a = 0') 165 | self.assertIn('name', kwargs) # Exact value of name is not tested. 166 | 167 | def test_suffix(self): 168 | self.assertEqual(self.idx.suffix, 'partial') 169 | 170 | def test_generated_name_ends_with_partial(self): 171 | idx = PartialIndex(fields=['a', 'b'], unique=False, where_postgresql='a = false', where_sqlite='a = 0') 172 | idx.set_name_with_model(AB) 173 | self.assertEqual(idx.name[-8:], '_partial') 174 | 175 | def test_where_postgresql_changes_generated_name(self): 176 | idx1 = PartialIndex(fields=['a', 'b'], unique=False, where_postgresql='a = false', where_sqlite='a = 0') 177 | idx1.set_name_with_model(AB) 178 | idx2 = PartialIndex(fields=['a', 'b'], unique=False, where_postgresql='a = true', where_sqlite='a = 0') 179 | idx2.set_name_with_model(AB) 180 | self.assertNotEqual(idx1.name, idx2.name) 181 | 182 | def test_where_sqlite_changes_generated_name(self): 183 | idx1 = PartialIndex(fields=['a', 'b'], unique=False, where_postgresql='a = false', where_sqlite='a = 0') 184 | idx1.set_name_with_model(AB) 185 | idx2 = PartialIndex(fields=['a', 'b'], unique=False, where_postgresql='a = false', where_sqlite='a = 1') 186 | idx2.set_name_with_model(AB) 187 | self.assertNotEqual(idx1.name, idx2.name) 188 | 189 | 190 | class PartialIndexSinglePQWhereTest(SimpleTestCase): 191 | """Test simple fields and methods on the PartialIndex class with a Q-based where predicate.""" 192 | 193 | def setUp(self): 194 | self.idx = PartialIndex(fields=['a', 'b'], unique=True, where=PQ(a__isnull=True)) 195 | 196 | def test_no_unique(self): 197 | with self.assertRaisesMessage(ValueError, 'Unique must be True or False'): 198 | PartialIndex(fields=['a', 'b'], where=PQ(a__isnull=True)) 199 | 200 | def test_fields(self): 201 | self.assertEqual(self.idx.unique, True) 202 | self.assertEqual(self.idx.where, PQ(a__isnull=True)) 203 | self.assertEqual(self.idx.where_postgresql, '') 204 | self.assertEqual(self.idx.where_sqlite, '') 205 | 206 | def test_repr(self): 207 | self.assertEqual(repr(self.idx), ">") 208 | 209 | def test_deconstruct_pq(self): 210 | path, args, kwargs = self.idx.deconstruct() 211 | self.assertEqual(path, 'partial_index.PartialIndex') 212 | self.assertEqual((), args) 213 | self.assertEqual(kwargs['fields'], ['a', 'b']) 214 | self.assertEqual(kwargs['unique'], True) 215 | self.assertEqual(kwargs['where'], PQ(a__isnull=True)) 216 | self.assertNotIn('where_postgresql', kwargs) 217 | self.assertNotIn('where_sqlite', kwargs) 218 | self.assertIn('name', kwargs) # Exact value of name is not tested. 219 | 220 | def test_suffix(self): 221 | self.assertEqual(self.idx.suffix, 'partial') 222 | 223 | def test_generated_name_ends_with_partial(self): 224 | idx = PartialIndex(fields=['a', 'b'], unique=False, where=PQ(a__isnull=True)) 225 | idx.set_name_with_model(AB) 226 | self.assertEqual(idx.name[-8:], '_partial') 227 | 228 | def test_same_args_same_name(self): 229 | idx1 = PartialIndex(fields=['a', 'b'], unique=False, where=PQ(a__isnull=True)) 230 | idx1.set_name_with_model(AB) 231 | idx2 = PartialIndex(fields=['a', 'b'], unique=False, where=PQ(a__isnull=True)) 232 | idx2.set_name_with_model(AB) 233 | self.assertEqual(idx1.name, idx2.name) 234 | 235 | def test_field_sort_changes_generated_name(self): 236 | idx1 = PartialIndex(fields=['a', 'b'], unique=False, where=PQ(a__isnull=True)) 237 | idx1.set_name_with_model(AB) 238 | idx2 = PartialIndex(fields=['a', '-b'], unique=False, where=PQ(a__isnull=True)) 239 | idx2.set_name_with_model(AB) 240 | self.assertNotEqual(idx1.name, idx2.name) 241 | 242 | def test_field_order_changes_generated_name(self): 243 | idx1 = PartialIndex(fields=['a', 'b'], unique=False, where=PQ(a__isnull=True)) 244 | idx1.set_name_with_model(AB) 245 | idx2 = PartialIndex(fields=['b', 'a'], unique=False, where=PQ(a__isnull=True)) 246 | idx2.set_name_with_model(AB) 247 | self.assertNotEqual(idx1.name, idx2.name) 248 | 249 | def test_unique_changes_generated_name(self): 250 | idx1 = PartialIndex(fields=['a', 'b'], unique=False, where=PQ(a__isnull=True)) 251 | idx1.set_name_with_model(AB) 252 | idx2 = PartialIndex(fields=['a', 'b'], unique=True, where=PQ(a__isnull=True)) 253 | idx2.set_name_with_model(AB) 254 | self.assertNotEqual(idx1.name, idx2.name) 255 | 256 | def test_where_changes_generated_name(self): 257 | idx1 = PartialIndex(fields=['a', 'b'], unique=False, where=PQ(a__isnull=True)) 258 | idx1.set_name_with_model(AB) 259 | idx2 = PartialIndex(fields=['a', 'b'], unique=False, where=PQ(a__isnull=False)) 260 | idx2.set_name_with_model(AB) 261 | self.assertNotEqual(idx1.name, idx2.name) 262 | --------------------------------------------------------------------------------