From db97a8849519a3933bf4abd2184efd68ebc21965 Mon Sep 17 00:00:00 2001 From: Andrei Kulakov Date: Tue, 31 Mar 2015 16:30:39 -0400 Subject: [PATCH] Fixed #24375 -- Added Migration.initial attribute The new attribute is checked when the `migrate --fake-initial` option is used. initial will be set to True for all initial migrations (this is particularly useful when initial migrations are split) as well as for squashed migrations. --- AUTHORS | 1 + .../management/commands/squashmigrations.py | 1 + django/db/migrations/autodetector.py | 2 + django/db/migrations/executor.py | 41 +++++++++++---- django/db/migrations/migration.py | 7 +++ django/db/migrations/writer.py | 6 ++- docs/releases/1.9.txt | 8 +++ docs/topics/migrations.txt | 35 ++++++++++--- tests/migrations/test_autodetector.py | 3 ++ tests/migrations/test_commands.py | 52 +++++++++++++++++++ .../test_migrations/0001_initial.py | 2 + .../0001_initial.py | 33 ++++++++++++ .../0002_second.py | 24 +++++++++ .../__init__.py | 0 .../0001_not_initial.py | 33 ++++++++++++ .../test_migrations_initial_false/__init__.py | 0 16 files changed, 232 insertions(+), 16 deletions(-) create mode 100644 tests/migrations/test_migrations_fake_split_initial/0001_initial.py create mode 100644 tests/migrations/test_migrations_fake_split_initial/0002_second.py create mode 100644 tests/migrations/test_migrations_fake_split_initial/__init__.py create mode 100644 tests/migrations/test_migrations_initial_false/0001_not_initial.py create mode 100644 tests/migrations/test_migrations_initial_false/__init__.py diff --git a/AUTHORS b/AUTHORS index 05f568c1ba..b982d35b1e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -40,6 +40,7 @@ answer newbie questions, and generally made Django that much better: Ana Krivokapic Andi Albrecht André Ericson + Andrei Kulakov Andreas Andreas Mock Andreas Pelme diff --git a/django/core/management/commands/squashmigrations.py b/django/core/management/commands/squashmigrations.py index 74e5ce78c3..db55195187 100644 --- a/django/core/management/commands/squashmigrations.py +++ b/django/core/management/commands/squashmigrations.py @@ -131,6 +131,7 @@ class Command(BaseCommand): "dependencies": dependencies, "operations": new_operations, "replaces": replaces, + "initial": True, }) new_migration = subclass("0001_squashed_%s" % migration.name, app_label) diff --git a/django/db/migrations/autodetector.py b/django/db/migrations/autodetector.py index ddf8e36b83..b20946999f 100644 --- a/django/db/migrations/autodetector.py +++ b/django/db/migrations/autodetector.py @@ -33,6 +33,7 @@ class MigrationAutodetector(object): self.from_state = from_state self.to_state = to_state self.questioner = questioner or MigrationQuestioner() + self.existing_apps = {app for app, model in from_state.models} def changes(self, graph, trim_to_apps=None, convert_apps=None, migration_name=None): """ @@ -297,6 +298,7 @@ class MigrationAutodetector(object): instance = subclass("auto_%i" % (len(self.migrations.get(app_label, [])) + 1), app_label) instance.dependencies = list(dependencies) instance.operations = chopped + instance.initial = app_label not in self.existing_apps self.migrations.setdefault(app_label, []).append(instance) chop_mode = False else: diff --git a/django/db/migrations/executor.py b/django/db/migrations/executor.py index aeb021e33c..c25aee6193 100644 --- a/django/db/migrations/executor.py +++ b/django/db/migrations/executor.py @@ -197,19 +197,25 @@ class MigrationExecutor(object): def detect_soft_applied(self, project_state, migration): """ Tests whether a migration has been implicitly applied - that the - tables it would create exist. This is intended only for use - on initial migrations (as it only looks for CreateModel). + tables or columns it would create exist. This is intended only for use + on initial migrations (as it only looks for CreateModel and AddField). """ - # Bail if the migration isn't the first one in its app - if [name for app, name in migration.dependencies if app == migration.app_label]: + if migration.initial is None: + # Bail if the migration isn't the first one in its app + if any(app == migration.app_label for app, name in migration.dependencies): + return False, project_state + elif migration.initial is False: + # Bail if it's NOT an initial migration return False, project_state + if project_state is None: after_state = self.loader.project_state((migration.app_label, migration.name), at_end=True) else: after_state = migration.mutate_state(project_state) apps = after_state.apps - found_create_migration = False - # Make sure all create model are done + found_create_model_migration = False + found_add_field_migration = False + # Make sure all create model and add field operations are done for operation in migration.operations: if isinstance(operation, migrations.CreateModel): model = apps.get_model(migration.app_label, operation.name) @@ -217,9 +223,26 @@ class MigrationExecutor(object): # We have to fetch the model to test with from the # main app cache, as it's not a direct dependency. model = global_apps.get_model(model._meta.swapped) + if model._meta.proxy or not model._meta.managed: + continue if model._meta.db_table not in self.connection.introspection.table_names(self.connection.cursor()): return False, project_state - found_create_migration = True - # If we get this far and we found at least one CreateModel migration, + found_create_model_migration = True + elif isinstance(operation, migrations.AddField): + model = apps.get_model(migration.app_label, operation.model_name) + if model._meta.swapped: + # We have to fetch the model to test with from the + # main app cache, as it's not a direct dependency. + model = global_apps.get_model(model._meta.swapped) + if model._meta.proxy or not model._meta.managed: + continue + + table = model._meta.db_table + db_field = model._meta.get_field(operation.name).column + fields = self.connection.introspection.get_table_description(self.connection.cursor(), table) + if db_field not in (f.name for f in fields): + return False, project_state + found_add_field_migration = True + # If we get this far and we found at least one CreateModel or AddField migration, # the migration is considered implicitly applied. - return found_create_migration, after_state + return (found_create_model_migration or found_add_field_migration), after_state diff --git a/django/db/migrations/migration.py b/django/db/migrations/migration.py index 59edf599f6..7641eb45bc 100644 --- a/django/db/migrations/migration.py +++ b/django/db/migrations/migration.py @@ -41,6 +41,13 @@ class Migration(object): # are not applied. replaces = [] + # Is this an initial migration? Initial migrations are skipped on + # --fake-initial if the table or fields already exist. If None, check if + # the migration has any dependencies to determine if there are dependencies + # to tell if db introspection needs to be done. If True, always perform + # introspection. If False, never perform introspection. + initial = None + def __init__(self, name, app_label): self.name = name self.app_label = app_label diff --git a/django/db/migrations/writer.py b/django/db/migrations/writer.py index a7848d744f..11ecb6ed2b 100644 --- a/django/db/migrations/writer.py +++ b/django/db/migrations/writer.py @@ -155,6 +155,7 @@ class MigrationWriter(object): """ items = { "replaces_str": "", + "initial_str": "", } imports = set() @@ -211,6 +212,9 @@ class MigrationWriter(object): if self.migration.replaces: items['replaces_str'] = "\n replaces = %s\n" % self.serialize(self.migration.replaces)[0] + if self.migration.initial: + items['initial_str'] = "\n initial = True\n" + return (MIGRATION_TEMPLATE % items).encode("utf8") @staticmethod @@ -508,7 +512,7 @@ from __future__ import unicode_literals %(imports)s class Migration(migrations.Migration): -%(replaces_str)s +%(replaces_str)s%(initial_str)s dependencies = [ %(dependencies)s\ ] diff --git a/docs/releases/1.9.txt b/docs/releases/1.9.txt index 4e8fad3bf8..d87e0a6618 100644 --- a/docs/releases/1.9.txt +++ b/docs/releases/1.9.txt @@ -368,6 +368,14 @@ Management Commands to the database using the password from your settings file (instead of requiring it to be manually entered). +Migrations +^^^^^^^^^^ + +* Initial migrations are now marked with an :attr:`initial = True + ` class attribute which allows + :djadminopt:`migrate --fake-initial <--fake-initial>` to more easily detect + initial migrations. + Models ^^^^^^ diff --git a/docs/topics/migrations.txt b/docs/topics/migrations.txt index 75373537c4..82354f96a8 100644 --- a/docs/topics/migrations.txt +++ b/docs/topics/migrations.txt @@ -276,6 +276,33 @@ class to make it importable:: Please refer to the notes about :ref:`historical-models` in migrations to see the implications that come along. +Initial migrations +~~~~~~~~~~~~~~~~~~ + +.. attribute:: Migration.initial + +.. versionadded:: 1.9 + +The "initial migrations" for an app are the migrations that create the first +version of that app's tables. Usually an app will have just one initial +migration, but in some cases of complex model interdependencies it may have two +or more. + +Initial migrations are marked with an ``initial = True`` class attribute on the +migration class. If an ``initial`` class attribute isn't found, a migration +will be considered "initial" if it is the first migration in the app (i.e. if +it has no dependencies on any other migration in the same app). + +When :djadmin:`migrate` is run with the :djadminopt:`--fake-initial` option, +these initial migrations are treated specially. For an initial migration that +creates one or more tables (``CreateModel`` operation), Django checks that all +of those tables already exist in the database and fake-applies the migration +if so. Similarly, for an initial migration that adds one or more fields +(``AddField`` operation), Django checks that all of the respective columns +already exist in the database and fake-applies the migration if so. Without +:djadminopt:`--fake-initial`, initial migrations are treated no differently +from any other migration. + Adding migrations to apps ------------------------- @@ -425,6 +452,7 @@ Then, open up the file; it should look something like this:: from django.db import models, migrations class Migration(migrations.Migration): + initial = True dependencies = [ ('yourappname', '0001_initial'), @@ -460,6 +488,7 @@ need to do is use the historical model and iterate over the rows:: person.save() class Migration(migrations.Migration): + initial = True dependencies = [ ('yourappname', '0001_initial'), @@ -761,12 +790,6 @@ If you already have pre-existing migrations created with without running them. (Django won't check that the table schema match your models, just that the right table names exist). -That's it! The only complication is if you have a circular dependency loop -of foreign keys; in this case, ``makemigrations`` might make more than one -initial migration, and you'll need to mark them all as applied using:: - - python manage.py migrate --fake yourappnamehere - .. versionchanged:: 1.8 The :djadminopt:`--fake-initial` flag was added to :djadmin:`migrate`; diff --git a/tests/migrations/test_autodetector.py b/tests/migrations/test_autodetector.py index c7c775ae30..7afce3d20b 100644 --- a/tests/migrations/test_autodetector.py +++ b/tests/migrations/test_autodetector.py @@ -877,6 +877,9 @@ class AutodetectorTests(TestCase): self.assertOperationTypes(changes, 'otherapp', 1, ["AddField"]) self.assertMigrationDependencies(changes, 'otherapp', 0, []) self.assertMigrationDependencies(changes, 'otherapp', 1, [("otherapp", "auto_1"), ("testapp", "auto_1")]) + # both split migrations should be `initial` + self.assertTrue(changes['otherapp'][0].initial) + self.assertTrue(changes['otherapp'][1].initial) def test_same_app_circular_fk_dependency(self): """ diff --git a/tests/migrations/test_commands.py b/tests/migrations/test_commands.py index d1bef38716..f50a1820ae 100644 --- a/tests/migrations/test_commands.py +++ b/tests/migrations/test_commands.py @@ -50,6 +50,30 @@ class MigrateTests(MigrationTestBase): self.assertTableNotExists("migrations_tribble") self.assertTableNotExists("migrations_book") + @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_initial_false"}) + def test_migrate_initial_false(self): + """ + `Migration.initial = False` skips fake-initial detection. + """ + # Make sure no tables are created + self.assertTableNotExists("migrations_author") + self.assertTableNotExists("migrations_tribble") + # Run the migrations to 0001 only + call_command("migrate", "migrations", "0001", verbosity=0) + # Fake rollback + call_command("migrate", "migrations", "zero", fake=True, verbosity=0) + # Make sure fake-initial detection does not run + with self.assertRaises(DatabaseError): + call_command("migrate", "migrations", "0001", fake_initial=True, verbosity=0) + + call_command("migrate", "migrations", "0001", fake=True, verbosity=0) + # Real rollback + call_command("migrate", "migrations", "zero", verbosity=0) + # Make sure it's all gone + self.assertTableNotExists("migrations_author") + self.assertTableNotExists("migrations_tribble") + self.assertTableNotExists("migrations_book") + @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations"}) def test_migrate_fake_initial(self): """ @@ -109,6 +133,24 @@ class MigrateTests(MigrationTestBase): self.assertTableNotExists("migrations_tribble") self.assertTableNotExists("migrations_book") + @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_fake_split_initial"}) + def test_migrate_fake_split_initial(self): + """ + Split initial migrations can be faked with --fake-initial. + """ + call_command("migrate", "migrations", "0002", verbosity=0) + call_command("migrate", "migrations", "zero", fake=True, verbosity=0) + out = six.StringIO() + with mock.patch('django.core.management.color.supports_color', lambda *args: False): + call_command("migrate", "migrations", "0002", fake_initial=True, stdout=out, verbosity=1) + value = out.getvalue().lower() + self.assertIn("migrations.0001_initial... faked", value) + self.assertIn("migrations.0002_second... faked", value) + # Fake an apply + call_command("migrate", "migrations", fake=True, verbosity=0) + # Unmigrate everything + call_command("migrate", "migrations", "zero", verbosity=0) + @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_conflict"}) def test_migrate_conflict_exit(self): """ @@ -409,6 +451,7 @@ class MakeMigrationsTests(MigrationTestBase): content = fp.read() self.assertIn('# -*- coding: utf-8 -*-', content) self.assertIn('migrations.CreateModel', content) + self.assertIn('initial = True', content) if six.PY3: self.assertIn('úñí©óðé µóðéø', content) # Meta.verbose_name @@ -882,6 +925,15 @@ class SquashMigrationsTests(MigrationTestBase): squashed_migration_file = os.path.join(migration_dir, "0001_squashed_0002_second.py") self.assertTrue(os.path.exists(squashed_migration_file)) + def test_squashmigrations_initial_attribute(self): + with self.temporary_migration_module(module="migrations.test_migrations") as migration_dir: + call_command("squashmigrations", "migrations", "0002", interactive=False, verbosity=0) + + squashed_migration_file = os.path.join(migration_dir, "0001_squashed_0002_second.py") + with codecs.open(squashed_migration_file, "r", encoding="utf-8") as fp: + content = fp.read() + self.assertIn("initial = True", content) + def test_squashmigrations_optimizes(self): """ Tests that squashmigrations optimizes operations. diff --git a/tests/migrations/test_migrations/0001_initial.py b/tests/migrations/test_migrations/0001_initial.py index 9286d0f02b..7a18c10c0d 100644 --- a/tests/migrations/test_migrations/0001_initial.py +++ b/tests/migrations/test_migrations/0001_initial.py @@ -6,6 +6,8 @@ from django.db import migrations, models class Migration(migrations.Migration): + initial = True + operations = [ migrations.CreateModel( diff --git a/tests/migrations/test_migrations_fake_split_initial/0001_initial.py b/tests/migrations/test_migrations_fake_split_initial/0001_initial.py new file mode 100644 index 0000000000..1f42af2fb8 --- /dev/null +++ b/tests/migrations/test_migrations_fake_split_initial/0001_initial.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + operations = [ + migrations.CreateModel( + "Author", + [ + ("id", models.AutoField(primary_key=True)), + ("name", models.CharField(max_length=255)), + ("slug", models.SlugField(null=True)), + ("age", models.IntegerField(default=0)), + ("silly_field", models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + "Tribble", + [ + ("id", models.AutoField(primary_key=True)), + ("fluffy", models.BooleanField(default=True)), + ], + ), + migrations.AlterUniqueTogether( + name='author', + unique_together=set([('name', 'slug')]), + ), + ] diff --git a/tests/migrations/test_migrations_fake_split_initial/0002_second.py b/tests/migrations/test_migrations_fake_split_initial/0002_second.py new file mode 100644 index 0000000000..786b77a654 --- /dev/null +++ b/tests/migrations/test_migrations_fake_split_initial/0002_second.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("migrations", "0001_initial"), + ] + + operations = [ + migrations.AddField("Author", "rating", models.IntegerField(default=0)), + migrations.CreateModel( + "Book", + [ + ("id", models.AutoField(primary_key=True)), + ("author", models.ForeignKey("migrations.Author", null=True)), + ], + ), + ] diff --git a/tests/migrations/test_migrations_fake_split_initial/__init__.py b/tests/migrations/test_migrations_fake_split_initial/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/migrations/test_migrations_initial_false/0001_not_initial.py b/tests/migrations/test_migrations_initial_false/0001_not_initial.py new file mode 100644 index 0000000000..9345321776 --- /dev/null +++ b/tests/migrations/test_migrations_initial_false/0001_not_initial.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = False + + operations = [ + migrations.CreateModel( + "Author", + [ + ("id", models.AutoField(primary_key=True)), + ("name", models.CharField(max_length=255)), + ("slug", models.SlugField(null=True)), + ("age", models.IntegerField(default=0)), + ("silly_field", models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + "Tribble", + [ + ("id", models.AutoField(primary_key=True)), + ("fluffy", models.BooleanField(default=True)), + ], + ), + migrations.AlterUniqueTogether( + name='author', + unique_together=set([('name', 'slug')]), + ), + ] diff --git a/tests/migrations/test_migrations_initial_false/__init__.py b/tests/migrations/test_migrations_initial_false/__init__.py new file mode 100644 index 0000000000..e69de29bb2