From 8ba5bf31986fa746ecc81683c64999dcea4f8e0a Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Mon, 18 Jun 2012 17:32:03 +0100 Subject: [PATCH] Very start of schema alteration port. Create/delete model and some tests. --- django/db/backends/__init__.py | 8 + .../db/backends/postgresql_psycopg2/base.py | 6 + .../db/backends/postgresql_psycopg2/schema.py | 5 + django/db/backends/schema.py | 178 ++++++++++++++++++ tests/modeltests/schema/__init__.py | 0 tests/modeltests/schema/models.py | 21 +++ tests/modeltests/schema/tests.py | 102 ++++++++++ 7 files changed, 320 insertions(+) create mode 100644 django/db/backends/postgresql_psycopg2/schema.py create mode 100644 django/db/backends/schema.py create mode 100644 tests/modeltests/schema/__init__.py create mode 100644 tests/modeltests/schema/models.py create mode 100644 tests/modeltests/schema/tests.py diff --git a/django/db/backends/__init__.py b/django/db/backends/__init__.py index d70fe54bdb..ed2a54277f 100644 --- a/django/db/backends/__init__.py +++ b/django/db/backends/__init__.py @@ -312,6 +312,11 @@ class BaseDatabaseWrapper(object): def make_debug_cursor(self, cursor): return util.CursorDebugWrapper(cursor, self) + def schema_editor(self): + "Returns a new instance of this backend's SchemaEditor" + raise NotImplementedError() + + class BaseDatabaseFeatures(object): allows_group_by_pk = False # True if django.db.backend.utils.typecast_timestamp is used on values @@ -411,6 +416,9 @@ class BaseDatabaseFeatures(object): # Support for the DISTINCT ON clause can_distinct_on_fields = False + # Can we roll back DDL in a transaction? + can_rollback_ddl = False + def __init__(self, connection): self.connection = connection diff --git a/django/db/backends/postgresql_psycopg2/base.py b/django/db/backends/postgresql_psycopg2/base.py index 61be680d83..6c56bb9c91 100644 --- a/django/db/backends/postgresql_psycopg2/base.py +++ b/django/db/backends/postgresql_psycopg2/base.py @@ -13,6 +13,7 @@ from django.db.backends.postgresql_psycopg2.client import DatabaseClient from django.db.backends.postgresql_psycopg2.creation import DatabaseCreation from django.db.backends.postgresql_psycopg2.version import get_version from django.db.backends.postgresql_psycopg2.introspection import DatabaseIntrospection +from django.db.backends.postgresql_psycopg2.schema import DatabaseSchemaEditor from django.utils.log import getLogger from django.utils.safestring import SafeUnicode, SafeString from django.utils.timezone import utc @@ -83,6 +84,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): has_bulk_insert = True supports_tablespaces = True can_distinct_on_fields = True + can_rollback_ddl = True class DatabaseWrapper(BaseDatabaseWrapper): vendor = 'postgresql' @@ -235,3 +237,7 @@ class DatabaseWrapper(BaseDatabaseWrapper): return self.connection.commit() except Database.IntegrityError as e: raise utils.IntegrityError, utils.IntegrityError(*tuple(e)), sys.exc_info()[2] + + def schema_editor(self): + "Returns a new instance of this backend's SchemaEditor" + return DatabaseSchemaEditor(self) diff --git a/django/db/backends/postgresql_psycopg2/schema.py b/django/db/backends/postgresql_psycopg2/schema.py new file mode 100644 index 0000000000..b86e0857bb --- /dev/null +++ b/django/db/backends/postgresql_psycopg2/schema.py @@ -0,0 +1,5 @@ +from django.db.backends.schema import BaseDatabaseSchemaEditor + + +class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): + pass diff --git a/django/db/backends/schema.py b/django/db/backends/schema.py new file mode 100644 index 0000000000..73a9b99b50 --- /dev/null +++ b/django/db/backends/schema.py @@ -0,0 +1,178 @@ +import sys +import time + +from django.conf import settings +from django.db import transaction +from django.db.utils import load_backend +from django.utils.log import getLogger + +logger = getLogger('django.db.backends.schema') + + +class BaseDatabaseSchemaEditor(object): + """ + This class (and its subclasses) are responsible for emitting schema-changing + statements to the databases - model creation/removal/alteration, field + renaming, index fiddling, and so on. + + It is intended to eventually completely replace DatabaseCreation. + + This class should be used by creating an instance for each set of schema + changes (e.g. a syncdb run, a migration file), and by first calling start(), + then the relevant actions, and then commit(). This is necessary to allow + things like circular foreign key references - FKs will only be created once + commit() is called. + """ + + # Overrideable SQL templates + sql_create_table = "CREATE TABLE %(table)s (%(definition)s)" + sql_rename_table = "ALTER TABLE %(old_table)s RENAME TO %(new_table)s" + sql_delete_table = "DROP TABLE %(table)s CASCADE" + + sql_create_column = "ALTER TABLE %(table)s ADD COLUMN %(definition)s" + sql_alter_column_type = "ALTER COLUMN %(column)s TYPE %(type)s" + sql_alter_column_null = "ALTER COLUMN %(column)s DROP NOT NULL" + sql_alter_column_not_null = "ALTER COLUMN %(column)s SET NOT NULL" + sql_delete_column = "ALTER TABLE %(table)s DROP COLUMN %(column)s CASCADE;" + + sql_create_check = "ADD CONSTRAINT %(name)s CHECK (%(check)s)" + sql_delete_check = "ALTER TABLE %(table)s DROP CONSTRAINT %(name)s" + + sql_create_unique = "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s UNIQUE (%(columns)s)" + sql_delete_unique = "ALTER TABLE %(table)s DROP CONSTRAINT %(name)s" + + sql_create_fk = "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s FOREIGN KEY (%(column)s) REFERENCES %(to_table)s (%(to_column)s) DEFERRABLE INITIALLY DEFERRED" + sql_delete_fk = "ALTER TABLE %(table)s DROP CONSTRAINT %(name)s" + + sql_create_index = "CREATE %(unique)s INDEX %(name)s ON %(table)s (%(columns)s)%s;" + sql_delete_index = "DROP INDEX %(name)s" + + sql_create_pk = "ALTER TABLE %(table)s ADD CONSTRAINT %(constraint)s PRIMARY KEY (%(columns)s)" + sql_delete_pk = "ALTER TABLE %(table)s DROP CONSTRAINT %(constraint)s" + + def __init__(self, connection): + self.connection = connection + + # State-managing methods + + def start(self): + "Marks the start of a schema-altering run" + self.deferred_sql = [] + self.connection.commit_unless_managed() + self.connection.enter_transaction_management() + self.connection.managed(True) + + def commit(self): + "Finishes a schema-altering run" + for sql in self.deferred_sql: + self.execute(sql) + self.connection.commit() + self.connection.leave_transaction_management() + + def rollback(self): + "Tries to roll back a schema-altering run. Call instead of commit()" + if not self.connection.features.can_rollback_ddl: + raise RuntimeError("Cannot rollback schema changes on this backend") + self.connection.rollback() + self.connection.leave_transaction_management() + + # Core utility functions + + def execute(self, sql, params=[], fetch_results=False): + """ + Executes the given SQL statement, with optional parameters. + """ + # Get the cursor + cursor = self.connection.cursor() + # Log the command we're running, then run it + logger.info("%s; (params %r)" % (sql, params)) + cursor.execute(sql, params) + + def quote_name(self, name): + return self.connection.ops.quote_name(name) + + # Actions + + def create_model(self, model): + """ + Takes a model and creates a table for it in the database. + Will also create any accompanying indexes or unique constraints. + """ + # Do nothing if this is an unmanaged or proxy model + if not model._meta.managed or model._meta.proxy: + return [], {} + # Create column SQL, add FK deferreds if needed + column_sqls = [] + for field in model._meta.local_fields: + # SQL + definition = self.column_sql(model, field) + if definition is None: + continue + column_sqls.append("%s %s" % ( + self.quote_name(field.column), + definition, + )) + # FK + if field.rel: + to_table = field.rel.to._meta.db_table + to_column = field.rel.to._meta.get_field(field.rel.field_name).column + self.deferred_sql.append( + self.sql_create_fk % { + "name": '%s_refs_%s_%x' % ( + field.column, + to_column, + abs(hash((model._meta.db_table, to_table))) + ), + "table": self.quote_name(model._meta.db_table), + "column": self.quote_name(field.column), + "to_table": self.quote_name(to_table), + "to_column": self.quote_name(to_column), + } + ) + # Make the table + sql = self.sql_create_table % { + "table": model._meta.db_table, + "definition": ", ".join(column_sqls) + } + self.execute(sql) + + def column_sql(self, model, field, include_default=False): + """ + Takes a field and returns its column definition. + The field must already have had set_attributes_from_name called. + """ + # Get the column's type and use that as the basis of the SQL + sql = field.db_type(connection=self.connection) + # Check for fields that aren't actually columns (e.g. M2M) + if sql is None: + return None + # Optionally add the tablespace if it's an implicitly indexed column + tablespace = field.db_tablespace or model._meta.db_tablespace + if tablespace and self.connection.features.supports_tablespaces and field.unique: + sql += " %s" % self.connection.ops.tablespace_sql(tablespace, inline=True) + # Work out nullability + null = field.null + # Oracle treats the empty string ('') as null, so coerce the null + # option whenever '' is a possible value. + if (field.empty_strings_allowed and not field.primary_key and + self.connection.features.interprets_empty_strings_as_nulls): + null = True + if null: + sql += " NULL" + else: + sql += " NOT NULL" + # Primary key/unique outputs + if field.primary_key: + sql += " PRIMARY KEY" + elif field.unique: + sql += " UNIQUE" + # If we were told to include a default value, do so + if include_default: + raise NotImplementedError() + # Return the sql + return sql + + def delete_model(self, model): + self.execute(self.sql_delete_table % { + "table": self.quote_name(model._meta.db_table), + }) diff --git a/tests/modeltests/schema/__init__.py b/tests/modeltests/schema/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/modeltests/schema/models.py b/tests/modeltests/schema/models.py new file mode 100644 index 0000000000..2c5dc829c6 --- /dev/null +++ b/tests/modeltests/schema/models.py @@ -0,0 +1,21 @@ +from django.db import models + +# Because we want to test creation and deletion of these as separate things, +# these models are all marked as unmanaged and only marked as managed while +# a schema test is running. + + +class Author(models.Model): + name = models.CharField(max_length=255) + + class Meta: + managed = False + + +class Book(models.Model): + author = models.ForeignKey(Author) + title = models.CharField(max_length=100) + pub_date = models.DateTimeField() + + class Meta: + managed = False diff --git a/tests/modeltests/schema/tests.py b/tests/modeltests/schema/tests.py new file mode 100644 index 0000000000..6d5d27cdf1 --- /dev/null +++ b/tests/modeltests/schema/tests.py @@ -0,0 +1,102 @@ +from __future__ import absolute_import +import copy +import datetime +from django.test import TestCase +from django.db.models.loading import cache +from django.db import connection, DatabaseError, IntegrityError +from .models import Author, Book + + +class SchemaTests(TestCase): + """ + Tests that the schema-alteration code works correctly. + + Be aware that these tests are more liable than most to false results, + as sometimes the code to check if a test has worked is almost as complex + as the code it is testing. + """ + + models = [Author, Book] + + def setUp(self): + # Make sure we're in manual transaction mode + connection.commit_unless_managed() + connection.enter_transaction_management() + connection.managed(True) + # The unmanaged models need to be removed after the test in order to + # prevent bad interactions with the flush operation in other tests. + self.old_app_models = copy.deepcopy(cache.app_models) + self.old_app_store = copy.deepcopy(cache.app_store) + for model in self.models: + model._meta.managed = True + + def tearDown(self): + # Rollback anything that may have happened + connection.rollback() + # Delete any tables made for our models + cursor = connection.cursor() + for model in self.models: + try: + cursor.execute("DROP TABLE %s CASCADE" % ( + connection.ops.quote_name(model._meta.db_table), + )) + except DatabaseError: + connection.rollback() + else: + connection.commit() + # Unhook our models + for model in self.models: + model._meta.managed = False + cache.app_models = self.old_app_models + cache.app_store = self.old_app_store + cache._get_models_cache = {} + + def test_creation_deletion(self): + """ + Tries creating a model's table, and then deleting it. + """ + # Create the table + editor = connection.schema_editor() + editor.start() + editor.create_model(Author) + editor.commit() + # Check that it's there + try: + list(Author.objects.all()) + except DatabaseError, e: + self.fail("Table not created: %s" % e) + # Clean up that table + editor.start() + editor.delete_model(Author) + editor.commit() + # Check that it's gone + self.assertRaises( + DatabaseError, + lambda: list(Author.objects.all()), + ) + + def test_creation_fk(self): + "Tests that creating tables out of FK order works" + # Create the table + editor = connection.schema_editor() + editor.start() + editor.create_model(Book) + editor.create_model(Author) + editor.commit() + # Check that both tables are there + try: + list(Author.objects.all()) + except DatabaseError, e: + self.fail("Author table not created: %s" % e) + try: + list(Book.objects.all()) + except DatabaseError, e: + self.fail("Book table not created: %s" % e) + # Make sure the FK constraint is present + with self.assertRaises(IntegrityError): + Book.objects.create( + author_id = 1, + title = "Much Ado About Foreign Keys", + pub_date = datetime.datetime.now(), + ) + connection.commit()