From f2582eb972303978a5fa115825a646a230624116 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 1 Mar 2007 13:11:08 +0000 Subject: [PATCH] Fixes #2333 -- Added test fixtures framework. git-svn-id: http://code.djangoproject.com/svn/django/trunk@4659 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/conf/global_settings.py | 7 + django/core/management.py | 324 ++++++++++++++---- django/core/serializers/__init__.py | 5 + django/core/serializers/base.py | 2 +- django/db/backends/ado_mssql/base.py | 13 + django/db/backends/dummy/base.py | 2 + django/db/backends/mysql/base.py | 30 ++ django/db/backends/oracle/base.py | 14 + django/db/backends/postgresql/base.py | 62 ++++ .../db/backends/postgresql_psycopg2/base.py | 58 ++++ django/db/backends/sqlite3/base.py | 18 + django/test/__init__.py | 6 + django/test/testcases.py | 20 ++ docs/django-admin.txt | 140 +++++++- docs/settings.txt | 24 ++ docs/testing.txt | 51 ++- tests/modeltests/fixtures/__init__.py | 2 + .../fixtures/fixtures/fixture1.json | 18 + .../fixtures/fixtures/fixture2.json | 18 + .../modeltests/fixtures/fixtures/fixture2.xml | 11 + .../modeltests/fixtures/fixtures/fixture3.xml | 11 + .../fixtures/fixtures/initial_data.json | 10 + tests/modeltests/fixtures/models.py | 88 +++++ .../test_client/fixtures/testdata.json | 20 ++ tests/modeltests/test_client/management.py | 10 - tests/modeltests/test_client/models.py | 7 +- tests/urls.py | 2 +- 27 files changed, 887 insertions(+), 86 deletions(-) create mode 100644 tests/modeltests/fixtures/__init__.py create mode 100644 tests/modeltests/fixtures/fixtures/fixture1.json create mode 100644 tests/modeltests/fixtures/fixtures/fixture2.json create mode 100644 tests/modeltests/fixtures/fixtures/fixture2.xml create mode 100644 tests/modeltests/fixtures/fixtures/fixture3.xml create mode 100644 tests/modeltests/fixtures/fixtures/initial_data.json create mode 100644 tests/modeltests/fixtures/models.py create mode 100644 tests/modeltests/test_client/fixtures/testdata.json delete mode 100644 tests/modeltests/test_client/management.py diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 148afe0e382..5ce856c37ba 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -319,3 +319,10 @@ TEST_RUNNER = 'django.test.simple.run_tests' # The name of the database to use for testing purposes. # If None, a name of 'test_' + DATABASE_NAME will be assumed TEST_DATABASE_NAME = None + +############ +# FIXTURES # +############ + +# The list of directories to search for fixtures +FIXTURE_DIRS = () diff --git a/django/core/management.py b/django/core/management.py index 9ba79273f9f..b7ff3e69557 100644 --- a/django/core/management.py +++ b/django/core/management.py @@ -68,6 +68,25 @@ def _get_table_list(): cursor = connection.cursor() return get_introspection_module().get_table_list(cursor) +def _get_sequence_list(): + "Returns a list of information about all DB sequences for all models in all apps" + from django.db import models + + apps = models.get_apps() + sequence_list = [] + + for app in apps: + for model in models.get_models(app): + for f in model._meta.fields: + if isinstance(f, models.AutoField): + sequence_list.append({'table':model._meta.db_table,'column':f.column,}) + break # Only one AutoField is allowed per model, so don't bother continuing. + + for f in model._meta.many_to_many: + sequence_list.append({'table':f.m2m_db_table(),'column':None,}) + + return sequence_list + # If the foreign key points to an AutoField, a PositiveIntegerField or a # PositiveSmallIntegerField, the foreign key should be an IntegerField, not the # referred field type. Otherwise, the foreign key should be the same type of @@ -334,7 +353,15 @@ def get_sql_reset(app): get_sql_reset.help_doc = "Prints the DROP TABLE SQL, then the CREATE TABLE SQL, for the given app name(s)." get_sql_reset.args = APP_ARGS -def get_sql_initial_data_for_model(model): +def get_sql_flush(): + "Returns a list of the SQL statements used to flush the database" + from django.db import backend + statements = backend.get_sql_flush(style, _get_table_list(), _get_sequence_list()) + return statements +get_sql_flush.help_doc = "Returns a list of the SQL statements required to return all tables in the database to the state they were in just after they were installed." +get_sql_flush.args = '' + +def get_custom_sql_for_model(model): from django.db import models from django.conf import settings @@ -361,8 +388,8 @@ def get_sql_initial_data_for_model(model): return output -def get_sql_initial_data(app): - "Returns a list of the initial INSERT SQL statements for the given app." +def get_custom_sql(app): + "Returns a list of the custom table modifying SQL statements for the given app." from django.db.models import get_models output = [] @@ -370,11 +397,17 @@ def get_sql_initial_data(app): app_dir = os.path.normpath(os.path.join(os.path.dirname(app.__file__), 'sql')) for model in app_models: - output.extend(get_sql_initial_data_for_model(model)) + output.extend(get_custom_sql_for_model(model)) return output -get_sql_initial_data.help_doc = "Prints the initial INSERT SQL statements for the given app name(s)." -get_sql_initial_data.args = APP_ARGS +get_custom_sql.help_doc = "Prints the custom table modifying SQL statements for the given app name(s)." +get_custom_sql.args = APP_ARGS + +def get_sql_initial_data(apps): + "Returns a list of the initial INSERT SQL statements for the given app." + return style.ERROR("This action has been renamed. Try './manage.py sqlcustom %s'." % ' '.join(apps and apps or ['app1', 'app2'])) +get_sql_initial_data.help_doc = "RENAMED: see 'sqlcustom'" +get_sql_initial_data.args = '' def get_sql_sequence_reset(app): "Returns a list of the SQL statements to reset PostgreSQL sequences for the given app." @@ -432,16 +465,26 @@ def get_sql_indexes_for_model(model): def get_sql_all(app): "Returns a list of CREATE TABLE SQL, initial-data inserts, and CREATE INDEX SQL for the given module." - return get_sql_create(app) + get_sql_initial_data(app) + get_sql_indexes(app) + return get_sql_create(app) + get_custom_sql(app) + get_sql_indexes(app) get_sql_all.help_doc = "Prints the CREATE TABLE, initial-data and CREATE INDEX SQL statements for the given model module name(s)." get_sql_all.args = APP_ARGS +def _emit_post_sync_signal(created_models, verbosity, interactive): + from django.db import models + from django.dispatch import dispatcher + # Emit the post_sync signal for every application. + for app in models.get_apps(): + app_name = app.__name__.split('.')[-2] + if verbosity >= 2: + print "Running post-sync handlers for application", app_name + dispatcher.send(signal=models.signals.post_syncdb, sender=app, + app=app, created_models=created_models, + verbosity=verbosity, interactive=interactive) + def syncdb(verbosity=1, interactive=True): "Creates the database tables for all apps in INSTALLED_APPS whose tables haven't already been created." from django.db import connection, transaction, models, get_creation_module - from django.db.models import signals from django.conf import settings - from django.dispatch import dispatcher disable_termcolors() @@ -503,27 +546,22 @@ def syncdb(verbosity=1, interactive=True): # Send the post_syncdb signal, so individual apps can do whatever they need # to do at this point. - for app in models.get_apps(): - app_name = app.__name__.split('.')[-2] - if verbosity >= 2: - print "Running post-sync handlers for application", app_name - dispatcher.send(signal=signals.post_syncdb, sender=app, - app=app, created_models=created_models, - verbosity=verbosity, interactive=interactive) + _emit_post_sync_signal(created_models, verbosity, interactive) - # Install initial data for the app (but only if this is a model we've - # just created) + # Install custom SQL for the app (but only if this + # is a model we've just created) + for app in models.get_apps(): for model in models.get_models(app): if model in created_models: - initial_sql = get_sql_initial_data_for_model(model) - if initial_sql: + custom_sql = get_custom_sql_for_model(model) + if custom_sql: if verbosity >= 1: - print "Installing initial data for %s.%s model" % (app_name, model._meta.object_name) + print "Installing custom SQL for %s.%s model" % (app_name, model._meta.object_name) try: - for sql in initial_sql: + for sql in custom_sql: cursor.execute(sql) except Exception, e: - sys.stderr.write("Failed to install initial SQL data for %s.%s model: %s" % \ + sys.stderr.write("Failed to install custom SQL for %s.%s model: %s" % \ (app_name, model._meta.object_name, e)) transaction.rollback_unless_managed() else: @@ -548,7 +586,10 @@ def syncdb(verbosity=1, interactive=True): else: transaction.commit_unless_managed() -syncdb.args = '' + # Install the 'initialdata' fixture, using format discovery + load_data(['initial_data'], verbosity=verbosity) +syncdb.help_doc = "Create the database tables for all apps in INSTALLED_APPS whose tables haven't already been created." +syncdb.args = '[--verbosity] [--interactive]' def get_admin_index(app): "Returns admin-index template snippet (in list form) for the given app." @@ -601,36 +642,6 @@ def diffsettings(): print '\n'.join(output) diffsettings.args = "" -def install(app): - "Executes the equivalent of 'get_sql_all' in the current database." - from django.db import connection, transaction - - app_name = app.__name__.split('.')[-2] - - disable_termcolors() - - # First, try validating the models. - _check_for_validation_errors(app) - - sql_list = get_sql_all(app) - - try: - cursor = connection.cursor() - for sql in sql_list: - cursor.execute(sql) - except Exception, e: - sys.stderr.write(style.ERROR("""Error: %s couldn't be installed. Possible reasons: - * The database isn't running or isn't configured correctly. - * At least one of the database tables already exists. - * The SQL was invalid. -Hint: Look at the output of 'django-admin.py sqlall %s'. That's the SQL this command wasn't able to run. -The full error: """ % (app_name, app_name)) + style.ERROR_OUTPUT(str(e)) + '\n') - transaction.rollback_unless_managed() - sys.exit(1) - transaction.commit_unless_managed() -install.help_doc = "Executes ``sqlall`` for the given app(s) in the current database." -install.args = APP_ARGS - def reset(app, interactive=True): "Executes the equivalent of 'get_sql_reset' in the current database." from django.db import connection, transaction @@ -672,7 +683,68 @@ The full error: """ % (app_name, app_name)) + style.ERROR_OUTPUT(str(e)) + '\n') else: print "Reset cancelled." reset.help_doc = "Executes ``sqlreset`` for the given app(s) in the current database." -reset.args = APP_ARGS +reset.args = '[--interactive]' + APP_ARGS + +def flush(verbosity=1, interactive=True): + "Returns all tables in the database to the same state they were in immediately after syncdb." + from django.conf import settings + from django.db import connection, transaction, models + from django.dispatch import dispatcher + + disable_termcolors() + + # First, try validating the models. + _check_for_validation_errors() + + # Import the 'management' module within each installed app, to register + # dispatcher events. + for app_name in settings.INSTALLED_APPS: + try: + __import__(app_name + '.management', {}, {}, ['']) + except ImportError: + pass + + sql_list = get_sql_flush() + + if interactive: + confirm = raw_input(""" +You have requested a flush of the database. +This will IRREVERSIBLY DESTROY all data currently in the database, +and return each table to the state it was in after syncdb. +Are you sure you want to do this? + +Type 'yes' to continue, or 'no' to cancel: """) + else: + confirm = 'yes' + + if confirm == 'yes': + try: + cursor = connection.cursor() + for sql in sql_list: + cursor.execute(sql) + except Exception, e: + sys.stderr.write(style.ERROR("""Error: Database %s couldn't be flushed. Possible reasons: + * The database isn't running or isn't configured correctly. + * At least one of the expected database tables doesn't exist. + * The SQL was invalid. +Hint: Look at the output of 'django-admin.py sqlflush'. That's the SQL this command wasn't able to run. +The full error: """ % settings.DATABASE_NAME + style.ERROR_OUTPUT(str(e)) + '\n')) + transaction.rollback_unless_managed() + sys.exit(1) + transaction.commit_unless_managed() + + # Emit the post sync signal. This allows individual + # applications to respond as if the database had been + # sync'd from scratch. + _emit_post_sync_signal(models.get_models(), verbosity, interactive) + + # Reinstall the initial_data fixture + load_data(['initial_data'], verbosity=verbosity) + + else: + print "Flush cancelled." +flush.help_doc = "Executes ``sqlflush`` on the current database." +flush.args = '[--verbosity] [--interactive]' def _start_helper(app_or_project, name, directory, other_name=''): other = {'project': 'app', 'app': 'project'}[app_or_project] @@ -755,7 +827,7 @@ def inspectdb(): yield "# * Make sure each model has one field with primary_key=True" yield "# Feel free to rename the models, but don't rename db_table values or field names." yield "#" - yield "# Also note: You'll have to insert the output of 'django-admin.py sqlinitialdata [appname]'" + yield "# Also note: You'll have to insert the output of 'django-admin.py sqlcustom [appname]'" yield "# into your database." yield '' yield 'from django.db import models' @@ -1251,6 +1323,124 @@ def test(app_labels, verbosity=1): test.help_doc = 'Runs the test suite for the specified applications, or the entire site if no apps are specified' test.args = '[--verbosity] ' + APP_ARGS +def load_data(fixture_labels, verbosity=1): + "Installs the provided fixture file(s) as data in the database." + from django.db.models import get_apps + from django.core import serializers + from django.db import connection, transaction + from django.conf import settings + import sys + + # Keep a count of the installed objects and fixtures + count = [0,0] + + humanize = lambda dirname: dirname and "'%s'" % dirname or 'absolute path' + + # Get a cursor (even though we don't need one yet). This has + # the side effect of initializing the test database (if + # it isn't already initialized). + cursor = connection.cursor() + + # Start transaction management. All fixtures are installed in a + # single transaction to ensure that all references are resolved. + transaction.commit_unless_managed() + transaction.enter_transaction_management() + transaction.managed(True) + + app_fixtures = [os.path.join(os.path.dirname(app.__file__),'fixtures') for app in get_apps()] + for fixture_label in fixture_labels: + if verbosity > 0: + print "Loading '%s' fixtures..." % fixture_label + for fixture_dir in app_fixtures + list(settings.FIXTURE_DIRS) + ['']: + if verbosity > 1: + print "Checking %s for fixtures..." % humanize(fixture_dir) + try: + fixture_name, format = fixture_label.rsplit('.', 1) + formats = [format] + except ValueError: + fixture_name = fixture_label + formats = serializers.get_serializer_formats() + + label_found = False + for format in formats: + serializer = serializers.get_serializer(format) + if verbosity > 1: + print "Trying %s for %s fixture '%s'..." % \ + (humanize(fixture_dir), format, fixture_name) + try: + full_path = os.path.join(fixture_dir, '.'.join([fixture_name, format])) + fixture = open(full_path, 'r') + if label_found: + fixture.close() + print style.ERROR("Multiple fixtures named '%s' in %s. Aborting." % + (fixture_name, humanize(fixture_dir))) + transaction.rollback() + transaction.leave_transaction_management() + return + else: + count[1] += 1 + if verbosity > 0: + print "Installing %s fixture '%s' from %s." % \ + (format, fixture_name, humanize(fixture_dir)) + try: + objects = serializers.deserialize(format, fixture) + for obj in objects: + count[0] += 1 + obj.save() + label_found = True + except Exception, e: + fixture.close() + sys.stderr.write( + style.ERROR("Problem installing fixture '%s': %s\n" % + (full_path, str(e)))) + transaction.rollback() + transaction.leave_transaction_management() + return + fixture.close() + except: + if verbosity > 1: + print "No %s fixture '%s' in %s." % \ + (format, fixture_name, humanize(fixture_dir)) + if count[0] == 0: + if verbosity > 0: + print "No fixtures found." + else: + if verbosity > 0: + print "Installed %d object(s) from %d fixture(s)" % tuple(count) + transaction.commit() + transaction.leave_transaction_management() + +load_data.help_doc = 'Installs the named fixture(s) in the database' +load_data.args = "[--verbosity] fixture, fixture, ..." + +def dump_data(app_labels, format='json'): + "Output the current contents of the database as a fixture of the given format" + from django.db.models import get_app, get_apps, get_models + from django.core import serializers + + if len(app_labels) == 0: + app_list = get_apps() + else: + app_list = [get_app(app_label) for app_label in app_labels] + + # Check that the serialization format exists; this is a shortcut to + # avoid collating all the objects and _then_ failing. + try: + serializers.get_serializer(format) + except KeyError: + sys.stderr.write(style.ERROR("Unknown serialization format: %s\n" % format)) + + objects = [] + for app in app_list: + for model in get_models(app): + objects.extend(model.objects.all()) + try: + print serializers.serialize(format, objects) + except Exception, e: + sys.stderr.write(style.ERROR("Unable to serialize database: %s\n" % e)) +dump_data.help_doc = 'Output the contents of the database as a fixture of the given format' +dump_data.args = '[--format]' + APP_ARGS + # Utilities for command-line script DEFAULT_ACTION_MAPPING = { @@ -1258,8 +1448,10 @@ DEFAULT_ACTION_MAPPING = { 'createcachetable' : createcachetable, 'dbshell': dbshell, 'diffsettings': diffsettings, + 'dumpdata': dump_data, + 'flush': flush, 'inspectdb': inspectdb, - 'install': install, + 'loaddata': load_data, 'reset': reset, 'runfcgi': runfcgi, 'runserver': runserver, @@ -1267,6 +1459,8 @@ DEFAULT_ACTION_MAPPING = { 'sql': get_sql_create, 'sqlall': get_sql_all, 'sqlclear': get_sql_delete, + 'sqlcustom': get_custom_sql, + 'sqlflush': get_sql_flush, 'sqlindexes': get_sql_indexes, 'sqlinitialdata': get_sql_initial_data, 'sqlreset': get_sql_reset, @@ -1283,7 +1477,6 @@ NO_SQL_TRANSACTION = ( 'createcachetable', 'dbshell', 'diffsettings', - 'install', 'reset', 'sqlindexes', 'syncdb', @@ -1330,6 +1523,8 @@ def execute_from_command_line(action_mapping=DEFAULT_ACTION_MAPPING, argv=None): help='Tells Django to NOT prompt the user for input of any kind.') parser.add_option('--noreload', action='store_false', dest='use_reloader', default=True, help='Tells Django to NOT use the auto-reloader when running the development server.') + parser.add_option('--format', default='json', dest='format', + help='Specifies the output serialization format for fixtures') parser.add_option('--verbosity', action='store', dest='verbosity', default='1', type='choice', choices=['0', '1', '2'], help='Verbosity level; 0=minimal output, 1=normal output, 2=all output'), @@ -1363,7 +1558,7 @@ def execute_from_command_line(action_mapping=DEFAULT_ACTION_MAPPING, argv=None): action_mapping[action](options.plain is True) elif action in ('validate', 'diffsettings', 'dbshell'): action_mapping[action]() - elif action == 'syncdb': + elif action in ('flush', 'syncdb'): action_mapping[action](int(options.verbosity), options.interactive) elif action == 'inspectdb': try: @@ -1377,11 +1572,16 @@ def execute_from_command_line(action_mapping=DEFAULT_ACTION_MAPPING, argv=None): action_mapping[action](args[1]) except IndexError: parser.print_usage_and_exit() - elif action == 'test': + elif action in ('test', 'loaddata'): try: action_mapping[action](args[1:], int(options.verbosity)) except IndexError: parser.print_usage_and_exit() + elif action == 'dumpdata': + try: + action_mapping[action](args[1:], options.format) + except IndexError: + parser.print_usage_and_exit() elif action in ('startapp', 'startproject'): try: name = args[1] @@ -1400,6 +1600,10 @@ def execute_from_command_line(action_mapping=DEFAULT_ACTION_MAPPING, argv=None): action_mapping[action](addr, port, options.use_reloader, options.admin_media_path) elif action == 'runfcgi': action_mapping[action](args[1:]) + elif action == 'sqlinitialdata': + print action_mapping[action](args[1:]) + elif action == 'sqlflush': + print '\n'.join(action_mapping[action]()) else: from django.db import models validate(silent_success=True) diff --git a/django/core/serializers/__init__.py b/django/core/serializers/__init__.py index a1268321f29..f20258c416b 100644 --- a/django/core/serializers/__init__.py +++ b/django/core/serializers/__init__.py @@ -40,6 +40,11 @@ def get_serializer(format): if not _serializers: _load_serializers() return _serializers[format].Serializer + +def get_serializer_formats(): + if not _serializers: + _load_serializers() + return _serializers.keys() def get_deserializer(format): if not _serializers: diff --git a/django/core/serializers/base.py b/django/core/serializers/base.py index 5b0acdc480d..0ec05d1cf38 100644 --- a/django/core/serializers/base.py +++ b/django/core/serializers/base.py @@ -141,7 +141,7 @@ class Deserializer(object): class DeserializedObject(object): """ - A deserialzed model. + A deserialized model. Basically a container for holding the pre-saved deserialized data along with the many-to-many data saved with the object. diff --git a/django/db/backends/ado_mssql/base.py b/django/db/backends/ado_mssql/base.py index 5e916491a57..9eaa5625d94 100644 --- a/django/db/backends/ado_mssql/base.py +++ b/django/db/backends/ado_mssql/base.py @@ -137,6 +137,19 @@ def get_drop_foreignkey_sql(): def get_pk_default_value(): return "DEFAULT" +def get_sql_flush(sql_styler, full_table_list): + """Return a list of SQL statements required to remove all data from + all tables in the database (without actually removing the tables + themselves) and put the database in an empty 'initial' state + """ + # Return a list of 'TRUNCATE x;', 'TRUNCATE y;', 'TRUNCATE z;'... style SQL statements + # TODO - SQL not actually tested against ADO MSSQL yet! + # TODO - autoincrement indices reset required? See other get_sql_flush() implementations + sql_list = ['%s %s;' % \ + (sql_styler.SQL_KEYWORD('TRUNCATE'), + sql_styler.SQL_FIELD(quote_name(table)) + ) for table in full_table_list] + OPERATOR_MAPPING = { 'exact': '= %s', 'iexact': 'LIKE %s', diff --git a/django/db/backends/dummy/base.py b/django/db/backends/dummy/base.py index 5fa3b9c90e5..e36a99e982f 100644 --- a/django/db/backends/dummy/base.py +++ b/django/db/backends/dummy/base.py @@ -39,4 +39,6 @@ get_random_function_sql = complain get_deferrable_sql = complain get_fulltext_search_sql = complain get_drop_foreignkey_sql = complain +get_sql_flush = complain + OPERATOR_MAPPING = {} diff --git a/django/db/backends/mysql/base.py b/django/db/backends/mysql/base.py index fb69d7d45f4..4ccb1fe5641 100644 --- a/django/db/backends/mysql/base.py +++ b/django/db/backends/mysql/base.py @@ -186,6 +186,36 @@ def get_drop_foreignkey_sql(): def get_pk_default_value(): return "DEFAULT" +def get_sql_flush(style, tables, sequences): + """Return a list of SQL statements required to remove all data from + all tables in the database (without actually removing the tables + themselves) and put the database in an empty 'initial' state + + """ + # NB: The generated SQL below is specific to MySQL + # 'TRUNCATE x;', 'TRUNCATE y;', 'TRUNCATE z;'... style SQL statements + # to clear all tables of all data + if tables: + sql = ['SET FOREIGN_KEY_CHECKS = 0;'] + \ + ['%s %s;' % \ + (style.SQL_KEYWORD('TRUNCATE'), + style.SQL_FIELD(quote_name(table)) + ) for table in tables] + \ + ['SET FOREIGN_KEY_CHECKS = 1;'] + + # 'ALTER TABLE table AUTO_INCREMENT = 1;'... style SQL statements + # to reset sequence indices + sql.extend(["%s %s %s %s %s;" % \ + (style.SQL_KEYWORD('ALTER'), + style.SQL_KEYWORD('TABLE'), + style.SQL_TABLE(quote_name(sequence['table'])), + style.SQL_KEYWORD('AUTO_INCREMENT'), + style.SQL_FIELD('= 1'), + ) for sequence in sequences]) + return sql + else: + return [] + OPERATOR_MAPPING = { 'exact': '= %s', 'iexact': 'LIKE %s', diff --git a/django/db/backends/oracle/base.py b/django/db/backends/oracle/base.py index ca694eb66d3..229e203ca1f 100644 --- a/django/db/backends/oracle/base.py +++ b/django/db/backends/oracle/base.py @@ -120,6 +120,20 @@ def get_drop_foreignkey_sql(): def get_pk_default_value(): return "DEFAULT" +def get_sql_flush(style, tables, sequences): + """Return a list of SQL statements required to remove all data from + all tables in the database (without actually removing the tables + themselves) and put the database in an empty 'initial' state + """ + # Return a list of 'TRUNCATE x;', 'TRUNCATE y;', 'TRUNCATE z;'... style SQL statements + # TODO - SQL not actually tested against Oracle yet! + # TODO - autoincrement indices reset required? See other get_sql_flush() implementations + sql = ['%s %s;' % \ + (style.SQL_KEYWORD('TRUNCATE'), + style.SQL_FIELD(quote_name(table)) + ) for table in tables] + + OPERATOR_MAPPING = { 'exact': '= %s', 'iexact': 'LIKE %s', diff --git a/django/db/backends/postgresql/base.py b/django/db/backends/postgresql/base.py index 8ccf98344c1..a7503ec5338 100644 --- a/django/db/backends/postgresql/base.py +++ b/django/db/backends/postgresql/base.py @@ -52,6 +52,8 @@ class UnicodeCursorWrapper(object): else: return getattr(self.cursor, attr) +postgres_version = None + class DatabaseWrapper(local): def __init__(self, **kwargs): self.connection = None @@ -81,6 +83,10 @@ class DatabaseWrapper(local): if set_tz: cursor.execute("SET TIME ZONE %s", [settings.TIME_ZONE]) cursor = UnicodeCursorWrapper(cursor, settings.DEFAULT_CHARSET) + global postgres_version + if not postgres_version: + cursor.execute("SELECT version()") + postgres_version = [int(val) for val in cursor.dictfetchone()['version'].split()[1].split('.')] if settings.DEBUG: return util.CursorDebugWrapper(cursor, self) return cursor @@ -151,6 +157,62 @@ def get_drop_foreignkey_sql(): def get_pk_default_value(): return "DEFAULT" +def get_sql_flush(style, tables, sequences): + """Return a list of SQL statements required to remove all data from + all tables in the database (without actually removing the tables + themselves) and put the database in an empty 'initial' state + + """ + if tables: + if postgres_version[0] >= 8 and postgres_version[1] >= 1: + # Postgres 8.1+ can do 'TRUNCATE x, y, z...;'. In fact, it *has to* in order to be able to + # truncate tables referenced by a foreign key in any other table. The result is a + # single SQL TRUNCATE statement. + sql = ['%s %s;' % \ + (style.SQL_KEYWORD('TRUNCATE'), + style.SQL_FIELD(', '.join(quote_name(table) for table in tables)) + )] + else: + # Older versions of Postgres can't do TRUNCATE in a single call, so they must use + # a simple delete. + sql = ['%s %s %s;' % \ + (style.SQL_KEYWORD('DELETE'), + style.SQL_KEYWORD('FROM'), + style.SQL_FIELD(quote_name(table)) + ) for table in tables] + + # 'ALTER SEQUENCE sequence_name RESTART WITH 1;'... style SQL statements + # to reset sequence indices + for sequence_info in sequences: + table_name = sequence_info['table'] + column_name = sequence_info['column'] + if column_name and len(column_name)>0: + # sequence name in this case will be __seq + sql.append("%s %s %s %s %s %s;" % \ + (style.SQL_KEYWORD('ALTER'), + style.SQL_KEYWORD('SEQUENCE'), + style.SQL_FIELD('%s_%s_seq' % (table_name, column_name)), + style.SQL_KEYWORD('RESTART'), + style.SQL_KEYWORD('WITH'), + style.SQL_FIELD('1') + ) + ) + else: + # sequence name in this case will be
_id_seq + sql.append("%s %s %s %s %s %s;" % \ + (style.SQL_KEYWORD('ALTER'), + style.SQL_KEYWORD('SEQUENCE'), + style.SQL_FIELD('%s_id_seq' % table_name), + style.SQL_KEYWORD('RESTART'), + style.SQL_KEYWORD('WITH'), + style.SQL_FIELD('1') + ) + ) + return sql + else: + return [] + + # Register these custom typecasts, because Django expects dates/times to be # in Python's native (standard-library) datetime/time format, whereas psycopg # use mx.DateTime by default. diff --git a/django/db/backends/postgresql_psycopg2/base.py b/django/db/backends/postgresql_psycopg2/base.py index abe01873b27..c1b8ae59163 100644 --- a/django/db/backends/postgresql_psycopg2/base.py +++ b/django/db/backends/postgresql_psycopg2/base.py @@ -20,6 +20,8 @@ except ImportError: # Import copy of _thread_local.py from Python 2.4 from django.utils._threading_local import local +postgres_version = None + class DatabaseWrapper(local): def __init__(self, **kwargs): self.connection = None @@ -49,6 +51,10 @@ class DatabaseWrapper(local): cursor.tzinfo_factory = None if set_tz: cursor.execute("SET TIME ZONE %s", [settings.TIME_ZONE]) + global postgres_version + if not postgres_version: + cursor.execute("SELECT version()") + postgres_version = [int(val) for val in cursor.dictfetchone()['version'].split()[1].split('.')] if settings.DEBUG: return util.CursorDebugWrapper(cursor, self) return cursor @@ -111,6 +117,58 @@ def get_drop_foreignkey_sql(): def get_pk_default_value(): return "DEFAULT" +def get_sql_flush(style, tables, sequences): + """Return a list of SQL statements required to remove all data from + all tables in the database (without actually removing the tables + themselves) and put the database in an empty 'initial' state + """ + if tables: + if postgres_version[0] >= 8 and postgres_version[1] >= 1: + # Postgres 8.1+ can do 'TRUNCATE x, y, z...;'. In fact, it *has to* in order to be able to + # truncate tables referenced by a foreign key in any other table. The result is a + # single SQL TRUNCATE statement + sql = ['%s %s;' % \ + (style.SQL_KEYWORD('TRUNCATE'), + style.SQL_FIELD(', '.join(quote_name(table) for table in tables)) + )] + else: + sql = ['%s %s %s;' % \ + (style.SQL_KEYWORD('DELETE'), + style.SQL_KEYWORD('FROM'), + style.SQL_FIELD(quote_name(table)) + ) for table in tables] + + # 'ALTER SEQUENCE sequence_name RESTART WITH 1;'... style SQL statements + # to reset sequence indices + for sequence in sequences: + table_name = sequence['table'] + column_name = sequence['column'] + if column_name and len(column_name) > 0: + # sequence name in this case will be
__seq + sql.append("%s %s %s %s %s %s;" % \ + (style.SQL_KEYWORD('ALTER'), + style.SQL_KEYWORD('SEQUENCE'), + style.SQL_FIELD('%s_%s_seq' % (table_name, column_name)), + style.SQL_KEYWORD('RESTART'), + style.SQL_KEYWORD('WITH'), + style.SQL_FIELD('1') + ) + ) + else: + # sequence name in this case will be
_id_seq + sql.append("%s %s %s %s %s %s;" % \ + (style.SQL_KEYWORD('ALTER'), + style.SQL_KEYWORD('SEQUENCE'), + style.SQL_FIELD('%s_id_seq' % table_name), + style.SQL_KEYWORD('RESTART'), + style.SQL_KEYWORD('WITH'), + style.SQL_FIELD('1') + ) + ) + return sql + else: + return [] + OPERATOR_MAPPING = { 'exact': '= %s', 'iexact': 'ILIKE %s', diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index db0393782de..ce343e189ea 100644 --- a/django/db/backends/sqlite3/base.py +++ b/django/db/backends/sqlite3/base.py @@ -151,6 +151,24 @@ def get_drop_foreignkey_sql(): def get_pk_default_value(): return "NULL" +def get_sql_flush(style, tables, sequences): + """Return a list of SQL statements required to remove all data from + all tables in the database (without actually removing the tables + themselves) and put the database in an empty 'initial' state + + """ + # NB: The generated SQL below is specific to SQLite + # Note: The DELETE FROM... SQL generated below works for SQLite databases + # because constraints don't exist + sql = ['%s %s %s;' % \ + (style.SQL_KEYWORD('DELETE'), + style.SQL_KEYWORD('FROM'), + style.SQL_FIELD(quote_name(table)) + ) for table in tables] + # Note: No requirement for reset of auto-incremented indices (cf. other + # get_sql_flush() implementations). Just return SQL at this point + return sql + def _sqlite_date_trunc(lookup_type, dt): try: dt = util.typecast_timestamp(dt) diff --git a/django/test/__init__.py b/django/test/__init__.py index e69de29bb2d..554e72bad37 100644 --- a/django/test/__init__.py +++ b/django/test/__init__.py @@ -0,0 +1,6 @@ +""" +Django Unit Test and Doctest framework. +""" + +from django.test.client import Client +from django.test.testcases import TestCase diff --git a/django/test/testcases.py b/django/test/testcases.py index 1cfef6f19ee..2bfb9a733ab 100644 --- a/django/test/testcases.py +++ b/django/test/testcases.py @@ -1,5 +1,7 @@ import re, doctest, unittest from django.db import transaction +from django.core import management +from django.db.models import get_apps normalize_long_ints = lambda s: re.sub(r'(?/fixtures/foo/bar/mydata.json`` for each installed +application, ``/foo/bar/mydata.json`` for each directory in +``FIXTURE_DIRS``, and the literal path ``foo/bar/mydata.json``. + +Note that the order in which fixture files are processed is undefined. However, +all fixture data is installed as a single transaction, so data in +one fixture can reference data in another fixture. If the database backend +supports row-level constraints, these constraints will be checked at the +end of the transaction. + +.. admonition:: MySQL and Fixtures + + Unfortunately, MySQL isn't capable of completely supporting all the + features of Django fixtures. If you use MyISAM tables, MySQL doesn't + support transactions or constraints, so you won't get a rollback if + multiple transaction files are found, or validation of fixture data. + If you use InnoDB tables, you won't be able to have any forward + references in your data files - MySQL doesn't provide a mechanism to + defer checking of row constraints until a transaction is committed. + reset [appname appname ...] --------------------------- Executes the equivalent of ``sqlreset`` for the given appnames. @@ -250,15 +347,12 @@ sqlclear [appname appname ...] Prints the DROP TABLE SQL statements for the given appnames. -sqlindexes [appname appname ...] ----------------------------------------- +sqlcustom [appname appname ...] +------------------------------- -Prints the CREATE INDEX SQL statements for the given appnames. +**New in Django development version** -sqlinitialdata [appname appname ...] --------------------------------------------- - -Prints the initial INSERT SQL statements for the given appnames. +Prints the custom SQL statements for the given appnames. For each model in each specified app, this command looks for the file ``/sql/.sql``, where ```` is the given appname and @@ -269,11 +363,23 @@ command. Each of the SQL files, if given, is expected to contain valid SQL. The SQL files are piped directly into the database after all of the models' -table-creation statements have been executed. Use this SQL hook to populate -tables with any necessary initial records, SQL functions or test data. +table-creation statements have been executed. Use this SQL hook to make any +table modifications, or insert any SQL functions into the database. Note that the order in which the SQL files are processed is undefined. +sqlindexes [appname appname ...] +---------------------------------------- + +Prints the CREATE INDEX SQL statements for the given appnames. + +sqlinitialdata [appname appname ...] +-------------------------------------------- + +**Removed in Django development version** + +This method has been renamed ``sqlcustom`` in the development version of Django. + sqlreset [appname appname ...] -------------------------------------- @@ -313,6 +419,10 @@ this command to install the default apps. If you're installing the ``django.contrib.auth`` application, ``syncdb`` will give you the option of creating a superuser immediately. +``syncdb`` will also search for and install any fixture named ``initial_data``. +See the documentation for ``loaddata`` for details on the specification of +fixture data files. + test ---- @@ -362,6 +472,18 @@ setting the Python path for you. .. _import search path: http://diveintopython.org/getting_to_know_python/everything_is_an_object.html +--format +--------- + +**New in Django development version** + +Example usage:: + + django-admin.py dumpdata --format=xml + +Specifies the output format that will be used. The name provided must be the name +of a registered serializer. + --help ------ diff --git a/docs/settings.txt b/docs/settings.txt index 873b70a5e21..b2ca11240a3 100644 --- a/docs/settings.txt +++ b/docs/settings.txt @@ -422,6 +422,19 @@ Subject-line prefix for e-mail messages sent with ``django.core.mail.mail_admins or ``django.core.mail.mail_managers``. You'll probably want to include the trailing space. +FIXTURE_DIRS +------------- + +**New in Django development version** + +Default: ``()`` (Empty tuple) + +List of locations of the fixture data files, in search order. Note that +these paths should use Unix-style forward slashes, even on Windows. See +`Testing Django Applications`_. + +.. _Testing Django Applications: ../testing/ + IGNORABLE_404_ENDS ------------------ @@ -653,6 +666,17 @@ link). This is only used if ``CommonMiddleware`` is installed (see the `middleware docs`_). See also ``IGNORABLE_404_STARTS``, ``IGNORABLE_404_ENDS`` and the section on `error reporting via e-mail`_ +SERIALIZATION_MODULES +--------------------- + +Default: Not defined. + +A dictionary of modules containing serializer definitions (provided as +strings), keyed by a string identifier for that serialization type. For +example, to define a YAML serializer, use:: + + SERIALIZATION_MODULES = { 'yaml' : 'path.to.yaml_serializer' } + SERVER_EMAIL ------------ diff --git a/docs/testing.txt b/docs/testing.txt index 57e50f483da..55782f4e493 100644 --- a/docs/testing.txt +++ b/docs/testing.txt @@ -198,7 +198,6 @@ used as test conditions. .. _Twill: http://twill.idyll.org/ .. _Selenium: http://www.openqa.org/selenium/ - Making requests ~~~~~~~~~~~~~~~ @@ -357,7 +356,55 @@ The following is a simple unit test using the Test Client:: Fixtures -------- -Feature still to come... +A test case for a database-backed website isn't much use if there isn't any +data in the database. To make it easy to put test data into the database, +Django provides a fixtures framework. + +A *Fixture* is a collection of files that contain the serialized contents of +the database. Each fixture has a unique name; however, the files that +comprise the fixture can be distributed over multiple directories, in +multiple applications. + +.. note:: + If you have synchronized a Django project, you have already experienced + the use of one fixture -- the ``initial_data`` fixture. Every time you + synchronize the database, Django installs the ``initial_data`` fixture. + This provides a mechanism to populate a new database with any initial + data (such as a default set of categories). Fixtures with other names + can be installed manually using ``django-admin.py loaddata``. + + +However, for the purposes of unit testing, each test must be able to +guarantee the contents of the database at the start of each and every +test. To do this, Django provides a TestCase baseclass that can integrate +with fixtures. + +Moving from a normal unittest TestCase to a Django TestCase is easy - just +change the base class of your test, and define a list of fixtures +to be used. For example, the test case from `Writing unittests`_ would +look like:: + + from django.test import TestCase + from myapp.models import Animal + + class AnimalTestCase(TestCase): + fixtures = ['mammals.json', 'birds'] + + def setUp(self): + # test definitions as before + +At the start of each test vase, before ``setUp()`` is run, Django will +flush the database, returning the database the state it was in directly +after ``syncdb`` was called. Then, all the named fixtures are installed. +In this example, any JSON fixture called ``mammals``, and any fixture +named ``birds`` will be installed. See the documentation on +`loading fixtures`_ for more details on defining and installing fixtures. + +.. _`loading fixtures`: ../django-admin/#loaddata-fixture-fixture + +This flush/load procedure is repeated for each test in the test case, so you +can be certain that the outcome of a test will not be affected by +another test, or the order of test execution. Running tests ============= diff --git a/tests/modeltests/fixtures/__init__.py b/tests/modeltests/fixtures/__init__.py new file mode 100644 index 00000000000..139597f9cb0 --- /dev/null +++ b/tests/modeltests/fixtures/__init__.py @@ -0,0 +1,2 @@ + + diff --git a/tests/modeltests/fixtures/fixtures/fixture1.json b/tests/modeltests/fixtures/fixtures/fixture1.json new file mode 100644 index 00000000000..cc11a3e926a --- /dev/null +++ b/tests/modeltests/fixtures/fixtures/fixture1.json @@ -0,0 +1,18 @@ +[ + { + "pk": "2", + "model": "fixtures.article", + "fields": { + "headline": "Poker has no place on ESPN", + "pub_date": "2006-06-16 12:00:00" + } + }, + { + "pk": "3", + "model": "fixtures.article", + "fields": { + "headline": "Time to reform copyright", + "pub_date": "2006-06-16 13:00:00" + } + } +] \ No newline at end of file diff --git a/tests/modeltests/fixtures/fixtures/fixture2.json b/tests/modeltests/fixtures/fixtures/fixture2.json new file mode 100644 index 00000000000..01b40d75350 --- /dev/null +++ b/tests/modeltests/fixtures/fixtures/fixture2.json @@ -0,0 +1,18 @@ +[ + { + "pk": "3", + "model": "fixtures.article", + "fields": { + "headline": "Copyright is fine the way it is", + "pub_date": "2006-06-16 14:00:00" + } + }, + { + "pk": "4", + "model": "fixtures.article", + "fields": { + "headline": "Django conquers world!", + "pub_date": "2006-06-16 15:00:00" + } + } +] \ No newline at end of file diff --git a/tests/modeltests/fixtures/fixtures/fixture2.xml b/tests/modeltests/fixtures/fixtures/fixture2.xml new file mode 100644 index 00000000000..9ced78162e4 --- /dev/null +++ b/tests/modeltests/fixtures/fixtures/fixture2.xml @@ -0,0 +1,11 @@ + + + + Poker on TV is great! + 2006-06-16 11:00:00 + + + XML identified as leading cause of cancer + 2006-06-16 16:00:00 + + \ No newline at end of file diff --git a/tests/modeltests/fixtures/fixtures/fixture3.xml b/tests/modeltests/fixtures/fixtures/fixture3.xml new file mode 100644 index 00000000000..9ced78162e4 --- /dev/null +++ b/tests/modeltests/fixtures/fixtures/fixture3.xml @@ -0,0 +1,11 @@ + + + + Poker on TV is great! + 2006-06-16 11:00:00 + + + XML identified as leading cause of cancer + 2006-06-16 16:00:00 + + \ No newline at end of file diff --git a/tests/modeltests/fixtures/fixtures/initial_data.json b/tests/modeltests/fixtures/fixtures/initial_data.json new file mode 100644 index 00000000000..477d781dbc9 --- /dev/null +++ b/tests/modeltests/fixtures/fixtures/initial_data.json @@ -0,0 +1,10 @@ +[ + { + "pk": "1", + "model": "fixtures.article", + "fields": { + "headline": "Python program becomes self aware", + "pub_date": "2006-06-16 11:00:00" + } + } +] \ No newline at end of file diff --git a/tests/modeltests/fixtures/models.py b/tests/modeltests/fixtures/models.py new file mode 100644 index 00000000000..d82886a6c4c --- /dev/null +++ b/tests/modeltests/fixtures/models.py @@ -0,0 +1,88 @@ +""" +39. Fixtures. + +Fixtures are a way of loading data into the database in bulk. Fixure data +can be stored in any serializable format (including JSON and XML). Fixtures +are identified by name, and are stored in either a directory named 'fixtures' +in the application directory, on in one of the directories named in the +FIXTURE_DIRS setting. +""" + +from django.db import models + +class Article(models.Model): + headline = models.CharField(maxlength=100, default='Default headline') + pub_date = models.DateTimeField() + + def __str__(self): + return self.headline + + class Meta: + ordering = ('-pub_date', 'headline') + +__test__ = {'API_TESTS': """ +>>> from django.core import management +>>> from django.db.models import get_app + +# Reset the database representation of this app. +# This will return the database to a clean initial state. +>>> management.flush(verbosity=0, interactive=False) + +# Syncdb introduces 1 initial data object from initial_data.json. +>>> Article.objects.all() +[] + +# Load fixture 1. Single JSON file, with two objects. +>>> management.load_data(['fixture1.json'], verbosity=0) +>>> Article.objects.all() +[, , ] + +# Load fixture 2. JSON file imported by default. Overwrites some existing objects +>>> management.load_data(['fixture2.json'], verbosity=0) +>>> Article.objects.all() +[, , , ] + +# Load fixture 3, XML format. +>>> management.load_data(['fixture3.xml'], verbosity=0) +>>> Article.objects.all() +[, , , , ] + +# Load a fixture that doesn't exist +>>> management.load_data(['unknown.json'], verbosity=0) + +# object list is unaffected +>>> Article.objects.all() +[, , , , ] + +# Reset the database representation of this app. This will delete all data. +>>> management.flush(verbosity=0, interactive=False) +>>> Article.objects.all() +[] + +# Load fixture 1 again, using format discovery +>>> management.load_data(['fixture1'], verbosity=0) +>>> Article.objects.all() +[, , ] + +# Try to load fixture 2 using format discovery; this will fail +# because there are two fixture2's in the fixtures directory +>>> management.load_data(['fixture2'], verbosity=0) # doctest: +ELLIPSIS +Multiple fixtures named 'fixture2' in '.../fixtures'. Aborting. + +>>> Article.objects.all() +[, , ] + +# Dump the current contents of the database as a JSON fixture +>>> management.dump_data(['fixtures'], format='json') +[{"pk": "3", "model": "fixtures.article", "fields": {"headline": "Time to reform copyright", "pub_date": "2006-06-16 13:00:00"}}, {"pk": "2", "model": "fixtures.article", "fields": {"headline": "Poker has no place on ESPN", "pub_date": "2006-06-16 12:00:00"}}, {"pk": "1", "model": "fixtures.article", "fields": {"headline": "Python program becomes self aware", "pub_date": "2006-06-16 11:00:00"}}] +"""} + +from django.test import TestCase + +class SampleTestCase(TestCase): + fixtures = ['fixture1.json', 'fixture2.json'] + + def testClassFixtures(self): + "Check that test case has installed 4 fixture objects" + self.assertEqual(Article.objects.count(), 4) + self.assertEquals(str(Article.objects.all()), "[, , , ]") diff --git a/tests/modeltests/test_client/fixtures/testdata.json b/tests/modeltests/test_client/fixtures/testdata.json new file mode 100644 index 00000000000..5c9e4152408 --- /dev/null +++ b/tests/modeltests/test_client/fixtures/testdata.json @@ -0,0 +1,20 @@ +[ + { + "pk": "1", + "model": "auth.user", + "fields": { + "username": "testclient", + "first_name": "Test", + "last_name": "Client", + "is_active": true, + "is_superuser": false, + "is_staff": false, + "last_login": "2006-12-17 07:03:31", + "groups": [], + "user_permissions": [], + "password": "sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161", + "email": "testclient@example.com", + "date_joined": "2006-12-17 07:03:31" + } + } +] \ No newline at end of file diff --git a/tests/modeltests/test_client/management.py b/tests/modeltests/test_client/management.py deleted file mode 100644 index 9b5a5c498e1..00000000000 --- a/tests/modeltests/test_client/management.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.dispatch import dispatcher -from django.db.models import signals -import models as test_client_app -from django.contrib.auth.models import User - -def setup_test(app, created_models, verbosity): - # Create a user account for the login-based tests - User.objects.create_user('testclient','testclient@example.com', 'password') - -dispatcher.connect(setup_test, sender=test_client_app, signal=signals.post_syncdb) diff --git a/tests/modeltests/test_client/models.py b/tests/modeltests/test_client/models.py index 1f91df96b25..a3a9749162c 100644 --- a/tests/modeltests/test_client/models.py +++ b/tests/modeltests/test_client/models.py @@ -19,10 +19,11 @@ testing against the contexts and templates produced by a view, rather than the HTML rendered to the end-user. """ -from django.test.client import Client -import unittest +from django.test import Client, TestCase -class ClientTest(unittest.TestCase): +class ClientTest(TestCase): + fixtures = ['testdata.json'] + def setUp(self): "Set up test environment" self.client = Client() diff --git a/tests/urls.py b/tests/urls.py index 9fefd936242..9dcdca944ef 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -6,7 +6,7 @@ urlpatterns = patterns('', # Always provide the auth system login and logout views (r'^accounts/login/$', 'django.contrib.auth.views.login', {'template_name': 'login.html'}), - (r'^accounts/logout/$', 'django.contrib.auth.views.login'), + (r'^accounts/logout/$', 'django.contrib.auth.views.logout'), # test urlconf for {% url %} template tag (r'^url_tag/', include('regressiontests.templates.urls')),