Refs #27064 -- Added RenameIndex migration operation.
This commit is contained in:
parent
20e65a34ae
commit
eacd4977f6
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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``
|
||||
-----------------
|
||||
|
||||
|
|
|
@ -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()``
|
||||
--------------------
|
||||
|
||||
|
|
|
@ -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
|
||||
~~~~~~
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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")
|
||||
),
|
||||
]
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue