Fixed #24184 -- Prevented automatic soft-apply of migrations
Previously Django only checked for the table name in CreateModel operations in initial migrations and faked the migration automatically. This led to various errors and unexpected behavior. The newly introduced --fake-initial flag to the migrate command must be passed to get the same behavior again. With this change Django will bail out in with a "duplicate relation / table" error instead. Thanks Carl Meyer and Tim Graham for the documentation update, report and review.
This commit is contained in:
parent
b4e1090ab2
commit
f287bec583
|
@ -40,6 +40,10 @@ class Command(BaseCommand):
|
||||||
'Defaults to the "default" database.')
|
'Defaults to the "default" database.')
|
||||||
parser.add_argument('--fake', action='store_true', dest='fake', default=False,
|
parser.add_argument('--fake', action='store_true', dest='fake', default=False,
|
||||||
help='Mark migrations as run without actually running them')
|
help='Mark migrations as run without actually running them')
|
||||||
|
parser.add_argument('--fake-initial', action='store_true', dest='fake_initial', default=False,
|
||||||
|
help='Detect if tables already exist and fake-apply initial migrations if so. Make sure '
|
||||||
|
'that the current database schema matches your initial migration before using this '
|
||||||
|
'flag. Django will only check for an existing table name.')
|
||||||
parser.add_argument('--list', '-l', action='store_true', dest='list', default=False,
|
parser.add_argument('--list', '-l', action='store_true', dest='list', default=False,
|
||||||
help='Show a list of all known migrations and which are applied')
|
help='Show a list of all known migrations and which are applied')
|
||||||
|
|
||||||
|
@ -186,7 +190,9 @@ class Command(BaseCommand):
|
||||||
"apply them."
|
"apply them."
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
executor.migrate(targets, plan, fake=options.get("fake", False))
|
fake = options.get("fake")
|
||||||
|
fake_initial = options.get("fake_initial")
|
||||||
|
executor.migrate(targets, plan, fake=fake, fake_initial=fake_initial)
|
||||||
|
|
||||||
# Send the post_migrate signal, so individual apps can do whatever they need
|
# Send the post_migrate signal, so individual apps can do whatever they need
|
||||||
# to do at this point.
|
# to do at this point.
|
||||||
|
|
|
@ -62,7 +62,7 @@ class MigrationExecutor(object):
|
||||||
applied.add(migration)
|
applied.add(migration)
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
def migrate(self, targets, plan=None, fake=False):
|
def migrate(self, targets, plan=None, fake=False, fake_initial=False):
|
||||||
"""
|
"""
|
||||||
Migrates the database up to the given targets.
|
Migrates the database up to the given targets.
|
||||||
|
|
||||||
|
@ -91,7 +91,7 @@ class MigrationExecutor(object):
|
||||||
# Phase 2 -- Run the migrations
|
# Phase 2 -- Run the migrations
|
||||||
for migration, backwards in plan:
|
for migration, backwards in plan:
|
||||||
if not backwards:
|
if not backwards:
|
||||||
self.apply_migration(states[migration], migration, fake=fake)
|
self.apply_migration(states[migration], migration, fake=fake, fake_initial=fake_initial)
|
||||||
else:
|
else:
|
||||||
self.unapply_migration(states[migration], migration, fake=fake)
|
self.unapply_migration(states[migration], migration, fake=fake)
|
||||||
|
|
||||||
|
@ -113,18 +113,19 @@ class MigrationExecutor(object):
|
||||||
statements.extend(schema_editor.collected_sql)
|
statements.extend(schema_editor.collected_sql)
|
||||||
return statements
|
return statements
|
||||||
|
|
||||||
def apply_migration(self, state, migration, fake=False):
|
def apply_migration(self, state, migration, fake=False, fake_initial=False):
|
||||||
"""
|
"""
|
||||||
Runs a migration forwards.
|
Runs a migration forwards.
|
||||||
"""
|
"""
|
||||||
if self.progress_callback:
|
if self.progress_callback:
|
||||||
self.progress_callback("apply_start", migration, fake)
|
self.progress_callback("apply_start", migration, fake)
|
||||||
if not fake:
|
if not fake:
|
||||||
# Test to see if this is an already-applied initial migration
|
if fake_initial:
|
||||||
applied, state = self.detect_soft_applied(state, migration)
|
# Test to see if this is an already-applied initial migration
|
||||||
if applied:
|
applied, state = self.detect_soft_applied(state, migration)
|
||||||
fake = True
|
if applied:
|
||||||
else:
|
fake = True
|
||||||
|
if not fake:
|
||||||
# Alright, do it normally
|
# Alright, do it normally
|
||||||
with self.connection.schema_editor() as schema_editor:
|
with self.connection.schema_editor() as schema_editor:
|
||||||
state = migration.apply(state, schema_editor)
|
state = migration.apply(state, schema_editor)
|
||||||
|
|
|
@ -721,6 +721,19 @@ be warned that using ``--fake`` runs the risk of putting the migration state
|
||||||
table into a state where manual recovery will be needed to make migrations
|
table into a state where manual recovery will be needed to make migrations
|
||||||
run correctly.
|
run correctly.
|
||||||
|
|
||||||
|
.. versionadded:: 1.8
|
||||||
|
|
||||||
|
.. django-admin-option:: --fake-initial
|
||||||
|
|
||||||
|
The ``--fake-initial`` option can be used to allow Django to skip an app's
|
||||||
|
initial migration if all database tables with the names of all models created
|
||||||
|
by all :class:`~django.db.migrations.operations.CreateModel` operations in that
|
||||||
|
migration already exist. This option is intended for use when first running
|
||||||
|
migrations against a database that preexisted the use of migrations. This
|
||||||
|
option does not, however, check for matching database schema beyond matching
|
||||||
|
table names and so is only safe to use if you are confident that your existing
|
||||||
|
schema matches what is recorded in your initial migration.
|
||||||
|
|
||||||
.. deprecated:: 1.8
|
.. deprecated:: 1.8
|
||||||
|
|
||||||
The ``--list`` option has been moved to the :djadmin:`showmigrations`
|
The ``--list`` option has been moved to the :djadmin:`showmigrations`
|
||||||
|
|
|
@ -1135,6 +1135,11 @@ Miscellaneous
|
||||||
has been removed by a migration and replaced by a property. That means it's
|
has been removed by a migration and replaced by a property. That means it's
|
||||||
not possible to query or filter a ``ContentType`` by this field any longer.
|
not possible to query or filter a ``ContentType`` by this field any longer.
|
||||||
|
|
||||||
|
* :djadmin:`migrate` now accepts the :djadminopt:`--fake-initial` option to
|
||||||
|
allow faking initial migrations. In 1.7 initial migrations were always
|
||||||
|
automatically faked if all tables created in an initial migration already
|
||||||
|
existed.
|
||||||
|
|
||||||
.. _deprecated-features-1.8:
|
.. _deprecated-features-1.8:
|
||||||
|
|
||||||
Features deprecated in 1.8
|
Features deprecated in 1.8
|
||||||
|
|
|
@ -140,6 +140,13 @@ developers (or your production servers) check out the code, they'll
|
||||||
get both the changes to your models and the accompanying migration at the
|
get both the changes to your models and the accompanying migration at the
|
||||||
same time.
|
same time.
|
||||||
|
|
||||||
|
.. versionadded:: 1.8
|
||||||
|
|
||||||
|
If you want to give the migration(s) a meaningful name instead of a generated
|
||||||
|
one, you can use the :djadminopt:`--name` option::
|
||||||
|
|
||||||
|
$ python manage.py makemigrations --name changed_my_model your_app_label
|
||||||
|
|
||||||
Version control
|
Version control
|
||||||
~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
@ -282,10 +289,12 @@ need to convert it to use migrations; this is a simple process::
|
||||||
|
|
||||||
$ python manage.py makemigrations your_app_label
|
$ python manage.py makemigrations your_app_label
|
||||||
|
|
||||||
This will make a new initial migration for your app. Now, when you run
|
This will make a new initial migration for your app. Now, run ``python
|
||||||
:djadmin:`migrate`, Django will detect that you have an initial migration
|
manage.py migrate --fake-initial``, and Django will detect that you have an
|
||||||
*and* that the tables it wants to create already exist, and will mark the
|
initial migration *and* that the tables it wants to create already exist, and
|
||||||
migration as already applied.
|
will mark the migration as already applied. (Without the
|
||||||
|
:djadminopt:`--fake-initial` flag, the :djadmin:`migrate` command would error
|
||||||
|
out because the tables it wants to create already exist.)
|
||||||
|
|
||||||
Note that this only works given two things:
|
Note that this only works given two things:
|
||||||
|
|
||||||
|
@ -297,12 +306,11 @@ Note that this only works given two things:
|
||||||
that your database doesn't match your models, you'll just get errors when
|
that your database doesn't match your models, you'll just get errors when
|
||||||
migrations try to modify those tables.
|
migrations try to modify those tables.
|
||||||
|
|
||||||
.. versionadded:: 1.8
|
.. versionchanged: 1.8
|
||||||
|
|
||||||
If you want to give the migration(s) a meaningful name instead of a generated one,
|
The ``--fake-initial`` flag to :djadmin:`migrate` was added. Previously,
|
||||||
you can use the :djadminopt:`--name` option::
|
Django would always automatically fake-apply initial migrations if it
|
||||||
|
detected that the tables exist.
|
||||||
$ python manage.py makemigrations --name changed_my_model your_app_label
|
|
||||||
|
|
||||||
.. _historical-models:
|
.. _historical-models:
|
||||||
|
|
||||||
|
@ -706,9 +714,10 @@ If you already have pre-existing migrations created with
|
||||||
``__init__.py`` - make sure you remove the ``.pyc`` files too.
|
``__init__.py`` - make sure you remove the ``.pyc`` files too.
|
||||||
* Run ``python manage.py makemigrations``. Django should see the empty
|
* Run ``python manage.py makemigrations``. Django should see the empty
|
||||||
migration directories and make new initial migrations in the new format.
|
migration directories and make new initial migrations in the new format.
|
||||||
* Run ``python manage.py migrate``. Django will see that the tables for the
|
* Run ``python manage.py migrate --fake-initial``. Django will see that the
|
||||||
initial migrations already exist and mark them as applied without running
|
tables for the initial migrations already exist and mark them as applied
|
||||||
them.
|
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
|
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
|
of foreign keys; in this case, ``makemigrations`` might make more than one
|
||||||
|
@ -716,6 +725,12 @@ initial migration, and you'll need to mark them all as applied using::
|
||||||
|
|
||||||
python manage.py migrate --fake yourappnamehere
|
python manage.py migrate --fake yourappnamehere
|
||||||
|
|
||||||
|
.. versionchanged:: 1.8
|
||||||
|
|
||||||
|
The :djadminopt:`--fake-initial` flag was added to :djadmin:`migrate`;
|
||||||
|
previously, initial migrations were always automatically fake-applied if
|
||||||
|
existing tables were detected.
|
||||||
|
|
||||||
Libraries/Third-party Apps
|
Libraries/Third-party Apps
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ import shutil
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.core.management import CommandError, call_command
|
from django.core.management import CommandError, call_command
|
||||||
from django.db import connection, models
|
from django.db import DatabaseError, connection, models
|
||||||
from django.db.migrations import questioner
|
from django.db.migrations import questioner
|
||||||
from django.test import ignore_warnings, mock, override_settings
|
from django.test import ignore_warnings, mock, override_settings
|
||||||
from django.utils import six
|
from django.utils import six
|
||||||
|
@ -52,6 +52,65 @@ class MigrateTests(MigrationTestBase):
|
||||||
self.assertTableNotExists("migrations_tribble")
|
self.assertTableNotExists("migrations_tribble")
|
||||||
self.assertTableNotExists("migrations_book")
|
self.assertTableNotExists("migrations_book")
|
||||||
|
|
||||||
|
@override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations"})
|
||||||
|
def test_migrate_fake_initial(self):
|
||||||
|
"""
|
||||||
|
#24184 - Tests that --fake-initial only works if all tables created in
|
||||||
|
the initial migration of an app exists
|
||||||
|
"""
|
||||||
|
# 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)
|
||||||
|
# Make sure the right tables exist
|
||||||
|
self.assertTableExists("migrations_author")
|
||||||
|
self.assertTableExists("migrations_tribble")
|
||||||
|
# Fake a roll-back
|
||||||
|
call_command("migrate", "migrations", "zero", fake=True, verbosity=0)
|
||||||
|
# Make sure the tables still exist
|
||||||
|
self.assertTableExists("migrations_author")
|
||||||
|
self.assertTableExists("migrations_tribble")
|
||||||
|
# Try to run initial migration
|
||||||
|
with self.assertRaises(DatabaseError):
|
||||||
|
call_command("migrate", "migrations", "0001", verbosity=0)
|
||||||
|
# Run initial migration with an explicit --fake-initial
|
||||||
|
out = six.StringIO()
|
||||||
|
with mock.patch('django.core.management.color.supports_color', lambda *args: False):
|
||||||
|
call_command("migrate", "migrations", "0001", fake_initial=True, stdout=out, verbosity=1)
|
||||||
|
self.assertIn(
|
||||||
|
"migrations.0001_initial... faked",
|
||||||
|
out.getvalue().lower()
|
||||||
|
)
|
||||||
|
# Run migrations all the way
|
||||||
|
call_command("migrate", verbosity=0)
|
||||||
|
# Make sure the right tables exist
|
||||||
|
self.assertTableExists("migrations_author")
|
||||||
|
self.assertTableNotExists("migrations_tribble")
|
||||||
|
self.assertTableExists("migrations_book")
|
||||||
|
# Fake a roll-back
|
||||||
|
call_command("migrate", "migrations", "zero", fake=True, verbosity=0)
|
||||||
|
# Make sure the tables still exist
|
||||||
|
self.assertTableExists("migrations_author")
|
||||||
|
self.assertTableNotExists("migrations_tribble")
|
||||||
|
self.assertTableExists("migrations_book")
|
||||||
|
# Try to run initial migration
|
||||||
|
with self.assertRaises(DatabaseError):
|
||||||
|
call_command("migrate", "migrations", verbosity=0)
|
||||||
|
# Run initial migration with an explicit --fake-initial
|
||||||
|
with self.assertRaises(DatabaseError):
|
||||||
|
# Fails because "migrations_tribble" does not exist but needs to in
|
||||||
|
# order to make --fake-initial work.
|
||||||
|
call_command("migrate", "migrations", fake_initial=True, verbosity=0)
|
||||||
|
# Fake a apply
|
||||||
|
call_command("migrate", "migrations", fake=True, verbosity=0)
|
||||||
|
# Unmigrate everything
|
||||||
|
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_conflict"})
|
@override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_conflict"})
|
||||||
def test_migrate_conflict_exit(self):
|
def test_migrate_conflict_exit(self):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -2,6 +2,7 @@ from django.apps.registry import apps as global_apps
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db.migrations.executor import MigrationExecutor
|
from django.db.migrations.executor import MigrationExecutor
|
||||||
from django.db.migrations.graph import MigrationGraph
|
from django.db.migrations.graph import MigrationGraph
|
||||||
|
from django.db.utils import DatabaseError
|
||||||
from django.test import TestCase, modify_settings, override_settings
|
from django.test import TestCase, modify_settings, override_settings
|
||||||
|
|
||||||
from .test_base import MigrationTestBase
|
from .test_base import MigrationTestBase
|
||||||
|
@ -186,7 +187,14 @@ class ExecutorTests(MigrationTestBase):
|
||||||
(executor.loader.graph.nodes["migrations", "0001_initial"], False),
|
(executor.loader.graph.nodes["migrations", "0001_initial"], False),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
executor.migrate([("migrations", "0001_initial")])
|
# Applying the migration should raise a database level error
|
||||||
|
# because we haven't given the --fake-initial option
|
||||||
|
with self.assertRaises(DatabaseError):
|
||||||
|
executor.migrate([("migrations", "0001_initial")])
|
||||||
|
# Reset the faked state
|
||||||
|
state = {"faked": None}
|
||||||
|
# Allow faking of initial CreateModel operations
|
||||||
|
executor.migrate([("migrations", "0001_initial")], fake_initial=True)
|
||||||
self.assertEqual(state["faked"], True)
|
self.assertEqual(state["faked"], True)
|
||||||
# And migrate back to clean up the database
|
# And migrate back to clean up the database
|
||||||
executor.loader.build_graph()
|
executor.loader.build_graph()
|
||||||
|
|
Loading…
Reference in New Issue