Autodetect ForeignKeys and add dependencies/split on circulars

This commit is contained in:
Andrew Godwin 2013-06-22 17:15:51 +01:00
parent 48493cff73
commit e2d7e83256
3 changed files with 164 additions and 11 deletions

View File

@ -36,9 +36,41 @@ class MigrationAutodetector(object):
""" """
# We'll store migrations as lists by app names for now # We'll store migrations as lists by app names for now
self.migrations = {} self.migrations = {}
# Adding models. old_app_cache = self.from_state.render()
new_app_cache = self.to_state.render()
# Adding models. Phase 1 is adding models with no outward relationships.
added_models = set(self.to_state.models.keys()) - set(self.from_state.models.keys()) added_models = set(self.to_state.models.keys()) - set(self.from_state.models.keys())
pending_add = {}
for app_label, model_name in added_models: for app_label, model_name in added_models:
model_state = self.to_state.models[app_label, model_name]
# Are there any relationships out from this model? if so, punt it to the next phase.
related_fields = []
for field in new_app_cache.get_model(app_label, model_name)._meta.fields:
if hasattr(field, "rel"):
if hasattr(field.rel, "to"):
related_fields.append((field.name, field.rel.to._meta.app_label.lower(), field.rel.to._meta.object_name.lower()))
if hasattr(field.rel, "through") and not field.rel.though._meta.auto_created:
related_fields.append((field.name, field.rel.through._meta.app_label.lower(), field.rel.through._meta.object_name.lower()))
if related_fields:
pending_add[app_label, model_name] = related_fields
else:
self.add_to_migration(
app_label,
operations.CreateModel(
name = model_state.name,
fields = model_state.fields,
options = model_state.options,
bases = model_state.bases,
)
)
# Phase 2 is progressively adding pending models, splitting up into two
# migrations if required.
pending_new_fks = []
while pending_add:
# Is there one we can add that has all dependencies satisfied?
satisfied = [(m, rf) for m, rf in pending_add.items() if all((al, mn) not in pending_add for f, al, mn in rf)]
if satisfied:
(app_label, model_name), related_fields = sorted(satisfied)[0]
model_state = self.to_state.models[app_label, model_name] model_state = self.to_state.models[app_label, model_name]
self.add_to_migration( self.add_to_migration(
app_label, app_label,
@ -49,6 +81,42 @@ class MigrationAutodetector(object):
bases = model_state.bases, bases = model_state.bases,
) )
) )
for field_name, other_app_label, other_model_name in related_fields:
self.add_dependency(app_label, other_app_label)
del pending_add[app_label, model_name]
# Ah well, we'll need to split one. Pick deterministically.
else:
(app_label, model_name), related_fields = sorted(pending_add.items())[0]
model_state = self.to_state.models[app_label, model_name]
# Work out the fields that need splitting out
bad_fields = dict((f, (al, mn)) for f, al, mn in related_fields if (al, mn) in pending_add)
# Create the model, without those
self.add_to_migration(
app_label,
operations.CreateModel(
name = model_state.name,
fields = [(n, f) for n, f in model_state.fields if n not in bad_fields],
options = model_state.options,
bases = model_state.bases,
)
)
# Add the bad fields to be made in a phase 3
for field_name, (other_app_label, other_model_name) in bad_fields.items():
pending_new_fks.append((app_label, model_name, field_name, other_app_label))
del pending_add[app_label, model_name]
# Phase 3 is adding the final set of FKs as separate new migrations
for app_label, model_name, field_name, other_app_label in pending_new_fks:
model_state = self.to_state.models[app_label, model_name]
self.add_to_migration(
app_label,
operations.AddField(
model_name = model_name,
name = field_name,
field = model_state.get_field_by_name(field_name),
),
new = True,
)
self.add_dependency(app_label, other_app_label)
# Removing models # Removing models
removed_models = set(self.from_state.models.keys()) - set(self.to_state.models.keys()) removed_models = set(self.from_state.models.keys()) - set(self.to_state.models.keys())
for app_label, model_name in removed_models: for app_label, model_name in removed_models:
@ -127,16 +195,31 @@ class MigrationAutodetector(object):
for app_label, migrations in self.migrations.items(): for app_label, migrations in self.migrations.items():
for m1, m2 in zip(migrations, migrations[1:]): for m1, m2 in zip(migrations, migrations[1:]):
m2.dependencies.append((app_label, m1.name)) m2.dependencies.append((app_label, m1.name))
# Clean up dependencies
for app_label, migrations in self.migrations.items():
for migration in migrations:
migration.dependencies = list(set(migration.dependencies))
return self.migrations return self.migrations
def add_to_migration(self, app_label, operation): def add_to_migration(self, app_label, operation, new=False):
migrations = self.migrations.setdefault(app_label, []) migrations = self.migrations.setdefault(app_label, [])
if not migrations: if not migrations or new:
subclass = type("Migration", (Migration,), {"operations": [], "dependencies": []}) subclass = type("Migration", (Migration,), {"operations": [], "dependencies": []})
instance = subclass("auto_%i" % (len(migrations) + 1), app_label) instance = subclass("auto_%i" % (len(migrations) + 1), app_label)
migrations.append(instance) migrations.append(instance)
migrations[-1].operations.append(operation) migrations[-1].operations.append(operation)
def add_dependency(self, app_label, other_app_label):
"""
Adds a dependency to app_label's newest migration on
other_app_label's latest migration.
"""
if self.migrations.get(other_app_label, []):
dependency = (other_app_label, self.migrations[other_app_label][-1].name)
else:
dependency = (other_app_label, "__first__")
self.migrations[app_label][-1].dependencies.append(dependency)
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

@ -70,7 +70,7 @@ class ModelState(object):
""" """
# Deconstruct the fields # Deconstruct the fields
fields = [] fields = []
for field in model._meta.local_fields: for field in model._meta.fields:
name, path, args, kwargs = field.deconstruct() name, path, args, kwargs = field.deconstruct()
field_class = import_by_path(path) field_class = import_by_path(path)
fields.append((name, field_class(*args, **kwargs))) fields.append((name, field_class(*args, **kwargs)))
@ -83,12 +83,15 @@ class ModelState(object):
if name in model._meta.original_attrs: if name in model._meta.original_attrs:
options[name] = model._meta.original_attrs[name] options[name] = model._meta.original_attrs[name]
# Make our record # Make our record
bases = tuple(model for model in model.__bases__ if (not hasattr(model, "_meta") or not model._meta.abstract))
if not bases:
bases = (models.Model, )
return cls( return cls(
model._meta.app_label, model._meta.app_label,
model._meta.object_name, model._meta.object_name,
fields, fields,
options, options,
model.__bases__, bases,
) )
def clone(self): def clone(self):

View File

@ -15,9 +15,12 @@ class AutodetectorTests(TestCase):
author_name = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("name", models.CharField(max_length=200))]) author_name = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("name", models.CharField(max_length=200))])
author_name_longer = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("name", models.CharField(max_length=400))]) author_name_longer = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("name", models.CharField(max_length=400))])
author_name_renamed = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("names", models.CharField(max_length=200))]) author_name_renamed = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("names", models.CharField(max_length=200))])
author_with_book = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("name", models.CharField(max_length=200)), ("book", models.ForeignKey("otherapp.Book"))])
other_pony = ModelState("otherapp", "Pony", [("id", models.AutoField(primary_key=True))]) other_pony = ModelState("otherapp", "Pony", [("id", models.AutoField(primary_key=True))])
other_stable = ModelState("otherapp", "Stable", [("id", models.AutoField(primary_key=True))]) other_stable = ModelState("otherapp", "Stable", [("id", models.AutoField(primary_key=True))])
third_thing = ModelState("thirdapp", "Thing", [("id", models.AutoField(primary_key=True))]) third_thing = ModelState("thirdapp", "Thing", [("id", models.AutoField(primary_key=True))])
book = ModelState("otherapp", "Book", [("id", models.AutoField(primary_key=True)), ("author", models.ForeignKey("testapp.Author")), ("title", models.CharField(max_length=200))])
edition = ModelState("thirdapp", "Edition", [("id", models.AutoField(primary_key=True)), ("book", models.ForeignKey("otherapp.Book"))])
def make_project_state(self, model_states): def make_project_state(self, model_states):
"Shortcut to make ProjectStates from lists of predefined models" "Shortcut to make ProjectStates from lists of predefined models"
@ -167,3 +170,67 @@ class AutodetectorTests(TestCase):
self.assertEqual(action.__class__.__name__, "RenameField") self.assertEqual(action.__class__.__name__, "RenameField")
self.assertEqual(action.old_name, "name") self.assertEqual(action.old_name, "name")
self.assertEqual(action.new_name, "names") self.assertEqual(action.new_name, "names")
def test_fk_dependency(self):
"Tests that having a ForeignKey automatically adds a dependency"
# Make state
before = self.make_project_state([])
after = self.make_project_state([self.author_name, self.book, self.edition])
autodetector = MigrationAutodetector(before, after)
changes = autodetector.changes()
# Right number of migrations?
self.assertEqual(len(changes['testapp']), 1)
self.assertEqual(len(changes['otherapp']), 1)
self.assertEqual(len(changes['thirdapp']), 1)
# Right number of actions?
migration1 = changes['testapp'][0]
self.assertEqual(len(migration1.operations), 1)
migration2 = changes['otherapp'][0]
self.assertEqual(len(migration2.operations), 1)
migration3 = changes['thirdapp'][0]
self.assertEqual(len(migration3.operations), 1)
# Right actions?
action = migration1.operations[0]
self.assertEqual(action.__class__.__name__, "CreateModel")
action = migration2.operations[0]
self.assertEqual(action.__class__.__name__, "CreateModel")
action = migration3.operations[0]
self.assertEqual(action.__class__.__name__, "CreateModel")
# Right dependencies?
self.assertEqual(migration1.dependencies, [])
self.assertEqual(migration2.dependencies, [("testapp", "auto_1")])
self.assertEqual(migration3.dependencies, [("otherapp", "auto_1")])
def test_circular_fk_dependency(self):
"""
Tests that having a circular ForeignKey dependency automatically
resolves the situation into 2 migrations on one side and 1 on the other.
"""
# Make state
before = self.make_project_state([])
after = self.make_project_state([self.author_with_book, self.book])
autodetector = MigrationAutodetector(before, after)
changes = autodetector.changes()
# Right number of migrations?
self.assertEqual(len(changes['testapp']), 1)
self.assertEqual(len(changes['otherapp']), 2)
# Right number of actions?
migration1 = changes['testapp'][0]
self.assertEqual(len(migration1.operations), 1)
migration2 = changes['otherapp'][0]
self.assertEqual(len(migration2.operations), 1)
migration3 = changes['otherapp'][1]
self.assertEqual(len(migration2.operations), 1)
# Right actions?
action = migration1.operations[0]
self.assertEqual(action.__class__.__name__, "CreateModel")
action = migration2.operations[0]
self.assertEqual(action.__class__.__name__, "CreateModel")
self.assertEqual(len(action.fields), 2)
action = migration3.operations[0]
self.assertEqual(action.__class__.__name__, "AddField")
self.assertEqual(action.name, "author")
# Right dependencies?
self.assertEqual(migration1.dependencies, [("otherapp", "auto_1")])
self.assertEqual(migration2.dependencies, [])
self.assertEqual(set(migration3.dependencies), set([("otherapp", "auto_1"), ("testapp", "auto_1")]))