Refs #27064 -- Added RenameIndex migration operation.

This commit is contained in:
David Wobrock 2022-05-02 17:22:54 +02:00 committed by Mariusz Felisiak
parent 20e65a34ae
commit eacd4977f6
14 changed files with 411 additions and 1 deletions

View File

@ -176,6 +176,9 @@ class BaseDatabaseFeatures:
# Can it create foreign key constraints inline when adding columns?
can_create_inline_fk = True
# Can an index be renamed?
can_rename_index = False
# Does it automatically index foreign keys?
indexes_foreign_keys = True

View File

@ -131,6 +131,7 @@ class BaseDatabaseSchemaEditor:
"CREATE UNIQUE INDEX %(name)s ON %(table)s "
"(%(columns)s)%(include)s%(condition)s"
)
sql_rename_index = "ALTER INDEX %(old_name)s RENAME TO %(new_name)s"
sql_delete_index = "DROP INDEX %(name)s"
sql_create_pk = (
@ -492,6 +493,16 @@ class BaseDatabaseSchemaEditor:
return None
self.execute(index.remove_sql(model, self))
def rename_index(self, model, old_index, new_index):
if self.connection.features.can_rename_index:
self.execute(
self._rename_index_sql(model, old_index.name, new_index.name),
params=None,
)
else:
self.remove_index(model, old_index)
self.add_index(model, new_index)
def add_constraint(self, model, constraint):
"""Add a constraint to a model."""
sql = constraint.create_sql(model, self)
@ -1361,6 +1372,14 @@ class BaseDatabaseSchemaEditor:
name=self.quote_name(name),
)
def _rename_index_sql(self, model, old_name, new_name):
return Statement(
self.sql_rename_index,
table=Table(model._meta.db_table, self.quote_name),
old_name=self.quote_name(old_name),
new_name=self.quote_name(new_name),
)
def _index_columns(self, table, columns, col_suffixes, opclasses):
return Columns(table, columns, self.quote_name, col_suffixes=col_suffixes)

View File

@ -344,3 +344,9 @@ class DatabaseFeatures(BaseDatabaseFeatures):
and self._mysql_storage_engine != "MyISAM"
and self.connection.mysql_version >= (8, 0, 13)
)
@cached_property
def can_rename_index(self):
if self.connection.mysql_is_mariadb:
return self.connection.mysql_version >= (10, 5, 2)
return True

View File

@ -23,6 +23,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
sql_delete_fk = "ALTER TABLE %(table)s DROP FOREIGN KEY %(name)s"
sql_delete_index = "DROP INDEX %(name)s ON %(table)s"
sql_rename_index = "ALTER TABLE %(table)s RENAME INDEX %(old_name)s TO %(new_name)s"
sql_create_pk = (
"ALTER TABLE %(table)s ADD CONSTRAINT %(name)s PRIMARY KEY (%(columns)s)"

View File

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

View File

@ -60,6 +60,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_update_conflicts = True
supports_update_conflicts_with_target = True
supports_covering_indexes = True
can_rename_index = True
test_collations = {
"non_default": "sv-x-icu",
"swedish_ci": "sv-x-icu",

View File

@ -12,6 +12,7 @@ from .models import (
DeleteModel,
RemoveConstraint,
RemoveIndex,
RenameIndex,
RenameModel,
)
from .special import RunPython, RunSQL, SeparateDatabaseAndState
@ -26,6 +27,7 @@ __all__ = [
"AlterModelOptions",
"AddIndex",
"RemoveIndex",
"RenameIndex",
"AddField",
"RemoveField",
"AlterField",

View File

@ -876,6 +876,152 @@ class RemoveIndex(IndexOperation):
return "remove_%s_%s" % (self.model_name_lower, self.name.lower())
class RenameIndex(IndexOperation):
"""Rename an index."""
def __init__(self, model_name, new_name, old_name=None, old_fields=None):
if not old_name and not old_fields:
raise ValueError(
"RenameIndex requires one of old_name and old_fields arguments to be "
"set."
)
if old_name and old_fields:
raise ValueError(
"RenameIndex.old_name and old_fields are mutually exclusive."
)
self.model_name = model_name
self.new_name = new_name
self.old_name = old_name
self.old_fields = old_fields
@cached_property
def old_name_lower(self):
return self.old_name.lower()
@cached_property
def new_name_lower(self):
return self.new_name.lower()
def deconstruct(self):
kwargs = {
"model_name": self.model_name,
"new_name": self.new_name,
}
if self.old_name:
kwargs["old_name"] = self.old_name
if self.old_fields:
kwargs["old_fields"] = self.old_fields
return (self.__class__.__qualname__, [], kwargs)
def state_forwards(self, app_label, state):
if self.old_fields:
state.add_index(
app_label,
self.model_name_lower,
models.Index(fields=self.old_fields, name=self.new_name),
)
state.remove_model_options(
app_label,
self.model_name_lower,
AlterIndexTogether.option_name,
self.old_fields,
)
else:
state.rename_index(
app_label, self.model_name_lower, self.old_name, self.new_name
)
def database_forwards(self, app_label, schema_editor, from_state, to_state):
model = to_state.apps.get_model(app_label, self.model_name)
if not self.allow_migrate_model(schema_editor.connection.alias, model):
return
if self.old_fields:
from_model = from_state.apps.get_model(app_label, self.model_name)
columns = [
from_model._meta.get_field(field).column for field in self.old_fields
]
matching_index_name = schema_editor._constraint_names(
from_model, column_names=columns, index=True
)
if len(matching_index_name) != 1:
raise ValueError(
"Found wrong number (%s) of indexes for %s(%s)."
% (
len(matching_index_name),
from_model._meta.db_table,
", ".join(columns),
)
)
old_index = models.Index(
fields=self.old_fields,
name=matching_index_name[0],
)
else:
from_model_state = from_state.models[app_label, self.model_name_lower]
old_index = from_model_state.get_index_by_name(self.old_name)
to_model_state = to_state.models[app_label, self.model_name_lower]
new_index = to_model_state.get_index_by_name(self.new_name)
schema_editor.rename_index(model, old_index, new_index)
def database_backwards(self, app_label, schema_editor, from_state, to_state):
if self.old_fields:
# Backward operation with unnamed index is a no-op.
return
self.new_name_lower, self.old_name_lower = (
self.old_name_lower,
self.new_name_lower,
)
self.new_name, self.old_name = self.old_name, self.new_name
self.database_forwards(app_label, schema_editor, from_state, to_state)
self.new_name_lower, self.old_name_lower = (
self.old_name_lower,
self.new_name_lower,
)
self.new_name, self.old_name = self.old_name, self.new_name
def describe(self):
if self.old_name:
return (
f"Rename index {self.old_name} on {self.model_name} to {self.new_name}"
)
return (
f"Rename unnamed index for {self.old_fields} on {self.model_name} to "
f"{self.new_name}"
)
@property
def migration_name_fragment(self):
if self.old_name:
return "rename_%s_%s" % (self.old_name_lower, self.new_name_lower)
return "rename_%s_%s_%s" % (
self.model_name_lower,
"_".join(self.old_fields),
self.new_name_lower,
)
def reduce(self, operation, app_label):
if (
isinstance(operation, RenameIndex)
and self.model_name_lower == operation.model_name_lower
and operation.old_name
and self.new_name_lower == operation.old_name_lower
):
return [
RenameIndex(
self.model_name,
new_name=operation.new_name,
old_name=self.old_name,
old_fields=self.old_fields,
)
]
return super().reduce(operation, app_label)
class AddConstraint(IndexOperation):
option_name = "constraints"

View File

@ -187,6 +187,14 @@ class ProjectState:
model_state.options.pop(key, False)
self.reload_model(app_label, model_name, delay=True)
def remove_model_options(self, app_label, model_name, option_name, value_to_remove):
model_state = self.models[app_label, model_name]
if objs := model_state.options.get(option_name):
model_state.options[option_name] = [
obj for obj in objs if tuple(obj) != tuple(value_to_remove)
]
self.reload_model(app_label, model_name, delay=True)
def alter_model_managers(self, app_label, model_name, managers):
model_state = self.models[app_label, model_name]
model_state.managers = list(managers)
@ -209,6 +217,20 @@ class ProjectState:
def remove_index(self, app_label, model_name, index_name):
self._remove_option(app_label, model_name, "indexes", index_name)
def rename_index(self, app_label, model_name, old_index_name, new_index_name):
model_state = self.models[app_label, model_name]
objs = model_state.options["indexes"]
new_indexes = []
for obj in objs:
if obj.name == old_index_name:
obj = obj.clone()
obj.name = new_index_name
new_indexes.append(obj)
model_state.options["indexes"] = new_indexes
self.reload_model(app_label, model_name, delay=True)
def add_constraint(self, app_label, model_name, constraint):
self._append_option(app_label, model_name, "constraints", constraint)

View File

@ -222,6 +222,22 @@ Creates an index in the database table for the model with ``model_name``.
Removes the index named ``name`` from the model with ``model_name``.
``RenameIndex``
---------------
.. versionadded:: 4.1
.. class:: RenameIndex(model_name, new_name, old_name=None, old_fields=None)
Renames an index in the database table for the model with ``model_name``.
Exactly one of ``old_name`` and ``old_fields`` can be provided. ``old_fields``
is an iterable of the strings, often corresponding to fields of
:attr:`~django.db.models.Options.index_together`.
On databases that don't support an index renaming statement (SQLite and MariaDB
< 10.5.2), the operation will drop and recreate the index, which can be
expensive.
``AddConstraint``
-----------------

View File

@ -79,6 +79,15 @@ Adds ``index`` to ``model``s table.
Removes ``index`` from ``model``s table.
``rename_index()``
------------------
.. versionadded:: 4.1
.. method:: BaseDatabaseSchemaEditor.rename_index(model, old_index, new_index)
Renames ``old_index`` from ``model``s table to ``new_index``.
``add_constraint()``
--------------------

View File

@ -342,7 +342,10 @@ Management Commands
Migrations
~~~~~~~~~~
* ...
* The new :class:`~django.db.migrations.operations.RenameIndex` operation
allows renaming indexes defined in the
:attr:`Meta.indexes <django.db.models.Options.indexes>` or
:attr:`~django.db.models.Options.index_together` options.
Models
~~~~~~

View File

@ -2900,6 +2900,120 @@ class OperationTests(OperationTestBase):
self.unapply_operations("test_rmin", project_state, operations=operations)
self.assertIndexExists("test_rmin_pony", ["pink", "weight"])
def test_rename_index(self):
app_label = "test_rnin"
project_state = self.set_up_test_model(app_label, index=True)
table_name = app_label + "_pony"
self.assertIndexNameExists(table_name, "pony_pink_idx")
self.assertIndexNameNotExists(table_name, "new_pony_test_idx")
operation = migrations.RenameIndex(
"Pony", new_name="new_pony_test_idx", old_name="pony_pink_idx"
)
self.assertEqual(
operation.describe(),
"Rename index pony_pink_idx on Pony to new_pony_test_idx",
)
self.assertEqual(
operation.migration_name_fragment,
"rename_pony_pink_idx_new_pony_test_idx",
)
new_state = project_state.clone()
operation.state_forwards(app_label, new_state)
# Rename index.
expected_queries = 1 if connection.features.can_rename_index else 2
with connection.schema_editor() as editor, self.assertNumQueries(
expected_queries
):
operation.database_forwards(app_label, editor, project_state, new_state)
self.assertIndexNameNotExists(table_name, "pony_pink_idx")
self.assertIndexNameExists(table_name, "new_pony_test_idx")
# Reversal.
with connection.schema_editor() as editor, self.assertNumQueries(
expected_queries
):
operation.database_backwards(app_label, editor, new_state, project_state)
self.assertIndexNameExists(table_name, "pony_pink_idx")
self.assertIndexNameNotExists(table_name, "new_pony_test_idx")
# Deconstruction.
definition = operation.deconstruct()
self.assertEqual(definition[0], "RenameIndex")
self.assertEqual(definition[1], [])
self.assertEqual(
definition[2],
{
"model_name": "Pony",
"old_name": "pony_pink_idx",
"new_name": "new_pony_test_idx",
},
)
def test_rename_index_arguments(self):
msg = "RenameIndex.old_name and old_fields are mutually exclusive."
with self.assertRaisesMessage(ValueError, msg):
migrations.RenameIndex(
"Pony",
new_name="new_idx_name",
old_name="old_idx_name",
old_fields=("weight", "pink"),
)
msg = "RenameIndex requires one of old_name and old_fields arguments to be set."
with self.assertRaisesMessage(ValueError, msg):
migrations.RenameIndex("Pony", new_name="new_idx_name")
def test_rename_index_unnamed_index(self):
app_label = "test_rninui"
project_state = self.set_up_test_model(app_label, index_together=True)
table_name = app_label + "_pony"
self.assertIndexNameNotExists(table_name, "new_pony_test_idx")
operation = migrations.RenameIndex(
"Pony", new_name="new_pony_test_idx", old_fields=("weight", "pink")
)
self.assertEqual(
operation.describe(),
"Rename unnamed index for ('weight', 'pink') on Pony to new_pony_test_idx",
)
self.assertEqual(
operation.migration_name_fragment,
"rename_pony_weight_pink_new_pony_test_idx",
)
new_state = project_state.clone()
operation.state_forwards(app_label, new_state)
# Rename index.
with connection.schema_editor() as editor:
operation.database_forwards(app_label, editor, project_state, new_state)
self.assertIndexNameExists(table_name, "new_pony_test_idx")
# Reverse is a no-op.
with connection.schema_editor() as editor, self.assertNumQueries(0):
operation.database_backwards(app_label, editor, new_state, project_state)
self.assertIndexNameExists(table_name, "new_pony_test_idx")
# Deconstruction.
definition = operation.deconstruct()
self.assertEqual(definition[0], "RenameIndex")
self.assertEqual(definition[1], [])
self.assertEqual(
definition[2],
{
"model_name": "Pony",
"new_name": "new_pony_test_idx",
"old_fields": ("weight", "pink"),
},
)
def test_rename_index_unknown_unnamed_index(self):
app_label = "test_rninuui"
project_state = self.set_up_test_model(app_label)
operation = migrations.RenameIndex(
"Pony", new_name="new_pony_test_idx", old_fields=("weight", "pink")
)
new_state = project_state.clone()
operation.state_forwards(app_label, new_state)
msg = "Found wrong number (0) of indexes for test_rninuui_pony(weight, pink)."
with connection.schema_editor() as editor:
with self.assertRaisesMessage(ValueError, msg):
operation.database_forwards(app_label, editor, project_state, new_state)
def test_add_index_state_forwards(self):
project_state = self.set_up_test_model("test_adinsf")
index = models.Index(fields=["pink"], name="test_adinsf_pony_pink_idx")
@ -2923,6 +3037,35 @@ class OperationTests(OperationTestBase):
new_model = new_state.apps.get_model("test_rminsf", "Pony")
self.assertIsNot(old_model, new_model)
def test_rename_index_state_forwards(self):
app_label = "test_rnidsf"
project_state = self.set_up_test_model(app_label, index=True)
old_model = project_state.apps.get_model(app_label, "Pony")
new_state = project_state.clone()
operation = migrations.RenameIndex(
"Pony", new_name="new_pony_pink_idx", old_name="pony_pink_idx"
)
operation.state_forwards(app_label, new_state)
new_model = new_state.apps.get_model(app_label, "Pony")
self.assertIsNot(old_model, new_model)
self.assertEqual(new_model._meta.indexes[0].name, "new_pony_pink_idx")
def test_rename_index_state_forwards_unnamed_index(self):
app_label = "test_rnidsfui"
project_state = self.set_up_test_model(app_label, index_together=True)
old_model = project_state.apps.get_model(app_label, "Pony")
new_state = project_state.clone()
operation = migrations.RenameIndex(
"Pony", new_name="new_pony_pink_idx", old_fields=("weight", "pink")
)
operation.state_forwards(app_label, new_state)
new_model = new_state.apps.get_model(app_label, "Pony")
self.assertIsNot(old_model, new_model)
self.assertEqual(new_model._meta.index_together, tuple())
self.assertEqual(new_model._meta.indexes[0].name, "new_pony_pink_idx")
@skipUnlessDBFeature("supports_expression_indexes")
def test_add_func_index(self):
app_label = "test_addfuncin"

View File

@ -1114,3 +1114,41 @@ class OptimizerTests(SimpleTestCase):
),
],
)
def test_rename_index(self):
self.assertOptimizesTo(
[
migrations.RenameIndex(
"Pony", new_name="mid_name", old_fields=("weight", "pink")
),
migrations.RenameIndex(
"Pony", new_name="new_name", old_name="mid_name"
),
],
[
migrations.RenameIndex(
"Pony", new_name="new_name", old_fields=("weight", "pink")
),
],
)
self.assertOptimizesTo(
[
migrations.RenameIndex(
"Pony", new_name="mid_name", old_name="old_name"
),
migrations.RenameIndex(
"Pony", new_name="new_name", old_name="mid_name"
),
],
[migrations.RenameIndex("Pony", new_name="new_name", old_name="old_name")],
)
self.assertDoesNotOptimize(
[
migrations.RenameIndex(
"Pony", new_name="mid_name", old_name="old_name"
),
migrations.RenameIndex(
"Pony", new_name="new_name", old_fields=("weight", "pink")
),
]
)