Fixed #30913 -- Added support for covering indexes on PostgreSQL 11+.

This commit is contained in:
Hannes Ljungberg 2019-10-31 13:33:53 +01:00 committed by Mariusz Felisiak
parent f997b5e6ae
commit 8c7992f658
20 changed files with 719 additions and 41 deletions

View File

@ -45,6 +45,7 @@ class PostGISSchemaEditor(DatabaseSchemaEditor):
columns=field_column,
extra='',
condition='',
include='',
)
def _alter_column_type_sql(self, table, old_field, new_field, new_type):

View File

@ -180,6 +180,10 @@ class GistIndex(PostgresIndex):
with_params.append('fillfactor = %d' % self.fillfactor)
return with_params
def check_supported(self, schema_editor):
if self.include and not schema_editor.connection.features.supports_covering_gist_indexes:
raise NotSupportedError('Covering GiST indexes requires PostgreSQL 12+.')
class HashIndex(PostgresIndex):
suffix = 'hash'

View File

@ -277,6 +277,8 @@ class BaseDatabaseFeatures:
# Does the backend support partial indexes (CREATE INDEX ... WHERE ...)?
supports_partial_indexes = True
supports_functions_in_partial_indexes = True
# Does the backend support covering indexes (CREATE INDEX ... INCLUDE ...)?
supports_covering_indexes = False
# Does the database allow more than one constraint or index on the same
# field(s)?

View File

@ -84,8 +84,8 @@ class BaseDatabaseSchemaEditor:
sql_create_column_inline_fk = None
sql_delete_fk = sql_delete_constraint
sql_create_index = "CREATE INDEX %(name)s ON %(table)s (%(columns)s)%(extra)s%(condition)s"
sql_create_unique_index = "CREATE UNIQUE INDEX %(name)s ON %(table)s (%(columns)s)%(condition)s"
sql_create_index = "CREATE INDEX %(name)s ON %(table)s (%(columns)s)%(include)s%(extra)s%(condition)s"
sql_create_unique_index = "CREATE UNIQUE INDEX %(name)s ON %(table)s (%(columns)s)%(include)s%(condition)s"
sql_delete_index = "DROP INDEX %(name)s"
sql_create_pk = "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s PRIMARY KEY (%(columns)s)"
@ -956,9 +956,17 @@ class BaseDatabaseSchemaEditor:
return ' WHERE ' + condition
return ''
def _index_include_sql(self, model, columns):
if not columns or not self.connection.features.supports_covering_indexes:
return ''
return Statement(
' INCLUDE (%(columns)s)',
columns=Columns(model._meta.db_table, columns, self.quote_name),
)
def _create_index_sql(self, model, fields, *, name=None, suffix='', using='',
db_tablespace=None, col_suffixes=(), sql=None, opclasses=(),
condition=None):
condition=None, include=None):
"""
Return the SQL statement to create the index for one or several fields.
`sql` can be specified if the syntax differs from the standard (GIS
@ -983,6 +991,7 @@ class BaseDatabaseSchemaEditor:
columns=self._index_columns(table, columns, col_suffixes, opclasses),
extra=tablespace_sql,
condition=self._index_condition_sql(condition),
include=self._index_include_sql(model, include),
)
def _delete_index_sql(self, model, name, sql=None):
@ -1083,16 +1092,22 @@ class BaseDatabaseSchemaEditor:
if deferrable == Deferrable.IMMEDIATE:
return ' DEFERRABLE INITIALLY IMMEDIATE'
def _unique_sql(self, model, fields, name, condition=None, deferrable=None):
def _unique_sql(self, model, fields, name, condition=None, deferrable=None, include=None):
if (
deferrable and
not self.connection.features.supports_deferrable_unique_constraints
):
return None
if condition:
# Databases support conditional unique constraints via a unique
# index.
sql = self._create_unique_sql(model, fields, name=name, condition=condition)
if condition or include:
# Databases support conditional and covering unique constraints via
# a unique index.
sql = self._create_unique_sql(
model,
fields,
name=name,
condition=condition,
include=include,
)
if sql:
self.deferred_sql.append(sql)
return None
@ -1105,10 +1120,14 @@ class BaseDatabaseSchemaEditor:
'constraint': constraint,
}
def _create_unique_sql(self, model, columns, name=None, condition=None, deferrable=None):
def _create_unique_sql(self, model, columns, name=None, condition=None, deferrable=None, include=None):
if (
(
deferrable and
not self.connection.features.supports_deferrable_unique_constraints
) or
(condition and not self.connection.features.supports_partial_indexes) or
(include and not self.connection.features.supports_covering_indexes)
):
return None
@ -1121,9 +1140,7 @@ class BaseDatabaseSchemaEditor:
else:
name = self.quote_name(name)
columns = Columns(table, columns, self.quote_name)
if condition:
if not self.connection.features.supports_partial_indexes:
return None
if condition or include:
sql = self.sql_create_unique_index
else:
sql = self.sql_create_unique
@ -1134,20 +1151,24 @@ class BaseDatabaseSchemaEditor:
columns=columns,
condition=self._index_condition_sql(condition),
deferrable=self._deferrable_constraint_sql(deferrable),
include=self._index_include_sql(model, include),
)
def _delete_unique_sql(self, model, name, condition=None, deferrable=None):
def _delete_unique_sql(self, model, name, condition=None, deferrable=None, include=None):
if (
(
deferrable and
not self.connection.features.supports_deferrable_unique_constraints
) or
(condition and not self.connection.features.supports_partial_indexes) or
(include and not self.connection.features.supports_covering_indexes)
):
return None
if condition:
return (
self._delete_constraint_sql(self.sql_delete_index, model, name)
if self.connection.features.supports_partial_indexes else None
)
return self._delete_constraint_sql(self.sql_delete_unique, model, name)
if condition or include:
sql = self.sql_delete_index
else:
sql = self.sql_delete_unique
return self._delete_constraint_sql(sql, model, name)
def _check_sql(self, name, check):
return self.sql_constraint % {

View File

@ -82,3 +82,5 @@ class DatabaseFeatures(BaseDatabaseFeatures):
has_brin_autosummarize = property(operator.attrgetter('is_postgresql_10'))
has_websearch_to_tsquery = property(operator.attrgetter('is_postgresql_11'))
supports_table_partitions = property(operator.attrgetter('is_postgresql_10'))
supports_covering_indexes = property(operator.attrgetter('is_postgresql_11'))
supports_covering_gist_indexes = property(operator.attrgetter('is_postgresql_12'))

View File

@ -12,9 +12,13 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
sql_set_sequence_max = "SELECT setval('%(sequence)s', MAX(%(column)s)) FROM %(table)s"
sql_set_sequence_owner = 'ALTER SEQUENCE %(sequence)s OWNED BY %(table)s.%(column)s'
sql_create_index = "CREATE INDEX %(name)s ON %(table)s%(using)s (%(columns)s)%(extra)s%(condition)s"
sql_create_index = (
'CREATE INDEX %(name)s ON %(table)s%(using)s '
'(%(columns)s)%(include)s%(extra)s%(condition)s'
)
sql_create_index_concurrently = (
"CREATE INDEX CONCURRENTLY %(name)s ON %(table)s%(using)s (%(columns)s)%(extra)s%(condition)s"
'CREATE INDEX CONCURRENTLY %(name)s ON %(table)s%(using)s '
'(%(columns)s)%(include)s%(extra)s%(condition)s'
)
sql_delete_index = "DROP INDEX IF EXISTS %(name)s"
sql_delete_index_concurrently = "DROP INDEX CONCURRENTLY IF EXISTS %(name)s"
@ -197,10 +201,11 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
def _create_index_sql(
self, model, fields, *, name=None, suffix='', using='',
db_tablespace=None, col_suffixes=(), sql=None, opclasses=(),
condition=None, concurrently=False,
condition=None, concurrently=False, include=None,
):
sql = self.sql_create_index if not concurrently else self.sql_create_index_concurrently
return super()._create_index_sql(
model, fields, name=name, suffix=suffix, using=using, db_tablespace=db_tablespace,
col_suffixes=col_suffixes, sql=sql, opclasses=opclasses, condition=condition,
include=include,
)

View File

@ -1633,6 +1633,7 @@ class Model(metaclass=ModelBase):
)
)
fields = [field for index in cls._meta.indexes for field, _ in index.fields_orders]
fields += [include for index in cls._meta.indexes for include in index.include]
errors.extend(cls._check_local_fields(fields, 'indexes'))
return errors
@ -1926,10 +1927,9 @@ class Model(metaclass=ModelBase):
id='models.W038',
)
)
fields = (
field
fields = chain.from_iterable(
(*constraint.fields, *constraint.include)
for constraint in cls._meta.constraints if isinstance(constraint, UniqueConstraint)
for field in constraint.fields
)
errors.extend(cls._check_local_fields(fields, 'constraints'))
return errors

View File

@ -77,7 +77,7 @@ class Deferrable(Enum):
class UniqueConstraint(BaseConstraint):
def __init__(self, *, fields, name, condition=None, deferrable=None):
def __init__(self, *, fields, name, condition=None, deferrable=None, include=None):
if not fields:
raise ValueError('At least one field is required to define a unique constraint.')
if not isinstance(condition, (type(None), Q)):
@ -90,9 +90,12 @@ class UniqueConstraint(BaseConstraint):
raise ValueError(
'UniqueConstraint.deferrable must be a Deferrable instance.'
)
if not isinstance(include, (type(None), list, tuple)):
raise ValueError('UniqueConstraint.include must be a list or tuple.')
self.fields = tuple(fields)
self.condition = condition
self.deferrable = deferrable
self.include = tuple(include) if include else ()
super().__init__(name)
def _get_condition_sql(self, model, schema_editor):
@ -106,31 +109,36 @@ class UniqueConstraint(BaseConstraint):
def constraint_sql(self, model, schema_editor):
fields = [model._meta.get_field(field_name).column for field_name in self.fields]
include = [model._meta.get_field(field_name).column for field_name in self.include]
condition = self._get_condition_sql(model, schema_editor)
return schema_editor._unique_sql(
model, fields, self.name, condition=condition,
deferrable=self.deferrable,
deferrable=self.deferrable, include=include,
)
def create_sql(self, model, schema_editor):
fields = [model._meta.get_field(field_name).column for field_name in self.fields]
include = [model._meta.get_field(field_name).column for field_name in self.include]
condition = self._get_condition_sql(model, schema_editor)
return schema_editor._create_unique_sql(
model, fields, self.name, condition=condition,
deferrable=self.deferrable,
deferrable=self.deferrable, include=include,
)
def remove_sql(self, model, schema_editor):
condition = self._get_condition_sql(model, schema_editor)
include = [model._meta.get_field(field_name).column for field_name in self.include]
return schema_editor._delete_unique_sql(
model, self.name, condition=condition, deferrable=self.deferrable,
include=include,
)
def __repr__(self):
return '<%s: fields=%r name=%r%s%s>' % (
return '<%s: fields=%r name=%r%s%s%s>' % (
self.__class__.__name__, self.fields, self.name,
'' if self.condition is None else ' condition=%s' % self.condition,
'' if self.deferrable is None else ' deferrable=%s' % self.deferrable,
'' if not self.include else ' include=%s' % repr(self.include),
)
def __eq__(self, other):
@ -139,7 +147,8 @@ class UniqueConstraint(BaseConstraint):
self.name == other.name and
self.fields == other.fields and
self.condition == other.condition and
self.deferrable == other.deferrable
self.deferrable == other.deferrable and
self.include == other.include
)
return super().__eq__(other)
@ -150,4 +159,6 @@ class UniqueConstraint(BaseConstraint):
kwargs['condition'] = self.condition
if self.deferrable:
kwargs['deferrable'] = self.deferrable
if self.include:
kwargs['include'] = self.include
return path, args, kwargs

View File

@ -11,7 +11,16 @@ class Index:
# cross-database compatibility with Oracle)
max_name_length = 30
def __init__(self, *, fields=(), name=None, db_tablespace=None, opclasses=(), condition=None):
def __init__(
self,
*,
fields=(),
name=None,
db_tablespace=None,
opclasses=(),
condition=None,
include=None,
):
if opclasses and not name:
raise ValueError('An index must be named to use opclasses.')
if not isinstance(condition, (type(None), Q)):
@ -26,6 +35,10 @@ class Index:
raise ValueError('Index.fields and Index.opclasses must have the same number of elements.')
if not fields:
raise ValueError('At least one field is required to define an index.')
if include and not name:
raise ValueError('A covering index must be named.')
if not isinstance(include, (type(None), list, tuple)):
raise ValueError('Index.include must be a list or tuple.')
self.fields = list(fields)
# A list of 2-tuple with the field name and ordering ('' or 'DESC').
self.fields_orders = [
@ -36,6 +49,7 @@ class Index:
self.db_tablespace = db_tablespace
self.opclasses = opclasses
self.condition = condition
self.include = tuple(include) if include else ()
def _get_condition_sql(self, model, schema_editor):
if self.condition is None:
@ -48,12 +62,13 @@ class Index:
def create_sql(self, model, schema_editor, using='', **kwargs):
fields = [model._meta.get_field(field_name) for field_name, _ in self.fields_orders]
include = [model._meta.get_field(field_name).column for field_name in self.include]
col_suffixes = [order[1] for order in self.fields_orders]
condition = self._get_condition_sql(model, schema_editor)
return schema_editor._create_index_sql(
model, fields, name=self.name, using=using, db_tablespace=self.db_tablespace,
col_suffixes=col_suffixes, opclasses=self.opclasses, condition=condition,
**kwargs,
include=include, **kwargs,
)
def remove_sql(self, model, schema_editor, **kwargs):
@ -69,6 +84,8 @@ class Index:
kwargs['opclasses'] = self.opclasses
if self.condition:
kwargs['condition'] = self.condition
if self.include:
kwargs['include'] = self.include
return (path, (), kwargs)
def clone(self):
@ -106,9 +123,10 @@ class Index:
self.name = 'D%s' % self.name[1:]
def __repr__(self):
return "<%s: fields='%s'%s>" % (
return "<%s: fields='%s'%s%s>" % (
self.__class__.__name__, ', '.join(self.fields),
'' if self.condition is None else ', condition=%s' % self.condition,
'' if not self.include else ", include='%s'" % ', '.join(self.include),
)
def __eq__(self, other):

View File

@ -73,7 +73,7 @@ constraint.
``UniqueConstraint``
====================
.. class:: UniqueConstraint(*, fields, name, condition=None, deferrable=None)
.. class:: UniqueConstraint(*, fields, name, condition=None, deferrable=None, include=None)
Creates a unique constraint in the database.
@ -145,3 +145,26 @@ enforced immediately after every command.
Deferred unique constraints may lead to a `performance penalty
<https://www.postgresql.org/docs/current/sql-createtable.html#id-1.9.3.85.9.4>`_.
``include``
-----------
.. attribute:: UniqueConstraint.include
.. versionadded:: 3.2
A list or tuple of the names of the fields to be included in the covering
unique index as non-key columns. This allows index-only scans to be used for
queries that select only included fields (:attr:`~UniqueConstraint.include`)
and filter only by unique fields (:attr:`~UniqueConstraint.fields`).
For example::
UniqueConstraint(name='unique_booking', fields=['room', 'date'], include=['full_name'])
will allow filtering on ``room`` and ``date``, also selecting ``full_name``,
while fetching data only from the index.
``include`` is supported only on PostgreSQL.
Non-key columns have the same database restrictions as :attr:`Index.include`.

View File

@ -21,7 +21,7 @@ options`_.
``Index`` options
=================
.. class:: Index(fields=(), name=None, db_tablespace=None, opclasses=(), condition=None)
.. class:: Index(fields=(), name=None, db_tablespace=None, opclasses=(), condition=None, include=None)
Creates an index (B-Tree) in the database.
@ -137,3 +137,40 @@ indexes records with more than 400 pages.
The ``condition`` argument is ignored with MySQL and MariaDB as neither
supports conditional indexes.
``include``
-----------
.. attribute:: Index.include
.. versionadded:: 3.2
A list or tuple of the names of the fields to be included in the covering index
as non-key columns. This allows index-only scans to be used for queries that
select only included fields (:attr:`~Index.include`) and filter only by indexed
fields (:attr:`~Index.fields`).
For example::
Index(name='covering_index', fields=['headline'], include=['pub_date'])
will allow filtering on ``headline``, also selecting ``pub_date``, while
fetching data only from the index.
Using ``include`` will produce a smaller index than using a multiple column
index but with the drawback that non-key columns can not be used for sorting or
filtering.
``include`` is ignored for databases besides PostgreSQL.
:attr:`Index.name` is required when using ``include``.
See the PostgreSQL documentation for more details about `covering indexes`_.
.. admonition:: Restrictions on PostgreSQL
PostgreSQL 11+ only supports covering B-Tree indexes, and PostgreSQL 12+
also supports covering :class:`GiST indexes
<django.contrib.postgres.indexes.GistIndex>`.
.. _covering indexes: https://www.postgresql.org/docs/current/indexes-index-only-scans.html

View File

@ -185,6 +185,10 @@ Models
* :class:`When() <django.db.models.expressions.When>` expression now allows
using the ``condition`` argument with ``lookups``.
* The new :attr:`.Index.include` and :attr:`.UniqueConstraint.include`
attributes allow creating covering indexes and covering unique constraints on
PostgreSQL 11+.
Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~
@ -263,6 +267,10 @@ backends.
* ``introspected_small_auto_field_type``
* ``introspected_boolean_field_type``
* To enable support for covering indexes (:attr:`.Index.include`) and covering
unique constraints (:attr:`.UniqueConstraint.include`), set
``DatabaseFeatures.supports_covering_indexes`` to ``True``.
:mod:`django.contrib.gis`
-------------------------

View File

@ -81,6 +81,23 @@ class UniqueConstraintDeferrable(models.Model):
]
class UniqueConstraintInclude(models.Model):
name = models.CharField(max_length=255)
color = models.CharField(max_length=32, null=True)
class Meta:
required_db_features = {
'supports_table_check_constraints',
}
constraints = [
models.UniqueConstraint(
fields=['name'],
name='name_include_color_uniq',
include=['color'],
),
]
class AbstractModel(models.Model):
age = models.IntegerField()

View File

@ -8,7 +8,8 @@ from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature
from .models import (
ChildModel, Product, UniqueConstraintConditionProduct,
UniqueConstraintDeferrable, UniqueConstraintProduct,
UniqueConstraintDeferrable, UniqueConstraintInclude,
UniqueConstraintProduct,
)
@ -181,6 +182,20 @@ class UniqueConstraintTests(TestCase):
self.assertEqual(constraint_1, constraint_1)
self.assertNotEqual(constraint_1, constraint_2)
def test_eq_with_include(self):
constraint_1 = models.UniqueConstraint(
fields=['foo', 'bar'],
name='include',
include=['baz_1'],
)
constraint_2 = models.UniqueConstraint(
fields=['foo', 'bar'],
name='include',
include=['baz_2'],
)
self.assertEqual(constraint_1, constraint_1)
self.assertNotEqual(constraint_1, constraint_2)
def test_repr(self):
fields = ['foo', 'bar']
name = 'unique_fields'
@ -214,6 +229,18 @@ class UniqueConstraintTests(TestCase):
"deferrable=Deferrable.IMMEDIATE>",
)
def test_repr_with_include(self):
constraint = models.UniqueConstraint(
fields=['foo', 'bar'],
name='include_fields',
include=['baz_1', 'baz_2'],
)
self.assertEqual(
repr(constraint),
"<UniqueConstraint: fields=('foo', 'bar') name='include_fields' "
"include=('baz_1', 'baz_2')>",
)
def test_deconstruction(self):
fields = ['foo', 'bar']
name = 'unique_fields'
@ -250,6 +277,20 @@ class UniqueConstraintTests(TestCase):
'deferrable': models.Deferrable.DEFERRED,
})
def test_deconstruction_with_include(self):
fields = ['foo', 'bar']
name = 'unique_fields'
include = ['baz_1', 'baz_2']
constraint = models.UniqueConstraint(fields=fields, name=name, include=include)
path, args, kwargs = constraint.deconstruct()
self.assertEqual(path, 'django.db.models.UniqueConstraint')
self.assertEqual(args, ())
self.assertEqual(kwargs, {
'fields': tuple(fields),
'name': name,
'include': tuple(include),
})
def test_database_constraint(self):
with self.assertRaises(IntegrityError):
UniqueConstraintProduct.objects.create(name=self.p1.name, color=self.p1.color)
@ -333,3 +374,21 @@ class UniqueConstraintTests(TestCase):
name='name_invalid',
deferrable='invalid',
)
@skipUnlessDBFeature(
'supports_table_check_constraints',
'supports_covering_indexes',
)
def test_include_database_constraint(self):
UniqueConstraintInclude.objects.create(name='p1', color='red')
with self.assertRaises(IntegrityError):
UniqueConstraintInclude.objects.create(name='p1', color='blue')
def test_invalid_include_argument(self):
msg = 'UniqueConstraint.include must be a list or tuple.'
with self.assertRaisesMessage(ValueError, msg):
models.UniqueConstraint(
name='uniq_include',
fields=['field'],
include='other',
)

View File

@ -236,6 +236,41 @@ class SchemaIndexesPostgreSQLTests(TransactionTestCase):
cursor.execute(self.get_opclass_query % indexname)
self.assertCountEqual(cursor.fetchall(), [('text_pattern_ops', indexname)])
@skipUnlessDBFeature('supports_covering_indexes')
def test_ops_class_include(self):
index_name = 'test_ops_class_include'
index = Index(
name=index_name,
fields=['body'],
opclasses=['text_pattern_ops'],
include=['headline'],
)
with connection.schema_editor() as editor:
editor.add_index(IndexedArticle2, index)
with editor.connection.cursor() as cursor:
cursor.execute(self.get_opclass_query % index_name)
self.assertCountEqual(cursor.fetchall(), [('text_pattern_ops', index_name)])
@skipUnlessDBFeature('supports_covering_indexes')
def test_ops_class_include_tablespace(self):
index_name = 'test_ops_class_include_tblspace'
index = Index(
name=index_name,
fields=['body'],
opclasses=['text_pattern_ops'],
include=['headline'],
db_tablespace='pg_default',
)
with connection.schema_editor() as editor:
editor.add_index(IndexedArticle2, index)
self.assertIn(
'TABLESPACE "pg_default"',
str(index.create_sql(IndexedArticle2, editor)),
)
with editor.connection.cursor() as cursor:
cursor.execute(self.get_opclass_query % index_name)
self.assertCountEqual(cursor.fetchall(), [('text_pattern_ops', index_name)])
def test_ops_class_columns_lists_sql(self):
index = Index(
fields=['headline'],
@ -417,3 +452,89 @@ class PartialIndexTests(TransactionTestCase):
cursor=cursor, table_name=Article._meta.db_table,
))
editor.remove_index(index=index, model=Article)
@skipUnlessDBFeature('supports_covering_indexes')
class CoveringIndexTests(TransactionTestCase):
available_apps = ['indexes']
def test_covering_index(self):
index = Index(
name='covering_headline_idx',
fields=['headline'],
include=['pub_date', 'published'],
)
with connection.schema_editor() as editor:
self.assertIn(
'(%s) INCLUDE (%s, %s)' % (
editor.quote_name('headline'),
editor.quote_name('pub_date'),
editor.quote_name('published'),
),
str(index.create_sql(Article, editor)),
)
editor.add_index(Article, index)
with connection.cursor() as cursor:
constraints = connection.introspection.get_constraints(
cursor=cursor, table_name=Article._meta.db_table,
)
self.assertIn(index.name, constraints)
self.assertEqual(
constraints[index.name]['columns'],
['headline', 'pub_date', 'published'],
)
editor.remove_index(Article, index)
with connection.cursor() as cursor:
self.assertNotIn(index.name, connection.introspection.get_constraints(
cursor=cursor, table_name=Article._meta.db_table,
))
def test_covering_partial_index(self):
index = Index(
name='covering_partial_headline_idx',
fields=['headline'],
include=['pub_date'],
condition=Q(pub_date__isnull=False),
)
with connection.schema_editor() as editor:
self.assertIn(
'(%s) INCLUDE (%s) WHERE %s ' % (
editor.quote_name('headline'),
editor.quote_name('pub_date'),
editor.quote_name('pub_date'),
),
str(index.create_sql(Article, editor)),
)
editor.add_index(Article, index)
with connection.cursor() as cursor:
constraints = connection.introspection.get_constraints(
cursor=cursor, table_name=Article._meta.db_table,
)
self.assertIn(index.name, constraints)
self.assertEqual(
constraints[index.name]['columns'],
['headline', 'pub_date'],
)
editor.remove_index(Article, index)
with connection.cursor() as cursor:
self.assertNotIn(index.name, connection.introspection.get_constraints(
cursor=cursor, table_name=Article._meta.db_table,
))
@skipIfDBFeature('supports_covering_indexes')
class CoveringIndexIgnoredTests(TransactionTestCase):
available_apps = ['indexes']
def test_covering_ignored(self):
index = Index(
name='test_covering_ignored',
fields=['headline'],
include=['pub_date'],
)
with connection.schema_editor() as editor:
editor.add_index(Article, index)
self.assertNotIn(
'INCLUDE (%s)' % editor.quote_name('headline'),
str(index.create_sql(Article, editor)),
)

View File

@ -375,6 +375,78 @@ class IndexesTests(TestCase):
self.assertEqual(Model.check(databases=self.databases), [])
def test_index_include_pointing_to_missing_field(self):
class Model(models.Model):
class Meta:
indexes = [
models.Index(fields=['id'], include=['missing_field'], name='name'),
]
self.assertEqual(Model.check(databases=self.databases), [
Error(
"'indexes' refers to the nonexistent field 'missing_field'.",
obj=Model,
id='models.E012',
),
])
def test_index_include_pointing_to_m2m_field(self):
class Model(models.Model):
m2m = models.ManyToManyField('self')
class Meta:
indexes = [models.Index(fields=['id'], include=['m2m'], name='name')]
self.assertEqual(Model.check(databases=self.databases), [
Error(
"'indexes' refers to a ManyToManyField 'm2m', but "
"ManyToManyFields are not permitted in 'indexes'.",
obj=Model,
id='models.E013',
),
])
def test_index_include_pointing_to_non_local_field(self):
class Parent(models.Model):
field1 = models.IntegerField()
class Child(Parent):
field2 = models.IntegerField()
class Meta:
indexes = [
models.Index(fields=['field2'], include=['field1'], name='name'),
]
self.assertEqual(Child.check(databases=self.databases), [
Error(
"'indexes' refers to field 'field1' which is not local to "
"model 'Child'.",
hint='This issue may be caused by multi-table inheritance.',
obj=Child,
id='models.E016',
),
])
def test_index_include_pointing_to_fk(self):
class Target(models.Model):
pass
class Model(models.Model):
fk_1 = models.ForeignKey(Target, models.CASCADE, related_name='target_1')
fk_2 = models.ForeignKey(Target, models.CASCADE, related_name='target_2')
class Meta:
constraints = [
models.Index(
fields=['id'],
include=['fk_1_id', 'fk_2'],
name='name',
),
]
self.assertEqual(Model.check(databases=self.databases), [])
@isolate_apps('invalid_models_tests')
class FieldNamesTests(TestCase):
@ -1568,3 +1640,90 @@ class ConstraintsTests(TestCase):
]
self.assertEqual(Model.check(databases=self.databases), [])
def test_unique_constraint_include_pointing_to_missing_field(self):
class Model(models.Model):
class Meta:
constraints = [
models.UniqueConstraint(
fields=['id'],
include=['missing_field'],
name='name',
),
]
self.assertEqual(Model.check(databases=self.databases), [
Error(
"'constraints' refers to the nonexistent field "
"'missing_field'.",
obj=Model,
id='models.E012',
),
])
def test_unique_constraint_include_pointing_to_m2m_field(self):
class Model(models.Model):
m2m = models.ManyToManyField('self')
class Meta:
constraints = [
models.UniqueConstraint(
fields=['id'],
include=['m2m'],
name='name',
),
]
self.assertEqual(Model.check(databases=self.databases), [
Error(
"'constraints' refers to a ManyToManyField 'm2m', but "
"ManyToManyFields are not permitted in 'constraints'.",
obj=Model,
id='models.E013',
),
])
def test_unique_constraint_include_pointing_to_non_local_field(self):
class Parent(models.Model):
field1 = models.IntegerField()
class Child(Parent):
field2 = models.IntegerField()
class Meta:
constraints = [
models.UniqueConstraint(
fields=['field2'],
include=['field1'],
name='name',
),
]
self.assertEqual(Child.check(databases=self.databases), [
Error(
"'constraints' refers to field 'field1' which is not local to "
"model 'Child'.",
hint='This issue may be caused by multi-table inheritance.',
obj=Child,
id='models.E016',
),
])
def test_unique_constraint_include_pointing_to_fk(self):
class Target(models.Model):
pass
class Model(models.Model):
fk_1 = models.ForeignKey(Target, models.CASCADE, related_name='target_1')
fk_2 = models.ForeignKey(Target, models.CASCADE, related_name='target_2')
class Meta:
constraints = [
models.UniqueConstraint(
fields=['id'],
include=['fk_1_id', 'fk_2'],
name='name',
),
]
self.assertEqual(Model.check(databases=self.databases), [])

View File

@ -448,6 +448,48 @@ class OperationTests(OperationTestBase):
[deferred_unique_constraint],
)
@skipUnlessDBFeature('supports_covering_indexes')
def test_create_model_with_covering_unique_constraint(self):
covering_unique_constraint = models.UniqueConstraint(
fields=['pink'],
include=['weight'],
name='test_constraint_pony_pink_covering_weight',
)
operation = migrations.CreateModel(
'Pony',
[
('id', models.AutoField(primary_key=True)),
('pink', models.IntegerField(default=3)),
('weight', models.FloatField()),
],
options={'constraints': [covering_unique_constraint]},
)
project_state = ProjectState()
new_state = project_state.clone()
operation.state_forwards('test_crmo', new_state)
self.assertEqual(len(new_state.models['test_crmo', 'pony'].options['constraints']), 1)
self.assertTableNotExists('test_crmo_pony')
# Create table.
with connection.schema_editor() as editor:
operation.database_forwards('test_crmo', editor, project_state, new_state)
self.assertTableExists('test_crmo_pony')
Pony = new_state.apps.get_model('test_crmo', 'Pony')
Pony.objects.create(pink=1, weight=4.0)
with self.assertRaises(IntegrityError):
Pony.objects.create(pink=1, weight=7.0)
# Reversal.
with connection.schema_editor() as editor:
operation.database_backwards('test_crmo', editor, new_state, project_state)
self.assertTableNotExists('test_crmo_pony')
# Deconstruction.
definition = operation.deconstruct()
self.assertEqual(definition[0], 'CreateModel')
self.assertEqual(definition[1], [])
self.assertEqual(
definition[2]['options']['constraints'],
[covering_unique_constraint],
)
def test_create_model_managers(self):
"""
The managers on a model are set.
@ -2236,6 +2278,88 @@ class OperationTests(OperationTestBase):
'name': 'deferred_pink_constraint_rm',
})
def test_add_covering_unique_constraint(self):
app_label = 'test_addcovering_uc'
project_state = self.set_up_test_model(app_label)
covering_unique_constraint = models.UniqueConstraint(
fields=['pink'],
name='covering_pink_constraint_add',
include=['weight'],
)
operation = migrations.AddConstraint('Pony', covering_unique_constraint)
self.assertEqual(
operation.describe(),
'Create constraint covering_pink_constraint_add on model Pony',
)
# Add constraint.
new_state = project_state.clone()
operation.state_forwards(app_label, new_state)
self.assertEqual(len(new_state.models[app_label, 'pony'].options['constraints']), 1)
Pony = new_state.apps.get_model(app_label, 'Pony')
self.assertEqual(len(Pony._meta.constraints), 1)
with connection.schema_editor() as editor:
operation.database_forwards(app_label, editor, project_state, new_state)
Pony.objects.create(pink=1, weight=4.0)
if connection.features.supports_covering_indexes:
with self.assertRaises(IntegrityError):
Pony.objects.create(pink=1, weight=4.0)
else:
Pony.objects.create(pink=1, weight=4.0)
# Reversal.
with connection.schema_editor() as editor:
operation.database_backwards(app_label, editor, new_state, project_state)
# Constraint doesn't work.
Pony.objects.create(pink=1, weight=4.0)
# Deconstruction.
definition = operation.deconstruct()
self.assertEqual(definition[0], 'AddConstraint')
self.assertEqual(definition[1], [])
self.assertEqual(
definition[2],
{'model_name': 'Pony', 'constraint': covering_unique_constraint},
)
def test_remove_covering_unique_constraint(self):
app_label = 'test_removecovering_uc'
covering_unique_constraint = models.UniqueConstraint(
fields=['pink'],
name='covering_pink_constraint_rm',
include=['weight'],
)
project_state = self.set_up_test_model(app_label, constraints=[covering_unique_constraint])
operation = migrations.RemoveConstraint('Pony', covering_unique_constraint.name)
self.assertEqual(
operation.describe(),
'Remove constraint covering_pink_constraint_rm from model Pony',
)
# Remove constraint.
new_state = project_state.clone()
operation.state_forwards(app_label, new_state)
self.assertEqual(len(new_state.models[app_label, 'pony'].options['constraints']), 0)
Pony = new_state.apps.get_model(app_label, 'Pony')
self.assertEqual(len(Pony._meta.constraints), 0)
with connection.schema_editor() as editor:
operation.database_forwards(app_label, editor, project_state, new_state)
# Constraint doesn't work.
Pony.objects.create(pink=1, weight=4.0)
Pony.objects.create(pink=1, weight=4.0).delete()
# Reversal.
with connection.schema_editor() as editor:
operation.database_backwards(app_label, editor, new_state, project_state)
if connection.features.supports_covering_indexes:
with self.assertRaises(IntegrityError):
Pony.objects.create(pink=1, weight=4.0)
else:
Pony.objects.create(pink=1, weight=4.0)
# Deconstruction.
definition = operation.deconstruct()
self.assertEqual(definition[0], 'RemoveConstraint')
self.assertEqual(definition[1], [])
self.assertEqual(definition[2], {
'model_name': 'Pony',
'name': 'covering_pink_constraint_rm',
})
def test_alter_model_options(self):
"""
Tests the AlterModelOptions operation.

View File

@ -17,9 +17,18 @@ class SimpleIndexesTests(SimpleTestCase):
index = models.Index(fields=['title'])
multi_col_index = models.Index(fields=['title', 'author'])
partial_index = models.Index(fields=['title'], name='long_books_idx', condition=models.Q(pages__gt=400))
covering_index = models.Index(
fields=['title'],
name='include_idx',
include=['author', 'pages'],
)
self.assertEqual(repr(index), "<Index: fields='title'>")
self.assertEqual(repr(multi_col_index), "<Index: fields='title, author'>")
self.assertEqual(repr(partial_index), "<Index: fields='title', condition=(AND: ('pages__gt', 400))>")
self.assertEqual(
repr(covering_index),
"<Index: fields='title', include='author, pages'>",
)
def test_eq(self):
index = models.Index(fields=['title'])
@ -65,6 +74,16 @@ class SimpleIndexesTests(SimpleTestCase):
with self.assertRaisesMessage(ValueError, 'Index.condition must be a Q instance.'):
models.Index(condition='invalid', name='long_book_idx')
def test_include_requires_list_or_tuple(self):
msg = 'Index.include must be a list or tuple.'
with self.assertRaisesMessage(ValueError, msg):
models.Index(name='test_include', fields=['field'], include='other')
def test_include_requires_index_name(self):
msg = 'A covering index must be named.'
with self.assertRaisesMessage(ValueError, msg):
models.Index(fields=['field'], include=['other'])
def test_name_auto_generation(self):
index = models.Index(fields=['author'])
index.set_name_with_model(Book)
@ -128,6 +147,25 @@ class SimpleIndexesTests(SimpleTestCase):
}
)
def test_deconstruct_with_include(self):
index = models.Index(
name='book_include_idx',
fields=['title'],
include=['author'],
)
index.set_name_with_model(Book)
path, args, kwargs = index.deconstruct()
self.assertEqual(path, 'django.db.models.Index')
self.assertEqual(args, ())
self.assertEqual(
kwargs,
{
'fields': ['title'],
'name': 'model_index_title_196f42_idx',
'include': ('author',),
},
)
def test_clone(self):
index = models.Index(fields=['title'])
new_index = index.clone()

View File

@ -11,7 +11,7 @@ from django.test import skipUnlessDBFeature
from django.test.utils import register_lookup
from . import PostgreSQLSimpleTestCase, PostgreSQLTestCase
from .models import CharFieldModel, IntegerArrayModel
from .models import CharFieldModel, IntegerArrayModel, Scene
class IndexTestMixin:
@ -373,6 +373,33 @@ class SchemaTests(PostgreSQLTestCase):
editor.remove_index(CharFieldModel, index)
self.assertNotIn(index_name, self.get_constraints(CharFieldModel._meta.db_table))
@skipUnlessDBFeature('supports_covering_gist_indexes')
def test_gist_include(self):
index_name = 'scene_gist_include_setting'
index = GistIndex(name=index_name, fields=['scene'], include=['setting'])
with connection.schema_editor() as editor:
editor.add_index(Scene, index)
constraints = self.get_constraints(Scene._meta.db_table)
self.assertIn(index_name, constraints)
self.assertEqual(constraints[index_name]['type'], GistIndex.suffix)
self.assertEqual(constraints[index_name]['columns'], ['scene', 'setting'])
with connection.schema_editor() as editor:
editor.remove_index(Scene, index)
self.assertNotIn(index_name, self.get_constraints(Scene._meta.db_table))
def test_gist_include_not_supported(self):
index_name = 'gist_include_exception'
index = GistIndex(fields=['scene'], name=index_name, include=['setting'])
msg = 'Covering GiST indexes requires PostgreSQL 12+.'
with self.assertRaisesMessage(NotSupportedError, msg):
with mock.patch(
'django.db.backends.postgresql.features.DatabaseFeatures.supports_covering_gist_indexes',
False,
):
with connection.schema_editor() as editor:
editor.add_index(Scene, index)
self.assertNotIn(index_name, self.get_constraints(Scene._meta.db_table))
def test_hash_index(self):
# Ensure the table is there and doesn't have an index.
self.assertNotIn('field', self.get_constraints(CharFieldModel._meta.db_table))

View File

@ -2587,6 +2587,7 @@ class SchemaTests(TransactionTestCase):
"columns": editor.quote_name(column),
"extra": "",
"condition": "",
"include": "",
}
)
self.assertIn(expected_constraint_name, self.get_constraints(model._meta.db_table))