From 509379a161d179f9e973fbaf6393e879531756f6 Mon Sep 17 00:00:00 2001 From: Jarek Glowacki Date: Sun, 8 May 2016 10:56:13 +1000 Subject: [PATCH] Fixed #25945, #26292 -- Refactored MigrationLoader.build_graph() --- AUTHORS | 1 + django/db/migrations/exceptions.py | 3 +- django/db/migrations/graph.py | 153 +++++++++++++- django/db/migrations/loader.py | 192 +++++++----------- tests/migrations/test_graph.py | 116 +++++++++++ tests/migrations/test_loader.py | 67 ++++++ .../__init__.py | 0 .../app1/1_auto.py | 8 + .../app1/2_auto.py | 9 + .../app1/2_squashed_3.py | 14 ++ .../app1/3_auto.py | 9 + .../app1/4_auto.py | 9 + .../app1/__init__.py | 0 .../app2/1_auto.py | 9 + .../app2/1_squashed_2.py | 14 ++ .../app2/2_auto.py | 9 + .../app2/__init__.py | 0 17 files changed, 479 insertions(+), 134 deletions(-) create mode 100644 tests/migrations/test_migrations_squashed_ref_squashed/__init__.py create mode 100644 tests/migrations/test_migrations_squashed_ref_squashed/app1/1_auto.py create mode 100644 tests/migrations/test_migrations_squashed_ref_squashed/app1/2_auto.py create mode 100644 tests/migrations/test_migrations_squashed_ref_squashed/app1/2_squashed_3.py create mode 100644 tests/migrations/test_migrations_squashed_ref_squashed/app1/3_auto.py create mode 100644 tests/migrations/test_migrations_squashed_ref_squashed/app1/4_auto.py create mode 100644 tests/migrations/test_migrations_squashed_ref_squashed/app1/__init__.py create mode 100644 tests/migrations/test_migrations_squashed_ref_squashed/app2/1_auto.py create mode 100644 tests/migrations/test_migrations_squashed_ref_squashed/app2/1_squashed_2.py create mode 100644 tests/migrations/test_migrations_squashed_ref_squashed/app2/2_auto.py create mode 100644 tests/migrations/test_migrations_squashed_ref_squashed/app2/__init__.py diff --git a/AUTHORS b/AUTHORS index b31b531b73..e93cb14148 100644 --- a/AUTHORS +++ b/AUTHORS @@ -323,6 +323,7 @@ answer newbie questions, and generally made Django that much better: Janos Guljas Jan Pazdziora Jan Rademaker + Jarek GÅ‚owacki Jarek Zgoda Jason Davies (Esaj) Jason Huggins diff --git a/django/db/migrations/exceptions.py b/django/db/migrations/exceptions.py index d9c9b22416..7b10bd0394 100644 --- a/django/db/migrations/exceptions.py +++ b/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): diff --git a/django/db/migrations/graph.py b/django/db/migrations/graph.py index 6ba4ec129b..1a7a70322f 100644 --- a/django/db/migrations/graph.py +++ b/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 '' % 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: diff --git a/django/db/migrations/loader.py b/django/db/migrations/loader.py index c9ffbcc495..094ac1dd80 100644 --- a/django/db/migrations/loader.py +++ b/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 diff --git a/tests/migrations/test_graph.py b/tests/migrations/test_graph.py index 50cefdf158..472ec5a3ed 100644 --- a/tests/migrations/test_graph.py +++ b/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: diff --git a/tests/migrations/test_loader.py b/tests/migrations/test_loader.py index ec9c30adee..c646bb6fc2 100644 --- a/tests/migrations/test_loader.py +++ b/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) diff --git a/tests/migrations/test_migrations_squashed_ref_squashed/__init__.py b/tests/migrations/test_migrations_squashed_ref_squashed/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/migrations/test_migrations_squashed_ref_squashed/app1/1_auto.py b/tests/migrations/test_migrations_squashed_ref_squashed/app1/1_auto.py new file mode 100644 index 0000000000..9a330e1067 --- /dev/null +++ b/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 diff --git a/tests/migrations/test_migrations_squashed_ref_squashed/app1/2_auto.py b/tests/migrations/test_migrations_squashed_ref_squashed/app1/2_auto.py new file mode 100644 index 0000000000..4428c73fc4 --- /dev/null +++ b/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")] diff --git a/tests/migrations/test_migrations_squashed_ref_squashed/app1/2_squashed_3.py b/tests/migrations/test_migrations_squashed_ref_squashed/app1/2_squashed_3.py new file mode 100644 index 0000000000..80501ef177 --- /dev/null +++ b/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")] diff --git a/tests/migrations/test_migrations_squashed_ref_squashed/app1/3_auto.py b/tests/migrations/test_migrations_squashed_ref_squashed/app1/3_auto.py new file mode 100644 index 0000000000..43863022a7 --- /dev/null +++ b/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")] diff --git a/tests/migrations/test_migrations_squashed_ref_squashed/app1/4_auto.py b/tests/migrations/test_migrations_squashed_ref_squashed/app1/4_auto.py new file mode 100644 index 0000000000..6ce3ee7953 --- /dev/null +++ b/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")] diff --git a/tests/migrations/test_migrations_squashed_ref_squashed/app1/__init__.py b/tests/migrations/test_migrations_squashed_ref_squashed/app1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/migrations/test_migrations_squashed_ref_squashed/app2/1_auto.py b/tests/migrations/test_migrations_squashed_ref_squashed/app2/1_auto.py new file mode 100644 index 0000000000..4428c73fc4 --- /dev/null +++ b/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")] diff --git a/tests/migrations/test_migrations_squashed_ref_squashed/app2/1_squashed_2.py b/tests/migrations/test_migrations_squashed_ref_squashed/app2/1_squashed_2.py new file mode 100644 index 0000000000..aca9a46f61 --- /dev/null +++ b/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")] diff --git a/tests/migrations/test_migrations_squashed_ref_squashed/app2/2_auto.py b/tests/migrations/test_migrations_squashed_ref_squashed/app2/2_auto.py new file mode 100644 index 0000000000..77ab95584a --- /dev/null +++ b/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")] diff --git a/tests/migrations/test_migrations_squashed_ref_squashed/app2/__init__.py b/tests/migrations/test_migrations_squashed_ref_squashed/app2/__init__.py new file mode 100644 index 0000000000..e69de29bb2