Commit e2d7e832 authored by Andrew Godwin's avatar Andrew Godwin
Browse files

Autodetect ForeignKeys and add dependencies/split on circulars

parent 48493cff
Loading
Loading
Loading
Loading
+92 −9
Original line number Diff line number Diff line
@@ -36,9 +36,41 @@ 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,
@@ -49,6 +81,42 @@ class MigrationAutodetector(object):
                        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
        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,
+5 −2
Original line number Diff line number Diff line
@@ -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):
+67 −0
Original line number Diff line number Diff line
@@ -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")]))