Persist non-schema-relevant Meta changes in migrations

This commit is contained in:
Andrew Godwin 2014-06-15 12:34:02 -07:00
parent f717ef083a
commit a58f49d104
5 changed files with 93 additions and 2 deletions

View File

@ -23,6 +23,17 @@ class MigrationAutodetector(object):
if it wishes, with the caveat that it may not always be possible. if it wishes, with the caveat that it may not always be possible.
""" """
# Model options we want to compare and preserve in an AlterModelOptions op
ALTER_OPTION_KEYS = [
"get_latest_by",
"ordering",
"permissions",
"default_permissions",
"select_on_save",
"verbose_name",
"verbose_name_plural",
]
def __init__(self, from_state, to_state, questioner=None): def __init__(self, from_state, to_state, questioner=None):
self.from_state = from_state self.from_state = from_state
self.to_state = to_state self.to_state = to_state
@ -144,6 +155,7 @@ class MigrationAutodetector(object):
# Generate non-rename model operations # Generate non-rename model operations
self.generate_created_models() self.generate_created_models()
self.generate_deleted_models() self.generate_deleted_models()
self.generate_altered_options()
# Generate field operations # Generate field operations
self.generate_added_fields() self.generate_added_fields()
@ -646,6 +658,28 @@ class MigrationAutodetector(object):
) )
) )
def generate_altered_options(self):
for app_label, model_name in sorted(self.kept_model_keys):
old_model_name = self.renamed_models.get((app_label, model_name), model_name)
old_model_state = self.from_state.models[app_label, old_model_name]
new_model_state = self.to_state.models[app_label, model_name]
old_options = dict(
option for option in old_model_state.options.items()
if option[0] in self.ALTER_OPTION_KEYS
)
new_options = dict(
option for option in new_model_state.options.items()
if option[0] in self.ALTER_OPTION_KEYS
)
if old_options != new_options:
self.add_operation(
app_label,
operations.AlterModelOptions(
name=model_name,
options=new_options,
)
)
def arrange_for_graph(self, changes, graph): def arrange_for_graph(self, changes, graph):
""" """
Takes in a result from changes() and a MigrationGraph, Takes in a result from changes() and a MigrationGraph,

View File

@ -1,11 +1,11 @@
from .models import (CreateModel, DeleteModel, AlterModelTable, from .models import (CreateModel, DeleteModel, AlterModelTable,
AlterUniqueTogether, AlterIndexTogether, RenameModel) AlterUniqueTogether, AlterIndexTogether, RenameModel, AlterModelOptions)
from .fields import AddField, RemoveField, AlterField, RenameField from .fields import AddField, RemoveField, AlterField, RenameField
from .special import SeparateDatabaseAndState, RunSQL, RunPython from .special import SeparateDatabaseAndState, RunSQL, RunPython
__all__ = [ __all__ = [
'CreateModel', 'DeleteModel', 'AlterModelTable', 'AlterUniqueTogether', 'CreateModel', 'DeleteModel', 'AlterModelTable', 'AlterUniqueTogether',
'RenameModel', 'AlterIndexTogether', 'RenameModel', 'AlterIndexTogether', 'AlterModelOptions',
'AddField', 'RemoveField', 'AlterField', 'RenameField', 'AddField', 'RemoveField', 'AlterField', 'RenameField',
'SeparateDatabaseAndState', 'RunSQL', 'RunPython', 'SeparateDatabaseAndState', 'RunSQL', 'RunPython',
] ]

View File

@ -283,3 +283,32 @@ class AlterIndexTogether(Operation):
def describe(self): def describe(self):
return "Alter index_together for %s (%s constraints)" % (self.name, len(self.index_together)) return "Alter index_together for %s (%s constraints)" % (self.name, len(self.index_together))
class AlterModelOptions(Operation):
"""
Sets new model options that don't directly affect the database schema
(like verbose_name, permissions, ordering). Python code in migrations
may still need them.
"""
def __init__(self, name, options):
self.name = name
self.options = options
def state_forwards(self, app_label, state):
model_state = state.models[app_label, self.name.lower()]
model_state.options = dict(model_state.options)
model_state.options.update(self.options)
def database_forwards(self, app_label, schema_editor, from_state, to_state):
pass
def database_backwards(self, app_label, schema_editor, from_state, to_state):
pass
def references_model(self, name, app_label=None):
return name.lower() == self.name.lower()
def describe(self):
return "Change Meta options on %s" % (self.name, )

View File

@ -44,6 +44,7 @@ class AutodetectorTests(TestCase):
("publishers", models.ManyToManyField("testapp.Publisher")), ("publishers", models.ManyToManyField("testapp.Publisher")),
]) ])
author_with_m2m_through = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("publishers", models.ManyToManyField("testapp.Publisher", through="testapp.Contract"))]) author_with_m2m_through = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("publishers", models.ManyToManyField("testapp.Publisher", through="testapp.Contract"))])
author_with_options = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True))], {"verbose_name": "Authi", "permissions": [('can_hire', 'Can hire')]})
contract = ModelState("testapp", "Contract", [("id", models.AutoField(primary_key=True)), ("author", models.ForeignKey("testapp.Author")), ("publisher", models.ForeignKey("testapp.Publisher"))]) contract = ModelState("testapp", "Contract", [("id", models.AutoField(primary_key=True)), ("author", models.ForeignKey("testapp.Author")), ("publisher", models.ForeignKey("testapp.Publisher"))])
publisher = ModelState("testapp", "Publisher", [("id", models.AutoField(primary_key=True)), ("name", models.CharField(max_length=100))]) publisher = ModelState("testapp", "Publisher", [("id", models.AutoField(primary_key=True)), ("name", models.CharField(max_length=100))])
publisher_with_author = ModelState("testapp", "Publisher", [("id", models.AutoField(primary_key=True)), ("author", models.ForeignKey("testapp.Author")), ("name", models.CharField(max_length=100))]) publisher_with_author = ModelState("testapp", "Publisher", [("id", models.AutoField(primary_key=True)), ("author", models.ForeignKey("testapp.Author")), ("name", models.CharField(max_length=100))])
@ -799,3 +800,17 @@ class AutodetectorTests(TestCase):
self.assertNumberMigrations(changes, "testapp", 1) self.assertNumberMigrations(changes, "testapp", 1)
# Right actions in right order? # Right actions in right order?
self.assertOperationTypes(changes, "testapp", 0, ["RemoveField", "RemoveField", "DeleteModel", "DeleteModel"]) self.assertOperationTypes(changes, "testapp", 0, ["RemoveField", "RemoveField", "DeleteModel", "DeleteModel"])
def test_alter_model_options(self):
"""
If two models with a ForeignKey from one to the other are removed at the same time,
the autodetector should remove them in the correct order.
"""
before = self.make_project_state([self.author_empty])
after = self.make_project_state([self.author_with_options])
autodetector = MigrationAutodetector(before, after)
changes = autodetector._detect_changes()
# Right number of migrations?
self.assertNumberMigrations(changes, "testapp", 1)
# Right actions in right order?
self.assertOperationTypes(changes, "testapp", 0, ["AlterModelOptions"])

View File

@ -790,6 +790,19 @@ class OperationTests(MigrationTestBase):
operation.database_backwards("test_alinto", editor, new_state, project_state) operation.database_backwards("test_alinto", editor, new_state, project_state)
self.assertIndexNotExists("test_alinto_pony", ["pink", "weight"]) self.assertIndexNotExists("test_alinto_pony", ["pink", "weight"])
def test_alter_model_options(self):
"""
Tests the AlterModelOptions operation.
"""
project_state = self.set_up_test_model("test_almoop")
# Test the state alteration (no DB alteration to test)
operation = migrations.AlterModelOptions("Pony", {"permissions": [("can_groom", "Can groom")]})
new_state = project_state.clone()
operation.state_forwards("test_almoop", new_state)
self.assertEqual(len(project_state.models["test_almoop", "pony"].options.get("permissions", [])), 0)
self.assertEqual(len(new_state.models["test_almoop", "pony"].options.get("permissions", [])), 1)
self.assertEqual(new_state.models["test_almoop", "pony"].options["permissions"][0][0], "can_groom")
@unittest.skipIf(sqlparse is None and connection.features.requires_sqlparse_for_splitting, "Missing sqlparse") @unittest.skipIf(sqlparse is None and connection.features.requires_sqlparse_for_splitting, "Missing sqlparse")
def test_run_sql(self): def test_run_sql(self):
""" """