瀏覽代碼

Fixed #25945, #26292 -- Refactored MigrationLoader.build_graph()

Jarek Glowacki 9 年之前
父節點
當前提交
509379a161

+ 1 - 0
AUTHORS

@@ -323,6 +323,7 @@ answer newbie questions, and generally made Django that much better:
     Janos Guljas
     Jan Pazdziora
     Jan Rademaker
+    Jarek Głowacki <jarekwg@gmail.com>
     Jarek Zgoda <jarek.zgoda@gmail.com>
     Jason Davies (Esaj) <http://www.jasondavies.com/>
     Jason Huggins <http://www.jrandolph.com/blog/>

+ 2 - 1
django/db/migrations/exceptions.py

@@ -52,8 +52,9 @@ class NodeNotFoundError(LookupError):
     Raised when an attempt on a node is made that is not available in the graph.
     """
 
-    def __init__(self, message, node):
+    def __init__(self, message, node, origin=None):
         self.message = message
+        self.origin = origin
         self.node = node
 
     def __str__(self):

+ 142 - 11
django/db/migrations/graph.py

@@ -1,10 +1,12 @@
 from __future__ import unicode_literals
 
+import sys
 import warnings
 from collections import deque
 from functools import total_ordering
 
 from django.db.migrations.state import ProjectState
+from django.utils import six
 from django.utils.datastructures import OrderedSet
 from django.utils.encoding import python_2_unicode_compatible
 
@@ -79,6 +81,29 @@ class Node(object):
         return self.__dict__['_descendants']
 
 
+class DummyNode(Node):
+    def __init__(self, key, origin, error_message):
+        super(DummyNode, self).__init__(key)
+        self.origin = origin
+        self.error_message = error_message
+
+    def __repr__(self):
+        return '<DummyNode: (%r, %r)>' % self.key
+
+    def promote(self):
+        """
+        Transition dummy to a normal node and clean off excess attribs.
+        Creating a Node object from scratch would be too much of a
+        hassle as many dependendies would need to be remapped.
+        """
+        del self.origin
+        del self.error_message
+        self.__class__ = Node
+
+    def raise_error(self):
+        raise NodeNotFoundError(self.error_message, self.key, origin=self.origin)
+
+
 @python_2_unicode_compatible
 class MigrationGraph(object):
     """
@@ -108,27 +133,133 @@ class MigrationGraph(object):
         self.nodes = {}
         self.cached = False
 
-    def add_node(self, key, implementation):
-        node = Node(key)
-        self.node_map[key] = node
-        self.nodes[key] = implementation
+    def add_node(self, key, migration):
+        # If the key already exists, then it must be a dummy node.
+        dummy_node = self.node_map.get(key)
+        if dummy_node:
+            # Promote DummyNode to Node.
+            dummy_node.promote()
+        else:
+            node = Node(key)
+            self.node_map[key] = node
+        self.nodes[key] = migration
         self.clear_cache()
 
-    def add_dependency(self, migration, child, parent):
+    def add_dummy_node(self, key, origin, error_message):
+        node = DummyNode(key, origin, error_message)
+        self.node_map[key] = node
+        self.nodes[key] = None
+
+    def add_dependency(self, migration, child, parent, skip_validation=False):
+        """
+        This may create dummy nodes if they don't yet exist.
+        If `skip_validation` is set, validate_consistency should be called afterwards.
+        """
         if child not in self.nodes:
-            raise NodeNotFoundError(
-                "Migration %s dependencies reference nonexistent child node %r" % (migration, child),
-                child
+            error_message = (
+                "Migration %s dependencies reference nonexistent"
+                " child node %r" % (migration, child)
             )
+            self.add_dummy_node(child, migration, error_message)
         if parent not in self.nodes:
-            raise NodeNotFoundError(
-                "Migration %s dependencies reference nonexistent parent node %r" % (migration, parent),
-                parent
+            error_message = (
+                "Migration %s dependencies reference nonexistent"
+                " parent node %r" % (migration, parent)
             )
+            self.add_dummy_node(parent, migration, error_message)
         self.node_map[child].add_parent(self.node_map[parent])
         self.node_map[parent].add_child(self.node_map[child])
+        if not skip_validation:
+            self.validate_consistency()
         self.clear_cache()
 
+    def remove_replaced_nodes(self, replacement, replaced):
+        """
+        Removes each of the `replaced` nodes (when they exist). Any
+        dependencies that were referencing them are changed to reference the
+        `replacement` node instead.
+        """
+        # Cast list of replaced keys to set to speed up lookup later.
+        replaced = set(replaced)
+        try:
+            replacement_node = self.node_map[replacement]
+        except KeyError as exc:
+            exc_value = NodeNotFoundError(
+                "Unable to find replacement node %r. It was either never added"
+                " to the migration graph, or has been removed." % (replacement, ),
+                replacement
+            )
+            exc_value.__cause__ = exc
+            if not hasattr(exc, '__traceback__'):
+                exc.__traceback__ = sys.exc_info()[2]
+            six.reraise(NodeNotFoundError, exc_value, sys.exc_info()[2])
+        for replaced_key in replaced:
+            self.nodes.pop(replaced_key, None)
+            replaced_node = self.node_map.pop(replaced_key, None)
+            if replaced_node:
+                for child in replaced_node.children:
+                    child.parents.remove(replaced_node)
+                    # We don't want to create dependencies between the replaced
+                    # node and the replacement node as this would lead to
+                    # self-referencing on the replacement node at a later iteration.
+                    if child.key not in replaced:
+                        replacement_node.add_child(child)
+                        child.add_parent(replacement_node)
+                for parent in replaced_node.parents:
+                    parent.children.remove(replaced_node)
+                    # Again, to avoid self-referencing.
+                    if parent.key not in replaced:
+                        replacement_node.add_parent(parent)
+                        parent.add_child(replacement_node)
+        self.clear_cache()
+
+    def remove_replacement_node(self, replacement, replaced):
+        """
+        The inverse operation to `remove_replaced_nodes`. Almost. Removes the
+        replacement node `replacement` and remaps its child nodes to
+        `replaced` - the list of nodes it would have replaced. Its parent
+        nodes are not remapped as they are expected to be correct already.
+        """
+        self.nodes.pop(replacement, None)
+        try:
+            replacement_node = self.node_map.pop(replacement)
+        except KeyError as exc:
+            exc_value = NodeNotFoundError(
+                "Unable to remove replacement node %r. It was either never added"
+                " to the migration graph, or has been removed already." % (replacement, ),
+                replacement
+            )
+            exc_value.__cause__ = exc
+            if not hasattr(exc, '__traceback__'):
+                exc.__traceback__ = sys.exc_info()[2]
+            six.reraise(NodeNotFoundError, exc_value, sys.exc_info()[2])
+        replaced_nodes = set()
+        replaced_nodes_parents = set()
+        for key in replaced:
+            replaced_node = self.node_map.get(key)
+            if replaced_node:
+                replaced_nodes.add(replaced_node)
+                replaced_nodes_parents |= replaced_node.parents
+        # We're only interested in the latest replaced node, so filter out
+        # replaced nodes that are parents of other replaced nodes.
+        replaced_nodes -= replaced_nodes_parents
+        for child in replacement_node.children:
+            child.parents.remove(replacement_node)
+            for replaced_node in replaced_nodes:
+                replaced_node.add_child(child)
+                child.add_parent(replaced_node)
+        for parent in replacement_node.parents:
+            parent.children.remove(replacement_node)
+            # NOTE: There is no need to remap parent dependencies as we can
+            # assume the replaced nodes already have the correct ancestry.
+        self.clear_cache()
+
+    def validate_consistency(self):
+        """
+        Ensure there are no dummy nodes remaining in the graph.
+        """
+        [n.raise_error() for n in self.node_map.values() if isinstance(n, DummyNode)]
+
     def clear_cache(self):
         if self.cached:
             for node in self.nodes:

+ 70 - 122
django/db/migrations/loader.py

@@ -165,6 +165,30 @@ class MigrationLoader(object):
                     raise ValueError("Dependency on app with no migrations: %s" % key[0])
         raise ValueError("Dependency on unknown app: %s" % key[0])
 
+    def add_internal_dependencies(self, key, migration):
+        """
+        Internal dependencies need to be added first to ensure `__first__`
+        dependencies find the correct root node.
+        """
+        for parent in migration.dependencies:
+            if parent[0] != key[0] or parent[1] == '__first__':
+                # Ignore __first__ references to the same app (#22325).
+                continue
+            self.graph.add_dependency(migration, key, parent, skip_validation=True)
+
+    def add_external_dependencies(self, key, migration):
+        for parent in migration.dependencies:
+            # Skip internal dependencies
+            if key[0] == parent[0]:
+                continue
+            parent = self.check_key(parent, key[0])
+            if parent is not None:
+                self.graph.add_dependency(migration, key, parent, skip_validation=True)
+        for child in migration.run_before:
+            child = self.check_key(child, key[0])
+            if child is not None:
+                self.graph.add_dependency(migration, child, key, skip_validation=True)
+
     def build_graph(self):
         """
         Builds a migration dependency graph using both the disk and database.
@@ -179,92 +203,54 @@ class MigrationLoader(object):
         else:
             recorder = MigrationRecorder(self.connection)
             self.applied_migrations = recorder.applied_migrations()
-        # Do a first pass to separate out replacing and non-replacing migrations
-        normal = {}
-        replacing = {}
+        # To start, populate the migration graph with nodes for ALL migrations
+        # and their dependencies. Also make note of replacing migrations at this step.
+        self.graph = MigrationGraph()
+        self.replacements = {}
         for key, migration in self.disk_migrations.items():
+            self.graph.add_node(key, migration)
+            # Internal (aka same-app) dependencies.
+            self.add_internal_dependencies(key, migration)
+            # Replacing migrations.
             if migration.replaces:
-                replacing[key] = migration
-            else:
-                normal[key] = migration
-        # Calculate reverse dependencies - i.e., for each migration, what depends on it?
-        # This is just for dependency re-pointing when applying replacements,
-        # so we ignore run_before here.
-        reverse_dependencies = {}
-        for key, migration in normal.items():
-            for parent in migration.dependencies:
-                reverse_dependencies.setdefault(parent, set()).add(key)
-        # Remember the possible replacements to generate more meaningful error
-        # messages
-        reverse_replacements = {}
-        for key, migration in replacing.items():
-            for replaced in migration.replaces:
-                reverse_replacements.setdefault(replaced, set()).add(key)
-        # Carry out replacements if we can - that is, if all replaced migrations
-        # are either unapplied or missing.
-        for key, migration in replacing.items():
-            # Ensure this replacement migration is not in applied_migrations
-            self.applied_migrations.discard(key)
-            # Do the check. We can replace if all our replace targets are
-            # applied, or if all of them are unapplied.
+                self.replacements[key] = migration
+        # Add external dependencies now that the internal ones have been resolved.
+        for key, migration in self.disk_migrations.items():
+            self.add_external_dependencies(key, migration)
+        # Carry out replacements where possible.
+        for key, migration in self.replacements.items():
+            # Get applied status of each of this migration's replacement targets.
             applied_statuses = [(target in self.applied_migrations) for target in migration.replaces]
-            can_replace = all(applied_statuses) or (not any(applied_statuses))
-            if not can_replace:
-                continue
-            # Alright, time to replace. Step through the replaced migrations
-            # and remove, repointing dependencies if needs be.
-            for replaced in migration.replaces:
-                if replaced in normal:
-                    # We don't care if the replaced migration doesn't exist;
-                    # the usage pattern here is to delete things after a while.
-                    del normal[replaced]
-                for child_key in reverse_dependencies.get(replaced, set()):
-                    if child_key in migration.replaces:
-                        continue
-                    # List of migrations whose dependency on `replaced` needs
-                    # to be updated to a dependency on `key`.
-                    to_update = []
-                    # Child key may itself be replaced, in which case it might
-                    # not be in `normal` anymore (depending on whether we've
-                    # processed its replacement yet). If it's present, we go
-                    # ahead and update it; it may be deleted later on if it is
-                    # replaced, but there's no harm in updating it regardless.
-                    if child_key in normal:
-                        to_update.append(normal[child_key])
-                    # If the child key is replaced, we update its replacement's
-                    # dependencies too, if necessary. (We don't know if this
-                    # replacement will actually take effect or not, but either
-                    # way it's OK to update the replacing migration).
-                    if child_key in reverse_replacements:
-                        for replaces_child_key in reverse_replacements[child_key]:
-                            if replaced in replacing[replaces_child_key].dependencies:
-                                to_update.append(replacing[replaces_child_key])
-                    # Actually perform the dependency update on all migrations
-                    # that require it.
-                    for migration_needing_update in to_update:
-                        migration_needing_update.dependencies.remove(replaced)
-                        migration_needing_update.dependencies.append(key)
-            normal[key] = migration
-            # Mark the replacement as applied if all its replaced ones are
+            # Ensure the replacing migration is only marked as applied if all of
+            # its replacement targets are.
             if all(applied_statuses):
                 self.applied_migrations.add(key)
-        # Store the replacement migrations for later checks
-        self.replacements = replacing
-        # Finally, make a graph and load everything into it
-        self.graph = MigrationGraph()
-        for key, migration in normal.items():
-            self.graph.add_node(key, migration)
-
-        def _reraise_missing_dependency(migration, missing, exc):
-            """
-            Checks if ``missing`` could have been replaced by any squash
-            migration but wasn't because the the squash migration was partially
-            applied before. In that case raise a more understandable exception.
-
-            #23556
-            """
-            if missing in reverse_replacements:
-                candidates = reverse_replacements.get(missing, set())
+            else:
+                self.applied_migrations.discard(key)
+            # A replacing migration can be used if either all or none of its
+            # replacement targets have been applied.
+            if all(applied_statuses) or (not any(applied_statuses)):
+                self.graph.remove_replaced_nodes(key, migration.replaces)
+            else:
+                # This replacing migration cannot be used because it is partially applied.
+                # Remove it from the graph and remap dependencies to it (#25945).
+                self.graph.remove_replacement_node(key, migration.replaces)
+        # Ensure the graph is consistent.
+        try:
+            self.graph.validate_consistency()
+        except NodeNotFoundError as exc:
+            # Check if the missing node could have been replaced by any squash
+            # migration but wasn't because the squash migration was partially
+            # applied before. In that case raise a more understandable exception
+            # (#23556).
+            # Get reverse replacements.
+            reverse_replacements = {}
+            for key, migration in self.replacements.items():
+                for replaced in migration.replaces:
+                    reverse_replacements.setdefault(replaced, set()).add(key)
+            # Try to reraise exception with more detail.
+            if exc.node in reverse_replacements:
+                candidates = reverse_replacements.get(exc.node, set())
                 is_replaced = any(candidate in self.graph.nodes for candidate in candidates)
                 if not is_replaced:
                     tries = ', '.join('%s.%s' % c for c in candidates)
@@ -273,54 +259,16 @@ class MigrationLoader(object):
                         "Django tried to replace migration {1}.{2} with any of [{3}] "
                         "but wasn't able to because some of the replaced migrations "
                         "are already applied.".format(
-                            migration, missing[0], missing[1], tries
+                            exc.origin, exc.node[0], exc.node[1], tries
                         ),
-                        missing)
+                        exc.node
+                    )
                     exc_value.__cause__ = exc
                     if not hasattr(exc, '__traceback__'):
                         exc.__traceback__ = sys.exc_info()[2]
                     six.reraise(NodeNotFoundError, exc_value, sys.exc_info()[2])
             raise exc
 
-        # Add all internal dependencies first to ensure __first__ dependencies
-        # find the correct root node.
-        for key, migration in normal.items():
-            for parent in migration.dependencies:
-                if parent[0] != key[0] or parent[1] == '__first__':
-                    # Ignore __first__ references to the same app (#22325)
-                    continue
-                try:
-                    self.graph.add_dependency(migration, key, parent)
-                except NodeNotFoundError as e:
-                    # Since we added "key" to the nodes before this implies
-                    # "parent" is not in there. To make the raised exception
-                    # more understandable we check if parent could have been
-                    # replaced but hasn't (eg partially applied squashed
-                    # migration)
-                    _reraise_missing_dependency(migration, parent, e)
-        for key, migration in normal.items():
-            for parent in migration.dependencies:
-                if parent[0] == key[0]:
-                    # Internal dependencies already added.
-                    continue
-                parent = self.check_key(parent, key[0])
-                if parent is not None:
-                    try:
-                        self.graph.add_dependency(migration, key, parent)
-                    except NodeNotFoundError as e:
-                        # Since we added "key" to the nodes before this implies
-                        # "parent" is not in there.
-                        _reraise_missing_dependency(migration, parent, e)
-            for child in migration.run_before:
-                child = self.check_key(child, key[0])
-                if child is not None:
-                    try:
-                        self.graph.add_dependency(migration, child, key)
-                    except NodeNotFoundError as e:
-                        # Since we added "key" to the nodes before this implies
-                        # "child" is not in there.
-                        _reraise_missing_dependency(migration, child, e)
-
     def check_consistent_history(self, connection):
         """
         Raise InconsistentMigrationHistory if any applied migrations have

+ 116 - 0
tests/migrations/test_graph.py

@@ -250,6 +250,122 @@ class GraphTests(SimpleTestCase):
         with self.assertRaisesMessage(NodeNotFoundError, msg):
             graph.add_dependency("app_a.0002", ("app_a", "0002"), ("app_a", "0001"))
 
+    def test_validate_consistency(self):
+        """
+        Tests for missing nodes, using `validate_consistency()` to raise the error.
+        """
+        # Build graph
+        graph = MigrationGraph()
+        graph.add_node(("app_a", "0001"), None)
+        # Add dependency with missing parent node (skipping validation).
+        graph.add_dependency("app_a.0001", ("app_a", "0001"), ("app_b", "0002"), skip_validation=True)
+        msg = "Migration app_a.0001 dependencies reference nonexistent parent node ('app_b', '0002')"
+        with self.assertRaisesMessage(NodeNotFoundError, msg):
+            graph.validate_consistency()
+        # Add missing parent node and ensure `validate_consistency()` no longer raises error.
+        graph.add_node(("app_b", "0002"), None)
+        graph.validate_consistency()
+        # Add dependency with missing child node (skipping validation).
+        graph.add_dependency("app_a.0002", ("app_a", "0002"), ("app_a", "0001"), skip_validation=True)
+        msg = "Migration app_a.0002 dependencies reference nonexistent child node ('app_a', '0002')"
+        with self.assertRaisesMessage(NodeNotFoundError, msg):
+            graph.validate_consistency()
+        # Add missing child node and ensure `validate_consistency()` no longer raises error.
+        graph.add_node(("app_a", "0002"), None)
+        graph.validate_consistency()
+        # Rawly add dummy node.
+        msg = "app_a.0001 (req'd by app_a.0002) is missing!"
+        graph.add_dummy_node(
+            key=("app_a", "0001"),
+            origin="app_a.0002",
+            error_message=msg
+        )
+        with self.assertRaisesMessage(NodeNotFoundError, msg):
+            graph.validate_consistency()
+
+    def test_remove_replaced_nodes(self):
+        """
+        Tests that replaced nodes are properly removed and dependencies remapped.
+        """
+        # Add some dummy nodes to be replaced.
+        graph = MigrationGraph()
+        graph.add_dummy_node(key=("app_a", "0001"), origin="app_a.0002", error_message="BAD!")
+        graph.add_dummy_node(key=("app_a", "0002"), origin="app_b.0001", error_message="BAD!")
+        graph.add_dependency("app_a.0002", ("app_a", "0002"), ("app_a", "0001"), skip_validation=True)
+        # Add some normal parent and child nodes to test dependency remapping.
+        graph.add_node(("app_c", "0001"), None)
+        graph.add_node(("app_b", "0001"), None)
+        graph.add_dependency("app_a.0001", ("app_a", "0001"), ("app_c", "0001"), skip_validation=True)
+        graph.add_dependency("app_b.0001", ("app_b", "0001"), ("app_a", "0002"), skip_validation=True)
+        # Try replacing before replacement node exists.
+        msg = (
+            "Unable to find replacement node ('app_a', '0001_squashed_0002'). It was either"
+            " never added to the migration graph, or has been removed."
+        )
+        with self.assertRaisesMessage(NodeNotFoundError, msg):
+            graph.remove_replaced_nodes(
+                replacement=("app_a", "0001_squashed_0002"),
+                replaced=[("app_a", "0001"), ("app_a", "0002")]
+            )
+        graph.add_node(("app_a", "0001_squashed_0002"), None)
+        # Ensure `validate_consistency()` still raises an error at this stage.
+        with self.assertRaisesMessage(NodeNotFoundError, "BAD!"):
+            graph.validate_consistency()
+        # Remove the dummy nodes.
+        graph.remove_replaced_nodes(
+            replacement=("app_a", "0001_squashed_0002"),
+            replaced=[("app_a", "0001"), ("app_a", "0002")]
+        )
+        # Ensure graph is now consistent and dependencies have been remapped
+        graph.validate_consistency()
+        parent_node = graph.node_map[("app_c", "0001")]
+        replacement_node = graph.node_map[("app_a", "0001_squashed_0002")]
+        child_node = graph.node_map[("app_b", "0001")]
+        self.assertIn(parent_node, replacement_node.parents)
+        self.assertIn(replacement_node, parent_node.children)
+        self.assertIn(child_node, replacement_node.children)
+        self.assertIn(replacement_node, child_node.parents)
+
+    def test_remove_replacement_node(self):
+        """
+        Tests that a replacement node is properly removed and child dependencies remapped.
+        We assume parent dependencies are already correct.
+        """
+        # Add some dummy nodes to be replaced.
+        graph = MigrationGraph()
+        graph.add_node(("app_a", "0001"), None)
+        graph.add_node(("app_a", "0002"), None)
+        graph.add_dependency("app_a.0002", ("app_a", "0002"), ("app_a", "0001"))
+        # Try removing replacement node before replacement node exists.
+        msg = (
+            "Unable to remove replacement node ('app_a', '0001_squashed_0002'). It was"
+            " either never added to the migration graph, or has been removed already."
+        )
+        with self.assertRaisesMessage(NodeNotFoundError, msg):
+            graph.remove_replacement_node(
+                replacement=("app_a", "0001_squashed_0002"),
+                replaced=[("app_a", "0001"), ("app_a", "0002")]
+            )
+        graph.add_node(("app_a", "0001_squashed_0002"), None)
+        # Add a child node to test dependency remapping.
+        graph.add_node(("app_b", "0001"), None)
+        graph.add_dependency("app_b.0001", ("app_b", "0001"), ("app_a", "0001_squashed_0002"))
+        # Remove the replacement node.
+        graph.remove_replacement_node(
+            replacement=("app_a", "0001_squashed_0002"),
+            replaced=[("app_a", "0001"), ("app_a", "0002")]
+        )
+        # Ensure graph is consistent and child dependency has been remapped
+        graph.validate_consistency()
+        replaced_node = graph.node_map[("app_a", "0002")]
+        child_node = graph.node_map[("app_b", "0001")]
+        self.assertIn(child_node, replaced_node.children)
+        self.assertIn(replaced_node, child_node.parents)
+        # Ensure child dependency hasn't also gotten remapped to the other replaced node.
+        other_replaced_node = graph.node_map[("app_a", "0001")]
+        self.assertNotIn(child_node, other_replaced_node.children)
+        self.assertNotIn(other_replaced_node, child_node.parents)
+
     def test_infinite_loop(self):
         """
         Tests a complex dependency graph:

+ 67 - 0
tests/migrations/test_loader.py

@@ -397,3 +397,70 @@ class LoaderTests(TestCase):
         msg = "Migration migrations.0002_second is applied before its dependency migrations.0001_initial"
         with self.assertRaisesMessage(InconsistentMigrationHistory, msg):
             loader.check_consistent_history(connection)
+
+    @override_settings(MIGRATION_MODULES={
+        "app1": "migrations.test_migrations_squashed_ref_squashed.app1",
+        "app2": "migrations.test_migrations_squashed_ref_squashed.app2",
+    })
+    @modify_settings(INSTALLED_APPS={'append': [
+        "migrations.test_migrations_squashed_ref_squashed.app1",
+        "migrations.test_migrations_squashed_ref_squashed.app2",
+    ]})
+    def test_loading_squashed_ref_squashed(self):
+        "Tests loading a squashed migration with a new migration referencing it"
+        """
+        The sample migrations are structred like this:
+
+        app_1       1 --> 2 ---------------------*--> 3        *--> 4
+                     \                          /             /
+                      *-------------------*----/--> 2_sq_3 --*
+                       \                 /    /
+        =============== \ ============= / == / ======================
+        app_2            *--> 1_sq_2 --*    /
+                          \                /
+                           *--> 1 --> 2 --*
+
+        Where 2_sq_3 is a replacing migration for 2 and 3 in app_1,
+        as 1_sq_2 is a replacing migration for 1 and 2 in app_2.
+        """
+
+        loader = MigrationLoader(connection)
+        recorder = MigrationRecorder(connection)
+        self.addCleanup(recorder.flush)
+
+        # Load with nothing applied: both migrations squashed.
+        loader.build_graph()
+        plan = set(loader.graph.forwards_plan(('app1', '4_auto')))
+        plan = plan - loader.applied_migrations
+        expected_plan = {
+            ('app1', '1_auto'),
+            ('app2', '1_squashed_2'),
+            ('app1', '2_squashed_3'),
+            ('app1', '4_auto'),
+        }
+        self.assertEqual(plan, expected_plan)
+
+        # Fake-apply a few from app1: unsquashes migration in app1.
+        recorder.record_applied('app1', '1_auto')
+        recorder.record_applied('app1', '2_auto')
+        loader.build_graph()
+        plan = set(loader.graph.forwards_plan(('app1', '4_auto')))
+        plan = plan - loader.applied_migrations
+        expected_plan = {
+            ('app2', '1_squashed_2'),
+            ('app1', '3_auto'),
+            ('app1', '4_auto'),
+        }
+        self.assertEqual(plan, expected_plan)
+
+        # Fake-apply one from app2: unsquashes migration in app2 too.
+        recorder.record_applied('app2', '1_auto')
+        loader.build_graph()
+        plan = set(loader.graph.forwards_plan(('app1', '4_auto')))
+        plan = plan - loader.applied_migrations
+        expected_plan = {
+            ('app2', '2_auto'),
+            ('app1', '3_auto'),
+            ('app1', '4_auto'),
+        }
+        self.assertEqual(plan, expected_plan)

+ 0 - 0
tests/migrations/test_migrations_squashed_ref_squashed/__init__.py


+ 8 - 0
tests/migrations/test_migrations_squashed_ref_squashed/app1/1_auto.py

@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    pass

+ 9 - 0
tests/migrations/test_migrations_squashed_ref_squashed/app1/2_auto.py

@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("app1", "1_auto")]

+ 14 - 0
tests/migrations/test_migrations_squashed_ref_squashed/app1/2_squashed_3.py

@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    replaces = [
+        ("app1", "2_auto"),
+        ("app1", "3_auto"),
+    ]
+
+    dependencies = [("app1", "1_auto"), ("app2", "1_squashed_2")]

+ 9 - 0
tests/migrations/test_migrations_squashed_ref_squashed/app1/3_auto.py

@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("app1", "2_auto"), ("app2", "2_auto")]

+ 9 - 0
tests/migrations/test_migrations_squashed_ref_squashed/app1/4_auto.py

@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("app1", "2_squashed_3")]

+ 0 - 0
tests/migrations/test_migrations_squashed_ref_squashed/app1/__init__.py


+ 9 - 0
tests/migrations/test_migrations_squashed_ref_squashed/app2/1_auto.py

@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("app1", "1_auto")]

+ 14 - 0
tests/migrations/test_migrations_squashed_ref_squashed/app2/1_squashed_2.py

@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    replaces = [
+        ("app2", "1_auto"),
+        ("app2", "2_auto"),
+    ]
+
+    dependencies = [("app1", "1_auto")]

+ 9 - 0
tests/migrations/test_migrations_squashed_ref_squashed/app2/2_auto.py

@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("app2", "1_auto")]

+ 0 - 0
tests/migrations/test_migrations_squashed_ref_squashed/app2/__init__.py