diff --git a/AUTHORS b/AUTHORS
index 8486283a33..460bb9a7df 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -214,6 +214,7 @@ answer newbie questions, and generally made Django that much better:
Daniel Poelzleithner
Daniel Pyrathon
Daniel Roseman
+ Daniel Tao
Daniel Wiesmann
Danilo Bargen
Dan Johnson
diff --git a/django/contrib/postgres/indexes.py b/django/contrib/postgres/indexes.py
index 7a5023c758..526e729f2a 100644
--- a/django/contrib/postgres/indexes.py
+++ b/django/contrib/postgres/indexes.py
@@ -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' % (
diff --git a/django/contrib/postgres/operations.py b/django/contrib/postgres/operations.py
index 95e7edcdea..9e417725ec 100644
--- a/django/contrib/postgres/operations.py
+++ b/django/contrib/postgres/operations.py
@@ -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)
diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py
index 3540fd5d0d..efb291f4e6 100644
--- a/django/db/backends/base/schema.py
+++ b/django/db/backends/base/schema.py
@@ -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),
)
diff --git a/django/db/backends/postgresql/schema.py b/django/db/backends/postgresql/schema.py
index eb5b182680..9384c5e3b2 100644
--- a/django/db/backends/postgresql/schema.py
+++ b/django/db/backends/postgresql/schema.py
@@ -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,
+ )
diff --git a/django/db/models/indexes.py b/django/db/models/indexes.py
index c093b45619..5154de056e 100644
--- a/django/db/models/indexes.py
+++ b/django/db/models/indexes.py
@@ -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__)
diff --git a/docs/ref/contrib/postgres/operations.txt b/docs/ref/contrib/postgres/operations.txt
index c56693478f..1329ff1f9e 100644
--- a/docs/ref/contrib/postgres/operations.txt
+++ b/docs/ref/contrib/postgres/operations.txt
@@ -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 `_.
+
+.. 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
+ `_.
+
+.. note::
+
+ The ``CONCURRENTLY`` option is not supported inside a transaction (see
+ :ref:`non-atomic migration `).
diff --git a/docs/releases/3.0.txt b/docs/releases/3.0.txt
index 5bb0691363..9489a38ca7 100644
--- a/docs/releases/3.0.txt
+++ b/docs/releases/3.0.txt
@@ -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`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/tests/migrations/test_base.py b/tests/migrations/test_base.py
index 7c1cf82e16..94ff6ff0c9 100644
--- a/tests/migrations/test_base.py
+++ b/tests/migrations/test_base.py
@@ -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=[
diff --git a/tests/postgres_tests/test_operations.py b/tests/postgres_tests/test_operations.py
new file mode 100644
index 0000000000..7be6de9eff
--- /dev/null
+++ b/tests/postgres_tests/test_operations.py
@@ -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'})