From e2d7e83256234251a81ad3388428f6579795a672 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sat, 22 Jun 2013 17:15:51 +0100 Subject: [PATCH] Autodetect ForeignKeys and add dependencies/split on circulars --- django/db/migrations/autodetector.py | 101 +++++++++++++++++++++++--- django/db/migrations/state.py | 7 +- tests/migrations/test_autodetector.py | 67 +++++++++++++++++ 3 files changed, 164 insertions(+), 11 deletions(-) diff --git a/django/db/migrations/autodetector.py b/django/db/migrations/autodetector.py index 107141d1cf1..bb065e99bfe 100644 --- a/django/db/migrations/autodetector.py +++ b/django/db/migrations/autodetector.py @@ -36,19 +36,87 @@ class MigrationAutodetector(object): """ # We'll store migrations as lists by app names for now 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()) + pending_add = {} 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] + self.add_to_migration( + app_label, + operations.CreateModel( + name = model_state.name, + fields = model_state.fields, + options = model_state.options, + 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.CreateModel( - name = model_state.name, - fields = model_state.fields, - options = model_state.options, - bases = model_state.bases, - ) + 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 removed_models = set(self.from_state.models.keys()) - set(self.to_state.models.keys()) for app_label, model_name in removed_models: @@ -127,16 +195,31 @@ class MigrationAutodetector(object): for app_label, migrations in self.migrations.items(): for m1, m2 in zip(migrations, migrations[1:]): 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 - def add_to_migration(self, app_label, operation): + def add_to_migration(self, app_label, operation, new=False): migrations = self.migrations.setdefault(app_label, []) - if not migrations: + if not migrations or new: subclass = type("Migration", (Migration,), {"operations": [], "dependencies": []}) instance = subclass("auto_%i" % (len(migrations) + 1), app_label) migrations.append(instance) 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): """ Takes in a result from changes() and a MigrationGraph, diff --git a/django/db/migrations/state.py b/django/db/migrations/state.py index d1ed22bc29e..4ecdb188967 100644 --- a/django/db/migrations/state.py +++ b/django/db/migrations/state.py @@ -70,7 +70,7 @@ class ModelState(object): """ # Deconstruct the fields fields = [] - for field in model._meta.local_fields: + for field in model._meta.fields: name, path, args, kwargs = field.deconstruct() field_class = import_by_path(path) fields.append((name, field_class(*args, **kwargs))) @@ -83,12 +83,15 @@ class ModelState(object): if name in model._meta.original_attrs: options[name] = model._meta.original_attrs[name] # 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( model._meta.app_label, model._meta.object_name, fields, options, - model.__bases__, + bases, ) def clone(self): diff --git a/tests/migrations/test_autodetector.py b/tests/migrations/test_autodetector.py index 7a2f4715fa4..540e84e8df5 100644 --- a/tests/migrations/test_autodetector.py +++ b/tests/migrations/test_autodetector.py @@ -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_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_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_stable = ModelState("otherapp", "Stable", [("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): "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.old_name, "name") 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")]))