From 31fc34e447137631e6ea58fc33f3642e65479472 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Thu, 5 Jun 2014 23:03:33 -0700 Subject: [PATCH] [1.7.x] Rewrote migration autodetector to involve actual computer science. Fixes #22605, #22735; also lays the ground for some other fixes. Conflicts: django/db/migrations/autodetector.py --- django/db/migrations/autodetector.py | 734 +++++++++++++++++--------- django/db/migrations/optimizer.py | 43 +- tests/migrations/test_autodetector.py | 151 +++++- 3 files changed, 631 insertions(+), 297 deletions(-) diff --git a/django/db/migrations/autodetector.py b/django/db/migrations/autodetector.py index cd6f724c75..0fd37ef5c9 100644 --- a/django/db/migrations/autodetector.py +++ b/django/db/migrations/autodetector.py @@ -7,6 +7,7 @@ from django.db import models from django.db.migrations import operations from django.db.migrations.migration import Migration from django.db.migrations.questioner import MigrationQuestioner +from django.db.migrations.optimizer import MigrationOptimizer class MigrationAutodetector(object): @@ -39,6 +40,43 @@ class MigrationAutodetector(object): changes = self._trim_to_apps(changes, trim_to_apps) return changes + def deep_deconstruct(self, obj): + """ + Recursive deconstruction for a field and its arguments. + Used for full comparison for rename/alter; sometimes a single-level + deconstruction will not compare correctly. + """ + if not hasattr(obj, 'deconstruct'): + return obj + deconstructed = obj.deconstruct() + if isinstance(obj, models.Field): + # we have a field which also returns a name + deconstructed = deconstructed[1:] + path, args, kwargs = deconstructed + return ( + path, + [self.deep_deconstruct(value) for value in args], + dict( + (key, self.deep_deconstruct(value)) + for key, value in kwargs.items() + ), + ) + + def only_relation_agnostic_fields(self, fields): + """ + Return a definition of the fields that ignores field names and + what related fields actually relate to. + Used for detecting renames (as, of course, the related fields + change during renames) + """ + fields_def = [] + for name, field in fields: + deconstruction = self.deep_deconstruct(field) + if field.rel and field.rel.to: + del deconstruction[2]['to'] + fields_def.append(deconstruction) + return fields_def + def _detect_changes(self): """ Returns a dict of migration plans which will achieve the @@ -48,245 +86,443 @@ class MigrationAutodetector(object): The resulting migrations aren't specially named, but the names do matter for dependencies inside the set. """ - # We'll store migrations as lists by app names for now - self.migrations = {} - old_apps = self.from_state.render(ignore_swappable=True) - new_apps = self.to_state.render() - # Prepare lists of old/new model keys that we care about - # (i.e. ignoring proxy ones and unmigrated ones) - old_model_keys = [] - for al, mn in self.from_state.models.keys(): - model = old_apps.get_model(al, mn) + # The first phase is generating all the operations for each app + # and gathering them into a big per-app list. + # We'll then go through that list later and order it and split + # into migrations to resolve dependencies caused by M2Ms and FKs. + self.generated_operations = {} + + # Prepare some old/new state and model lists, ignoring + # proxy models and unmigrated apps. + self.old_apps = self.from_state.render(ignore_swappable=True) + self.new_apps = self.to_state.render() + self.old_model_keys = [] + for al, mn in sorted(self.from_state.models.keys()): + model = self.old_apps.get_model(al, mn) if not model._meta.proxy and model._meta.managed and al not in self.from_state.real_apps: - old_model_keys.append((al, mn)) + self.old_model_keys.append((al, mn)) + self.new_model_keys = [] + for al, mn in sorted(self.to_state.models.keys()): + model = self.new_apps.get_model(al, mn) + if not model._meta.proxy and model._meta.managed and al not in self.from_state.real_apps: + self.new_model_keys.append((al, mn)) - new_model_keys = [] - for al, mn in self.to_state.models.keys(): - model = new_apps.get_model(al, mn) - if not model._meta.proxy and model._meta.managed and al not in self.to_state.real_apps: - new_model_keys.append((al, mn)) + # Renames have to come first + self.generate_renamed_models() - def _deep_deconstruct(obj, field=True): - """ - Recursive deconstruction for a field and its arguments. - """ - if not hasattr(obj, 'deconstruct'): - return obj - deconstructed = obj.deconstruct() - if field: - deconstructed = deconstructed[1:] - name, args, kwargs = deconstructed + # Prepare field lists, and prepare a list of the fields that used + # through models in the old state so we can make dependencies + # from the through model deletion to the field that uses it. + self.kept_model_keys = set(self.old_model_keys).intersection(self.new_model_keys) + self.through_users = {} + self.old_field_keys = set() + self.new_field_keys = set() + for app_label, model_name in sorted(self.kept_model_keys): + old_model_name = self.renamed_models.get((app_label, model_name), model_name) + old_model_state = self.from_state.models[app_label, old_model_name] + new_model_state = self.to_state.models[app_label, model_name] + self.old_field_keys.update((app_label, model_name, x) for x, y in old_model_state.fields) + self.new_field_keys.update((app_label, model_name, x) for x, y in new_model_state.fields) + # Through model stuff + for field_name, field in old_model_state.fields: + old_field = self.old_apps.get_model(app_label, old_model_name)._meta.get_field_by_name(field_name)[0] + if hasattr(old_field, "rel") and hasattr(old_field.rel, "through") and not old_field.rel.through._meta.auto_created: + through_key = ( + old_field.rel.through._meta.app_label, + old_field.rel.through._meta.object_name.lower(), + ) + self.through_users[through_key] = (app_label, old_model_name, field_name) + + # Generate non-rename model operations + self.generate_created_models() + self.generate_deleted_models() + + # Generate field operations + self.generate_added_fields() + self.generate_removed_fields() + self.generate_altered_fields() + self.generate_altered_unique_together() + self.generate_altered_index_together() + + # Now, reordering to make things possible. The order we have already + # isn't bad, but we need to pull a few things around so FKs work nicely + # inside the same app + for app_label, ops in sorted(self.generated_operations.items()): + for i in range(10000): + found = False + for i, op in enumerate(ops): + for dep in op._auto_deps: + if dep[0] == app_label: + # Alright, there's a dependency on the same app. + for j, op2 in enumerate(ops): + if self.check_dependency(op2, dep) and j > i: + ops = ops[:i] + ops[i+1:j+1] + [op] + ops[j+1:] + found = True + break + if found: + break + if found: + break + if not found: + break + else: + raise ValueError("Infinite loop caught in operation dependency resolution") + self.generated_operations[app_label] = ops + + # Now, we need to chop the lists of operations up into migrations with + # dependencies on each other. + # We do this by stepping up an app's list of operations until we + # find one that has an outgoing dependency that isn't in another app's + # migration yet (hasn't been chopped off its list). We then chop off the + # operations before it into a migration and move onto the next app. + # If we loop back around without doing anything, there's a circular + # dependency (which _should_ be impossible as the operations are all + # split at this point so they can't depend and be depended on) + + self.migrations = {} + num_ops = sum(len(x) for x in self.generated_operations.values()) + chop_mode = False + while num_ops: + # On every iteration, we step through all the apps and see if there + # is a completed set of operations. + # If we find that a subset of the operations are complete we can + # try to chop it off from the rest and continue, but we only + # do this if we've already been through the list once before + # without any chopping and nothing has changed. + for app_label in sorted(self.generated_operations.keys()): + chopped = [] + dependencies = set() + for operation in list(self.generated_operations[app_label]): + deps_satisfied = True + operation_dependencies = set() + for dep in operation._auto_deps: + if dep[0] == "__setting__": + operation_dependencies.add((dep[0], dep[1])) + elif dep[0] != app_label: + # External app dependency. See if it's not yet + # satisfied. + for other_operation in self.generated_operations[dep[0]]: + if self.check_dependency(other_operation, dep): + deps_satisfied = False + break + if not deps_satisfied: + break + else: + if self.migrations.get(dep[0], None): + operation_dependencies.add((dep[0], self.migrations[dep[0]][-1].name)) + else: + operation_dependencies.add((dep[0], "__latest__")) + if deps_satisfied: + chopped.append(operation) + dependencies.update(operation_dependencies) + self.generated_operations[app_label] = self.generated_operations[app_label][1:] + else: + break + # Make a migration! Well, only if there's stuff to put in it + if dependencies or chopped: + if not self.generated_operations[app_label] or chop_mode: + subclass = type(str("Migration"), (Migration,), {"operations": [], "dependencies": []}) + instance = subclass("auto_%i" % (len(self.migrations.get(app_label, [])) + 1), app_label) + instance.dependencies = list(dependencies) + instance.operations = chopped + self.migrations.setdefault(app_label, []).append(instance) + chop_mode = False + else: + self.generated_operations[app_label] = chopped + self.generated_operations[app_label] + new_num_ops = sum(len(x) for x in self.generated_operations.values()) + if new_num_ops == num_ops: + if not chop_mode: + chop_mode = True + else: + raise ValueError("Cannot resolve operation dependencies") + num_ops = new_num_ops + + # OK, add in internal dependencies among the migrations + for app_label, migrations in self.migrations.items(): + for m1, m2 in zip(migrations, migrations[1:]): + m2.dependencies.append((app_label, m1.name)) + + # De-dupe dependencies + for app_label, migrations in self.migrations.items(): + for migration in migrations: + migration.dependencies = list(set(migration.dependencies)) + + # Optimize migrations + for app_label, migrations in self.migrations.items(): + for migration in migrations: + migration.operations = MigrationOptimizer().optimize(migration.operations, app_label=app_label) + + return self.migrations + + def check_dependency(self, operation, dependency): + """ + Checks if an operation dependency matches an operation. + """ + # Created model + if dependency[2] is None and dependency[3] is True: return ( - name, - [_deep_deconstruct(value, field=False) for value in args], - dict([(key, _deep_deconstruct(value, field=False)) - for key, value in kwargs.items()]) + isinstance(operation, operations.CreateModel) and + operation.name.lower() == dependency[1].lower() ) + # Created field + elif dependency[2] is not None and dependency[3] is True: + return ( + ( + isinstance(operation, operations.CreateModel) and + operation.name.lower() == dependency[1].lower() and + any(dependency[2] == x for x, y in operation.fields) + ) or + ( + isinstance(operation, operations.AddField) and + operation.model_name.lower() == dependency[1].lower() and + operation.name.lower() == dependency[2].lower() + ) + ) + # Removed field + elif dependency[2] is not None and dependency[3] is False: + return ( + isinstance(operation, operations.RemoveField) and + operation.model_name.lower() == dependency[1].lower() and + operation.name.lower() == dependency[2].lower() + ) + # Unknown dependency. Raise an error. + else: + raise ValueError("Can't handle dependency %r" % dependency) - def _rel_agnostic_fields_def(fields): - """ - Return a definition of the fields that ignores field names and - what related fields actually relate to. - """ - fields_def = [] - for name, field in fields: - deconstruction = _deep_deconstruct(field) - if field.rel and field.rel.to: - del deconstruction[2]['to'] - fields_def.append(deconstruction) - return fields_def + def add_operation(self, app_label, operation, dependencies=None): + # Dependencies are (app_label, model_name, field_name, create/delete as True/False) + operation._auto_deps = dependencies or [] + self.generated_operations.setdefault(app_label, []).append(operation) - # Find any renamed models. - renamed_models = {} - renamed_models_rel = {} - added_models = set(new_model_keys) - set(old_model_keys) - for app_label, model_name in added_models: + def generate_renamed_models(self): + """ + Finds any renamed models, and generates the operations for them, + and removes the old entry from the model lists. + Must be run before other model-level generation. + """ + self.renamed_models = {} + self.renamed_models_rel = {} + added_models = set(self.new_model_keys) - set(self.old_model_keys) + for app_label, model_name in sorted(added_models): model_state = self.to_state.models[app_label, model_name] - model_fields_def = _rel_agnostic_fields_def(model_state.fields) + model_fields_def = self.only_relation_agnostic_fields(model_state.fields) - removed_models = set(old_model_keys) - set(new_model_keys) + removed_models = set(self.old_model_keys) - set(self.new_model_keys) for rem_app_label, rem_model_name in removed_models: if rem_app_label == app_label: rem_model_state = self.from_state.models[rem_app_label, rem_model_name] - rem_model_fields_def = _rel_agnostic_fields_def(rem_model_state.fields) + rem_model_fields_def = self.only_relation_agnostic_fields(rem_model_state.fields) if model_fields_def == rem_model_fields_def: if self.questioner.ask_rename_model(rem_model_state, model_state): - self.add_to_migration( + self.add_operation( app_label, operations.RenameModel( old_name=rem_model_state.name, new_name=model_state.name, ) ) - renamed_models[app_label, model_name] = rem_model_name - renamed_models_rel['%s.%s' % (rem_model_state.app_label, rem_model_state.name)] = '%s.%s' % (model_state.app_label, model_state.name) - old_model_keys.remove((rem_app_label, rem_model_name)) - old_model_keys.append((app_label, model_name)) + self.renamed_models[app_label, model_name] = rem_model_name + self.renamed_models_rel['%s.%s' % (rem_model_state.app_label, rem_model_state.name)] = '%s.%s' % (model_state.app_label, model_state.name) + self.old_model_keys.remove((rem_app_label, rem_model_name)) + self.old_model_keys.append((app_label, model_name)) break - # Adding models. Phase 1 is adding models with no outward relationships. - added_models = set(new_model_keys) - set(old_model_keys) - pending_add = {} - for app_label, model_name in added_models: + def generate_created_models(self): + """ + Find all new models and make creation operations for them, + and separate operations to create any foreign key or M2M relationships + (we'll optimise these back in later if we can) + + We also defer any model options that refer to collections of fields + that might be deferred (e.g. unique_together, index_together) + """ + added_models = set(self.new_model_keys) - set(self.old_model_keys) + for app_label, model_name in sorted(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_apps.get_model(app_label, model_name)._meta.local_fields: + # Gather related fields + related_fields = {} + for field in self.new_apps.get_model(app_label, model_name)._meta.local_fields: if field.rel: if field.rel.to: - related_fields.append((field.name, field.rel.to._meta.app_label, field.rel.to._meta.model_name)) + related_fields[field.name] = field if hasattr(field.rel, "through") and not field.rel.through._meta.auto_created: - related_fields.append((field.name, field.rel.through._meta.app_label, field.rel.through._meta.model_name)) - for field in new_apps.get_model(app_label, model_name)._meta.local_many_to_many: + related_fields[field.name] = field + for field in self.new_apps.get_model(app_label, model_name)._meta.local_many_to_many: if field.rel.to: - related_fields.append((field.name, field.rel.to._meta.app_label, field.rel.to._meta.model_name)) + related_fields[field.name] = field if hasattr(field.rel, "through") and not field.rel.through._meta.auto_created: - related_fields.append((field.name, field.rel.through._meta.app_label, field.rel.through._meta.model_name)) - 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, - ) + related_fields[field.name] = field + # Are there unique/index_together to defer? + unique_together = model_state.options.pop('unique_together', None) + index_together = model_state.options.pop('index_together', None) + # Generate creation operatoin + self.add_operation( + app_label, + operations.CreateModel( + name=model_state.name, + fields=[d for d in model_state.fields if d[0] not in related_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 = [] - pending_unique_together = [] - added_phase_2 = set() - 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, - ), - # If it's already been added in phase 2 put it in a new - # migration for safety. - new=any((al, mn) in added_phase_2 for f, al, mn in related_fields), - ) - added_phase_2.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] - # Defer unique together constraints creation, see ticket #22275 - unique_together_constraints = model_state.options.pop('unique_together', None) - if unique_together_constraints: - pending_unique_together.append((app_label, model_name, - unique_together_constraints)) - # 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)) - for field_name, other_app_label, other_model_name in related_fields: - # If it depends on a swappable something, add a dynamic depend'cy - swappable_setting = new_apps.get_model(app_label, model_name)._meta.get_field_by_name(field_name)[0].swappable_setting + ) + # Generate operations for each related field + for name, field in sorted(related_fields.items()): + # Account for FKs to swappable models + swappable_setting = getattr(field, 'swappable_setting', None) if swappable_setting is not None: - self.add_swappable_dependency(app_label, swappable_setting) - elif app_label != other_app_label: - self.add_dependency(app_label, 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, - ) - # If it depends on a swappable something, add a dynamic depend'cy - swappable_setting = new_apps.get_model(app_label, model_name)._meta.get_field_by_name(field_name)[0].swappable_setting - if swappable_setting is not None: - self.add_swappable_dependency(app_label, swappable_setting) - elif app_label != other_app_label: - self.add_dependency(app_label, other_app_label) - # Phase 3.1 - unique together constraints - for app_label, model_name, unique_together in pending_unique_together: - self.add_to_migration( - app_label, - operations.AlterUniqueTogether( - name=model_name, - unique_together=unique_together + dep_app_label = "__setting__" + dep_object_name = swappable_setting + else: + dep_app_label = field.rel.to._meta.app_label + dep_object_name = field.rel.to._meta.object_name + # Make operation + self.add_operation( + app_label, + operations.AddField( + model_name=model_name, + name=name, + field=field, + ), + dependencies = [ + (dep_app_label, dep_object_name, None, True), + ] ) - ) - # Changes within models - kept_models = set(old_model_keys).intersection(new_model_keys) - old_fields = set() - new_fields = set() - unique_together_operations = [] - for app_label, model_name in kept_models: - old_model_name = renamed_models.get((app_label, model_name), model_name) - old_model_state = self.from_state.models[app_label, old_model_name] - new_model_state = self.to_state.models[app_label, model_name] - # Collect field changes for later global dealing with (so AddFields - # always come before AlterFields even on separate models) - old_fields.update((app_label, model_name, x) for x, y in old_model_state.fields) - new_fields.update((app_label, model_name, x) for x, y in new_model_state.fields) - # Unique_together changes. Operations will be added to migration a - # bit later, after fields creation. See ticket #22035. - if old_model_state.options.get("unique_together", set()) != new_model_state.options.get("unique_together", set()): - unique_together_operations.append(( + # Generate other opns + if unique_together: + self.add_operation( app_label, operations.AlterUniqueTogether( name=model_name, - unique_together=new_model_state.options.get("unique_together", set()), + unique_together=unique_together, + ), + dependencies = [ + (app_label, model_name, name, True) + for name, field in sorted(related_fields.items()) + ] + ) + if index_together: + self.add_operation( + app_label, + operations.AlterIndexTogether( + name=model_name, + index_together=index_together, + ), + dependencies = [ + (app_label, model_name, name, True) + for name, field in sorted(related_fields.items()) + ] + ) + + def generate_deleted_models(self): + """ + Find all deleted models and make creation operations for them, + and separate operations to delete any foreign key or M2M relationships + (we'll optimise these back in later if we can) + + We also bring forward removal of any model options that refer to + collections of fields - the inverse of generate_created_models. + """ + deleted_models = set(self.old_model_keys) - set(self.new_model_keys) + for app_label, model_name in sorted(deleted_models): + model_state = self.from_state.models[app_label, model_name] + model = self.old_apps.get_model(app_label, model_name) + # Gather related fields + related_fields = {} + for field in model._meta.local_fields: + if field.rel: + if field.rel.to: + related_fields[field.name] = field + if hasattr(field.rel, "through") and not field.rel.through._meta.auto_created: + related_fields[field.name] = field + for field in model._meta.local_many_to_many: + if field.rel.to: + related_fields[field.name] = field + if hasattr(field.rel, "through") and not field.rel.through._meta.auto_created: + related_fields[field.name] = field + # Generate option removal first + unique_together = model_state.options.pop('unique_together', None) + index_together = model_state.options.pop('index_together', None) + if unique_together: + self.add_operation( + app_label, + operations.AlterUniqueTogether( + name=model_name, + unique_together=None, ) + ) + if index_together: + self.add_operation( + app_label, + operations.AlterIndexTogether( + name=model_name, + index_together=None, + ) + ) + # Then remove each related field + for name, field in sorted(related_fields.items()): + self.add_operation( + app_label, + operations.RemoveField( + model_name=model_name, + name=name, + ) + ) + # Finally, remove the model. + # This depends on both the removal of all incoming fields + # and the removal of all its own related fields, and if it's + # a through model the field that references it. + dependencies = [] + for related_object in model._meta.get_all_related_objects(): + dependencies.append(( + related_object.model._meta.app_label, + related_object.model._meta.object_name, + related_object.field.name, + False, )) + for related_object in model._meta.get_all_related_many_to_many_objects(): + dependencies.append(( + related_object.model._meta.app_label, + related_object.model._meta.object_name, + related_object.field.name, + False, + )) + for name, field in sorted(related_fields.items()): + dependencies.append((app_label, model_name, name, False)) + # We're referenced in another field's through= + through_user = self.through_users.get((app_label, model_state.name.lower()), None) + if through_user: + dependencies.append((through_user[0], through_user[1], through_user[2], False)) + # Finally, make the operation, deduping any dependencies + self.add_operation( + app_label, + operations.DeleteModel( + name=model_state.name, + ), + dependencies = list(set(dependencies)), + ) + + def generate_added_fields(self): # New fields - renamed_fields = {} - for app_label, model_name, field_name in new_fields - old_fields: - old_model_name = renamed_models.get((app_label, model_name), model_name) + self.renamed_fields = {} + for app_label, model_name, field_name in sorted(self.new_field_keys - self.old_field_keys): + old_model_name = self.renamed_models.get((app_label, model_name), model_name) old_model_state = self.from_state.models[app_label, old_model_name] new_model_state = self.to_state.models[app_label, model_name] field = new_model_state.get_field_by_name(field_name) # Scan to see if this is actually a rename! - field_dec = _deep_deconstruct(field) + field_dec = self.deep_deconstruct(field) found_rename = False - for rem_app_label, rem_model_name, rem_field_name in (old_fields - new_fields): + for rem_app_label, rem_model_name, rem_field_name in sorted(self.old_field_keys - self.new_field_keys): if rem_app_label == app_label and rem_model_name == model_name: - old_field_dec = _deep_deconstruct(old_model_state.get_field_by_name(rem_field_name)) + old_field_dec = self.deep_deconstruct(old_model_state.get_field_by_name(rem_field_name)) if field.rel and field.rel.to and 'to' in old_field_dec[2]: old_rel_to = old_field_dec[2]['to'] - if old_rel_to in renamed_models_rel: - old_field_dec[2]['to'] = renamed_models_rel[old_rel_to] + if old_rel_to in self.renamed_models_rel: + old_field_dec[2]['to'] = self.renamed_models_rel[old_rel_to] if old_field_dec == field_dec: if self.questioner.ask_rename(model_name, rem_field_name, field_name, field): - self.add_to_migration( + self.add_operation( app_label, operations.RenameField( model_name=model_name, @@ -294,9 +530,9 @@ class MigrationAutodetector(object): new_name=field_name, ) ) - old_fields.remove((rem_app_label, rem_model_name, rem_field_name)) - old_fields.add((app_label, model_name, field_name)) - renamed_fields[app_label, model_name, field_name] = rem_field_name + self.old_field_keys.remove((rem_app_label, rem_model_name, rem_field_name)) + self.old_field_keys.add((app_label, model_name, field_name)) + self.renamed_fields[app_label, model_name, field_name] = rem_field_name found_rename = True break if found_rename: @@ -305,7 +541,7 @@ class MigrationAutodetector(object): if not field.null and not field.has_default() and not isinstance(field, models.ManyToManyField): field = field.clone() field.default = self.questioner.ask_not_null_addition(field_name, model_name) - self.add_to_migration( + self.add_operation( app_label, operations.AddField( model_name=model_name, @@ -315,7 +551,7 @@ class MigrationAutodetector(object): ) ) else: - self.add_to_migration( + self.add_operation( app_label, operations.AddField( model_name=model_name, @@ -323,33 +559,34 @@ class MigrationAutodetector(object): field=field, ) ) - new_field = new_apps.get_model(app_label, model_name)._meta.get_field_by_name(field_name)[0] - swappable_setting = getattr(new_field, 'swappable_setting', None) - if swappable_setting is not None: - self.add_swappable_dependency(app_label, swappable_setting) - # Old fields - for app_label, model_name, field_name in old_fields - new_fields: - old_model_name = renamed_models.get((app_label, model_name), model_name) - old_model_state = self.from_state.models[app_label, old_model_name] - new_model_state = self.to_state.models[app_label, model_name] - self.add_to_migration( + + def generate_removed_fields(self): + """ + Fields that have been removed. + """ + for app_label, model_name, field_name in sorted(self.old_field_keys - self.new_field_keys): + self.add_operation( app_label, operations.RemoveField( model_name=model_name, name=field_name, ) ) - # The same fields - for app_label, model_name, field_name in old_fields.intersection(new_fields): + + def generate_altered_fields(self): + """ + Fields that have been altered. + """ + for app_label, model_name, field_name in sorted(self.old_field_keys.intersection(self.new_field_keys)): # Did the field change? - old_model_name = renamed_models.get((app_label, model_name), model_name) + old_model_name = self.renamed_models.get((app_label, model_name), model_name) old_model_state = self.from_state.models[app_label, old_model_name] new_model_state = self.to_state.models[app_label, model_name] - old_field_name = renamed_fields.get((app_label, model_name, field_name), field_name) - old_field_dec = _deep_deconstruct(old_model_state.get_field_by_name(old_field_name)) - new_field_dec = _deep_deconstruct(new_model_state.get_field_by_name(field_name)) + old_field_name = self.renamed_fields.get((app_label, model_name, field_name), field_name) + old_field_dec = self.deep_deconstruct(old_model_state.get_field_by_name(old_field_name)) + new_field_dec = self.deep_deconstruct(new_model_state.get_field_by_name(field_name)) if old_field_dec != new_field_dec: - self.add_to_migration( + self.add_operation( app_label, operations.AlterField( model_name=model_name, @@ -357,53 +594,34 @@ class MigrationAutodetector(object): field=new_model_state.get_field_by_name(field_name), ) ) - for app_label, operation in unique_together_operations: - self.add_to_migration(app_label, operation) - # Removing models - removed_models = set(old_model_keys) - set(new_model_keys) - for app_label, model_name in removed_models: - model_state = self.from_state.models[app_label, model_name] - self.add_to_migration( - app_label, - operations.DeleteModel( - model_state.name, + + def generate_altered_unique_together(self): + for app_label, model_name in sorted(self.kept_model_keys): + old_model_name = self.renamed_models.get((app_label, model_name), model_name) + old_model_state = self.from_state.models[app_label, old_model_name] + new_model_state = self.to_state.models[app_label, model_name] + if old_model_state.options.get("unique_together", None) != new_model_state.options.get("unique_together", None): + self.add_operation( + app_label, + operations.AlterUniqueTogether( + name=model_name, + unique_together=new_model_state.options['unique_together'], + ) ) - ) - # Alright, now add internal dependencies - 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, new=False): - migrations = self.migrations.setdefault(app_label, []) - if not migrations or new: - subclass = type(str("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 add_swappable_dependency(self, app_label, setting_name): - """ - Adds a dependency to the value of a swappable model setting. - """ - dependency = ("__setting__", setting_name) - self.migrations[app_label][-1].dependencies.append(dependency) + def generate_altered_index_together(self): + for app_label, model_name in sorted(self.kept_model_keys): + old_model_name = self.renamed_models.get((app_label, model_name), model_name) + old_model_state = self.from_state.models[app_label, old_model_name] + new_model_state = self.to_state.models[app_label, model_name] + if old_model_state.options.get("index_together", None) != new_model_state.options.get("index_together", None): + self.add_operation( + app_label, + operations.AlterIndexTogether( + name=model_name, + index_together=new_model_state.options['index_together'], + ) + ) def arrange_for_graph(self, changes, graph): """ diff --git a/django/db/migrations/optimizer.py b/django/db/migrations/optimizer.py index e81d0f92fa..81060886a0 100644 --- a/django/db/migrations/optimizer.py +++ b/django/db/migrations/optimizer.py @@ -51,7 +51,7 @@ class MigrationOptimizer(object): for i, operation in enumerate(operations): # Compare it to each operation after it for j, other in enumerate(operations[i + 1:]): - result = self.reduce(operation, other) + result = self.reduce(operation, other, operations[i+1:i+j+1]) if result is not None: # Optimize! Add result, then remaining others, then return new_operations.extend(result) @@ -67,7 +67,7 @@ class MigrationOptimizer(object): #### REDUCTION #### - def reduce(self, operation, other): + def reduce(self, operation, other, in_between=None): """ Either returns a list of zero, one or two operations, or None, meaning this pair cannot be optimized. @@ -156,24 +156,24 @@ class MigrationOptimizer(object): ] for ia, ib, om in submethods: if isinstance(operation, ia) and isinstance(other, ib): - return om(operation, other) + return om(operation, other, in_between or []) return None - def reduce_model_create_delete(self, operation, other): + def reduce_model_create_delete(self, operation, other, in_between): """ Folds a CreateModel and a DeleteModel into nothing. """ if operation.name.lower() == other.name.lower(): return [] - def reduce_model_alter_delete(self, operation, other): + def reduce_model_alter_delete(self, operation, other, in_between): """ Folds an AlterModelSomething and a DeleteModel into just delete. """ if operation.name.lower() == other.name.lower(): return [other] - def reduce_model_create_rename(self, operation, other): + def reduce_model_create_rename(self, operation, other, in_between): """ Folds a model rename into its create """ @@ -187,7 +187,7 @@ class MigrationOptimizer(object): ) ] - def reduce_model_rename_self(self, operation, other): + def reduce_model_rename_self(self, operation, other, in_between): """ Folds a model rename into another one """ @@ -199,8 +199,17 @@ class MigrationOptimizer(object): ) ] - def reduce_create_model_add_field(self, operation, other): + def reduce_create_model_add_field(self, operation, other, in_between): if operation.name.lower() == other.model_name.lower(): + # Don't allow optimisations of FKs through models they reference + if hasattr(other.field, "rel") and other.field.rel: + for between in in_between: + if between.references_model( + other.field.rel.to._meta.object_name, + other.field.rel.to._meta.app_label, + ): + return None + # OK, that's fine return [ migrations.CreateModel( operation.name, @@ -210,7 +219,7 @@ class MigrationOptimizer(object): ) ] - def reduce_create_model_alter_field(self, operation, other): + def reduce_create_model_alter_field(self, operation, other, in_between): if operation.name.lower() == other.model_name.lower(): return [ migrations.CreateModel( @@ -224,7 +233,7 @@ class MigrationOptimizer(object): ) ] - def reduce_create_model_rename_field(self, operation, other): + def reduce_create_model_rename_field(self, operation, other, in_between): if operation.name.lower() == other.model_name.lower(): return [ migrations.CreateModel( @@ -238,7 +247,7 @@ class MigrationOptimizer(object): ) ] - def reduce_create_model_remove_field(self, operation, other): + def reduce_create_model_remove_field(self, operation, other, in_between): if operation.name.lower() == other.model_name.lower(): return [ migrations.CreateModel( @@ -253,7 +262,7 @@ class MigrationOptimizer(object): ) ] - def reduce_add_field_alter_field(self, operation, other): + def reduce_add_field_alter_field(self, operation, other, in_between): if operation.model_name.lower() == other.model_name.lower() and operation.name.lower() == other.name.lower(): return [ migrations.AddField( @@ -263,15 +272,15 @@ class MigrationOptimizer(object): ) ] - def reduce_add_field_delete_field(self, operation, other): + def reduce_add_field_delete_field(self, operation, other, in_between): if operation.model_name.lower() == other.model_name.lower() and operation.name.lower() == other.name.lower(): return [] - def reduce_alter_field_delete_field(self, operation, other): + def reduce_alter_field_delete_field(self, operation, other, in_between): if operation.model_name.lower() == other.model_name.lower() and operation.name.lower() == other.name.lower(): return [other] - def reduce_add_field_rename_field(self, operation, other): + def reduce_add_field_rename_field(self, operation, other, in_between): if operation.model_name.lower() == other.model_name.lower() and operation.name.lower() == other.old_name.lower(): return [ migrations.AddField( @@ -281,7 +290,7 @@ class MigrationOptimizer(object): ) ] - def reduce_alter_field_rename_field(self, operation, other): + def reduce_alter_field_rename_field(self, operation, other, in_between): if operation.model_name.lower() == other.model_name.lower() and operation.name.lower() == other.old_name.lower(): return [ other, @@ -292,7 +301,7 @@ class MigrationOptimizer(object): ), ] - def reduce_rename_field_self(self, operation, other): + def reduce_rename_field_self(self, operation, other, in_between): if operation.model_name.lower() == other.model_name.lower() and operation.new_name.lower() == other.old_name.lower(): return [ migrations.RenameField( diff --git a/tests/migrations/test_autodetector.py b/tests/migrations/test_autodetector.py index cceb450452..629acbd610 100644 --- a/tests/migrations/test_autodetector.py +++ b/tests/migrations/test_autodetector.py @@ -41,6 +41,8 @@ class AutodetectorTests(TestCase): ("id", models.AutoField(primary_key=True)), ("publishers", models.ManyToManyField("testapp.Publisher")), ]) + author_with_m2m_through = ModelState("testapp", "Author", [("id", models.AutoField(primary_key=True)), ("publishers", models.ManyToManyField("testapp.Publisher", through="testapp.Contract"))]) + contract = ModelState("testapp", "Contract", [("id", models.AutoField(primary_key=True)), ("author", models.ForeignKey("testapp.Author")), ("publisher", models.ForeignKey("testapp.Publisher"))]) publisher = ModelState("testapp", "Publisher", [("id", models.AutoField(primary_key=True)), ("name", models.CharField(max_length=100))]) publisher_with_author = ModelState("testapp", "Publisher", [("id", models.AutoField(primary_key=True)), ("author", models.ForeignKey("testapp.Author")), ("name", models.CharField(max_length=100))]) publisher_with_book = ModelState("testapp", "Publisher", [("id", models.AutoField(primary_key=True)), ("author", models.ForeignKey("otherapp.Book")), ("name", models.CharField(max_length=100))]) @@ -62,6 +64,67 @@ class AutodetectorTests(TestCase): knight = ModelState("eggs", "Knight", [("id", models.AutoField(primary_key=True))]) rabbit = ModelState("eggs", "Rabbit", [("id", models.AutoField(primary_key=True)), ("knight", models.ForeignKey("eggs.Knight")), ("parent", models.ForeignKey("eggs.Rabbit"))], {"unique_together": [("parent", "knight")]}) + def repr_changes(self, changes): + output = "" + for app_label, migrations in sorted(changes.items()): + output += " %s:\n" % app_label + for migration in migrations: + output += " %s\n" % migration.name + for operation in migration.operations: + output += " %s\n" % operation + return output + + def assertNumberMigrations(self, changes, app_label, number): + if not changes.get(app_label, None): + self.fail("No migrations found for %s\n%s" % (app_label, self.repr_changes(changes))) + if len(changes[app_label]) != number: + self.fail("Incorrect number of migrations (%s) for %s (expected %s)\n%s" % ( + len(changes[app_label]), + app_label, + number, + self.repr_changes(changes), + )) + + def assertOperationTypes(self, changes, app_label, index, types): + if not changes.get(app_label, None): + self.fail("No migrations found for %s\n%s" % (app_label, self.repr_changes(changes))) + if len(changes[app_label]) < index + 1: + self.fail("No migration at index %s for %s\n%s" % (index, app_label, self.repr_changes(changes))) + migration = changes[app_label][index] + real_types = [operation.__class__.__name__ for operation in migration.operations] + if types != real_types: + self.fail("Operation type mismatch for %s.%s (expected %s):\n%s" % ( + app_label, + migration.name, + types, + self.repr_changes(changes), + )) + + def assertOperationAttributes(self, changes, app_label, index, operation_index, **attrs): + if not changes.get(app_label, None): + self.fail("No migrations found for %s\n%s" % (app_label, self.repr_changes(changes))) + if len(changes[app_label]) < index + 1: + self.fail("No migration at index %s for %s\n%s" % (index, app_label, self.repr_changes(changes))) + migration = changes[app_label][index] + if len(changes[app_label]) < index + 1: + self.fail("No operation at index %s for %s.%s\n%s" % ( + operation_index, + app_label, + migration.name, + self.repr_changes(changes), + )) + operation = migration.operations[operation_index] + for attr, value in attrs.items(): + if getattr(operation, attr, None) != value: + self.fail("Attribute mismatch for %s.%s op #%s, %s (expected %r):\n%s" % ( + app_label, + migration.name, + operation_index + 1, + attr, + value, + self.repr_changes(changes), + )) + def make_project_state(self, model_states): "Shortcut to make ProjectStates from lists of predefined models" project_state = ProjectState() @@ -281,6 +344,9 @@ class AutodetectorTests(TestCase): def test_fk_dependency(self): "Tests that having a ForeignKey automatically adds a dependency" # Make state + # Note that testapp (author) has no dependencies, + # otherapp (book) depends on testapp (author), + # thirdapp (edition) depends on otherapp (book) before = self.make_project_state([]) after = self.make_project_state([self.author_name, self.book, self.edition]) autodetector = MigrationAutodetector(before, after) @@ -322,12 +388,15 @@ class AutodetectorTests(TestCase): self.assertEqual(len(changes['testapp']), 1) # Right number of actions? migration = changes['testapp'][0] - self.assertEqual(len(migration.operations), 2) + self.assertEqual(len(migration.operations), 3) # Right actions? action = migration.operations[0] self.assertEqual(action.__class__.__name__, "CreateModel") action = migration.operations[1] self.assertEqual(action.__class__.__name__, "CreateModel") + # Third action might vanish one day if the optimizer improves. + action = migration.operations[2] + self.assertEqual(action.__class__.__name__, "AddField") # Right dependencies? self.assertEqual(migration.dependencies, []) @@ -350,10 +419,12 @@ class AutodetectorTests(TestCase): migration2 = changes['otherapp'][0] self.assertEqual(len(migration2.operations), 1) migration3 = changes['otherapp'][1] - self.assertEqual(len(migration2.operations), 1) + self.assertEqual(len(migration3.operations), 1) # Right actions? action = migration1.operations[0] self.assertEqual(action.__class__.__name__, "CreateModel") + self.assertEqual(action.name, "Author") + self.assertEqual(len(action.fields), 3) action = migration2.operations[0] self.assertEqual(action.__class__.__name__, "CreateModel") self.assertEqual(len(action.fields), 2) @@ -362,8 +433,8 @@ class AutodetectorTests(TestCase): self.assertEqual(action.name, "author") # Right dependencies? self.assertEqual(migration1.dependencies, [("otherapp", "auto_1")]) - self.assertEqual(migration2.dependencies, [('testapp', '__first__')]) - self.assertEqual(set(migration3.dependencies), set([("otherapp", "auto_1"), ("testapp", "auto_1")])) + self.assertEqual(migration2.dependencies, []) + self.assertEqual(set(migration3.dependencies), set([("testapp", "auto_1"), ("otherapp", "auto_1")])) def test_same_app_circular_fk_dependency(self): """ @@ -376,23 +447,23 @@ class AutodetectorTests(TestCase): autodetector = MigrationAutodetector(before, after) changes = autodetector._detect_changes() # Right number of migrations? - self.assertEqual(len(changes['testapp']), 2) + self.assertEqual(len(changes['testapp']), 1) # Right number of actions? migration1 = changes['testapp'][0] - self.assertEqual(len(migration1.operations), 2) - migration2 = changes['testapp'][1] - self.assertEqual(len(migration2.operations), 1) + self.assertEqual(len(migration1.operations), 4) # Right actions? action = migration1.operations[0] self.assertEqual(action.__class__.__name__, "CreateModel") action = migration1.operations[1] self.assertEqual(action.__class__.__name__, "CreateModel") - action = migration2.operations[0] + action = migration1.operations[2] self.assertEqual(action.__class__.__name__, "AddField") self.assertEqual(action.name, "publisher") + action = migration1.operations[3] + self.assertEqual(action.__class__.__name__, "AddField") + self.assertEqual(action.name, "author") # Right dependencies? self.assertEqual(migration1.dependencies, []) - self.assertEqual(migration2.dependencies, [("testapp", "auto_1")]) def test_same_app_circular_fk_dependency_and_unique_together(self): """ @@ -406,29 +477,22 @@ class AutodetectorTests(TestCase): autodetector = MigrationAutodetector(before, after) changes = autodetector._detect_changes() # Right number of migrations? - self.assertEqual(len(changes['eggs']), 2) + self.assertEqual(len(changes['eggs']), 1) # Right number of actions? migration1 = changes['eggs'][0] - self.assertEqual(len(migration1.operations), 2) - migration2 = changes['eggs'][1] - self.assertEqual(len(migration2.operations), 2) + self.assertEqual(len(migration1.operations), 3) # Right actions? action = migration1.operations[0] self.assertEqual(action.__class__.__name__, "CreateModel") action = migration1.operations[1] self.assertEqual(action.__class__.__name__, "CreateModel") - # CreateModel action for Rabbit should not have unique_together now self.assertEqual(action.name, "Rabbit") self.assertFalse("unique_together" in action.options) - action = migration2.operations[0] - self.assertEqual(action.__class__.__name__, "AddField") - self.assertEqual(action.name, "parent") - action = migration2.operations[1] + action = migration1.operations[2] self.assertEqual(action.__class__.__name__, "AlterUniqueTogether") self.assertEqual(action.name, "rabbit") # Right dependencies? self.assertEqual(migration1.dependencies, []) - self.assertEqual(migration2.dependencies, [("eggs", "auto_1")]) def test_unique_together(self): "Tests unique_together detection" @@ -658,11 +722,54 @@ class AutodetectorTests(TestCase): self.assertEqual(len(changes['otherapp']), 1) # Right number of actions? migration = changes['otherapp'][0] - self.assertEqual(len(migration.operations), 2) + self.assertEqual(len(migration.operations), 4) # Right actions in right order? + # The first two are because we can't optimise RemoveField + # into DeleteModel reliably. action = migration.operations[0] self.assertEqual(action.__class__.__name__, "RemoveField") - self.assertEqual(action.name, "authors") + self.assertEqual(action.name, "author") action = migration.operations[1] + self.assertEqual(action.__class__.__name__, "RemoveField") + self.assertEqual(action.name, "book") + action = migration.operations[2] + self.assertEqual(action.__class__.__name__, "RemoveField") + self.assertEqual(action.name, "authors") + action = migration.operations[3] self.assertEqual(action.__class__.__name__, "DeleteModel") self.assertEqual(action.name, "Attribution") + + def test_m2m_w_through_multistep_remove(self): + """ + A model with a m2m field that specifies a "through" model cannot be removed in the same + migration as that through model as the schema will pass through an inconsistent state. + The autodetector should produce two migrations to avoid this issue. + """ + before = self.make_project_state([self.author_with_m2m_through, self.publisher, self.contract]) + after = self.make_project_state([self.publisher]) + autodetector = MigrationAutodetector(before, after) + changes = autodetector._detect_changes() + # Right number of migrations? + self.assertNumberMigrations(changes, "testapp", 1) + # Right actions in right order? + self.assertOperationTypes(changes, "testapp", 0, ["RemoveField", "RemoveField", "DeleteModel", "RemoveField", "DeleteModel"]) + # Actions touching the right stuff? + self.assertOperationAttributes(changes, "testapp", 0, 0, name="publishers") + self.assertOperationAttributes(changes, "testapp", 0, 1, name="author") + self.assertOperationAttributes(changes, "testapp", 0, 2, name="Author") + self.assertOperationAttributes(changes, "testapp", 0, 3, name="publisher") + self.assertOperationAttributes(changes, "testapp", 0, 4, name="Contract") + + def test_non_circular_foreignkey_dependency_removal(self): + """ + If two models with a ForeignKey from one to the other are removed at the same time, + the autodetector should remove them in the correct order. + """ + before = self.make_project_state([self.author_with_publisher, self.publisher_with_author]) + after = self.make_project_state([]) + autodetector = MigrationAutodetector(before, after) + changes = autodetector._detect_changes() + # Right number of migrations? + self.assertNumberMigrations(changes, "testapp", 1) + # Right actions in right order? + self.assertOperationTypes(changes, "testapp", 0, ["RemoveField", "RemoveField", "DeleteModel", "DeleteModel"])