mirror of https://github.com/django/django.git
Fixed #21039 -- Added AddIndexConcurrently/RemoveIndexConcurrently operations for PostgreSQL.
Thanks to Simon Charettes for review. Co-Authored-By: Daniel Tao <daniel.tao@gmail.com>
This commit is contained in:
parent
9a88e43aeb
commit
85ac838d9e
1
AUTHORS
1
AUTHORS
|
@ -214,6 +214,7 @@ answer newbie questions, and generally made Django that much better:
|
|||
Daniel Poelzleithner <https://poelzi.org/>
|
||||
Daniel Pyrathon <pirosb3@gmail.com>
|
||||
Daniel Roseman <http://roseman.org.uk/>
|
||||
Daniel Tao <https://philosopherdeveloper.com/>
|
||||
Daniel Wiesmann <daniel.wiesmann@gmail.com>
|
||||
Danilo Bargen
|
||||
Dan Johnson <danj.py@gmail.com>
|
||||
|
|
|
@ -18,9 +18,9 @@ class PostgresIndex(Index):
|
|||
# indexes.
|
||||
return Index.max_name_length - len(Index.suffix) + len(self.suffix)
|
||||
|
||||
def create_sql(self, model, schema_editor, using=''):
|
||||
def create_sql(self, model, schema_editor, using='', **kwargs):
|
||||
self.check_supported(schema_editor)
|
||||
statement = super().create_sql(model, schema_editor, using=' USING %s' % self.suffix)
|
||||
statement = super().create_sql(model, schema_editor, using=' USING %s' % self.suffix, **kwargs)
|
||||
with_params = self.get_with_params()
|
||||
if with_params:
|
||||
statement.parts['extra'] = 'WITH (%s) %s' % (
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
from django.contrib.postgres.signals import (
|
||||
get_citext_oids, get_hstore_oids, register_type_handlers,
|
||||
)
|
||||
from django.db.migrations import AddIndex, RemoveIndex
|
||||
from django.db.migrations.operations.base import Operation
|
||||
from django.db.utils import NotSupportedError
|
||||
|
||||
|
||||
class CreateExtension(Operation):
|
||||
|
@ -75,3 +77,61 @@ class UnaccentExtension(CreateExtension):
|
|||
|
||||
def __init__(self):
|
||||
self.name = 'unaccent'
|
||||
|
||||
|
||||
class NotInTransactionMixin:
|
||||
def _ensure_not_in_transaction(self, schema_editor):
|
||||
if schema_editor.connection.in_atomic_block:
|
||||
raise NotSupportedError(
|
||||
'The %s operation cannot be executed inside a transaction '
|
||||
'(set atomic = False on the migration).'
|
||||
% self.__class__.__name__
|
||||
)
|
||||
|
||||
|
||||
class AddIndexConcurrently(NotInTransactionMixin, AddIndex):
|
||||
"""Create an index using PostgreSQL's CREATE INDEX CONCURRENTLY syntax."""
|
||||
atomic = False
|
||||
|
||||
def describe(self):
|
||||
return 'Concurrently create index %s on field(s) %s of model %s' % (
|
||||
self.index.name,
|
||||
', '.join(self.index.fields),
|
||||
self.model_name,
|
||||
)
|
||||
|
||||
def database_forwards(self, app_label, schema_editor, from_state, to_state):
|
||||
self._ensure_not_in_transaction(schema_editor)
|
||||
model = to_state.apps.get_model(app_label, self.model_name)
|
||||
if self.allow_migrate_model(schema_editor.connection.alias, model):
|
||||
schema_editor.add_index(model, self.index, concurrently=True)
|
||||
|
||||
def database_backwards(self, app_label, schema_editor, from_state, to_state):
|
||||
self._ensure_not_in_transaction(schema_editor)
|
||||
model = from_state.apps.get_model(app_label, self.model_name)
|
||||
if self.allow_migrate_model(schema_editor.connection.alias, model):
|
||||
schema_editor.remove_index(model, self.index, concurrently=True)
|
||||
|
||||
|
||||
class RemoveIndexConcurrently(NotInTransactionMixin, RemoveIndex):
|
||||
"""Remove an index using PostgreSQL's DROP INDEX CONCURRENTLY syntax."""
|
||||
atomic = False
|
||||
|
||||
def describe(self):
|
||||
return 'Concurrently remove index %s from %s' % (self.name, self.model_name)
|
||||
|
||||
def database_forwards(self, app_label, schema_editor, from_state, to_state):
|
||||
self._ensure_not_in_transaction(schema_editor)
|
||||
model = from_state.apps.get_model(app_label, self.model_name)
|
||||
if self.allow_migrate_model(schema_editor.connection.alias, model):
|
||||
from_model_state = from_state.models[app_label, self.model_name_lower]
|
||||
index = from_model_state.get_index_by_name(self.name)
|
||||
schema_editor.remove_index(model, index, concurrently=True)
|
||||
|
||||
def database_backwards(self, app_label, schema_editor, from_state, to_state):
|
||||
self._ensure_not_in_transaction(schema_editor)
|
||||
model = to_state.apps.get_model(app_label, self.model_name)
|
||||
if self.allow_migrate_model(schema_editor.connection.alias, model):
|
||||
to_model_state = to_state.models[app_label, self.model_name_lower]
|
||||
index = to_model_state.get_index_by_name(self.name)
|
||||
schema_editor.add_index(model, index, concurrently=True)
|
||||
|
|
|
@ -959,9 +959,9 @@ class BaseDatabaseSchemaEditor:
|
|||
condition=(' WHERE ' + condition) if condition else '',
|
||||
)
|
||||
|
||||
def _delete_index_sql(self, model, name):
|
||||
def _delete_index_sql(self, model, name, sql=None):
|
||||
return Statement(
|
||||
self.sql_delete_index,
|
||||
sql or self.sql_delete_index,
|
||||
table=Table(model._meta.db_table, self.quote_name),
|
||||
name=self.quote_name(name),
|
||||
)
|
||||
|
|
|
@ -13,7 +13,11 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
|
|||
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_concurrently = (
|
||||
"CREATE INDEX CONCURRENTLY %(name)s ON %(table)s%(using)s (%(columns)s)%(extra)s%(condition)s"
|
||||
)
|
||||
sql_delete_index = "DROP INDEX IF EXISTS %(name)s"
|
||||
sql_delete_index_concurrently = "DROP INDEX CONCURRENTLY IF EXISTS %(name)s"
|
||||
|
||||
sql_create_column_inline_fk = 'REFERENCES %(to_table)s(%(to_column)s)%(deferrable)s'
|
||||
# Setting the constraint to IMMEDIATE runs any deferred checks to allow
|
||||
|
@ -157,3 +161,24 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
|
|||
if opclasses:
|
||||
return IndexColumns(table, columns, self.quote_name, col_suffixes=col_suffixes, opclasses=opclasses)
|
||||
return super()._index_columns(table, columns, col_suffixes, opclasses)
|
||||
|
||||
def add_index(self, model, index, concurrently=False):
|
||||
self.execute(index.create_sql(model, self, concurrently=concurrently), params=None)
|
||||
|
||||
def remove_index(self, model, index, concurrently=False):
|
||||
self.execute(index.remove_sql(model, self, concurrently=concurrently))
|
||||
|
||||
def _delete_index_sql(self, model, name, sql=None, concurrently=False):
|
||||
sql = self.sql_delete_index_concurrently if concurrently else self.sql_delete_index
|
||||
return super()._delete_index_sql(model, name, sql)
|
||||
|
||||
def _create_index_sql(
|
||||
self, model, fields, *, name=None, suffix='', using='',
|
||||
db_tablespace=None, col_suffixes=(), sql=None, opclasses=(),
|
||||
condition=None, concurrently=False,
|
||||
):
|
||||
sql = self.sql_create_index if not concurrently else self.sql_create_index_concurrently
|
||||
return super()._create_index_sql(
|
||||
model, fields, name=name, suffix=suffix, using=using, db_tablespace=db_tablespace,
|
||||
col_suffixes=col_suffixes, sql=sql, opclasses=opclasses, condition=condition,
|
||||
)
|
||||
|
|
|
@ -49,17 +49,18 @@ class Index:
|
|||
# it's handled outside of that class, the work is done here.
|
||||
return sql % tuple(map(schema_editor.quote_value, params))
|
||||
|
||||
def create_sql(self, model, schema_editor, using=''):
|
||||
def create_sql(self, model, schema_editor, using='', **kwargs):
|
||||
fields = [model._meta.get_field(field_name) for field_name, _ in self.fields_orders]
|
||||
col_suffixes = [order[1] for order in self.fields_orders]
|
||||
condition = self._get_condition_sql(model, schema_editor)
|
||||
return schema_editor._create_index_sql(
|
||||
model, fields, name=self.name, using=using, db_tablespace=self.db_tablespace,
|
||||
col_suffixes=col_suffixes, opclasses=self.opclasses, condition=condition,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def remove_sql(self, model, schema_editor):
|
||||
return schema_editor._delete_index_sql(model, self.name)
|
||||
def remove_sql(self, model, schema_editor, **kwargs):
|
||||
return schema_editor._delete_index_sql(model, self.name, **kwargs)
|
||||
|
||||
def deconstruct(self):
|
||||
path = '%s.%s' % (self.__class__.__module__, self.__class__.__name__)
|
||||
|
|
|
@ -98,3 +98,33 @@ run the query ``CREATE EXTENSION IF NOT EXISTS hstore;``.
|
|||
.. class:: UnaccentExtension()
|
||||
|
||||
Installs the ``unaccent`` extension.
|
||||
|
||||
Index concurrent operations
|
||||
===========================
|
||||
|
||||
.. versionadded:: 3.0
|
||||
|
||||
PostgreSQL supports the ``CONCURRENTLY`` option to ``CREATE INDEX`` and
|
||||
``DROP INDEX`` statements to add and remove indexes without locking out writes.
|
||||
This option is useful for adding or removing an index in a live production
|
||||
database.
|
||||
|
||||
.. class:: AddIndexConcurrently(model_name, index)
|
||||
|
||||
Like :class:`~django.db.migrations.operations.AddIndex`, but creates an
|
||||
index with the ``CONCURRENTLY`` option. This has a few caveats to be aware
|
||||
of when using this option, see `the PostgreSQL documentation of building
|
||||
indexes concurrently <https://www.postgresql.org/docs/current/
|
||||
sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY>`_.
|
||||
|
||||
.. class:: RemoveIndexConcurrently(model_name, name)
|
||||
|
||||
Like :class:`~django.db.migrations.operations.RemoveIndex`, but removes the
|
||||
index with the ``CONCURRENTLY`` option. This has a few caveats to be aware
|
||||
of when using this option, see `the PostgreSQL documentation
|
||||
<https://www.postgresql.org/docs/current/sql-dropindex.html>`_.
|
||||
|
||||
.. note::
|
||||
|
||||
The ``CONCURRENTLY`` option is not supported inside a transaction (see
|
||||
:ref:`non-atomic migration <non-atomic-migrations>`).
|
||||
|
|
|
@ -153,6 +153,10 @@ Minor features
|
|||
* The new :class:`~django.contrib.postgres.fields.RangeBoundary` expression
|
||||
represents the range boundaries.
|
||||
|
||||
* The new :class:`~django.contrib.postgres.operations.AddIndexConcurrently`
|
||||
and :class:`~django.contrib.postgres.operations.RemoveIndexConcurrently`
|
||||
classes allow creating and dropping indexes ``CONCURRENTLY`` on PostgreSQL.
|
||||
|
||||
:mod:`django.contrib.redirects`
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
|
|
@ -13,8 +13,6 @@ from django.test import TransactionTestCase
|
|||
from django.test.utils import extend_sys_path
|
||||
from django.utils.module_loading import module_dir
|
||||
|
||||
from .models import FoodManager, FoodQuerySet
|
||||
|
||||
|
||||
class MigrationTestBase(TransactionTestCase):
|
||||
"""
|
||||
|
@ -57,14 +55,14 @@ class MigrationTestBase(TransactionTestCase):
|
|||
def assertColumnNotNull(self, table, column, using='default'):
|
||||
self.assertEqual(self._get_column_allows_null(table, column, using), False)
|
||||
|
||||
def assertIndexExists(self, table, columns, value=True, using='default'):
|
||||
def assertIndexExists(self, table, columns, value=True, using='default', index_type=None):
|
||||
with connections[using].cursor() as cursor:
|
||||
self.assertEqual(
|
||||
value,
|
||||
any(
|
||||
c["index"]
|
||||
for c in connections[using].introspection.get_constraints(cursor, table).values()
|
||||
if c['columns'] == list(columns)
|
||||
if c['columns'] == list(columns) and (index_type is None or c['type'] == index_type)
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -266,6 +264,7 @@ class OperationTestBase(MigrationTestBase):
|
|||
bases=['%s.Pony' % app_label],
|
||||
))
|
||||
if manager_model:
|
||||
from .models import FoodManager, FoodQuerySet
|
||||
operations.append(migrations.CreateModel(
|
||||
'Food',
|
||||
fields=[
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
import unittest
|
||||
|
||||
from migrations.test_base import OperationTestBase
|
||||
|
||||
from django.db import connection, models
|
||||
from django.db.models import Index
|
||||
from django.db.utils import NotSupportedError
|
||||
from django.test import modify_settings
|
||||
|
||||
try:
|
||||
from django.contrib.postgres.operations import (
|
||||
AddIndexConcurrently, RemoveIndexConcurrently,
|
||||
)
|
||||
from django.contrib.postgres.indexes import BrinIndex, BTreeIndex
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
@unittest.skipUnless(connection.vendor == 'postgresql', 'PostgreSQL specific tests.')
|
||||
@modify_settings(INSTALLED_APPS={'append': 'migrations'})
|
||||
class AddIndexConcurrentlyTests(OperationTestBase):
|
||||
app_label = 'test_add_concurrently'
|
||||
|
||||
def test_requires_atomic_false(self):
|
||||
project_state = self.set_up_test_model(self.app_label)
|
||||
new_state = project_state.clone()
|
||||
operation = AddIndexConcurrently(
|
||||
'Pony',
|
||||
models.Index(fields=['pink'], name='pony_pink_idx'),
|
||||
)
|
||||
msg = (
|
||||
'The AddIndexConcurrently operation cannot be executed inside '
|
||||
'a transaction (set atomic = False on the migration).'
|
||||
)
|
||||
with self.assertRaisesMessage(NotSupportedError, msg):
|
||||
with connection.schema_editor(atomic=True) as editor:
|
||||
operation.database_forwards(self.app_label, editor, project_state, new_state)
|
||||
|
||||
def test_add(self):
|
||||
project_state = self.set_up_test_model(self.app_label, index=False)
|
||||
table_name = '%s_pony' % self.app_label
|
||||
index = Index(fields=['pink'], name='pony_pink_idx')
|
||||
new_state = project_state.clone()
|
||||
operation = AddIndexConcurrently('Pony', index)
|
||||
self.assertEqual(
|
||||
operation.describe(),
|
||||
'Concurrently create index pony_pink_idx on field(s) pink of '
|
||||
'model Pony'
|
||||
)
|
||||
operation.state_forwards(self.app_label, new_state)
|
||||
self.assertEqual(len(new_state.models[self.app_label, 'pony'].options['indexes']), 1)
|
||||
self.assertIndexNotExists(table_name, ['pink'])
|
||||
# Add index.
|
||||
with connection.schema_editor(atomic=False) as editor:
|
||||
operation.database_forwards(self.app_label, editor, project_state, new_state)
|
||||
self.assertIndexExists(table_name, ['pink'])
|
||||
# Reversal.
|
||||
with connection.schema_editor(atomic=False) as editor:
|
||||
operation.database_backwards(self.app_label, editor, new_state, project_state)
|
||||
self.assertIndexNotExists(table_name, ['pink'])
|
||||
# Deconstruction.
|
||||
name, args, kwargs = operation.deconstruct()
|
||||
self.assertEqual(name, 'AddIndexConcurrently')
|
||||
self.assertEqual(args, [])
|
||||
self.assertEqual(kwargs, {'model_name': 'Pony', 'index': index})
|
||||
|
||||
def test_add_other_index_type(self):
|
||||
project_state = self.set_up_test_model(self.app_label, index=False)
|
||||
table_name = '%s_pony' % self.app_label
|
||||
new_state = project_state.clone()
|
||||
operation = AddIndexConcurrently(
|
||||
'Pony',
|
||||
BrinIndex(fields=['pink'], name='pony_pink_brin_idx'),
|
||||
)
|
||||
self.assertIndexNotExists(table_name, ['pink'])
|
||||
# Add index.
|
||||
with connection.schema_editor(atomic=False) as editor:
|
||||
operation.database_forwards(self.app_label, editor, project_state, new_state)
|
||||
self.assertIndexExists(table_name, ['pink'], index_type='brin')
|
||||
# Reversal.
|
||||
with connection.schema_editor(atomic=False) as editor:
|
||||
operation.database_backwards(self.app_label, editor, new_state, project_state)
|
||||
self.assertIndexNotExists(table_name, ['pink'])
|
||||
|
||||
def test_add_with_options(self):
|
||||
project_state = self.set_up_test_model(self.app_label, index=False)
|
||||
table_name = '%s_pony' % self.app_label
|
||||
new_state = project_state.clone()
|
||||
index = BTreeIndex(fields=['pink'], name='pony_pink_btree_idx', fillfactor=70)
|
||||
operation = AddIndexConcurrently('Pony', index)
|
||||
self.assertIndexNotExists(table_name, ['pink'])
|
||||
# Add index.
|
||||
with connection.schema_editor(atomic=False) as editor:
|
||||
operation.database_forwards(self.app_label, editor, project_state, new_state)
|
||||
self.assertIndexExists(table_name, ['pink'], index_type='btree')
|
||||
# Reversal.
|
||||
with connection.schema_editor(atomic=False) as editor:
|
||||
operation.database_backwards(self.app_label, editor, new_state, project_state)
|
||||
self.assertIndexNotExists(table_name, ['pink'])
|
||||
|
||||
|
||||
@unittest.skipUnless(connection.vendor == 'postgresql', 'PostgreSQL specific tests.')
|
||||
@modify_settings(INSTALLED_APPS={'append': 'migrations'})
|
||||
class RemoveIndexConcurrentlyTests(OperationTestBase):
|
||||
app_label = 'test_rm_concurrently'
|
||||
|
||||
def test_requires_atomic_false(self):
|
||||
project_state = self.set_up_test_model(self.app_label, index=True)
|
||||
new_state = project_state.clone()
|
||||
operation = RemoveIndexConcurrently('Pony', 'pony_pink_idx')
|
||||
msg = (
|
||||
'The RemoveIndexConcurrently operation cannot be executed inside '
|
||||
'a transaction (set atomic = False on the migration).'
|
||||
)
|
||||
with self.assertRaisesMessage(NotSupportedError, msg):
|
||||
with connection.schema_editor(atomic=True) as editor:
|
||||
operation.database_forwards(self.app_label, editor, project_state, new_state)
|
||||
|
||||
def test_remove(self):
|
||||
project_state = self.set_up_test_model(self.app_label, index=True)
|
||||
table_name = '%s_pony' % self.app_label
|
||||
self.assertTableExists(table_name)
|
||||
new_state = project_state.clone()
|
||||
operation = RemoveIndexConcurrently('Pony', 'pony_pink_idx')
|
||||
self.assertEqual(
|
||||
operation.describe(),
|
||||
'Concurrently remove index pony_pink_idx from Pony',
|
||||
)
|
||||
operation.state_forwards(self.app_label, new_state)
|
||||
self.assertEqual(len(new_state.models[self.app_label, 'pony'].options['indexes']), 0)
|
||||
self.assertIndexExists(table_name, ['pink'])
|
||||
# Remove index.
|
||||
with connection.schema_editor(atomic=False) as editor:
|
||||
operation.database_forwards(self.app_label, editor, project_state, new_state)
|
||||
self.assertIndexNotExists(table_name, ['pink'])
|
||||
# Reversal.
|
||||
with connection.schema_editor(atomic=False) as editor:
|
||||
operation.database_backwards(self.app_label, editor, new_state, project_state)
|
||||
self.assertIndexExists(table_name, ['pink'])
|
||||
# Deconstruction.
|
||||
name, args, kwargs = operation.deconstruct()
|
||||
self.assertEqual(name, 'RemoveIndexConcurrently')
|
||||
self.assertEqual(args, [])
|
||||
self.assertEqual(kwargs, {'model_name': 'Pony', 'name': 'pony_pink_idx'})
|
Loading…
Reference in New Issue