From 08218252d8be4633ac0e94863da527d824648d63 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sun, 8 Jun 2014 19:30:15 -0700 Subject: [PATCH] [1.7.x] Fixed #22487: Optional rollback emulation for migrated apps Conflicts: django/db/backends/creation.py django/test/runner.py docs/ref/settings.txt docs/topics/testing/advanced.txt --- django/conf/global_settings.py | 4 + django/core/management/commands/flush.py | 9 +-- django/core/management/commands/testserver.py | 2 +- django/db/backends/creation.py | 75 +++++++++++++++---- django/test/runner.py | 5 +- django/test/testcases.py | 19 +++++ docs/ref/migration-operations.txt | 13 +++- docs/ref/settings.txt | 18 +++++ docs/releases/1.7.txt | 4 + docs/topics/testing/advanced.txt | 9 ++- docs/topics/testing/overview.txt | 28 +++++++ docs/topics/testing/tools.txt | 14 +++- .../__init__.py | 0 .../migrations/0001_initial.py | 34 +++++++++ .../migrations/__init__.py | 0 .../migration_test_data_persistence/models.py | 5 ++ .../migration_test_data_persistence/tests.py | 33 ++++++++ 17 files changed, 246 insertions(+), 26 deletions(-) create mode 100644 tests/migration_test_data_persistence/__init__.py create mode 100644 tests/migration_test_data_persistence/migrations/0001_initial.py create mode 100644 tests/migration_test_data_persistence/migrations/__init__.py create mode 100644 tests/migration_test_data_persistence/models.py create mode 100644 tests/migration_test_data_persistence/tests.py diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index b9dc33caff5..94230d3ca52 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -595,6 +595,10 @@ DEFAULT_EXCEPTION_REPORTER_FILTER = 'django.views.debug.SafeExceptionReporterFil # The name of the class to use to run the test suite TEST_RUNNER = 'django.test.runner.DiscoverRunner' +# Apps that don't need to be serialized at test database creation time +# (only apps with migrations are to start with) +TEST_NON_SERIALIZED_APPS = [] + ############ # FIXTURES # ############ diff --git a/django/core/management/commands/flush.py b/django/core/management/commands/flush.py index d99deb951ef..1c21495b353 100644 --- a/django/core/management/commands/flush.py +++ b/django/core/management/commands/flush.py @@ -22,10 +22,9 @@ class Command(NoArgsCommand): make_option('--no-initial-data', action='store_false', dest='load_initial_data', default=True, help='Tells Django not to load any initial data after database synchronization.'), ) - help = ('Returns the database to the state it was in immediately after ' - 'migrate was first executed. This means that all data will be removed ' - 'from the database, any post-migration handlers will be ' - 're-executed, and the initial_data fixture will be re-installed.') + help = ('Removes ALL DATA from the database, including data added during ' + 'migrations. Unmigrated apps will also have their initial_data ' + 'fixture reloaded. Does not achieve a "fresh install" state.') def handle_noargs(self, **options): db = options.get('database') @@ -54,7 +53,7 @@ class Command(NoArgsCommand): if interactive: confirm = input("""You have requested a flush of the database. This will IRREVERSIBLY DESTROY all data currently in the %r database, -and return each table to a fresh state. +and return each table to an empty state. Are you sure you want to do this? Type 'yes' to continue, or 'no' to cancel: """ % connection.settings_dict['NAME']) diff --git a/django/core/management/commands/testserver.py b/django/core/management/commands/testserver.py index 04096604144..78885bbdb2e 100644 --- a/django/core/management/commands/testserver.py +++ b/django/core/management/commands/testserver.py @@ -27,7 +27,7 @@ class Command(BaseCommand): addrport = options.get('addrport') # Create a test database. - db_name = connection.creation.create_test_db(verbosity=verbosity, autoclobber=not interactive) + db_name = connection.creation.create_test_db(verbosity=verbosity, autoclobber=not interactive, serialize=False) # Import the fixture data into the test database. call_command('loaddata', *fixture_labels, **{'verbosity': verbosity}) diff --git a/django/db/backends/creation.py b/django/db/backends/creation.py index a2a288a9dae..a5fb9fe0867 100644 --- a/django/db/backends/creation.py +++ b/django/db/backends/creation.py @@ -9,6 +9,11 @@ from django.utils.deprecation import RemovedInDjango18Warning from django.utils.encoding import force_bytes from django.utils.functional import cached_property from django.utils.six.moves import input +from django.utils.six import StringIO +from django.core.management.commands.dumpdata import sort_dependencies +from django.db import router +from django.apps import apps +from django.core import serializers from .utils import truncate_name @@ -334,7 +339,7 @@ class BaseDatabaseCreation(object): ";", ] - def create_test_db(self, verbosity=1, autoclobber=False): + def create_test_db(self, verbosity=1, autoclobber=False, serialize=True): """ Creates a test database, prompting the user for confirmation if the database already exists. Returns the name of the test database created. @@ -357,25 +362,31 @@ class BaseDatabaseCreation(object): settings.DATABASES[self.connection.alias]["NAME"] = test_database_name self.connection.settings_dict["NAME"] = test_database_name - # Report migrate messages at one level lower than that requested. + # We report migrate messages at one level lower than that requested. # This ensures we don't get flooded with messages during testing - # (unless you really ask to be flooded) - call_command('migrate', + # (unless you really ask to be flooded). + call_command( + 'migrate', verbosity=max(verbosity - 1, 0), interactive=False, database=self.connection.alias, - load_initial_data=False, - test_database=True) + test_database=True, + ) - # We need to then do a flush to ensure that any data installed by - # custom SQL has been removed. The only test data should come from - # test fixtures, or autogenerated from post_migrate triggers. - # This has the side effect of loading initial data (which was - # intentionally skipped in the syncdb). - call_command('flush', + # We then serialize the current state of the database into a string + # and store it on the connection. This slightly horrific process is so people + # who are testing on databases without transactions or who are using + # a TransactionTestCase still get a clean database on every test run. + if serialize: + self.connection._test_serialized_contents = self.serialize_db_to_string() + + # Finally, we flush the database to clean + call_command( + 'flush', verbosity=max(verbosity - 1, 0), interactive=False, - database=self.connection.alias) + database=self.connection.alias + ) call_command('createcachetable', database=self.connection.alias) @@ -384,6 +395,44 @@ class BaseDatabaseCreation(object): return test_database_name + def serialize_db_to_string(self): + """ + Serializes all data in the database into a JSON string. + Designed only for test runner usage; will not handle large + amounts of data. + """ + # Build list of all apps to serialize + from django.db.migrations.loader import MigrationLoader + loader = MigrationLoader(self.connection) + app_list = [] + for app_config in apps.get_app_configs(): + if ( + app_config.models_module is not None and + app_config.label in loader.migrated_apps and + app_config.name not in settings.TEST_NON_SERIALIZED_APPS + ): + app_list.append((app_config, None)) + # Make a function to iteratively return every object + def get_objects(): + for model in sort_dependencies(app_list): + if not model._meta.proxy and router.allow_migrate(self.connection.alias, model): + queryset = model._default_manager.using(self.connection.alias).order_by(model._meta.pk.name) + for obj in queryset.iterator(): + yield obj + # Serialise to a string + out = StringIO() + serializers.serialize("json", get_objects(), indent=None, stream=out) + return out.getvalue() + + def deserialize_db_from_string(self, data): + """ + Reloads the database with data from a string generated by + the serialize_db_to_string method. + """ + data = StringIO(data) + for obj in serializers.deserialize("json", data, using=self.connection.alias): + obj.save() + def _get_test_db_name(self): """ Internal implementation - returns the name of the test DB that will be diff --git a/django/test/runner.py b/django/test/runner.py index 5eafe354163..38beb93042a 100644 --- a/django/test/runner.py +++ b/django/test/runner.py @@ -294,7 +294,10 @@ def setup_databases(verbosity, interactive, **kwargs): connection = connections[alias] if test_db_name is None: test_db_name = connection.creation.create_test_db( - verbosity, autoclobber=not interactive) + verbosity, + autoclobber=not interactive, + serialize=connection.settings_dict.get("TEST_SERIALIZE", True), + ) destroy = True else: connection.settings_dict['NAME'] = test_db_name diff --git a/django/test/testcases.py b/django/test/testcases.py index 0b2bcc83997..e07d62b5ecc 100644 --- a/django/test/testcases.py +++ b/django/test/testcases.py @@ -727,6 +727,12 @@ class TransactionTestCase(SimpleTestCase): # Subclasses can define fixtures which will be automatically installed. fixtures = None + # If transactions aren't available, Django will serialize the database + # contents into a fixture during setup and flush and reload them + # during teardown (as flush does not restore data from migrations). + # This can be slow; this flag allows enabling on a per-case basis. + serialized_rollback = False + def _pre_setup(self): """Performs any pre-test setup. This includes: @@ -782,6 +788,17 @@ class TransactionTestCase(SimpleTestCase): if self.reset_sequences: self._reset_sequences(db_name) + # If we need to provide replica initial data from migrated apps, + # then do so. + if self.serialized_rollback and hasattr(connections[db_name], "_test_serialized_contents"): + if self.available_apps is not None: + apps.unset_available_apps() + connections[db_name].creation.deserialize_db_from_string( + connections[db_name]._test_serialized_contents + ) + if self.available_apps is not None: + apps.set_available_apps(self.available_apps) + if self.fixtures: # We have to use this slightly awkward syntax due to the fact # that we're using *args and **kwargs together. @@ -818,12 +835,14 @@ class TransactionTestCase(SimpleTestCase): # Allow TRUNCATE ... CASCADE and don't emit the post_migrate signal # when flushing only a subset of the apps for db_name in self._databases_names(include_mirrors=False): + # Flush the database call_command('flush', verbosity=0, interactive=False, database=db_name, skip_checks=True, reset_sequences=False, allow_cascade=self.available_apps is not None, inhibit_post_migrate=self.available_apps is not None) + def assertQuerysetEqual(self, qs, values, transform=repr, ordered=True, msg=None): items = six.moves.map(transform, qs) if not ordered: diff --git a/docs/ref/migration-operations.txt b/docs/ref/migration-operations.txt index 40ef3849ac5..95c9a65020e 100644 --- a/docs/ref/migration-operations.txt +++ b/docs/ref/migration-operations.txt @@ -199,8 +199,9 @@ model:: # We get the model from the versioned app registry; # if we directly import it, it'll be the wrong version Country = apps.get_model("myapp", "Country") - Country.objects.create(name="USA", code="us") - Country.objects.create(name="France", code="fr") + db_alias = schema_editor.connection.alias + Country.objects.create(name="USA", code="us", using=db_alias) + Country.objects.create(name="France", code="fr", using=db_alias) class Migration(migrations.Migration): @@ -236,6 +237,14 @@ Oracle). This should be safe, but may cause a crash if you attempt to use the ``schema_editor`` provided on these backends; in this case, please set ``atomic=False``. +.. warning:: + + RunPython does not magically alter the connection of the models for you; + any model methods you call will go to the default database unless you + give them the current database alias (available from + ``schema_editor.connection.alias``, where ``schema_editor`` is the second + argument to your function). + SeparateDatabaseAndState ------------------------ diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index c6951315652..7f41939735d 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -2116,6 +2116,24 @@ The name of the class to use for starting the test suite. See Previously the default ``TEST_RUNNER`` was ``django.test.simple.DjangoTestSuiteRunner``. +.. setting:: TEST_NON_SERIALIZED_APPS + +TEST_NON_SERIALIZED_APPS +------------------------ + +Default: ``[]`` + +In order to restore the database state between tests for TransactionTestCases +and database backends without transactions, Django will :ref:`serialize the +contents of all apps with migrations ` when it +starts the test run so it can then reload from that copy before tests that +need it. + +This slows down the startup time of the test runner; if you have apps that +you know don't need this feature, you can add their full names in here (e.g. +``django.contrib.contenttypes``) to exclude them from this serialization +process. + .. setting:: THOUSAND_SEPARATOR THOUSAND_SEPARATOR diff --git a/docs/releases/1.7.txt b/docs/releases/1.7.txt index e65637f0852..6d7c9538352 100644 --- a/docs/releases/1.7.txt +++ b/docs/releases/1.7.txt @@ -63,6 +63,10 @@ but a few of the key features are: * ``initial_data`` fixtures are no longer loaded for apps with migrations; if you want to load initial data for an app, we suggest you do it in a migration. +* Test rollback behaviour is different for apps with migrations; in particular, + Django will no longer emulate rollbacks on non-transactional databases or + inside ``TransactionTestCase`` :ref:`unless specifically asked `. + App-loading refactor ~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/topics/testing/advanced.txt b/docs/topics/testing/advanced.txt index 71d6ca4a414..5a991598d01 100644 --- a/docs/topics/testing/advanced.txt +++ b/docs/topics/testing/advanced.txt @@ -488,7 +488,7 @@ django.db.connection.creation The creation module of the database backend also provides some utilities that can be useful during testing. -.. function:: create_test_db([verbosity=1, autoclobber=False]) +.. function:: create_test_db([verbosity=1, autoclobber=False, serialize=True]) Creates a new test database and runs ``migrate`` against it. @@ -504,6 +504,13 @@ can be useful during testing. * If autoclobber is ``True``, the database will be destroyed without consulting the user. + + ``serialize`` determines if Django serializes the database into an + in-memory JSON string before running tests (used to restore the database + state between tests if you don't have transactions). You can set this to + False to significantly speed up creation time if you know you don't need + data persistance outside of test fixtures. + Returns the name of the test database that it created. ``create_test_db()`` has the side effect of modifying the value of diff --git a/docs/topics/testing/overview.txt b/docs/topics/testing/overview.txt index db7c074a4f4..0fa158172d1 100644 --- a/docs/topics/testing/overview.txt +++ b/docs/topics/testing/overview.txt @@ -241,6 +241,33 @@ the Django test runner reorders tests in the following way: database by a given :class:`~django.test.TransactionTestCase` test, they must be updated to be able to run independently. +.. _test-case-serialized-rollback: + +Rollback emulation +------------------ + +Any initial data loaded in migrations will only be available in ``TestCase`` +tests and not in ``TransactionTestCase`` tests, and additionally only on +backends where transactions are supported (the most important exception being +MyISAM). + +Django can re-load that data for you on a per-testcase basis by +setting the ``serialized_rollback`` option to ``True`` in the body of the +``TestCase`` or ``TransactionTestCase``, but note that this will slow down +that test suite by approximately 3x. + +Third-party apps or those developing against MyISAM will need to set this; +in general, however, you should be developing your own projects against a +transactional database and be using ``TestCase`` for most tests, and thus +not need this setting. + +The initial serialization is usually very quick, but if you wish to exclude +some apps from this process (and speed up test runs slightly), you may add +those apps to :setting:`TEST_NON_SERIALIZED_APPS`. + +Apps without migrations are not affected; ``initial_data`` fixtures are +reloaded as usual. + Other test conditions --------------------- @@ -256,6 +283,7 @@ used. This behavior `may change`_ in the future. .. _may change: https://code.djangoproject.com/ticket/11505 + Understanding the test output ----------------------------- diff --git a/docs/topics/testing/tools.txt b/docs/topics/testing/tools.txt index c973ffdc321..78af69a3a46 100644 --- a/docs/topics/testing/tools.txt +++ b/docs/topics/testing/tools.txt @@ -605,9 +605,17 @@ to test the effects of commit and rollback: guarantees that the rollback at the end of the test restores the database to its initial state. - When running on a database that does not support rollback (e.g. MySQL with the - MyISAM storage engine), ``TestCase`` falls back to initializing the database - by truncating tables and reloading initial data. +.. warning:: + + ``TestCase`` running on a database that does not support rollback (e.g. MySQL with the + MyISAM storage engine), and all instances of ``TransactionTestCase``, will + roll back at the end of the test by deleting all data from the test database + and reloading initial data for apps without migrations. + + Apps with migrations :ref:`will not see their data reloaded `; + if you need this functionality (for example, third-party apps should enable + this) you can set ``serialized_rollback = True`` inside the + ``TestCase`` body. .. warning:: diff --git a/tests/migration_test_data_persistence/__init__.py b/tests/migration_test_data_persistence/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/migration_test_data_persistence/migrations/0001_initial.py b/tests/migration_test_data_persistence/migrations/0001_initial.py new file mode 100644 index 00000000000..0b13e8b2009 --- /dev/null +++ b/tests/migration_test_data_persistence/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +def add_book(apps, schema_editor): + apps.get_model("migration_test_data_persistence", "Book").objects.using( + schema_editor.connection.alias, + ).create( + title="I Love Django", + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Book', + fields=[ + ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), + ('title', models.CharField(max_length=100)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.RunPython( + add_book, + ), + ] diff --git a/tests/migration_test_data_persistence/migrations/__init__.py b/tests/migration_test_data_persistence/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/migration_test_data_persistence/models.py b/tests/migration_test_data_persistence/models.py new file mode 100644 index 00000000000..1b0b795d2ce --- /dev/null +++ b/tests/migration_test_data_persistence/models.py @@ -0,0 +1,5 @@ +from django.db import models + + +class Book(models.Model): + title = models.CharField(max_length=100) diff --git a/tests/migration_test_data_persistence/tests.py b/tests/migration_test_data_persistence/tests.py new file mode 100644 index 00000000000..1b89c17b8b4 --- /dev/null +++ b/tests/migration_test_data_persistence/tests.py @@ -0,0 +1,33 @@ +from django.test import TransactionTestCase +from .models import Book + + +class MigrationDataPersistenceTestCase(TransactionTestCase): + """ + Tests that data loaded in migrations is available if we set + serialized_rollback = True. + """ + + available_apps = ["migration_test_data_persistence"] + serialized_rollback = True + + def test_persistence(self): + self.assertEqual( + Book.objects.count(), + 1, + ) + + +class MigrationDataNoPersistenceTestCase(TransactionTestCase): + """ + Tests the failure case + """ + + available_apps = ["migration_test_data_persistence"] + serialized_rollback = False + + def test_no_persistence(self): + self.assertEqual( + Book.objects.count(), + 0, + )