From b44efdfe543c9b9f12690b59777e6b275cb08103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Rever=C3=B3n=20Molina?= Date: Mon, 16 Dec 2024 13:54:51 +0100 Subject: [PATCH] Fixed #34856 -- Fixed references to index_together in historical migrations. While AlterUniqueTogether has been documented to be still allowed in historical migrations for the foreseeable future it has been crashing since 2abf417c815c20 was merged because the latter removed support for Meta.index_together which the migration framework uses to render models to perform schema changes. CreateModel(options["unique_together"]) was also affected. Refs #27236. Co-authored-by: Simon Charette --- django/db/migrations/operations/models.py | 18 +- django/db/migrations/state.py | 19 +- docs/releases/5.1.5.txt | 3 +- tests/migrations/test_operations.py | 205 ++++++++++++++++++++++ 4 files changed, 234 insertions(+), 11 deletions(-) diff --git a/django/db/migrations/operations/models.py b/django/db/migrations/operations/models.py index 40526b94c3..266a3efadc 100644 --- a/django/db/migrations/operations/models.py +++ b/django/db/migrations/operations/models.py @@ -95,6 +95,17 @@ class CreateModel(ModelOperation): model = to_state.apps.get_model(app_label, self.name) if self.allow_migrate_model(schema_editor.connection.alias, model): schema_editor.create_model(model) + # While the `index_together` option has been deprecated some + # historical migrations might still have references to them. + # This can be moved to the schema editor once it's adapted to + # from model states instead of rendered models (#29898). + to_model_state = to_state.models[app_label, self.name_lower] + if index_together := to_model_state.options.get("index_together"): + schema_editor.alter_index_together( + model, + set(), + index_together, + ) def database_backwards(self, app_label, schema_editor, from_state, to_state): model = from_state.apps.get_model(app_label, self.name) @@ -700,12 +711,13 @@ class AlterTogetherOptionOperation(ModelOptionOperation): def database_forwards(self, app_label, schema_editor, from_state, to_state): new_model = to_state.apps.get_model(app_label, self.name) if self.allow_migrate_model(schema_editor.connection.alias, new_model): - old_model = from_state.apps.get_model(app_label, self.name) + from_model_state = from_state.models[app_label, self.name_lower] + to_model_state = to_state.models[app_label, self.name_lower] alter_together = getattr(schema_editor, "alter_%s" % self.option_name) alter_together( new_model, - getattr(old_model._meta, self.option_name, set()), - getattr(new_model._meta, self.option_name, set()), + from_model_state.options.get(self.option_name) or set(), + to_model_state.options.get(self.option_name) or set(), ) def database_backwards(self, app_label, schema_editor, from_state, to_state): diff --git a/django/db/migrations/state.py b/django/db/migrations/state.py index f803e8a709..6cf675d0e4 100644 --- a/django/db/migrations/state.py +++ b/django/db/migrations/state.py @@ -323,13 +323,14 @@ class ProjectState: for from_field_name in from_fields ] ) - # Fix unique_together to refer to the new field. + # Fix index/unique_together to refer to the new field. options = model_state.options - if "unique_together" in options: - options["unique_together"] = [ - [new_name if n == old_name else n for n in together] - for together in options["unique_together"] - ] + for option in ("index_together", "unique_together"): + if option in options: + options[option] = [ + [new_name if n == old_name else n for n in together] + for together in options[option] + ] # Fix to_fields to refer to the new field. delay = True references = get_references(self, model_key, (old_name, found)) @@ -947,7 +948,11 @@ class ModelState: def render(self, apps): """Create a Model object from our current state into the given apps.""" # First, make a Meta object - meta_contents = {"app_label": self.app_label, "apps": apps, **self.options} + meta_options = {**self.options} + # Prune index_together from options as it's no longer an allowed meta + # attribute. + meta_options.pop("index_together", None) + meta_contents = {"app_label": self.app_label, "apps": apps, **meta_options} meta = type("Meta", (), meta_contents) # Then, work out our bases try: diff --git a/docs/releases/5.1.5.txt b/docs/releases/5.1.5.txt index af0dab545c..2dfe283e00 100644 --- a/docs/releases/5.1.5.txt +++ b/docs/releases/5.1.5.txt @@ -9,4 +9,5 @@ Django 5.1.5 fixes several bugs in 5.1.4. Bugfixes ======== -* ... +* Fixed a crash when applying migrations with references to the removed + ``Meta.index_together`` option (:ticket:`34856`). diff --git a/tests/migrations/test_operations.py b/tests/migrations/test_operations.py index 6312a7d4a2..33a7b6dc3d 100644 --- a/tests/migrations/test_operations.py +++ b/tests/migrations/test_operations.py @@ -3329,6 +3329,52 @@ class OperationTests(OperationTestBase): self.assertColumnExists("test_rnflut_pony", "pink") self.assertColumnNotExists("test_rnflut_pony", "blue") + def test_rename_field_index_together(self): + app_label = "test_rnflit" + operations = [ + migrations.CreateModel( + "Pony", + fields=[ + ("id", models.AutoField(primary_key=True)), + ("pink", models.IntegerField(default=3)), + ("weight", models.FloatField()), + ], + options={ + "index_together": [("weight", "pink")], + }, + ), + ] + project_state = self.apply_operations(app_label, ProjectState(), operations) + + operation = migrations.RenameField("Pony", "pink", "blue") + new_state = project_state.clone() + operation.state_forwards("test_rnflit", new_state) + self.assertIn("blue", new_state.models["test_rnflit", "pony"].fields) + self.assertNotIn("pink", new_state.models["test_rnflit", "pony"].fields) + # index_together has the renamed column. + self.assertIn( + "blue", new_state.models["test_rnflit", "pony"].options["index_together"][0] + ) + self.assertNotIn( + "pink", new_state.models["test_rnflit", "pony"].options["index_together"][0] + ) + + # Rename field. + self.assertColumnExists("test_rnflit_pony", "pink") + self.assertColumnNotExists("test_rnflit_pony", "blue") + with connection.schema_editor() as editor: + operation.database_forwards("test_rnflit", editor, project_state, new_state) + self.assertColumnExists("test_rnflit_pony", "blue") + self.assertColumnNotExists("test_rnflit_pony", "pink") + # The index constraint has been ported over. + self.assertIndexExists("test_rnflit_pony", ["weight", "blue"]) + # Reversal. + with connection.schema_editor() as editor: + operation.database_backwards( + "test_rnflit", editor, new_state, project_state + ) + self.assertIndexExists("test_rnflit_pony", ["weight", "pink"]) + def test_rename_field_with_db_column(self): project_state = self.apply_operations( "test_rfwdbc", @@ -3822,6 +3868,63 @@ class OperationTests(OperationTestBase): with self.assertRaisesMessage(ValueError, msg): migrations.RenameIndex("Pony", new_name="new_idx_name") + def test_rename_index_unnamed_index(self): + app_label = "test_rninui" + operations = [ + migrations.CreateModel( + "Pony", + fields=[ + ("id", models.AutoField(primary_key=True)), + ("pink", models.IntegerField(default=3)), + ("weight", models.FloatField()), + ], + options={ + "index_together": [("weight", "pink")], + }, + ), + ] + project_state = self.apply_operations(app_label, ProjectState(), operations) + 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") + # Reapply, RenameIndex operation is a noop when the old and new name + # match. + with connection.schema_editor() as editor: + operation.database_forwards(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) @@ -3892,6 +3995,33 @@ class OperationTests(OperationTestBase): 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" + operations = [ + migrations.CreateModel( + "Pony", + fields=[ + ("id", models.AutoField(primary_key=True)), + ("pink", models.IntegerField(default=3)), + ("weight", models.FloatField()), + ], + options={ + "index_together": [("weight", "pink")], + }, + ), + ] + project_state = self.apply_operations(app_label, ProjectState(), operations) + 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.indexes[0].name, "new_pony_pink_idx") + @skipUnlessDBFeature("supports_expression_indexes") def test_add_func_index(self): app_label = "test_addfuncin" @@ -4011,6 +4141,58 @@ class OperationTests(OperationTestBase): # Ensure the index is still there self.assertIndexExists("test_alflin_pony", ["pink"]) + def test_alter_index_together(self): + """ + Tests the AlterIndexTogether operation. + """ + project_state = self.set_up_test_model("test_alinto") + # Test the state alteration + operation = migrations.AlterIndexTogether("Pony", [("pink", "weight")]) + self.assertEqual( + operation.describe(), "Alter index_together for Pony (1 constraint(s))" + ) + self.assertEqual( + operation.migration_name_fragment, + "alter_pony_index_together", + ) + new_state = project_state.clone() + operation.state_forwards("test_alinto", new_state) + self.assertEqual( + len( + project_state.models["test_alinto", "pony"].options.get( + "index_together", set() + ) + ), + 0, + ) + self.assertEqual( + len( + new_state.models["test_alinto", "pony"].options.get( + "index_together", set() + ) + ), + 1, + ) + # Make sure there's no matching index + self.assertIndexNotExists("test_alinto_pony", ["pink", "weight"]) + # Test the database alteration + with connection.schema_editor() as editor: + operation.database_forwards("test_alinto", editor, project_state, new_state) + self.assertIndexExists("test_alinto_pony", ["pink", "weight"]) + # And test reversal + with connection.schema_editor() as editor: + operation.database_backwards( + "test_alinto", editor, new_state, project_state + ) + self.assertIndexNotExists("test_alinto_pony", ["pink", "weight"]) + # And deconstruction + definition = operation.deconstruct() + self.assertEqual(definition[0], "AlterIndexTogether") + self.assertEqual(definition[1], []) + self.assertEqual( + definition[2], {"name": "Pony", "index_together": {("pink", "weight")}} + ) + def test_alter_index_together_remove(self): operation = migrations.AlterIndexTogether("Pony", None) self.assertEqual( @@ -4021,6 +4203,29 @@ class OperationTests(OperationTestBase): "~ Alter index_together for Pony (0 constraint(s))", ) + @skipUnlessDBFeature("allows_multiple_constraints_on_same_fields") + def test_alter_index_together_remove_with_unique_together(self): + app_label = "test_alintoremove_wunto" + table_name = "%s_pony" % app_label + project_state = self.set_up_test_model(app_label, unique_together=True) + self.assertUniqueConstraintExists(table_name, ["pink", "weight"]) + # Add index together. + new_state = project_state.clone() + operation = migrations.AlterIndexTogether("Pony", [("pink", "weight")]) + operation.state_forwards(app_label, new_state) + with connection.schema_editor() as editor: + operation.database_forwards(app_label, editor, project_state, new_state) + self.assertIndexExists(table_name, ["pink", "weight"]) + # Remove index together. + project_state = new_state + new_state = project_state.clone() + operation = migrations.AlterIndexTogether("Pony", set()) + operation.state_forwards(app_label, new_state) + with connection.schema_editor() as editor: + operation.database_forwards(app_label, editor, project_state, new_state) + self.assertIndexNotExists(table_name, ["pink", "weight"]) + self.assertUniqueConstraintExists(table_name, ["pink", "weight"]) + def test_add_constraint(self): project_state = self.set_up_test_model("test_addconstraint") gt_check = models.Q(pink__gt=2)