Fixed #30913 -- Added support for covering indexes on PostgreSQL 11+.
This commit is contained in:
parent
f997b5e6ae
commit
8c7992f658
|
@ -45,6 +45,7 @@ class PostGISSchemaEditor(DatabaseSchemaEditor):
|
||||||
columns=field_column,
|
columns=field_column,
|
||||||
extra='',
|
extra='',
|
||||||
condition='',
|
condition='',
|
||||||
|
include='',
|
||||||
)
|
)
|
||||||
|
|
||||||
def _alter_column_type_sql(self, table, old_field, new_field, new_type):
|
def _alter_column_type_sql(self, table, old_field, new_field, new_type):
|
||||||
|
|
|
@ -180,6 +180,10 @@ class GistIndex(PostgresIndex):
|
||||||
with_params.append('fillfactor = %d' % self.fillfactor)
|
with_params.append('fillfactor = %d' % self.fillfactor)
|
||||||
return with_params
|
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):
|
class HashIndex(PostgresIndex):
|
||||||
suffix = 'hash'
|
suffix = 'hash'
|
||||||
|
|
|
@ -277,6 +277,8 @@ class BaseDatabaseFeatures:
|
||||||
# Does the backend support partial indexes (CREATE INDEX ... WHERE ...)?
|
# Does the backend support partial indexes (CREATE INDEX ... WHERE ...)?
|
||||||
supports_partial_indexes = True
|
supports_partial_indexes = True
|
||||||
supports_functions_in_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
|
# Does the database allow more than one constraint or index on the same
|
||||||
# field(s)?
|
# field(s)?
|
||||||
|
|
|
@ -84,8 +84,8 @@ class BaseDatabaseSchemaEditor:
|
||||||
sql_create_column_inline_fk = None
|
sql_create_column_inline_fk = None
|
||||||
sql_delete_fk = sql_delete_constraint
|
sql_delete_fk = sql_delete_constraint
|
||||||
|
|
||||||
sql_create_index = "CREATE INDEX %(name)s ON %(table)s (%(columns)s)%(extra)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)%(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_delete_index = "DROP INDEX %(name)s"
|
||||||
|
|
||||||
sql_create_pk = "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s PRIMARY KEY (%(columns)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 ' WHERE ' + condition
|
||||||
return ''
|
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='',
|
def _create_index_sql(self, model, fields, *, name=None, suffix='', using='',
|
||||||
db_tablespace=None, col_suffixes=(), sql=None, opclasses=(),
|
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.
|
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
|
`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),
|
columns=self._index_columns(table, columns, col_suffixes, opclasses),
|
||||||
extra=tablespace_sql,
|
extra=tablespace_sql,
|
||||||
condition=self._index_condition_sql(condition),
|
condition=self._index_condition_sql(condition),
|
||||||
|
include=self._index_include_sql(model, include),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _delete_index_sql(self, model, name, sql=None):
|
def _delete_index_sql(self, model, name, sql=None):
|
||||||
|
@ -1083,16 +1092,22 @@ class BaseDatabaseSchemaEditor:
|
||||||
if deferrable == Deferrable.IMMEDIATE:
|
if deferrable == Deferrable.IMMEDIATE:
|
||||||
return ' DEFERRABLE INITIALLY 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 (
|
if (
|
||||||
deferrable and
|
deferrable and
|
||||||
not self.connection.features.supports_deferrable_unique_constraints
|
not self.connection.features.supports_deferrable_unique_constraints
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
if condition:
|
if condition or include:
|
||||||
# Databases support conditional unique constraints via a unique
|
# Databases support conditional and covering unique constraints via
|
||||||
# index.
|
# a unique index.
|
||||||
sql = self._create_unique_sql(model, fields, name=name, condition=condition)
|
sql = self._create_unique_sql(
|
||||||
|
model,
|
||||||
|
fields,
|
||||||
|
name=name,
|
||||||
|
condition=condition,
|
||||||
|
include=include,
|
||||||
|
)
|
||||||
if sql:
|
if sql:
|
||||||
self.deferred_sql.append(sql)
|
self.deferred_sql.append(sql)
|
||||||
return None
|
return None
|
||||||
|
@ -1105,10 +1120,14 @@ class BaseDatabaseSchemaEditor:
|
||||||
'constraint': constraint,
|
'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 (
|
if (
|
||||||
deferrable and
|
(
|
||||||
not self.connection.features.supports_deferrable_unique_constraints
|
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
|
return None
|
||||||
|
|
||||||
|
@ -1121,9 +1140,7 @@ class BaseDatabaseSchemaEditor:
|
||||||
else:
|
else:
|
||||||
name = self.quote_name(name)
|
name = self.quote_name(name)
|
||||||
columns = Columns(table, columns, self.quote_name)
|
columns = Columns(table, columns, self.quote_name)
|
||||||
if condition:
|
if condition or include:
|
||||||
if not self.connection.features.supports_partial_indexes:
|
|
||||||
return None
|
|
||||||
sql = self.sql_create_unique_index
|
sql = self.sql_create_unique_index
|
||||||
else:
|
else:
|
||||||
sql = self.sql_create_unique
|
sql = self.sql_create_unique
|
||||||
|
@ -1134,20 +1151,24 @@ class BaseDatabaseSchemaEditor:
|
||||||
columns=columns,
|
columns=columns,
|
||||||
condition=self._index_condition_sql(condition),
|
condition=self._index_condition_sql(condition),
|
||||||
deferrable=self._deferrable_constraint_sql(deferrable),
|
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 (
|
if (
|
||||||
deferrable and
|
(
|
||||||
not self.connection.features.supports_deferrable_unique_constraints
|
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
|
return None
|
||||||
if condition:
|
if condition or include:
|
||||||
return (
|
sql = self.sql_delete_index
|
||||||
self._delete_constraint_sql(self.sql_delete_index, model, name)
|
else:
|
||||||
if self.connection.features.supports_partial_indexes else None
|
sql = self.sql_delete_unique
|
||||||
)
|
return self._delete_constraint_sql(sql, model, name)
|
||||||
return self._delete_constraint_sql(self.sql_delete_unique, model, name)
|
|
||||||
|
|
||||||
def _check_sql(self, name, check):
|
def _check_sql(self, name, check):
|
||||||
return self.sql_constraint % {
|
return self.sql_constraint % {
|
||||||
|
|
|
@ -82,3 +82,5 @@ class DatabaseFeatures(BaseDatabaseFeatures):
|
||||||
has_brin_autosummarize = property(operator.attrgetter('is_postgresql_10'))
|
has_brin_autosummarize = property(operator.attrgetter('is_postgresql_10'))
|
||||||
has_websearch_to_tsquery = property(operator.attrgetter('is_postgresql_11'))
|
has_websearch_to_tsquery = property(operator.attrgetter('is_postgresql_11'))
|
||||||
supports_table_partitions = property(operator.attrgetter('is_postgresql_10'))
|
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'))
|
||||||
|
|
|
@ -12,9 +12,13 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
|
||||||
sql_set_sequence_max = "SELECT setval('%(sequence)s', MAX(%(column)s)) FROM %(table)s"
|
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_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 = (
|
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 = "DROP INDEX IF EXISTS %(name)s"
|
||||||
sql_delete_index_concurrently = "DROP INDEX CONCURRENTLY 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(
|
def _create_index_sql(
|
||||||
self, model, fields, *, name=None, suffix='', using='',
|
self, model, fields, *, name=None, suffix='', using='',
|
||||||
db_tablespace=None, col_suffixes=(), sql=None, opclasses=(),
|
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
|
sql = self.sql_create_index if not concurrently else self.sql_create_index_concurrently
|
||||||
return super()._create_index_sql(
|
return super()._create_index_sql(
|
||||||
model, fields, name=name, suffix=suffix, using=using, db_tablespace=db_tablespace,
|
model, fields, name=name, suffix=suffix, using=using, db_tablespace=db_tablespace,
|
||||||
col_suffixes=col_suffixes, sql=sql, opclasses=opclasses, condition=condition,
|
col_suffixes=col_suffixes, sql=sql, opclasses=opclasses, condition=condition,
|
||||||
|
include=include,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1633,6 +1633,7 @@ class Model(metaclass=ModelBase):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
fields = [field for index in cls._meta.indexes for field, _ in index.fields_orders]
|
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'))
|
errors.extend(cls._check_local_fields(fields, 'indexes'))
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
@ -1926,10 +1927,9 @@ class Model(metaclass=ModelBase):
|
||||||
id='models.W038',
|
id='models.W038',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
fields = (
|
fields = chain.from_iterable(
|
||||||
field
|
(*constraint.fields, *constraint.include)
|
||||||
for constraint in cls._meta.constraints if isinstance(constraint, UniqueConstraint)
|
for constraint in cls._meta.constraints if isinstance(constraint, UniqueConstraint)
|
||||||
for field in constraint.fields
|
|
||||||
)
|
)
|
||||||
errors.extend(cls._check_local_fields(fields, 'constraints'))
|
errors.extend(cls._check_local_fields(fields, 'constraints'))
|
||||||
return errors
|
return errors
|
||||||
|
|
|
@ -77,7 +77,7 @@ class Deferrable(Enum):
|
||||||
|
|
||||||
|
|
||||||
class UniqueConstraint(BaseConstraint):
|
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:
|
if not fields:
|
||||||
raise ValueError('At least one field is required to define a unique constraint.')
|
raise ValueError('At least one field is required to define a unique constraint.')
|
||||||
if not isinstance(condition, (type(None), Q)):
|
if not isinstance(condition, (type(None), Q)):
|
||||||
|
@ -90,9 +90,12 @@ class UniqueConstraint(BaseConstraint):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'UniqueConstraint.deferrable must be a Deferrable instance.'
|
'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.fields = tuple(fields)
|
||||||
self.condition = condition
|
self.condition = condition
|
||||||
self.deferrable = deferrable
|
self.deferrable = deferrable
|
||||||
|
self.include = tuple(include) if include else ()
|
||||||
super().__init__(name)
|
super().__init__(name)
|
||||||
|
|
||||||
def _get_condition_sql(self, model, schema_editor):
|
def _get_condition_sql(self, model, schema_editor):
|
||||||
|
@ -106,31 +109,36 @@ class UniqueConstraint(BaseConstraint):
|
||||||
|
|
||||||
def constraint_sql(self, model, schema_editor):
|
def constraint_sql(self, model, schema_editor):
|
||||||
fields = [model._meta.get_field(field_name).column for field_name in self.fields]
|
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)
|
condition = self._get_condition_sql(model, schema_editor)
|
||||||
return schema_editor._unique_sql(
|
return schema_editor._unique_sql(
|
||||||
model, fields, self.name, condition=condition,
|
model, fields, self.name, condition=condition,
|
||||||
deferrable=self.deferrable,
|
deferrable=self.deferrable, include=include,
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_sql(self, model, schema_editor):
|
def create_sql(self, model, schema_editor):
|
||||||
fields = [model._meta.get_field(field_name).column for field_name in self.fields]
|
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)
|
condition = self._get_condition_sql(model, schema_editor)
|
||||||
return schema_editor._create_unique_sql(
|
return schema_editor._create_unique_sql(
|
||||||
model, fields, self.name, condition=condition,
|
model, fields, self.name, condition=condition,
|
||||||
deferrable=self.deferrable,
|
deferrable=self.deferrable, include=include,
|
||||||
)
|
)
|
||||||
|
|
||||||
def remove_sql(self, model, schema_editor):
|
def remove_sql(self, model, schema_editor):
|
||||||
condition = self._get_condition_sql(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(
|
return schema_editor._delete_unique_sql(
|
||||||
model, self.name, condition=condition, deferrable=self.deferrable,
|
model, self.name, condition=condition, deferrable=self.deferrable,
|
||||||
|
include=include,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
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,
|
self.__class__.__name__, self.fields, self.name,
|
||||||
'' if self.condition is None else ' condition=%s' % self.condition,
|
'' if self.condition is None else ' condition=%s' % self.condition,
|
||||||
'' if self.deferrable is None else ' deferrable=%s' % self.deferrable,
|
'' if self.deferrable is None else ' deferrable=%s' % self.deferrable,
|
||||||
|
'' if not self.include else ' include=%s' % repr(self.include),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
|
@ -139,7 +147,8 @@ class UniqueConstraint(BaseConstraint):
|
||||||
self.name == other.name and
|
self.name == other.name and
|
||||||
self.fields == other.fields and
|
self.fields == other.fields and
|
||||||
self.condition == other.condition and
|
self.condition == other.condition and
|
||||||
self.deferrable == other.deferrable
|
self.deferrable == other.deferrable and
|
||||||
|
self.include == other.include
|
||||||
)
|
)
|
||||||
return super().__eq__(other)
|
return super().__eq__(other)
|
||||||
|
|
||||||
|
@ -150,4 +159,6 @@ class UniqueConstraint(BaseConstraint):
|
||||||
kwargs['condition'] = self.condition
|
kwargs['condition'] = self.condition
|
||||||
if self.deferrable:
|
if self.deferrable:
|
||||||
kwargs['deferrable'] = self.deferrable
|
kwargs['deferrable'] = self.deferrable
|
||||||
|
if self.include:
|
||||||
|
kwargs['include'] = self.include
|
||||||
return path, args, kwargs
|
return path, args, kwargs
|
||||||
|
|
|
@ -11,7 +11,16 @@ class Index:
|
||||||
# cross-database compatibility with Oracle)
|
# cross-database compatibility with Oracle)
|
||||||
max_name_length = 30
|
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:
|
if opclasses and not name:
|
||||||
raise ValueError('An index must be named to use opclasses.')
|
raise ValueError('An index must be named to use opclasses.')
|
||||||
if not isinstance(condition, (type(None), Q)):
|
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.')
|
raise ValueError('Index.fields and Index.opclasses must have the same number of elements.')
|
||||||
if not fields:
|
if not fields:
|
||||||
raise ValueError('At least one field is required to define an index.')
|
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)
|
self.fields = list(fields)
|
||||||
# A list of 2-tuple with the field name and ordering ('' or 'DESC').
|
# A list of 2-tuple with the field name and ordering ('' or 'DESC').
|
||||||
self.fields_orders = [
|
self.fields_orders = [
|
||||||
|
@ -36,6 +49,7 @@ class Index:
|
||||||
self.db_tablespace = db_tablespace
|
self.db_tablespace = db_tablespace
|
||||||
self.opclasses = opclasses
|
self.opclasses = opclasses
|
||||||
self.condition = condition
|
self.condition = condition
|
||||||
|
self.include = tuple(include) if include else ()
|
||||||
|
|
||||||
def _get_condition_sql(self, model, schema_editor):
|
def _get_condition_sql(self, model, schema_editor):
|
||||||
if self.condition is None:
|
if self.condition is None:
|
||||||
|
@ -48,12 +62,13 @@ class Index:
|
||||||
|
|
||||||
def create_sql(self, model, schema_editor, using='', **kwargs):
|
def create_sql(self, model, schema_editor, using='', **kwargs):
|
||||||
fields = [model._meta.get_field(field_name) for field_name, _ in self.fields_orders]
|
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]
|
col_suffixes = [order[1] for order in self.fields_orders]
|
||||||
condition = self._get_condition_sql(model, schema_editor)
|
condition = self._get_condition_sql(model, schema_editor)
|
||||||
return schema_editor._create_index_sql(
|
return schema_editor._create_index_sql(
|
||||||
model, fields, name=self.name, using=using, db_tablespace=self.db_tablespace,
|
model, fields, name=self.name, using=using, db_tablespace=self.db_tablespace,
|
||||||
col_suffixes=col_suffixes, opclasses=self.opclasses, condition=condition,
|
col_suffixes=col_suffixes, opclasses=self.opclasses, condition=condition,
|
||||||
**kwargs,
|
include=include, **kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
def remove_sql(self, model, schema_editor, **kwargs):
|
def remove_sql(self, model, schema_editor, **kwargs):
|
||||||
|
@ -69,6 +84,8 @@ class Index:
|
||||||
kwargs['opclasses'] = self.opclasses
|
kwargs['opclasses'] = self.opclasses
|
||||||
if self.condition:
|
if self.condition:
|
||||||
kwargs['condition'] = self.condition
|
kwargs['condition'] = self.condition
|
||||||
|
if self.include:
|
||||||
|
kwargs['include'] = self.include
|
||||||
return (path, (), kwargs)
|
return (path, (), kwargs)
|
||||||
|
|
||||||
def clone(self):
|
def clone(self):
|
||||||
|
@ -106,9 +123,10 @@ class Index:
|
||||||
self.name = 'D%s' % self.name[1:]
|
self.name = 'D%s' % self.name[1:]
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<%s: fields='%s'%s>" % (
|
return "<%s: fields='%s'%s%s>" % (
|
||||||
self.__class__.__name__, ', '.join(self.fields),
|
self.__class__.__name__, ', '.join(self.fields),
|
||||||
'' if self.condition is None else ', condition=%s' % self.condition,
|
'' if self.condition is None else ', condition=%s' % self.condition,
|
||||||
|
'' if not self.include else ", include='%s'" % ', '.join(self.include),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
|
|
|
@ -73,7 +73,7 @@ constraint.
|
||||||
``UniqueConstraint``
|
``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.
|
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
|
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>`_.
|
<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`.
|
||||||
|
|
|
@ -21,7 +21,7 @@ options`_.
|
||||||
``Index`` 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.
|
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
|
The ``condition`` argument is ignored with MySQL and MariaDB as neither
|
||||||
supports conditional indexes.
|
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
|
||||||
|
|
|
@ -185,6 +185,10 @@ Models
|
||||||
* :class:`When() <django.db.models.expressions.When>` expression now allows
|
* :class:`When() <django.db.models.expressions.When>` expression now allows
|
||||||
using the ``condition`` argument with ``lookups``.
|
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
|
Requests and Responses
|
||||||
~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
@ -263,6 +267,10 @@ backends.
|
||||||
* ``introspected_small_auto_field_type``
|
* ``introspected_small_auto_field_type``
|
||||||
* ``introspected_boolean_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`
|
:mod:`django.contrib.gis`
|
||||||
-------------------------
|
-------------------------
|
||||||
|
|
||||||
|
|
|
@ -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):
|
class AbstractModel(models.Model):
|
||||||
age = models.IntegerField()
|
age = models.IntegerField()
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,8 @@ from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
ChildModel, Product, UniqueConstraintConditionProduct,
|
ChildModel, Product, UniqueConstraintConditionProduct,
|
||||||
UniqueConstraintDeferrable, UniqueConstraintProduct,
|
UniqueConstraintDeferrable, UniqueConstraintInclude,
|
||||||
|
UniqueConstraintProduct,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -181,6 +182,20 @@ class UniqueConstraintTests(TestCase):
|
||||||
self.assertEqual(constraint_1, constraint_1)
|
self.assertEqual(constraint_1, constraint_1)
|
||||||
self.assertNotEqual(constraint_1, constraint_2)
|
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):
|
def test_repr(self):
|
||||||
fields = ['foo', 'bar']
|
fields = ['foo', 'bar']
|
||||||
name = 'unique_fields'
|
name = 'unique_fields'
|
||||||
|
@ -214,6 +229,18 @@ class UniqueConstraintTests(TestCase):
|
||||||
"deferrable=Deferrable.IMMEDIATE>",
|
"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):
|
def test_deconstruction(self):
|
||||||
fields = ['foo', 'bar']
|
fields = ['foo', 'bar']
|
||||||
name = 'unique_fields'
|
name = 'unique_fields'
|
||||||
|
@ -250,6 +277,20 @@ class UniqueConstraintTests(TestCase):
|
||||||
'deferrable': models.Deferrable.DEFERRED,
|
'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):
|
def test_database_constraint(self):
|
||||||
with self.assertRaises(IntegrityError):
|
with self.assertRaises(IntegrityError):
|
||||||
UniqueConstraintProduct.objects.create(name=self.p1.name, color=self.p1.color)
|
UniqueConstraintProduct.objects.create(name=self.p1.name, color=self.p1.color)
|
||||||
|
@ -333,3 +374,21 @@ class UniqueConstraintTests(TestCase):
|
||||||
name='name_invalid',
|
name='name_invalid',
|
||||||
deferrable='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',
|
||||||
|
)
|
||||||
|
|
|
@ -236,6 +236,41 @@ class SchemaIndexesPostgreSQLTests(TransactionTestCase):
|
||||||
cursor.execute(self.get_opclass_query % indexname)
|
cursor.execute(self.get_opclass_query % indexname)
|
||||||
self.assertCountEqual(cursor.fetchall(), [('text_pattern_ops', 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):
|
def test_ops_class_columns_lists_sql(self):
|
||||||
index = Index(
|
index = Index(
|
||||||
fields=['headline'],
|
fields=['headline'],
|
||||||
|
@ -417,3 +452,89 @@ class PartialIndexTests(TransactionTestCase):
|
||||||
cursor=cursor, table_name=Article._meta.db_table,
|
cursor=cursor, table_name=Article._meta.db_table,
|
||||||
))
|
))
|
||||||
editor.remove_index(index=index, model=Article)
|
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)),
|
||||||
|
)
|
||||||
|
|
|
@ -375,6 +375,78 @@ class IndexesTests(TestCase):
|
||||||
|
|
||||||
self.assertEqual(Model.check(databases=self.databases), [])
|
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')
|
@isolate_apps('invalid_models_tests')
|
||||||
class FieldNamesTests(TestCase):
|
class FieldNamesTests(TestCase):
|
||||||
|
@ -1568,3 +1640,90 @@ class ConstraintsTests(TestCase):
|
||||||
]
|
]
|
||||||
|
|
||||||
self.assertEqual(Model.check(databases=self.databases), [])
|
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), [])
|
||||||
|
|
|
@ -448,6 +448,48 @@ class OperationTests(OperationTestBase):
|
||||||
[deferred_unique_constraint],
|
[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):
|
def test_create_model_managers(self):
|
||||||
"""
|
"""
|
||||||
The managers on a model are set.
|
The managers on a model are set.
|
||||||
|
@ -2236,6 +2278,88 @@ class OperationTests(OperationTestBase):
|
||||||
'name': 'deferred_pink_constraint_rm',
|
'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):
|
def test_alter_model_options(self):
|
||||||
"""
|
"""
|
||||||
Tests the AlterModelOptions operation.
|
Tests the AlterModelOptions operation.
|
||||||
|
|
|
@ -17,9 +17,18 @@ class SimpleIndexesTests(SimpleTestCase):
|
||||||
index = models.Index(fields=['title'])
|
index = models.Index(fields=['title'])
|
||||||
multi_col_index = models.Index(fields=['title', 'author'])
|
multi_col_index = models.Index(fields=['title', 'author'])
|
||||||
partial_index = models.Index(fields=['title'], name='long_books_idx', condition=models.Q(pages__gt=400))
|
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(index), "<Index: fields='title'>")
|
||||||
self.assertEqual(repr(multi_col_index), "<Index: fields='title, author'>")
|
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(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):
|
def test_eq(self):
|
||||||
index = models.Index(fields=['title'])
|
index = models.Index(fields=['title'])
|
||||||
|
@ -65,6 +74,16 @@ class SimpleIndexesTests(SimpleTestCase):
|
||||||
with self.assertRaisesMessage(ValueError, 'Index.condition must be a Q instance.'):
|
with self.assertRaisesMessage(ValueError, 'Index.condition must be a Q instance.'):
|
||||||
models.Index(condition='invalid', name='long_book_idx')
|
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):
|
def test_name_auto_generation(self):
|
||||||
index = models.Index(fields=['author'])
|
index = models.Index(fields=['author'])
|
||||||
index.set_name_with_model(Book)
|
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):
|
def test_clone(self):
|
||||||
index = models.Index(fields=['title'])
|
index = models.Index(fields=['title'])
|
||||||
new_index = index.clone()
|
new_index = index.clone()
|
||||||
|
|
|
@ -11,7 +11,7 @@ from django.test import skipUnlessDBFeature
|
||||||
from django.test.utils import register_lookup
|
from django.test.utils import register_lookup
|
||||||
|
|
||||||
from . import PostgreSQLSimpleTestCase, PostgreSQLTestCase
|
from . import PostgreSQLSimpleTestCase, PostgreSQLTestCase
|
||||||
from .models import CharFieldModel, IntegerArrayModel
|
from .models import CharFieldModel, IntegerArrayModel, Scene
|
||||||
|
|
||||||
|
|
||||||
class IndexTestMixin:
|
class IndexTestMixin:
|
||||||
|
@ -373,6 +373,33 @@ class SchemaTests(PostgreSQLTestCase):
|
||||||
editor.remove_index(CharFieldModel, index)
|
editor.remove_index(CharFieldModel, index)
|
||||||
self.assertNotIn(index_name, self.get_constraints(CharFieldModel._meta.db_table))
|
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):
|
def test_hash_index(self):
|
||||||
# Ensure the table is there and doesn't have an index.
|
# Ensure the table is there and doesn't have an index.
|
||||||
self.assertNotIn('field', self.get_constraints(CharFieldModel._meta.db_table))
|
self.assertNotIn('field', self.get_constraints(CharFieldModel._meta.db_table))
|
||||||
|
|
|
@ -2587,6 +2587,7 @@ class SchemaTests(TransactionTestCase):
|
||||||
"columns": editor.quote_name(column),
|
"columns": editor.quote_name(column),
|
||||||
"extra": "",
|
"extra": "",
|
||||||
"condition": "",
|
"condition": "",
|
||||||
|
"include": "",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.assertIn(expected_constraint_name, self.get_constraints(model._meta.db_table))
|
self.assertIn(expected_constraint_name, self.get_constraints(model._meta.db_table))
|
||||||
|
|
Loading…
Reference in New Issue