Fixed #26167 -- Added support for functional indexes.

Thanks Simon Charette, Mads Jensen, and Mariusz Felisiak for reviews.

Co-authored-by: Markus Holtermann <info@markusholtermann.eu>
This commit is contained in:
Hannes Ljungberg 2019-10-10 20:04:17 +02:00 committed by Mariusz Felisiak
parent e3ece0144a
commit 83fcfc9ec8
28 changed files with 1306 additions and 65 deletions

View File

@ -6,10 +6,13 @@ from django.apps import AppConfig
from django.db import connections from django.db import connections
from django.db.backends.signals import connection_created from django.db.backends.signals import connection_created
from django.db.migrations.writer import MigrationWriter from django.db.migrations.writer import MigrationWriter
from django.db.models import CharField, TextField from django.db.models import CharField, OrderBy, TextField
from django.db.models.functions import Collate
from django.db.models.indexes import IndexExpression
from django.test.signals import setting_changed from django.test.signals import setting_changed
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .indexes import OpClass
from .lookups import SearchLookup, TrigramSimilar, Unaccent from .lookups import SearchLookup, TrigramSimilar, Unaccent
from .serializers import RangeSerializer from .serializers import RangeSerializer
from .signals import register_type_handlers from .signals import register_type_handlers
@ -63,3 +66,4 @@ class PostgresConfig(AppConfig):
CharField.register_lookup(TrigramSimilar) CharField.register_lookup(TrigramSimilar)
TextField.register_lookup(TrigramSimilar) TextField.register_lookup(TrigramSimilar)
MigrationWriter.register_serializer(RANGE_TYPES, RangeSerializer) MigrationWriter.register_serializer(RANGE_TYPES, RangeSerializer)
IndexExpression.register_wrappers(OrderBy, OpClass, Collate)

View File

@ -1,5 +1,5 @@
from django.db import NotSupportedError from django.db import NotSupportedError
from django.db.models import Index from django.db.models import Func, Index
from django.utils.functional import cached_property from django.utils.functional import cached_property
__all__ = [ __all__ = [
@ -39,8 +39,8 @@ class PostgresIndex(Index):
class BloomIndex(PostgresIndex): class BloomIndex(PostgresIndex):
suffix = 'bloom' suffix = 'bloom'
def __init__(self, *, length=None, columns=(), **kwargs): def __init__(self, *expressions, length=None, columns=(), **kwargs):
super().__init__(**kwargs) super().__init__(*expressions, **kwargs)
if len(self.fields) > 32: if len(self.fields) > 32:
raise ValueError('Bloom indexes support a maximum of 32 fields.') raise ValueError('Bloom indexes support a maximum of 32 fields.')
if not isinstance(columns, (list, tuple)): if not isinstance(columns, (list, tuple)):
@ -83,12 +83,12 @@ class BloomIndex(PostgresIndex):
class BrinIndex(PostgresIndex): class BrinIndex(PostgresIndex):
suffix = 'brin' suffix = 'brin'
def __init__(self, *, autosummarize=None, pages_per_range=None, **kwargs): def __init__(self, *expressions, autosummarize=None, pages_per_range=None, **kwargs):
if pages_per_range is not None and pages_per_range <= 0: if pages_per_range is not None and pages_per_range <= 0:
raise ValueError('pages_per_range must be None or a positive integer') raise ValueError('pages_per_range must be None or a positive integer')
self.autosummarize = autosummarize self.autosummarize = autosummarize
self.pages_per_range = pages_per_range self.pages_per_range = pages_per_range
super().__init__(**kwargs) super().__init__(*expressions, **kwargs)
def deconstruct(self): def deconstruct(self):
path, args, kwargs = super().deconstruct() path, args, kwargs = super().deconstruct()
@ -114,9 +114,9 @@ class BrinIndex(PostgresIndex):
class BTreeIndex(PostgresIndex): class BTreeIndex(PostgresIndex):
suffix = 'btree' suffix = 'btree'
def __init__(self, *, fillfactor=None, **kwargs): def __init__(self, *expressions, fillfactor=None, **kwargs):
self.fillfactor = fillfactor self.fillfactor = fillfactor
super().__init__(**kwargs) super().__init__(*expressions, **kwargs)
def deconstruct(self): def deconstruct(self):
path, args, kwargs = super().deconstruct() path, args, kwargs = super().deconstruct()
@ -134,10 +134,10 @@ class BTreeIndex(PostgresIndex):
class GinIndex(PostgresIndex): class GinIndex(PostgresIndex):
suffix = 'gin' suffix = 'gin'
def __init__(self, *, fastupdate=None, gin_pending_list_limit=None, **kwargs): def __init__(self, *expressions, fastupdate=None, gin_pending_list_limit=None, **kwargs):
self.fastupdate = fastupdate self.fastupdate = fastupdate
self.gin_pending_list_limit = gin_pending_list_limit self.gin_pending_list_limit = gin_pending_list_limit
super().__init__(**kwargs) super().__init__(*expressions, **kwargs)
def deconstruct(self): def deconstruct(self):
path, args, kwargs = super().deconstruct() path, args, kwargs = super().deconstruct()
@ -159,10 +159,10 @@ class GinIndex(PostgresIndex):
class GistIndex(PostgresIndex): class GistIndex(PostgresIndex):
suffix = 'gist' suffix = 'gist'
def __init__(self, *, buffering=None, fillfactor=None, **kwargs): def __init__(self, *expressions, buffering=None, fillfactor=None, **kwargs):
self.buffering = buffering self.buffering = buffering
self.fillfactor = fillfactor self.fillfactor = fillfactor
super().__init__(**kwargs) super().__init__(*expressions, **kwargs)
def deconstruct(self): def deconstruct(self):
path, args, kwargs = super().deconstruct() path, args, kwargs = super().deconstruct()
@ -188,9 +188,9 @@ class GistIndex(PostgresIndex):
class HashIndex(PostgresIndex): class HashIndex(PostgresIndex):
suffix = 'hash' suffix = 'hash'
def __init__(self, *, fillfactor=None, **kwargs): def __init__(self, *expressions, fillfactor=None, **kwargs):
self.fillfactor = fillfactor self.fillfactor = fillfactor
super().__init__(**kwargs) super().__init__(*expressions, **kwargs)
def deconstruct(self): def deconstruct(self):
path, args, kwargs = super().deconstruct() path, args, kwargs = super().deconstruct()
@ -208,9 +208,9 @@ class HashIndex(PostgresIndex):
class SpGistIndex(PostgresIndex): class SpGistIndex(PostgresIndex):
suffix = 'spgist' suffix = 'spgist'
def __init__(self, *, fillfactor=None, **kwargs): def __init__(self, *expressions, fillfactor=None, **kwargs):
self.fillfactor = fillfactor self.fillfactor = fillfactor
super().__init__(**kwargs) super().__init__(*expressions, **kwargs)
def deconstruct(self): def deconstruct(self):
path, args, kwargs = super().deconstruct() path, args, kwargs = super().deconstruct()
@ -223,3 +223,10 @@ class SpGistIndex(PostgresIndex):
if self.fillfactor is not None: if self.fillfactor is not None:
with_params.append('fillfactor = %d' % self.fillfactor) with_params.append('fillfactor = %d' % self.fillfactor)
return with_params return with_params
class OpClass(Func):
template = '%(expressions)s %(name)s'
def __init__(self, expression, name):
super().__init__(expression, name=name)

View File

@ -281,6 +281,10 @@ class BaseDatabaseFeatures:
supports_functions_in_partial_indexes = True supports_functions_in_partial_indexes = True
# Does the backend support covering indexes (CREATE INDEX ... INCLUDE ...)? # Does the backend support covering indexes (CREATE INDEX ... INCLUDE ...)?
supports_covering_indexes = False supports_covering_indexes = False
# Does the backend support indexes on expressions?
supports_expression_indexes = False
# Does the backend treat COLLATE as an indexed expression?
collate_as_index_expression = 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)?

View File

@ -2,10 +2,11 @@ import logging
from datetime import datetime from datetime import datetime
from django.db.backends.ddl_references import ( from django.db.backends.ddl_references import (
Columns, ForeignKeyName, IndexName, Statement, Table, Columns, Expressions, ForeignKeyName, IndexName, Statement, Table,
) )
from django.db.backends.utils import names_digest, split_identifier from django.db.backends.utils import names_digest, split_identifier
from django.db.models import Deferrable, Index from django.db.models import Deferrable, Index
from django.db.models.sql import Query
from django.db.transaction import TransactionManagementError, atomic from django.db.transaction import TransactionManagementError, atomic
from django.utils import timezone from django.utils import timezone
@ -354,10 +355,20 @@ class BaseDatabaseSchemaEditor:
def add_index(self, model, index): def add_index(self, model, index):
"""Add an index on a model.""" """Add an index on a model."""
if (
index.contains_expressions and
not self.connection.features.supports_expression_indexes
):
return None
self.execute(index.create_sql(model, self), params=None) self.execute(index.create_sql(model, self), params=None)
def remove_index(self, model, index): def remove_index(self, model, index):
"""Remove an index from a model.""" """Remove an index from a model."""
if (
index.contains_expressions and
not self.connection.features.supports_expression_indexes
):
return None
self.execute(index.remove_sql(model, self)) self.execute(index.remove_sql(model, self))
def add_constraint(self, model, constraint): def add_constraint(self, model, constraint):
@ -992,12 +1003,17 @@ class BaseDatabaseSchemaEditor:
def _create_index_sql(self, model, *, fields=None, name=None, suffix='', using='', def _create_index_sql(self, model, *, fields=None, name=None, suffix='', using='',
db_tablespace=None, col_suffixes=(), sql=None, opclasses=(), db_tablespace=None, col_suffixes=(), sql=None, opclasses=(),
condition=None, include=None): condition=None, include=None, expressions=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 or expressions. `sql` can be specified if the syntax differs from the
indexes, ...). standard (GIS indexes, ...).
""" """
fields = fields or []
expressions = expressions or []
compiler = Query(model, alias_cols=False).get_compiler(
connection=self.connection,
)
tablespace_sql = self._get_index_tablespace_sql(model, fields, db_tablespace=db_tablespace) tablespace_sql = self._get_index_tablespace_sql(model, fields, db_tablespace=db_tablespace)
columns = [field.column for field in fields] columns = [field.column for field in fields]
sql_create_index = sql or self.sql_create_index sql_create_index = sql or self.sql_create_index
@ -1014,7 +1030,11 @@ class BaseDatabaseSchemaEditor:
table=Table(table, self.quote_name), table=Table(table, self.quote_name),
name=IndexName(table, columns, suffix, create_index_name), name=IndexName(table, columns, suffix, create_index_name),
using=using, using=using,
columns=self._index_columns(table, columns, col_suffixes, opclasses), columns=(
self._index_columns(table, columns, col_suffixes, opclasses)
if columns
else Expressions(table, expressions, compiler, self.quote_value)
),
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), include=self._index_include_sql(model, include),
@ -1046,7 +1066,11 @@ class BaseDatabaseSchemaEditor:
output.append(self._create_index_sql(model, fields=fields, suffix='_idx')) output.append(self._create_index_sql(model, fields=fields, suffix='_idx'))
for index in model._meta.indexes: for index in model._meta.indexes:
output.append(index.create_sql(model, self)) if (
not index.contains_expressions or
self.connection.features.supports_expression_indexes
):
output.append(index.create_sql(model, self))
return output return output
def _field_indexes_sql(self, model, field): def _field_indexes_sql(self, model, field):

View File

@ -2,6 +2,7 @@
Helpers to manipulate deferred DDL statements that might need to be adjusted or Helpers to manipulate deferred DDL statements that might need to be adjusted or
discarded within when executing a migration. discarded within when executing a migration.
""" """
from copy import deepcopy
class Reference: class Reference:
@ -198,3 +199,38 @@ class Statement(Reference):
def __str__(self): def __str__(self):
return self.template % self.parts return self.template % self.parts
class Expressions(TableColumns):
def __init__(self, table, expressions, compiler, quote_value):
self.compiler = compiler
self.expressions = expressions
self.quote_value = quote_value
columns = [col.target.column for col in self.compiler.query._gen_cols([self.expressions])]
super().__init__(table, columns)
def rename_table_references(self, old_table, new_table):
if self.table != old_table:
return
expressions = deepcopy(self.expressions)
self.columns = []
for col in self.compiler.query._gen_cols([expressions]):
col.alias = new_table
self.expressions = expressions
super().rename_table_references(old_table, new_table)
def rename_column_references(self, table, old_column, new_column):
if self.table != table:
return
expressions = deepcopy(self.expressions)
self.columns = []
for col in self.compiler.query._gen_cols([expressions]):
if col.target.column == old_column:
col.target.column = new_column
self.columns.append(col.target.column)
self.expressions = expressions
def __str__(self):
sql, params = self.compiler.compile(self.expressions)
params = map(self.quote_value, params)
return sql % tuple(params)

View File

@ -41,6 +41,10 @@ class DatabaseFeatures(BaseDatabaseFeatures):
""" """
# Neither MySQL nor MariaDB support partial indexes. # Neither MySQL nor MariaDB support partial indexes.
supports_partial_indexes = False supports_partial_indexes = False
# COLLATE must be wrapped in parentheses because MySQL treats COLLATE as an
# indexed expression.
collate_as_index_expression = True
supports_order_by_nulls_modifier = False supports_order_by_nulls_modifier = False
order_by_nulls_first = True order_by_nulls_first = True
test_collations = { test_collations = {
@ -60,6 +64,10 @@ class DatabaseFeatures(BaseDatabaseFeatures):
'model_fields.test_textfield.TextFieldTests.test_emoji', 'model_fields.test_textfield.TextFieldTests.test_emoji',
'model_fields.test_charfield.TestCharField.test_emoji', 'model_fields.test_charfield.TestCharField.test_emoji',
}, },
"MySQL doesn't support functional indexes on a function that "
"returns JSON": {
'schema.tests.SchemaTests.test_func_index_json_key_transform',
},
} }
if 'ONLY_FULL_GROUP_BY' in self.connection.sql_mode: if 'ONLY_FULL_GROUP_BY' in self.connection.sql_mode:
skips.update({ skips.update({
@ -225,3 +233,10 @@ class DatabaseFeatures(BaseDatabaseFeatures):
not self.connection.mysql_is_mariadb and not self.connection.mysql_is_mariadb and
self.connection.mysql_version >= (8, 0, 1) self.connection.mysql_version >= (8, 0, 1)
) )
@cached_property
def supports_expression_indexes(self):
return (
not self.connection.mysql_is_mariadb and
self.connection.mysql_version >= (8, 0, 13)
)

View File

@ -59,6 +59,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_ignore_conflicts = False supports_ignore_conflicts = False
max_query_params = 2**16 - 1 max_query_params = 2**16 - 1
supports_partial_indexes = False supports_partial_indexes = False
supports_expression_indexes = True
supports_slicing_ordering_in_compound = True supports_slicing_ordering_in_compound = True
allows_multiple_constraints_on_same_fields = False allows_multiple_constraints_on_same_fields = False
supports_boolean_expr_in_select_clause = False supports_boolean_expr_in_select_clause = False

View File

@ -58,6 +58,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_deferrable_unique_constraints = True supports_deferrable_unique_constraints = True
has_json_operators = True has_json_operators = True
json_key_contains_list_matching_requires_list = True json_key_contains_list_matching_requires_list = True
supports_expression_indexes = True
django_test_skips = { django_test_skips = {
'opclasses are PostgreSQL only.': { 'opclasses are PostgreSQL only.': {

View File

@ -227,11 +227,12 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
def _create_index_sql( def _create_index_sql(
self, model, *, fields=None, name=None, suffix='', using='', self, model, *, fields=None, name=None, suffix='', using='',
db_tablespace=None, col_suffixes=(), sql=None, opclasses=(), db_tablespace=None, col_suffixes=(), sql=None, opclasses=(),
condition=None, concurrently=False, include=None, condition=None, concurrently=False, include=None, expressions=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=fields, name=name, suffix=suffix, using=using, model, fields=fields, name=name, suffix=suffix, using=using,
db_tablespace=db_tablespace, col_suffixes=col_suffixes, sql=sql, db_tablespace=db_tablespace, col_suffixes=col_suffixes, sql=sql,
opclasses=opclasses, condition=condition, include=include, opclasses=opclasses, condition=condition, include=include,
expressions=expressions,
) )

View File

@ -38,6 +38,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_pragma_foreign_key_check = Database.sqlite_version_info >= (3, 20, 0) supports_pragma_foreign_key_check = Database.sqlite_version_info >= (3, 20, 0)
can_defer_constraint_checks = supports_pragma_foreign_key_check can_defer_constraint_checks = supports_pragma_foreign_key_check
supports_functions_in_partial_indexes = Database.sqlite_version_info >= (3, 15, 0) supports_functions_in_partial_indexes = Database.sqlite_version_info >= (3, 15, 0)
supports_expression_indexes = True
supports_over_clause = Database.sqlite_version_info >= (3, 25, 0) supports_over_clause = Database.sqlite_version_info >= (3, 25, 0)
supports_frame_range_fixed_distance = Database.sqlite_version_info >= (3, 28, 0) supports_frame_range_fixed_distance = Database.sqlite_version_info >= (3, 28, 0)
supports_aggregate_filter_clause = Database.sqlite_version_info >= (3, 30, 1) supports_aggregate_filter_clause = Database.sqlite_version_info >= (3, 30, 1)

View File

@ -416,9 +416,9 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
if constraints[index]['index'] and not constraints[index]['unique']: if constraints[index]['index'] and not constraints[index]['unique']:
# SQLite doesn't support any index type other than b-tree # SQLite doesn't support any index type other than b-tree
constraints[index]['type'] = Index.suffix constraints[index]['type'] = Index.suffix
order_info = sql.split('(')[-1].split(')')[0].split(',') orders = self._get_index_columns_orders(sql)
orders = ['DESC' if info.endswith('DESC') else 'ASC' for info in order_info] if orders is not None:
constraints[index]['orders'] = orders constraints[index]['orders'] = orders
# Get the PK # Get the PK
pk_column = self.get_primary_key_column(cursor, table_name) pk_column = self.get_primary_key_column(cursor, table_name)
if pk_column: if pk_column:
@ -437,6 +437,14 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
constraints.update(self._get_foreign_key_constraints(cursor, table_name)) constraints.update(self._get_foreign_key_constraints(cursor, table_name))
return constraints return constraints
def _get_index_columns_orders(self, sql):
tokens = sqlparse.parse(sql)[0]
for token in tokens:
if isinstance(token, sqlparse.sql.Parenthesis):
columns = str(token).strip('()').split(', ')
return ['DESC' if info.endswith('DESC') else 'ASC' for info in columns]
return None
def _get_column_collations(self, cursor, table_name): def _get_column_collations(self, cursor, table_name):
row = cursor.execute(""" row = cursor.execute("""
SELECT sql SELECT sql

View File

@ -777,6 +777,12 @@ class AddIndex(IndexOperation):
) )
def describe(self): def describe(self):
if self.index.expressions:
return 'Create index %s on %s on model %s' % (
self.index.name,
', '.join([str(expression) for expression in self.index.expressions]),
self.model_name,
)
return 'Create index %s on field(s) %s of model %s' % ( return 'Create index %s on field(s) %s of model %s' % (
self.index.name, self.index.name,
', '.join(self.index.fields), ', '.join(self.index.fields),

View File

@ -1681,6 +1681,22 @@ class Model(metaclass=ModelBase):
id='models.W040', id='models.W040',
) )
) )
if not (
connection.features.supports_expression_indexes or
'supports_expression_indexes' in cls._meta.required_db_features
) and any(index.contains_expressions for index in cls._meta.indexes):
errors.append(
checks.Warning(
'%s does not support indexes on expressions.'
% connection.display_name,
hint=(
"An index won't be created. Silence this warning "
"if you don't care about it."
),
obj=cls,
id='models.W043',
)
)
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] 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'))

View File

@ -375,7 +375,10 @@ class BaseExpression:
yield self yield self
for expr in self.get_source_expressions(): for expr in self.get_source_expressions():
if expr: if expr:
yield from expr.flatten() if hasattr(expr, 'flatten'):
yield from expr.flatten()
else:
yield expr
def select_format(self, compiler, sql, params): def select_format(self, compiler, sql, params):
""" """
@ -897,6 +900,10 @@ class ExpressionList(Func):
def __str__(self): def __str__(self):
return self.arg_joiner.join(str(arg) for arg in self.source_expressions) return self.arg_joiner.join(str(arg) for arg in self.source_expressions)
def as_sqlite(self, compiler, connection, **extra_context):
# Casting to numeric is unnecessary.
return self.as_sql(compiler, connection, **extra_context)
class ExpressionWrapper(Expression): class ExpressionWrapper(Expression):
""" """

View File

@ -1,6 +1,9 @@
from django.db.backends.utils import names_digest, split_identifier from django.db.backends.utils import names_digest, split_identifier
from django.db.models.expressions import Col, ExpressionList, F, Func, OrderBy
from django.db.models.functions import Collate
from django.db.models.query_utils import Q from django.db.models.query_utils import Q
from django.db.models.sql import Query from django.db.models.sql import Query
from django.utils.functional import partition
__all__ = ['Index'] __all__ = ['Index']
@ -13,7 +16,7 @@ class Index:
def __init__( def __init__(
self, self,
*, *expressions,
fields=(), fields=(),
name=None, name=None,
db_tablespace=None, db_tablespace=None,
@ -31,11 +34,25 @@ class Index:
raise ValueError('Index.fields must be a list or tuple.') raise ValueError('Index.fields must be a list or tuple.')
if not isinstance(opclasses, (list, tuple)): if not isinstance(opclasses, (list, tuple)):
raise ValueError('Index.opclasses must be a list or tuple.') raise ValueError('Index.opclasses must be a list or tuple.')
if not expressions and not fields:
raise ValueError(
'At least one field or expression is required to define an '
'index.'
)
if expressions and fields:
raise ValueError(
'Index.fields and expressions are mutually exclusive.',
)
if expressions and not name:
raise ValueError('An index must be named to use expressions.')
if expressions and opclasses:
raise ValueError(
'Index.opclasses cannot be used with expressions. Use '
'django.contrib.postgres.indexes.OpClass() instead.'
)
if opclasses and len(fields) != len(opclasses): if opclasses and len(fields) != len(opclasses):
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 fields and not all(isinstance(field, str) for field in fields):
raise ValueError('At least one field is required to define an index.')
if not all(isinstance(field, str) for field in fields):
raise ValueError('Index.fields must contain only strings with field names.') raise ValueError('Index.fields must contain only strings with field names.')
if include and not name: if include and not name:
raise ValueError('A covering index must be named.') raise ValueError('A covering index must be named.')
@ -52,6 +69,14 @@ class Index:
self.opclasses = opclasses self.opclasses = opclasses
self.condition = condition self.condition = condition
self.include = tuple(include) if include else () self.include = tuple(include) if include else ()
self.expressions = tuple(
F(expression) if isinstance(expression, str) else expression
for expression in expressions
)
@property
def contains_expressions(self):
return bool(self.expressions)
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:
@ -63,15 +88,31 @@ class Index:
return sql % tuple(schema_editor.quote_value(p) for p in params) return sql % tuple(schema_editor.quote_value(p) for p in params)
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]
include = [model._meta.get_field(field_name).column for field_name in self.include] 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) condition = self._get_condition_sql(model, schema_editor)
if self.expressions:
index_expressions = []
for expression in self.expressions:
index_expression = IndexExpression(expression)
index_expression.set_wrapper_classes(schema_editor.connection)
index_expressions.append(index_expression)
expressions = ExpressionList(*index_expressions).resolve_expression(
Query(model, alias_cols=False),
)
fields = None
col_suffixes = None
else:
fields = [
model._meta.get_field(field_name)
for field_name, _ in self.fields_orders
]
col_suffixes = [order[1] for order in self.fields_orders]
expressions = None
return schema_editor._create_index_sql( return schema_editor._create_index_sql(
model, fields=fields, name=self.name, using=using, model, fields=fields, name=self.name, using=using,
db_tablespace=self.db_tablespace, col_suffixes=col_suffixes, db_tablespace=self.db_tablespace, col_suffixes=col_suffixes,
opclasses=self.opclasses, condition=condition, include=include, opclasses=self.opclasses, condition=condition, include=include,
**kwargs, expressions=expressions, **kwargs,
) )
def remove_sql(self, model, schema_editor, **kwargs): def remove_sql(self, model, schema_editor, **kwargs):
@ -80,7 +121,9 @@ class Index:
def deconstruct(self): def deconstruct(self):
path = '%s.%s' % (self.__class__.__module__, self.__class__.__name__) path = '%s.%s' % (self.__class__.__module__, self.__class__.__name__)
path = path.replace('django.db.models.indexes', 'django.db.models') path = path.replace('django.db.models.indexes', 'django.db.models')
kwargs = {'fields': self.fields, 'name': self.name} kwargs = {'name': self.name}
if self.fields:
kwargs['fields'] = self.fields
if self.db_tablespace is not None: if self.db_tablespace is not None:
kwargs['db_tablespace'] = self.db_tablespace kwargs['db_tablespace'] = self.db_tablespace
if self.opclasses: if self.opclasses:
@ -89,12 +132,12 @@ class Index:
kwargs['condition'] = self.condition kwargs['condition'] = self.condition
if self.include: if self.include:
kwargs['include'] = self.include kwargs['include'] = self.include
return (path, (), kwargs) return (path, self.expressions, kwargs)
def clone(self): def clone(self):
"""Create a copy of this Index.""" """Create a copy of this Index."""
_, _, kwargs = self.deconstruct() _, args, kwargs = self.deconstruct()
return self.__class__(**kwargs) return self.__class__(*args, **kwargs)
def set_name_with_model(self, model): def set_name_with_model(self, model):
""" """
@ -126,8 +169,12 @@ 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%s%s>" % ( return '<%s:%s%s%s%s%s>' % (
self.__class__.__name__, ', '.join(self.fields), self.__class__.__name__,
'' if not self.fields else " fields='%s'" % ', '.join(self.fields),
'' if not self.expressions else " expressions='%s'" % ', '.join([
str(expression) for expression in self.expressions
]),
'' 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), '' if not self.include else " include='%s'" % ', '.join(self.include),
'' if not self.opclasses else " opclasses='%s'" % ', '.join(self.opclasses), '' if not self.opclasses else " opclasses='%s'" % ', '.join(self.opclasses),
@ -137,3 +184,84 @@ class Index:
if self.__class__ == other.__class__: if self.__class__ == other.__class__:
return self.deconstruct() == other.deconstruct() return self.deconstruct() == other.deconstruct()
return NotImplemented return NotImplemented
class IndexExpression(Func):
"""Order and wrap expressions for CREATE INDEX statements."""
template = '%(expressions)s'
wrapper_classes = (OrderBy, Collate)
def set_wrapper_classes(self, connection=None):
# Some databases (e.g. MySQL) treats COLLATE as an indexed expression.
if connection and connection.features.collate_as_index_expression:
self.wrapper_classes = tuple([
wrapper_cls
for wrapper_cls in self.wrapper_classes
if wrapper_cls is not Collate
])
@classmethod
def register_wrappers(cls, *wrapper_classes):
cls.wrapper_classes = wrapper_classes
def resolve_expression(
self,
query=None,
allow_joins=True,
reuse=None,
summarize=False,
for_save=False,
):
expressions = list(self.flatten())
# Split expressions and wrappers.
index_expressions, wrappers = partition(
lambda e: isinstance(e, self.wrapper_classes),
expressions,
)
wrapper_types = [type(wrapper) for wrapper in wrappers]
if len(wrapper_types) != len(set(wrapper_types)):
raise ValueError(
"Multiple references to %s can't be used in an indexed "
"expression." % ', '.join([
wrapper_cls.__qualname__ for wrapper_cls in self.wrapper_classes
])
)
if expressions[1:len(wrappers) + 1] != wrappers:
raise ValueError(
'%s must be topmost expressions in an indexed expression.'
% ', '.join([
wrapper_cls.__qualname__ for wrapper_cls in self.wrapper_classes
])
)
# Wrap expressions in parentheses if they are not column references.
root_expression = index_expressions[1]
resolve_root_expression = root_expression.resolve_expression(
query,
allow_joins,
reuse,
summarize,
for_save,
)
if not isinstance(resolve_root_expression, Col):
root_expression = Func(root_expression, template='(%(expressions)s)')
if wrappers:
# Order wrappers and set their expressions.
wrappers = sorted(
wrappers,
key=lambda w: self.wrapper_classes.index(type(w)),
)
wrappers = [wrapper.copy() for wrapper in wrappers]
for i, wrapper in enumerate(wrappers[:-1]):
wrapper.set_source_expressions([wrappers[i + 1]])
# Set the root expression on the deepest wrapper.
wrappers[-1].set_source_expressions([root_expression])
self.set_source_expressions([wrappers[0]])
else:
# Use the root expression, if there are no wrappers.
self.set_source_expressions([root_expression])
return super().resolve_expression(query, allow_joins, reuse, summarize, for_save)
def as_sqlite(self, compiler, connection, **extra_context):
# Casting to numeric is unnecessary.
return self.as_sql(compiler, connection, **extra_context)

View File

@ -381,6 +381,7 @@ Models
* **models.E041**: ``constraints`` refers to the joined field ``<field name>``. * **models.E041**: ``constraints`` refers to the joined field ``<field name>``.
* **models.W042**: Auto-created primary key used when not defining a primary * **models.W042**: Auto-created primary key used when not defining a primary
key type, by default ``django.db.models.AutoField``. key type, by default ``django.db.models.AutoField``.
* **models.W043**: ``<database>`` does not support indexes on expressions.
Security Security
-------- --------

View File

@ -10,7 +10,7 @@ available from the ``django.contrib.postgres.indexes`` module.
``BloomIndex`` ``BloomIndex``
============== ==============
.. class:: BloomIndex(length=None, columns=(), **options) .. class:: BloomIndex(*expressions, length=None, columns=(), **options)
.. versionadded:: 3.1 .. versionadded:: 3.1
@ -30,10 +30,15 @@ available from the ``django.contrib.postgres.indexes`` module.
.. _bloom: https://www.postgresql.org/docs/current/bloom.html .. _bloom: https://www.postgresql.org/docs/current/bloom.html
.. versionchanged:: 3.2
Positional argument ``*expressions`` was added in order to support
functional indexes.
``BrinIndex`` ``BrinIndex``
============= =============
.. class:: BrinIndex(autosummarize=None, pages_per_range=None, **options) .. class:: BrinIndex(*expressions, autosummarize=None, pages_per_range=None, **options)
Creates a `BRIN index Creates a `BRIN index
<https://www.postgresql.org/docs/current/brin-intro.html>`_. <https://www.postgresql.org/docs/current/brin-intro.html>`_.
@ -43,24 +48,34 @@ available from the ``django.contrib.postgres.indexes`` module.
The ``pages_per_range`` argument takes a positive integer. The ``pages_per_range`` argument takes a positive integer.
.. versionchanged:: 3.2
Positional argument ``*expressions`` was added in order to support
functional indexes.
.. _automatic summarization: https://www.postgresql.org/docs/current/brin-intro.html#BRIN-OPERATION .. _automatic summarization: https://www.postgresql.org/docs/current/brin-intro.html#BRIN-OPERATION
``BTreeIndex`` ``BTreeIndex``
============== ==============
.. class:: BTreeIndex(fillfactor=None, **options) .. class:: BTreeIndex(*expressions, fillfactor=None, **options)
Creates a B-Tree index. Creates a B-Tree index.
Provide an integer value from 10 to 100 to the fillfactor_ parameter to Provide an integer value from 10 to 100 to the fillfactor_ parameter to
tune how packed the index pages will be. PostgreSQL's default is 90. tune how packed the index pages will be. PostgreSQL's default is 90.
.. versionchanged:: 3.2
Positional argument ``*expressions`` was added in order to support
functional indexes.
.. _fillfactor: https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-STORAGE-PARAMETERS .. _fillfactor: https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-STORAGE-PARAMETERS
``GinIndex`` ``GinIndex``
============ ============
.. class:: GinIndex(fastupdate=None, gin_pending_list_limit=None, **options) .. class:: GinIndex(*expressions, fastupdate=None, gin_pending_list_limit=None, **options)
Creates a `gin index <https://www.postgresql.org/docs/current/gin.html>`_. Creates a `gin index <https://www.postgresql.org/docs/current/gin.html>`_.
@ -79,13 +94,18 @@ available from the ``django.contrib.postgres.indexes`` module.
to tune the maximum size of the GIN pending list which is used when to tune the maximum size of the GIN pending list which is used when
``fastupdate`` is enabled. ``fastupdate`` is enabled.
.. versionchanged:: 3.2
Positional argument ``*expressions`` was added in order to support
functional indexes.
.. _GIN Fast Update Technique: https://www.postgresql.org/docs/current/gin-implementation.html#GIN-FAST-UPDATE .. _GIN Fast Update Technique: https://www.postgresql.org/docs/current/gin-implementation.html#GIN-FAST-UPDATE
.. _gin_pending_list_limit: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-GIN-PENDING-LIST-LIMIT .. _gin_pending_list_limit: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-GIN-PENDING-LIST-LIMIT
``GistIndex`` ``GistIndex``
============= =============
.. class:: GistIndex(buffering=None, fillfactor=None, **options) .. class:: GistIndex(*expressions, buffering=None, fillfactor=None, **options)
Creates a `GiST index Creates a `GiST index
<https://www.postgresql.org/docs/current/gist.html>`_. These indexes are <https://www.postgresql.org/docs/current/gist.html>`_. These indexes are
@ -109,13 +129,18 @@ available from the ``django.contrib.postgres.indexes`` module.
Provide an integer value from 10 to 100 to the fillfactor_ parameter to Provide an integer value from 10 to 100 to the fillfactor_ parameter to
tune how packed the index pages will be. PostgreSQL's default is 90. tune how packed the index pages will be. PostgreSQL's default is 90.
.. versionchanged:: 3.2
Positional argument ``*expressions`` was added in order to support
functional indexes.
.. _buffering build: https://www.postgresql.org/docs/current/gist-implementation.html#GIST-BUFFERING-BUILD .. _buffering build: https://www.postgresql.org/docs/current/gist-implementation.html#GIST-BUFFERING-BUILD
.. _fillfactor: https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-STORAGE-PARAMETERS .. _fillfactor: https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-STORAGE-PARAMETERS
``HashIndex`` ``HashIndex``
============= =============
.. class:: HashIndex(fillfactor=None, **options) .. class:: HashIndex(*expressions, fillfactor=None, **options)
Creates a hash index. Creates a hash index.
@ -127,12 +152,17 @@ available from the ``django.contrib.postgres.indexes`` module.
Hash indexes have been available in PostgreSQL for a long time, but Hash indexes have been available in PostgreSQL for a long time, but
they suffer from a number of data integrity issues in older versions. they suffer from a number of data integrity issues in older versions.
.. versionchanged:: 3.2
Positional argument ``*expressions`` was added in order to support
functional indexes.
.. _fillfactor: https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-STORAGE-PARAMETERS .. _fillfactor: https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-STORAGE-PARAMETERS
``SpGistIndex`` ``SpGistIndex``
=============== ===============
.. class:: SpGistIndex(fillfactor=None, **options) .. class:: SpGistIndex(*expressions, fillfactor=None, **options)
Creates an `SP-GiST index Creates an `SP-GiST index
<https://www.postgresql.org/docs/current/spgist.html>`_. <https://www.postgresql.org/docs/current/spgist.html>`_.
@ -140,4 +170,30 @@ available from the ``django.contrib.postgres.indexes`` module.
Provide an integer value from 10 to 100 to the fillfactor_ parameter to Provide an integer value from 10 to 100 to the fillfactor_ parameter to
tune how packed the index pages will be. PostgreSQL's default is 90. tune how packed the index pages will be. PostgreSQL's default is 90.
.. versionchanged:: 3.2
Positional argument ``*expressions`` was added in order to support
functional indexes.
.. _fillfactor: https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-STORAGE-PARAMETERS .. _fillfactor: https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-STORAGE-PARAMETERS
``OpClass()`` expressions
=========================
.. versionadded:: 3.2
.. class:: OpClass(expression, name)
An ``OpClass()`` expression represents the ``expression`` with a custom
`operator class`_ that can be used to define functional indexes. To use it,
you need to add ``'django.contrib.postgres'`` in your
:setting:`INSTALLED_APPS`. Set the ``name`` parameter to the name of the
`operator class`_.
For example::
Index(OpClass(Lower('username'), name='varchar_pattern_ops'))
creates an index on ``Lower('username')`` using ``varchar_pattern_ops``.
.. _operator class: https://www.postgresql.org/docs/current/indexes-opclass.html

View File

@ -21,10 +21,55 @@ options`_.
``Index`` options ``Index`` options
================= =================
.. class:: Index(fields=(), name=None, db_tablespace=None, opclasses=(), condition=None, include=None) .. class:: Index(*expressions, 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.
``expressions``
---------------
.. attribute:: Index.expressions
.. versionadded:: 3.2
Positional argument ``*expressions`` allows creating functional indexes on
expressions and database functions.
For example::
Index(Lower('title').desc(), 'pub_date', name='lower_title_date_idx')
creates an index on the lowercased value of the ``title`` field in descending
order and the ``pub_date`` field in the default ascending order.
Another example::
Index(F('height') * F('weight'), Round('weight'), name='calc_idx')
creates an index on the result of multiplying fields ``height`` and ``weight``
and the ``weight`` rounded to the nearest integer.
:attr:`Index.name` is required when using ``*expressions``.
.. admonition:: Restrictions on Oracle
Oracle requires functions referenced in an index to be marked as
``DETERMINISTIC``. Django doesn't validate this but Oracle will error. This
means that functions such as
:class:`Random() <django.db.models.functions.Random>` aren't accepted.
.. admonition:: Restrictions on PostgreSQL
PostgreSQL requires functions and operators referenced in an index to be
marked as ``IMMUTABLE``. Django doesn't validate this but PostgreSQL will
error. This means that functions such as
:class:`Concat() <django.db.models.functions.Concat>` aren't accepted.
.. admonition:: MySQL and MariaDB
Functional indexes are ignored with MySQL < 8.0.13 and MariaDB as neither
supports them.
``fields`` ``fields``
---------- ----------
@ -130,8 +175,8 @@ indexes records with more than 400 pages.
.. admonition:: Oracle .. admonition:: Oracle
Oracle does not support partial indexes. Instead, partial indexes can be Oracle does not support partial indexes. Instead, partial indexes can be
emulated using functional indexes. Use a :doc:`migration emulated by using functional indexes together with
</topics/migrations>` to add the index using :class:`.RunSQL`. :class:`~django.db.models.expressions.Case` expressions.
.. admonition:: MySQL and MariaDB .. admonition:: MySQL and MariaDB

View File

@ -95,6 +95,42 @@ or on a per-model basis::
In anticipation of the changing default, a system check will provide a warning In anticipation of the changing default, a system check will provide a warning
if you do not have an explicit setting for :setting:`DEFAULT_AUTO_FIELD`. if you do not have an explicit setting for :setting:`DEFAULT_AUTO_FIELD`.
.. _new_functional_indexes:
Functional indexes
------------------
The new :attr:`*expressions <django.db.models.Index.expressions>` positional
argument of :class:`Index() <django.db.models.Index>` enables creating
functional indexes on expressions and database functions. For example::
from django.db import models
from django.db.models import F, Index, Value
from django.db.models.functions import Lower, Upper
class MyModel(models.Model):
first_name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255)
height = models.IntegerField()
weight = models.IntegerField()
class Meta:
indexes = [
Index(
Lower('first_name'),
Upper('last_name').desc(),
name='first_last_name_idx',
),
Index(
F('height') / (F('weight') + Value(5)),
name='calc_idx',
),
]
Functional indexes are added to models using the
:attr:`Meta.indexes <django.db.models.Options.indexes>` option.
``pymemcache`` support ``pymemcache`` support
---------------------- ----------------------
@ -210,6 +246,10 @@ Minor features
* Lookups for :class:`~django.contrib.postgres.fields.ArrayField` now allow * Lookups for :class:`~django.contrib.postgres.fields.ArrayField` now allow
(non-nested) arrays containing expressions as right-hand sides. (non-nested) arrays containing expressions as right-hand sides.
* The new :class:`OpClass() <django.contrib.postgres.indexes.OpClass>`
expression allows creating functional indexes on expressions with a custom
operator class. See :ref:`new_functional_indexes` for more details.
:mod:`django.contrib.redirects` :mod:`django.contrib.redirects`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -1,7 +1,13 @@
from django.db import connection
from django.db.backends.ddl_references import ( from django.db.backends.ddl_references import (
Columns, ForeignKeyName, IndexName, Statement, Table, Columns, Expressions, ForeignKeyName, IndexName, Statement, Table,
) )
from django.test import SimpleTestCase from django.db.models import ExpressionList, F
from django.db.models.functions import Upper
from django.db.models.indexes import IndexExpression
from django.test import SimpleTestCase, TransactionTestCase
from .models import Person
class TableTests(SimpleTestCase): class TableTests(SimpleTestCase):
@ -181,3 +187,66 @@ class StatementTests(SimpleTestCase):
reference = MockReference('reference', {}, {}) reference = MockReference('reference', {}, {})
statement = Statement("%(reference)s - %(non_reference)s", reference=reference, non_reference='non_reference') statement = Statement("%(reference)s - %(non_reference)s", reference=reference, non_reference='non_reference')
self.assertEqual(str(statement), 'reference - non_reference') self.assertEqual(str(statement), 'reference - non_reference')
class ExpressionsTests(TransactionTestCase):
available_apps = []
def setUp(self):
compiler = Person.objects.all().query.get_compiler(connection.alias)
self.editor = connection.schema_editor()
self.expressions = Expressions(
table=Person._meta.db_table,
expressions=ExpressionList(
IndexExpression(F('first_name')),
IndexExpression(F('last_name').desc()),
IndexExpression(Upper('last_name')),
).resolve_expression(compiler.query),
compiler=compiler,
quote_value=self.editor.quote_value,
)
def test_references_table(self):
self.assertIs(self.expressions.references_table(Person._meta.db_table), True)
self.assertIs(self.expressions.references_table('other'), False)
def test_references_column(self):
table = Person._meta.db_table
self.assertIs(self.expressions.references_column(table, 'first_name'), True)
self.assertIs(self.expressions.references_column(table, 'last_name'), True)
self.assertIs(self.expressions.references_column(table, 'other'), False)
def test_rename_table_references(self):
table = Person._meta.db_table
self.expressions.rename_table_references(table, 'other')
self.assertIs(self.expressions.references_table(table), False)
self.assertIs(self.expressions.references_table('other'), True)
self.assertIn(
'%s.%s' % (
self.editor.quote_name('other'),
self.editor.quote_name('first_name'),
),
str(self.expressions),
)
def test_rename_column_references(self):
table = Person._meta.db_table
self.expressions.rename_column_references(table, 'first_name', 'other')
self.assertIs(self.expressions.references_column(table, 'other'), True)
self.assertIs(self.expressions.references_column(table, 'first_name'), False)
self.assertIn(
'%s.%s' % (self.editor.quote_name(table), self.editor.quote_name('other')),
str(self.expressions),
)
def test_str(self):
table_name = self.editor.quote_name(Person._meta.db_table)
expected_str = '%s.%s, %s.%s DESC, (UPPER(%s.%s))' % (
table_name,
self.editor.quote_name('first_name'),
table_name,
self.editor.quote_name('last_name'),
table_name,
self.editor.quote_name('last_name'),
)
self.assertEqual(str(self.expressions), expected_str)

View File

@ -3,6 +3,7 @@ from unittest import skipUnless
from django.db import connection from django.db import connection
from django.db.models import CASCADE, ForeignKey, Index, Q from django.db.models import CASCADE, ForeignKey, Index, Q
from django.db.models.functions import Lower
from django.test import ( from django.test import (
TestCase, TransactionTestCase, skipIfDBFeature, skipUnlessDBFeature, TestCase, TransactionTestCase, skipIfDBFeature, skipUnlessDBFeature,
) )
@ -452,6 +453,40 @@ class PartialIndexTests(TransactionTestCase):
)) ))
editor.remove_index(index=index, model=Article) editor.remove_index(index=index, model=Article)
@skipUnlessDBFeature('supports_expression_indexes')
def test_partial_func_index(self):
index_name = 'partial_func_idx'
index = Index(
Lower('headline').desc(),
name=index_name,
condition=Q(pub_date__isnull=False),
)
with connection.schema_editor() as editor:
editor.add_index(index=index, model=Article)
sql = index.create_sql(Article, schema_editor=editor)
table = Article._meta.db_table
self.assertIs(sql.references_column(table, 'headline'), True)
sql = str(sql)
self.assertIn('LOWER(%s)' % editor.quote_name('headline'), sql)
self.assertIn(
'WHERE %s IS NOT NULL' % editor.quote_name('pub_date'),
sql,
)
self.assertGreater(sql.find('WHERE'), sql.find('LOWER'))
with connection.cursor() as cursor:
constraints = connection.introspection.get_constraints(
cursor=cursor, table_name=table,
)
self.assertIn(index_name, constraints)
if connection.features.supports_index_column_ordering:
self.assertEqual(constraints[index_name]['orders'], ['DESC'])
with connection.schema_editor() as editor:
editor.remove_index(Article, index)
with connection.cursor() as cursor:
self.assertNotIn(index_name, connection.introspection.get_constraints(
cursor=cursor, table_name=table,
))
@skipUnlessDBFeature('supports_covering_indexes') @skipUnlessDBFeature('supports_covering_indexes')
class CoveringIndexTests(TransactionTestCase): class CoveringIndexTests(TransactionTestCase):
@ -520,6 +555,31 @@ class CoveringIndexTests(TransactionTestCase):
cursor=cursor, table_name=Article._meta.db_table, cursor=cursor, table_name=Article._meta.db_table,
)) ))
def test_covering_func_index(self):
index_name = 'covering_func_headline_idx'
index = Index(Lower('headline'), name=index_name, include=['pub_date'])
with connection.schema_editor() as editor:
editor.add_index(index=index, model=Article)
sql = index.create_sql(Article, schema_editor=editor)
table = Article._meta.db_table
self.assertIs(sql.references_column(table, 'headline'), True)
sql = str(sql)
self.assertIn('LOWER(%s)' % editor.quote_name('headline'), sql)
self.assertIn('INCLUDE (%s)' % editor.quote_name('pub_date'), sql)
self.assertGreater(sql.find('INCLUDE'), sql.find('LOWER'))
with connection.cursor() as cursor:
constraints = connection.introspection.get_constraints(
cursor=cursor, table_name=table,
)
self.assertIn(index_name, constraints)
self.assertIn('pub_date', constraints[index_name]['columns'])
with connection.schema_editor() as editor:
editor.remove_index(Article, index)
with connection.cursor() as cursor:
self.assertNotIn(index_name, connection.introspection.get_constraints(
cursor=cursor, table_name=table,
))
@skipIfDBFeature('supports_covering_indexes') @skipIfDBFeature('supports_covering_indexes')
class CoveringIndexIgnoredTests(TransactionTestCase): class CoveringIndexIgnoredTests(TransactionTestCase):

View File

@ -495,6 +495,36 @@ class IndexesTests(TestCase):
self.assertEqual(Model.check(databases=self.databases), []) self.assertEqual(Model.check(databases=self.databases), [])
def test_func_index(self):
class Model(models.Model):
name = models.CharField(max_length=10)
class Meta:
indexes = [models.Index(Lower('name'), name='index_lower_name')]
warn = Warning(
'%s does not support indexes on expressions.'
% connection.display_name,
hint=(
"An index won't be created. Silence this warning if you don't "
"care about it."
),
obj=Model,
id='models.W043',
)
expected = [] if connection.features.supports_expression_indexes else [warn]
self.assertEqual(Model.check(databases=self.databases), expected)
def test_func_index_required_db_features(self):
class Model(models.Model):
name = models.CharField(max_length=10)
class Meta:
indexes = [models.Index(Lower('name'), name='index_lower_name')]
required_db_features = {'supports_expression_indexes'}
self.assertEqual(Model.check(databases=self.databases), [])
@isolate_apps('invalid_models_tests') @isolate_apps('invalid_models_tests')
class FieldNamesTests(TestCase): class FieldNamesTests(TestCase):

View File

@ -73,6 +73,20 @@ class MigrationTestBase(TransactionTestCase):
def assertIndexNotExists(self, table, columns): def assertIndexNotExists(self, table, columns):
return self.assertIndexExists(table, columns, False) return self.assertIndexExists(table, columns, False)
def assertIndexNameExists(self, table, index, using='default'):
with connections[using].cursor() as cursor:
self.assertIn(
index,
connection.introspection.get_constraints(cursor, table),
)
def assertIndexNameNotExists(self, table, index, using='default'):
with connections[using].cursor() as cursor:
self.assertNotIn(
index,
connection.introspection.get_constraints(cursor, table),
)
def assertConstraintExists(self, table, name, value=True, using='default'): def assertConstraintExists(self, table, name, value=True, using='default'):
with connections[using].cursor() as cursor: with connections[using].cursor() as cursor:
constraints = connections[using].introspection.get_constraints(cursor, table).items() constraints = connections[using].introspection.get_constraints(cursor, table).items()
@ -194,6 +208,7 @@ class OperationTestBase(MigrationTestBase):
multicol_index=False, related_model=False, mti_model=False, multicol_index=False, related_model=False, mti_model=False,
proxy_model=False, manager_model=False, unique_together=False, proxy_model=False, manager_model=False, unique_together=False,
options=False, db_table=None, index_together=False, constraints=None, options=False, db_table=None, index_together=False, constraints=None,
indexes=None,
): ):
"""Creates a test model state and database table.""" """Creates a test model state and database table."""
# Make the "current" state. # Make the "current" state.
@ -225,6 +240,9 @@ class OperationTestBase(MigrationTestBase):
'Pony', 'Pony',
models.Index(fields=['pink', 'weight'], name='pony_test_idx'), models.Index(fields=['pink', 'weight'], name='pony_test_idx'),
)) ))
if indexes:
for index in indexes:
operations.append(migrations.AddIndex('Pony', index))
if constraints: if constraints:
for constraint in constraints: for constraint in constraints:
operations.append(migrations.AddConstraint('Pony', constraint)) operations.append(migrations.AddConstraint('Pony', constraint))

View File

@ -5,6 +5,7 @@ from django.db import (
from django.db.migrations.migration import Migration from django.db.migrations.migration import Migration
from django.db.migrations.operations.fields import FieldOperation from django.db.migrations.operations.fields import FieldOperation
from django.db.migrations.state import ModelState, ProjectState from django.db.migrations.state import ModelState, ProjectState
from django.db.models.functions import Abs
from django.db.transaction import atomic from django.db.transaction import atomic
from django.test import SimpleTestCase, override_settings, skipUnlessDBFeature from django.test import SimpleTestCase, override_settings, skipUnlessDBFeature
@ -1939,6 +1940,76 @@ class OperationTests(OperationTestBase):
new_model = new_state.apps.get_model('test_rminsf', 'Pony') new_model = new_state.apps.get_model('test_rminsf', 'Pony')
self.assertIsNot(old_model, new_model) self.assertIsNot(old_model, new_model)
@skipUnlessDBFeature('supports_expression_indexes')
def test_add_func_index(self):
app_label = 'test_addfuncin'
index_name = f'{app_label}_pony_abs_idx'
table_name = f'{app_label}_pony'
project_state = self.set_up_test_model(app_label)
index = models.Index(Abs('weight'), name=index_name)
operation = migrations.AddIndex('Pony', index)
self.assertEqual(
operation.describe(),
'Create index test_addfuncin_pony_abs_idx on Abs(F(weight)) on model Pony',
)
self.assertEqual(
operation.migration_name_fragment,
'pony_test_addfuncin_pony_abs_idx',
)
new_state = project_state.clone()
operation.state_forwards(app_label, new_state)
self.assertEqual(len(new_state.models[app_label, 'pony'].options['indexes']), 1)
self.assertIndexNameNotExists(table_name, index_name)
# Add index.
with connection.schema_editor() as editor:
operation.database_forwards(app_label, editor, project_state, new_state)
self.assertIndexNameExists(table_name, index_name)
# Reversal.
with connection.schema_editor() as editor:
operation.database_backwards(app_label, editor, new_state, project_state)
self.assertIndexNameNotExists(table_name, index_name)
# Deconstruction.
definition = operation.deconstruct()
self.assertEqual(definition[0], 'AddIndex')
self.assertEqual(definition[1], [])
self.assertEqual(definition[2], {'model_name': 'Pony', 'index': index})
@skipUnlessDBFeature('supports_expression_indexes')
def test_remove_func_index(self):
app_label = 'test_rmfuncin'
index_name = f'{app_label}_pony_abs_idx'
table_name = f'{app_label}_pony'
project_state = self.set_up_test_model(app_label, indexes=[
models.Index(Abs('weight'), name=index_name),
])
self.assertTableExists(table_name)
self.assertIndexNameExists(table_name, index_name)
operation = migrations.RemoveIndex('Pony', index_name)
self.assertEqual(
operation.describe(),
'Remove index test_rmfuncin_pony_abs_idx from Pony',
)
self.assertEqual(
operation.migration_name_fragment,
'remove_pony_test_rmfuncin_pony_abs_idx',
)
new_state = project_state.clone()
operation.state_forwards(app_label, new_state)
self.assertEqual(len(new_state.models[app_label, 'pony'].options['indexes']), 0)
# Remove index.
with connection.schema_editor() as editor:
operation.database_forwards(app_label, editor, project_state, new_state)
self.assertIndexNameNotExists(table_name, index_name)
# Reversal.
with connection.schema_editor() as editor:
operation.database_backwards(app_label, editor, new_state, project_state)
self.assertIndexNameExists(table_name, index_name)
# Deconstruction.
definition = operation.deconstruct()
self.assertEqual(definition[0], 'RemoveIndex')
self.assertEqual(definition[1], [])
self.assertEqual(definition[2], {'model_name': 'Pony', 'name': index_name})
def test_alter_field_with_index(self): def test_alter_field_with_index(self):
""" """
Test AlterField operation with an index to ensure indexes created via Test AlterField operation with an index to ensure indexes created via

View File

@ -2,6 +2,7 @@ from unittest import mock
from django.conf import settings from django.conf import settings
from django.db import connection, models from django.db import connection, models
from django.db.models.functions import Lower, Upper
from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature
from django.test.utils import isolate_apps from django.test.utils import isolate_apps
@ -27,6 +28,7 @@ class SimpleIndexesTests(SimpleTestCase):
name='opclasses_idx', name='opclasses_idx',
opclasses=['varchar_pattern_ops', 'text_pattern_ops'], opclasses=['varchar_pattern_ops', 'text_pattern_ops'],
) )
func_index = models.Index(Lower('title'), name='book_func_idx')
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))>")
@ -39,6 +41,7 @@ class SimpleIndexesTests(SimpleTestCase):
"<Index: fields='headline, body' " "<Index: fields='headline, body' "
"opclasses='varchar_pattern_ops, text_pattern_ops'>", "opclasses='varchar_pattern_ops, text_pattern_ops'>",
) )
self.assertEqual(repr(func_index), "<Index: expressions='Lower(F(title))'>")
def test_eq(self): def test_eq(self):
index = models.Index(fields=['title']) index = models.Index(fields=['title'])
@ -51,6 +54,14 @@ class SimpleIndexesTests(SimpleTestCase):
self.assertEqual(index, mock.ANY) self.assertEqual(index, mock.ANY)
self.assertNotEqual(index, another_index) self.assertNotEqual(index, another_index)
def test_eq_func(self):
index = models.Index(Lower('title'), models.F('author'), name='book_func_idx')
same_index = models.Index(Lower('title'), 'author', name='book_func_idx')
another_index = models.Index(Lower('title'), name='book_func_idx')
self.assertEqual(index, same_index)
self.assertEqual(index, mock.ANY)
self.assertNotEqual(index, another_index)
def test_index_fields_type(self): def test_index_fields_type(self):
with self.assertRaisesMessage(ValueError, 'Index.fields must be a list or tuple.'): with self.assertRaisesMessage(ValueError, 'Index.fields must be a list or tuple.'):
models.Index(fields='title') models.Index(fields='title')
@ -63,11 +74,16 @@ class SimpleIndexesTests(SimpleTestCase):
def test_fields_tuple(self): def test_fields_tuple(self):
self.assertEqual(models.Index(fields=('title',)).fields, ['title']) self.assertEqual(models.Index(fields=('title',)).fields, ['title'])
def test_raises_error_without_field(self): def test_requires_field_or_expression(self):
msg = 'At least one field is required to define an index.' msg = 'At least one field or expression is required to define an index.'
with self.assertRaisesMessage(ValueError, msg): with self.assertRaisesMessage(ValueError, msg):
models.Index() models.Index()
def test_expressions_and_fields_mutually_exclusive(self):
msg = "Index.fields and expressions are mutually exclusive."
with self.assertRaisesMessage(ValueError, msg):
models.Index(Upper('foo'), fields=['field'])
def test_opclasses_requires_index_name(self): def test_opclasses_requires_index_name(self):
with self.assertRaisesMessage(ValueError, 'An index must be named to use opclasses.'): with self.assertRaisesMessage(ValueError, 'An index must be named to use opclasses.'):
models.Index(opclasses=['jsonb_path_ops']) models.Index(opclasses=['jsonb_path_ops'])
@ -85,6 +101,23 @@ class SimpleIndexesTests(SimpleTestCase):
with self.assertRaisesMessage(ValueError, 'An index must be named to use condition.'): with self.assertRaisesMessage(ValueError, 'An index must be named to use condition.'):
models.Index(condition=models.Q(pages__gt=400)) models.Index(condition=models.Q(pages__gt=400))
def test_expressions_requires_index_name(self):
msg = 'An index must be named to use expressions.'
with self.assertRaisesMessage(ValueError, msg):
models.Index(Lower('field'))
def test_expressions_with_opclasses(self):
msg = (
'Index.opclasses cannot be used with expressions. Use '
'django.contrib.postgres.indexes.OpClass() instead.'
)
with self.assertRaisesMessage(ValueError, msg):
models.Index(
Lower('field'),
name='test_func_opclass',
opclasses=['jsonb_path_ops'],
)
def test_condition_must_be_q(self): def test_condition_must_be_q(self):
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')
@ -181,12 +214,25 @@ class SimpleIndexesTests(SimpleTestCase):
}, },
) )
def test_deconstruct_with_expressions(self):
index = models.Index(Upper('title'), name='book_func_idx')
path, args, kwargs = index.deconstruct()
self.assertEqual(path, 'django.db.models.Index')
self.assertEqual(args, (Upper('title'),))
self.assertEqual(kwargs, {'name': 'book_func_idx'})
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()
self.assertIsNot(index, new_index) self.assertIsNot(index, new_index)
self.assertEqual(index.fields, new_index.fields) self.assertEqual(index.fields, new_index.fields)
def test_clone_with_expressions(self):
index = models.Index(Upper('title'), name='book_func_idx')
new_index = index.clone()
self.assertIsNot(index, new_index)
self.assertEqual(index.expressions, new_index.expressions)
def test_name_set(self): def test_name_set(self):
index_names = [index.name for index in Book._meta.indexes] index_names = [index.name for index in Book._meta.indexes]
self.assertCountEqual( self.assertCountEqual(
@ -248,3 +294,29 @@ class IndexesTests(TestCase):
# db_tablespace. # db_tablespace.
index = models.Index(fields=['shortcut']) index = models.Index(fields=['shortcut'])
self.assertIn('"idx_tbls"', str(index.create_sql(Book, editor)).lower()) self.assertIn('"idx_tbls"', str(index.create_sql(Book, editor)).lower())
@skipUnlessDBFeature('supports_tablespaces')
def test_func_with_tablespace(self):
# Functional index with db_tablespace attribute.
index = models.Index(
Lower('shortcut').desc(),
name='functional_tbls',
db_tablespace='idx_tbls2',
)
with connection.schema_editor() as editor:
sql = str(index.create_sql(Book, editor))
self.assertIn(editor.quote_name('idx_tbls2'), sql)
# Functional index without db_tablespace attribute.
index = models.Index(Lower('shortcut').desc(), name='functional_no_tbls')
with connection.schema_editor() as editor:
sql = str(index.create_sql(Book, editor))
# The DEFAULT_INDEX_TABLESPACE setting can't be tested because it's
# evaluated when the model class is defined. As a consequence,
# @override_settings doesn't work.
if settings.DEFAULT_INDEX_TABLESPACE:
self.assertIn(
editor.quote_name(settings.DEFAULT_INDEX_TABLESPACE),
sql,
)
else:
self.assertNotIn('TABLESPACE', sql)

View File

@ -12,7 +12,7 @@ try:
CITextField, DateRangeField, DateTimeRangeField, DecimalRangeField, CITextField, DateRangeField, DateTimeRangeField, DecimalRangeField,
HStoreField, IntegerRangeField, HStoreField, IntegerRangeField,
) )
from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.search import SearchVector, SearchVectorField
except ImportError: except ImportError:
class DummyArrayField(models.Field): class DummyArrayField(models.Field):
def __init__(self, base_field, size=None, **kwargs): def __init__(self, base_field, size=None, **kwargs):
@ -36,6 +36,7 @@ except ImportError:
DecimalRangeField = models.Field DecimalRangeField = models.Field
HStoreField = models.Field HStoreField = models.Field
IntegerRangeField = models.Field IntegerRangeField = models.Field
SearchVector = models.Expression
SearchVectorField = models.Field SearchVectorField = models.Field

View File

@ -1,17 +1,18 @@
from unittest import mock from unittest import mock
from django.contrib.postgres.indexes import ( from django.contrib.postgres.indexes import (
BloomIndex, BrinIndex, BTreeIndex, GinIndex, GistIndex, HashIndex, BloomIndex, BrinIndex, BTreeIndex, GinIndex, GistIndex, HashIndex, OpClass,
SpGistIndex, SpGistIndex,
) )
from django.db import NotSupportedError, connection from django.db import NotSupportedError, connection
from django.db.models import CharField, Q from django.db.models import CharField, F, Index, Q
from django.db.models.functions import Length from django.db.models.functions import Cast, Collate, Length, Lower
from django.test import skipUnlessDBFeature from django.test import skipUnlessDBFeature
from django.test.utils import register_lookup from django.test.utils import modify_settings, register_lookup
from . import PostgreSQLSimpleTestCase, PostgreSQLTestCase from . import PostgreSQLSimpleTestCase, PostgreSQLTestCase
from .models import CharFieldModel, IntegerArrayModel, Scene from .fields import SearchVector, SearchVectorField
from .models import CharFieldModel, IntegerArrayModel, Scene, TextFieldModel
class IndexTestMixin: class IndexTestMixin:
@ -28,6 +29,17 @@ class IndexTestMixin:
self.assertEqual(args, ()) self.assertEqual(args, ())
self.assertEqual(kwargs, {'fields': ['title'], 'name': 'test_title_%s' % self.index_class.suffix}) self.assertEqual(kwargs, {'fields': ['title'], 'name': 'test_title_%s' % self.index_class.suffix})
def test_deconstruction_with_expressions_no_customization(self):
name = f'test_title_{self.index_class.suffix}'
index = self.index_class(Lower('title'), name=name)
path, args, kwargs = index.deconstruct()
self.assertEqual(
path,
f'django.contrib.postgres.indexes.{self.index_class.__name__}',
)
self.assertEqual(args, (Lower('title'),))
self.assertEqual(kwargs, {'name': name})
class BloomIndexTests(IndexTestMixin, PostgreSQLSimpleTestCase): class BloomIndexTests(IndexTestMixin, PostgreSQLSimpleTestCase):
index_class = BloomIndex index_class = BloomIndex
@ -181,7 +193,14 @@ class SpGistIndexTests(IndexTestMixin, PostgreSQLSimpleTestCase):
self.assertEqual(kwargs, {'fields': ['title'], 'name': 'test_title_spgist', 'fillfactor': 80}) self.assertEqual(kwargs, {'fields': ['title'], 'name': 'test_title_spgist', 'fillfactor': 80})
@modify_settings(INSTALLED_APPS={'append': 'django.contrib.postgres'})
class SchemaTests(PostgreSQLTestCase): class SchemaTests(PostgreSQLTestCase):
get_opclass_query = '''
SELECT opcname, c.relname FROM pg_opclass AS oc
JOIN pg_index as i on oc.oid = ANY(i.indclass)
JOIN pg_class as c on c.oid = i.indexrelid
WHERE c.relname = %s
'''
def get_constraints(self, table): def get_constraints(self, table):
""" """
@ -260,6 +279,37 @@ class SchemaTests(PostgreSQLTestCase):
editor.remove_index(IntegerArrayModel, index) editor.remove_index(IntegerArrayModel, index)
self.assertNotIn(index_name, self.get_constraints(IntegerArrayModel._meta.db_table)) self.assertNotIn(index_name, self.get_constraints(IntegerArrayModel._meta.db_table))
def test_trigram_op_class_gin_index(self):
index_name = 'trigram_op_class_gin'
index = GinIndex(OpClass(F('scene'), name='gin_trgm_ops'), name=index_name)
with connection.schema_editor() as editor:
editor.add_index(Scene, index)
with editor.connection.cursor() as cursor:
cursor.execute(self.get_opclass_query, [index_name])
self.assertCountEqual(cursor.fetchall(), [('gin_trgm_ops', index_name)])
constraints = self.get_constraints(Scene._meta.db_table)
self.assertIn(index_name, constraints)
self.assertIn(constraints[index_name]['type'], GinIndex.suffix)
with connection.schema_editor() as editor:
editor.remove_index(Scene, index)
self.assertNotIn(index_name, self.get_constraints(Scene._meta.db_table))
def test_cast_search_vector_gin_index(self):
index_name = 'cast_search_vector_gin'
index = GinIndex(Cast('field', SearchVectorField()), name=index_name)
with connection.schema_editor() as editor:
editor.add_index(TextFieldModel, index)
sql = index.create_sql(TextFieldModel, editor)
table = TextFieldModel._meta.db_table
constraints = self.get_constraints(table)
self.assertIn(index_name, constraints)
self.assertIn(constraints[index_name]['type'], GinIndex.suffix)
self.assertIs(sql.references_column(table, 'field'), True)
self.assertIn('::tsvector', str(sql))
with connection.schema_editor() as editor:
editor.remove_index(TextFieldModel, index)
self.assertNotIn(index_name, self.get_constraints(table))
def test_bloom_index(self): def test_bloom_index(self):
index_name = 'char_field_model_field_bloom' index_name = 'char_field_model_field_bloom'
index = BloomIndex(fields=['field'], name=index_name) index = BloomIndex(fields=['field'], name=index_name)
@ -400,6 +450,28 @@ class SchemaTests(PostgreSQLTestCase):
editor.add_index(Scene, index) editor.add_index(Scene, index)
self.assertNotIn(index_name, self.get_constraints(Scene._meta.db_table)) self.assertNotIn(index_name, self.get_constraints(Scene._meta.db_table))
def test_tsvector_op_class_gist_index(self):
index_name = 'tsvector_op_class_gist'
index = GistIndex(
OpClass(
SearchVector('scene', 'setting', config='english'),
name='tsvector_ops',
),
name=index_name,
)
with connection.schema_editor() as editor:
editor.add_index(Scene, index)
sql = index.create_sql(Scene, editor)
table = Scene._meta.db_table
constraints = self.get_constraints(table)
self.assertIn(index_name, constraints)
self.assertIn(constraints[index_name]['type'], GistIndex.suffix)
self.assertIs(sql.references_column(table, 'scene'), True)
self.assertIs(sql.references_column(table, 'setting'), True)
with connection.schema_editor() as editor:
editor.remove_index(Scene, index)
self.assertNotIn(index_name, self.get_constraints(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))
@ -455,3 +527,83 @@ class SchemaTests(PostgreSQLTestCase):
with connection.schema_editor() as editor: with connection.schema_editor() as editor:
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))
def test_op_class(self):
index_name = 'test_op_class'
index = Index(
OpClass(Lower('field'), name='text_pattern_ops'),
name=index_name,
)
with connection.schema_editor() as editor:
editor.add_index(TextFieldModel, index)
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_op_class_descending_collation(self):
collation = connection.features.test_collations.get('non_default')
if not collation:
self.skipTest(
'This backend does not support case-insensitive collations.'
)
index_name = 'test_op_class_descending_collation'
index = Index(
Collate(
OpClass(Lower('field'), name='text_pattern_ops').desc(nulls_last=True),
collation=collation,
),
name=index_name,
)
with connection.schema_editor() as editor:
editor.add_index(TextFieldModel, index)
self.assertIn(
'COLLATE %s' % editor.quote_name(collation),
str(index.create_sql(TextFieldModel, editor)),
)
with editor.connection.cursor() as cursor:
cursor.execute(self.get_opclass_query, [index_name])
self.assertCountEqual(cursor.fetchall(), [('text_pattern_ops', index_name)])
table = TextFieldModel._meta.db_table
constraints = self.get_constraints(table)
self.assertIn(index_name, constraints)
self.assertEqual(constraints[index_name]['orders'], ['DESC'])
with connection.schema_editor() as editor:
editor.remove_index(TextFieldModel, index)
self.assertNotIn(index_name, self.get_constraints(table))
def test_op_class_descending_partial(self):
index_name = 'test_op_class_descending_partial'
index = Index(
OpClass(Lower('field'), name='text_pattern_ops').desc(),
name=index_name,
condition=Q(field__contains='China'),
)
with connection.schema_editor() as editor:
editor.add_index(TextFieldModel, index)
with editor.connection.cursor() as cursor:
cursor.execute(self.get_opclass_query, [index_name])
self.assertCountEqual(cursor.fetchall(), [('text_pattern_ops', index_name)])
constraints = self.get_constraints(TextFieldModel._meta.db_table)
self.assertIn(index_name, constraints)
self.assertEqual(constraints[index_name]['orders'], ['DESC'])
def test_op_class_descending_partial_tablespace(self):
index_name = 'test_op_class_descending_partial_tablespace'
index = Index(
OpClass(Lower('field').desc(), name='text_pattern_ops'),
name=index_name,
condition=Q(field__contains='China'),
db_tablespace='pg_default',
)
with connection.schema_editor() as editor:
editor.add_index(TextFieldModel, index)
self.assertIn(
'TABLESPACE "pg_default" ',
str(index.create_sql(TextFieldModel, editor))
)
with editor.connection.cursor() as cursor:
cursor.execute(self.get_opclass_query, [index_name])
self.assertCountEqual(cursor.fetchall(), [('text_pattern_ops', index_name)])
constraints = self.get_constraints(TextFieldModel._meta.db_table)
self.assertIn(index_name, constraints)
self.assertEqual(constraints[index_name]['orders'], ['DESC'])

View File

@ -4,6 +4,7 @@ import unittest
from copy import copy from copy import copy
from unittest import mock from unittest import mock
from django.core.exceptions import FieldError
from django.core.management.color import no_style from django.core.management.color import no_style
from django.db import ( from django.db import (
DatabaseError, DataError, IntegrityError, OperationalError, connection, DatabaseError, DataError, IntegrityError, OperationalError, connection,
@ -11,15 +12,21 @@ from django.db import (
from django.db.models import ( from django.db.models import (
CASCADE, PROTECT, AutoField, BigAutoField, BigIntegerField, BinaryField, CASCADE, PROTECT, AutoField, BigAutoField, BigIntegerField, BinaryField,
BooleanField, CharField, CheckConstraint, DateField, DateTimeField, BooleanField, CharField, CheckConstraint, DateField, DateTimeField,
ForeignKey, ForeignObject, Index, IntegerField, ManyToManyField, Model, DecimalField, F, FloatField, ForeignKey, ForeignObject, Index,
OneToOneField, PositiveIntegerField, Q, SlugField, SmallAutoField, IntegerField, JSONField, ManyToManyField, Model, OneToOneField, OrderBy,
SmallIntegerField, TextField, TimeField, UniqueConstraint, UUIDField, PositiveIntegerField, Q, SlugField, SmallAutoField, SmallIntegerField,
TextField, TimeField, UniqueConstraint, UUIDField, Value,
) )
from django.db.models.fields.json import KeyTextTransform
from django.db.models.functions import Abs, Cast, Collate, Lower, Random, Upper
from django.db.models.indexes import IndexExpression
from django.db.transaction import TransactionManagementError, atomic from django.db.transaction import TransactionManagementError, atomic
from django.test import ( from django.test import (
TransactionTestCase, skipIfDBFeature, skipUnlessDBFeature, TransactionTestCase, skipIfDBFeature, skipUnlessDBFeature,
) )
from django.test.utils import CaptureQueriesContext, isolate_apps from django.test.utils import (
CaptureQueriesContext, isolate_apps, register_lookup,
)
from django.utils import timezone from django.utils import timezone
from .fields import ( from .fields import (
@ -2481,6 +2488,366 @@ class SchemaTests(TransactionTestCase):
assertion = self.assertIn if connection.features.supports_index_on_text_field else self.assertNotIn assertion = self.assertIn if connection.features.supports_index_on_text_field else self.assertNotIn
assertion('text_field', self.get_indexes(AuthorTextFieldWithIndex._meta.db_table)) assertion('text_field', self.get_indexes(AuthorTextFieldWithIndex._meta.db_table))
def _index_expressions_wrappers(self):
index_expression = IndexExpression()
index_expression.set_wrapper_classes(connection)
return ', '.join([
wrapper_cls.__qualname__ for wrapper_cls in index_expression.wrapper_classes
])
@skipUnlessDBFeature('supports_expression_indexes')
def test_func_index_multiple_wrapper_references(self):
index = Index(OrderBy(F('name').desc(), descending=True), name='name')
msg = (
"Multiple references to %s can't be used in an indexed expression."
% self._index_expressions_wrappers()
)
with connection.schema_editor() as editor:
with self.assertRaisesMessage(ValueError, msg):
editor.add_index(Author, index)
@skipUnlessDBFeature('supports_expression_indexes')
def test_func_index_invalid_topmost_expressions(self):
index = Index(Upper(F('name').desc()), name='name')
msg = (
'%s must be topmost expressions in an indexed expression.'
% self._index_expressions_wrappers()
)
with connection.schema_editor() as editor:
with self.assertRaisesMessage(ValueError, msg):
editor.add_index(Author, index)
@skipUnlessDBFeature('supports_expression_indexes')
def test_func_index(self):
with connection.schema_editor() as editor:
editor.create_model(Author)
index = Index(Lower('name').desc(), name='func_lower_idx')
# Add index.
with connection.schema_editor() as editor:
editor.add_index(Author, index)
sql = index.create_sql(Author, editor)
table = Author._meta.db_table
if connection.features.supports_index_column_ordering:
self.assertIndexOrder(table, index.name, ['DESC'])
# SQL contains a database function.
self.assertIs(sql.references_column(table, 'name'), True)
self.assertIn('LOWER(%s)' % editor.quote_name('name'), str(sql))
# Remove index.
with connection.schema_editor() as editor:
editor.remove_index(Author, index)
self.assertNotIn(index.name, self.get_constraints(table))
@skipUnlessDBFeature('supports_expression_indexes')
def test_func_index_f(self):
with connection.schema_editor() as editor:
editor.create_model(Tag)
index = Index('slug', F('title').desc(), name='func_f_idx')
# Add index.
with connection.schema_editor() as editor:
editor.add_index(Tag, index)
sql = index.create_sql(Tag, editor)
table = Tag._meta.db_table
self.assertIn(index.name, self.get_constraints(table))
if connection.features.supports_index_column_ordering:
self.assertIndexOrder(Tag._meta.db_table, index.name, ['ASC', 'DESC'])
# SQL contains columns.
self.assertIs(sql.references_column(table, 'slug'), True)
self.assertIs(sql.references_column(table, 'title'), True)
# Remove index.
with connection.schema_editor() as editor:
editor.remove_index(Tag, index)
self.assertNotIn(index.name, self.get_constraints(table))
@skipUnlessDBFeature('supports_expression_indexes')
def test_func_index_lookups(self):
with connection.schema_editor() as editor:
editor.create_model(Author)
with register_lookup(CharField, Lower), register_lookup(IntegerField, Abs):
index = Index(
F('name__lower'),
F('weight__abs'),
name='func_lower_abs_lookup_idx',
)
# Add index.
with connection.schema_editor() as editor:
editor.add_index(Author, index)
sql = index.create_sql(Author, editor)
table = Author._meta.db_table
self.assertIn(index.name, self.get_constraints(table))
# SQL contains columns.
self.assertIs(sql.references_column(table, 'name'), True)
self.assertIs(sql.references_column(table, 'weight'), True)
# Remove index.
with connection.schema_editor() as editor:
editor.remove_index(Author, index)
self.assertNotIn(index.name, self.get_constraints(table))
@skipUnlessDBFeature('supports_expression_indexes')
def test_composite_func_index(self):
with connection.schema_editor() as editor:
editor.create_model(Author)
index = Index(Lower('name'), Upper('name'), name='func_lower_upper_idx')
# Add index.
with connection.schema_editor() as editor:
editor.add_index(Author, index)
sql = index.create_sql(Author, editor)
table = Author._meta.db_table
self.assertIn(index.name, self.get_constraints(table))
# SQL contains database functions.
self.assertIs(sql.references_column(table, 'name'), True)
sql = str(sql)
self.assertIn('LOWER(%s)' % editor.quote_name('name'), sql)
self.assertIn('UPPER(%s)' % editor.quote_name('name'), sql)
self.assertLess(sql.index('LOWER'), sql.index('UPPER'))
# Remove index.
with connection.schema_editor() as editor:
editor.remove_index(Author, index)
self.assertNotIn(index.name, self.get_constraints(table))
@skipUnlessDBFeature('supports_expression_indexes')
def test_composite_func_index_field_and_expression(self):
with connection.schema_editor() as editor:
editor.create_model(Author)
editor.create_model(Book)
index = Index(
F('author').desc(),
Lower('title').asc(),
'pub_date',
name='func_f_lower_field_idx',
)
# Add index.
with connection.schema_editor() as editor:
editor.add_index(Book, index)
sql = index.create_sql(Book, editor)
table = Book._meta.db_table
constraints = self.get_constraints(table)
if connection.features.supports_index_column_ordering:
self.assertIndexOrder(table, index.name, ['DESC', 'ASC', 'ASC'])
self.assertEqual(len(constraints[index.name]['columns']), 3)
self.assertEqual(constraints[index.name]['columns'][2], 'pub_date')
# SQL contains database functions and columns.
self.assertIs(sql.references_column(table, 'author_id'), True)
self.assertIs(sql.references_column(table, 'title'), True)
self.assertIs(sql.references_column(table, 'pub_date'), True)
self.assertIn('LOWER(%s)' % editor.quote_name('title'), str(sql))
# Remove index.
with connection.schema_editor() as editor:
editor.remove_index(Book, index)
self.assertNotIn(index.name, self.get_constraints(table))
@skipUnlessDBFeature('supports_expression_indexes')
@isolate_apps('schema')
def test_func_index_f_decimalfield(self):
class Node(Model):
value = DecimalField(max_digits=5, decimal_places=2)
class Meta:
app_label = 'schema'
with connection.schema_editor() as editor:
editor.create_model(Node)
index = Index(F('value'), name='func_f_decimalfield_idx')
# Add index.
with connection.schema_editor() as editor:
editor.add_index(Node, index)
sql = index.create_sql(Node, editor)
table = Node._meta.db_table
self.assertIn(index.name, self.get_constraints(table))
self.assertIs(sql.references_column(table, 'value'), True)
# SQL doesn't contain casting.
self.assertNotIn('CAST', str(sql))
# Remove index.
with connection.schema_editor() as editor:
editor.remove_index(Node, index)
self.assertNotIn(index.name, self.get_constraints(table))
@skipUnlessDBFeature('supports_expression_indexes')
def test_func_index_cast(self):
with connection.schema_editor() as editor:
editor.create_model(Author)
index = Index(Cast('weight', FloatField()), name='func_cast_idx')
# Add index.
with connection.schema_editor() as editor:
editor.add_index(Author, index)
sql = index.create_sql(Author, editor)
table = Author._meta.db_table
self.assertIn(index.name, self.get_constraints(table))
self.assertIs(sql.references_column(table, 'weight'), True)
# Remove index.
with connection.schema_editor() as editor:
editor.remove_index(Author, index)
self.assertNotIn(index.name, self.get_constraints(table))
@skipUnlessDBFeature('supports_expression_indexes')
def test_func_index_collate(self):
collation = connection.features.test_collations.get('non_default')
if not collation:
self.skipTest(
'This backend does not support case-insensitive collations.'
)
with connection.schema_editor() as editor:
editor.create_model(Author)
editor.create_model(BookWithSlug)
index = Index(
Collate(F('title'), collation=collation).desc(),
Collate('slug', collation=collation),
name='func_collate_idx',
)
# Add index.
with connection.schema_editor() as editor:
editor.add_index(BookWithSlug, index)
sql = index.create_sql(BookWithSlug, editor)
table = Book._meta.db_table
self.assertIn(index.name, self.get_constraints(table))
if connection.features.supports_index_column_ordering:
self.assertIndexOrder(table, index.name, ['DESC', 'ASC'])
# SQL contains columns and a collation.
self.assertIs(sql.references_column(table, 'title'), True)
self.assertIs(sql.references_column(table, 'slug'), True)
self.assertIn('COLLATE %s' % editor.quote_name(collation), str(sql))
# Remove index.
with connection.schema_editor() as editor:
editor.remove_index(Book, index)
self.assertNotIn(index.name, self.get_constraints(table))
@skipUnlessDBFeature('supports_expression_indexes')
@skipIfDBFeature('collate_as_index_expression')
def test_func_index_collate_f_ordered(self):
collation = connection.features.test_collations.get('non_default')
if not collation:
self.skipTest(
'This backend does not support case-insensitive collations.'
)
with connection.schema_editor() as editor:
editor.create_model(Author)
index = Index(
Collate(F('name').desc(), collation=collation),
name='func_collate_f_desc_idx',
)
# Add index.
with connection.schema_editor() as editor:
editor.add_index(Author, index)
sql = index.create_sql(Author, editor)
table = Author._meta.db_table
self.assertIn(index.name, self.get_constraints(table))
if connection.features.supports_index_column_ordering:
self.assertIndexOrder(table, index.name, ['DESC'])
# SQL contains columns and a collation.
self.assertIs(sql.references_column(table, 'name'), True)
self.assertIn('COLLATE %s' % editor.quote_name(collation), str(sql))
# Remove index.
with connection.schema_editor() as editor:
editor.remove_index(Author, index)
self.assertNotIn(index.name, self.get_constraints(table))
@skipUnlessDBFeature('supports_expression_indexes')
def test_func_index_calc(self):
with connection.schema_editor() as editor:
editor.create_model(Author)
index = Index(F('height') / (F('weight') + Value(5)), name='func_calc_idx')
# Add index.
with connection.schema_editor() as editor:
editor.add_index(Author, index)
sql = index.create_sql(Author, editor)
table = Author._meta.db_table
self.assertIn(index.name, self.get_constraints(table))
# SQL contains columns and expressions.
self.assertIs(sql.references_column(table, 'height'), True)
self.assertIs(sql.references_column(table, 'weight'), True)
sql = str(sql)
self.assertIs(
sql.index(editor.quote_name('height')) <
sql.index('/') <
sql.index(editor.quote_name('weight')) <
sql.index('+') <
sql.index('5'),
True,
)
# Remove index.
with connection.schema_editor() as editor:
editor.remove_index(Author, index)
self.assertNotIn(index.name, self.get_constraints(table))
@skipUnlessDBFeature('supports_expression_indexes', 'supports_json_field')
@isolate_apps('schema')
def test_func_index_json_key_transform(self):
class JSONModel(Model):
field = JSONField()
class Meta:
app_label = 'schema'
with connection.schema_editor() as editor:
editor.create_model(JSONModel)
self.isolated_local_models = [JSONModel]
index = Index('field__some_key', name='func_json_key_idx')
with connection.schema_editor() as editor:
editor.add_index(JSONModel, index)
sql = index.create_sql(JSONModel, editor)
table = JSONModel._meta.db_table
self.assertIn(index.name, self.get_constraints(table))
self.assertIs(sql.references_column(table, 'field'), True)
with connection.schema_editor() as editor:
editor.remove_index(JSONModel, index)
self.assertNotIn(index.name, self.get_constraints(table))
@skipUnlessDBFeature('supports_expression_indexes', 'supports_json_field')
@isolate_apps('schema')
def test_func_index_json_key_transform_cast(self):
class JSONModel(Model):
field = JSONField()
class Meta:
app_label = 'schema'
with connection.schema_editor() as editor:
editor.create_model(JSONModel)
self.isolated_local_models = [JSONModel]
index = Index(
Cast(KeyTextTransform('some_key', 'field'), IntegerField()),
name='func_json_key_cast_idx',
)
with connection.schema_editor() as editor:
editor.add_index(JSONModel, index)
sql = index.create_sql(JSONModel, editor)
table = JSONModel._meta.db_table
self.assertIn(index.name, self.get_constraints(table))
self.assertIs(sql.references_column(table, 'field'), True)
with connection.schema_editor() as editor:
editor.remove_index(JSONModel, index)
self.assertNotIn(index.name, self.get_constraints(table))
@skipIfDBFeature('supports_expression_indexes')
def test_func_index_unsupported(self):
# Index is ignored on databases that don't support indexes on
# expressions.
with connection.schema_editor() as editor:
editor.create_model(Author)
index = Index(F('name'), name='random_idx')
with connection.schema_editor() as editor, self.assertNumQueries(0):
self.assertIsNone(editor.add_index(Author, index))
self.assertIsNone(editor.remove_index(Author, index))
@skipUnlessDBFeature('supports_expression_indexes')
def test_func_index_nonexistent_field(self):
index = Index(Lower('nonexistent'), name='func_nonexistent_idx')
msg = (
"Cannot resolve keyword 'nonexistent' into field. Choices are: "
"height, id, name, uuid, weight"
)
with self.assertRaisesMessage(FieldError, msg):
with connection.schema_editor() as editor:
editor.add_index(Author, index)
@skipUnlessDBFeature('supports_expression_indexes')
def test_func_index_nondeterministic(self):
with connection.schema_editor() as editor:
editor.create_model(Author)
index = Index(Random(), name='func_random_idx')
with connection.schema_editor() as editor:
with self.assertRaises(DatabaseError):
editor.add_index(Author, index)
def test_primary_key(self): def test_primary_key(self):
""" """
Tests altering of the primary key Tests altering of the primary key