diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py index 4b4d5c6d75..a7bc85e683 100644 --- a/django/db/backends/base/features.py +++ b/django/db/backends/base/features.py @@ -125,6 +125,9 @@ class BaseDatabaseFeatures(object): # This is True for all core backends. can_introspect_null = True + # Can the backend introspect the default value of a column? + can_introspect_default = True + # Confirm support for introspected foreign keys # Every database can do this reliably, except MySQL, # which can't do it for MyISAM tables diff --git a/django/db/backends/mysql/introspection.py b/django/db/backends/mysql/introspection.py index dc78a00bfc..aa3c008d13 100644 --- a/django/db/backends/mysql/introspection.py +++ b/django/db/backends/mysql/introspection.py @@ -9,8 +9,8 @@ from django.utils.encoding import force_text from MySQLdb.constants import FIELD_TYPE -FieldInfo = namedtuple('FieldInfo', FieldInfo._fields + ('extra',)) - +FieldInfo = namedtuple('FieldInfo', FieldInfo._fields + ('extra', 'default')) +InfoLine = namedtuple('InfoLine', 'col_name data_type max_len num_prec num_scale extra column_default') foreign_key_re = re.compile(r"\sCONSTRAINT `[^`]*` FOREIGN KEY \(`([^`]*)`\) REFERENCES `([^`]*)` \(`([^`]*)`\)") @@ -61,9 +61,9 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): # not visible length (#5725) # - precision and scale (for decimal fields) (#5014) # - auto_increment is not available in cursor.description - InfoLine = namedtuple('InfoLine', 'col_name data_type max_len num_prec num_scale extra') cursor.execute(""" - SELECT column_name, data_type, character_maximum_length, numeric_precision, numeric_scale, extra + SELECT column_name, data_type, character_maximum_length, numeric_precision, + numeric_scale, extra, column_default FROM information_schema.columns WHERE table_name = %s AND table_schema = DATABASE()""", [table_name]) field_info = {line[0]: InfoLine(*line) for line in cursor.fetchall()} @@ -80,7 +80,8 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): to_int(field_info[col_name].num_prec) or line[4], to_int(field_info[col_name].num_scale) or line[5]) + (line[6],) - + (field_info[col_name].extra,))) + + (field_info[col_name].extra,) + + (field_info[col_name].column_default,))) ) return fields diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py index 93c0beaf1e..ffa5c6474c 100644 --- a/django/db/backends/oracle/features.py +++ b/django/db/backends/oracle/features.py @@ -28,6 +28,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): has_bulk_insert = True supports_tablespaces = True supports_sequence_reset = False + can_introspect_default = False # Pending implementation by an interested person. can_introspect_max_length = False can_introspect_time_field = False atomic_transactions = False diff --git a/django/db/backends/sqlite3/introspection.py b/django/db/backends/sqlite3/introspection.py index 13b658bd6a..a002b79933 100644 --- a/django/db/backends/sqlite3/introspection.py +++ b/django/db/backends/sqlite3/introspection.py @@ -1,4 +1,5 @@ import re +from collections import namedtuple from django.db.backends.base.introspection import ( BaseDatabaseIntrospection, FieldInfo, TableInfo, @@ -6,6 +7,7 @@ from django.db.backends.base.introspection import ( field_size_re = re.compile(r'^\s*(?:var)?char\s*\(\s*(\d+)\s*\)\s*$') +FieldInfo = namedtuple('FieldInfo', FieldInfo._fields + ('default',)) def get_field_size(name): @@ -69,8 +71,18 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): def get_table_description(self, cursor, table_name): "Returns a description of the table, with the DB-API cursor.description interface." - return [FieldInfo(info['name'], info['type'], None, info['size'], None, None, - info['null_ok']) for info in self._table_info(cursor, table_name)] + return [ + FieldInfo( + info['name'], + info['type'], + None, + info['size'], + None, + None, + info['null_ok'], + info['default'], + ) for info in self._table_info(cursor, table_name) + ] def column_name_converter(self, name): """ @@ -211,13 +223,15 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): def _table_info(self, cursor, name): cursor.execute('PRAGMA table_info(%s)' % self.connection.ops.quote_name(name)) - # cid, name, type, notnull, dflt_value, pk - return [{'name': field[1], - 'type': field[2], - 'size': get_field_size(field[2]), - 'null_ok': not field[3], - 'pk': field[5] # undocumented - } for field in cursor.fetchall()] + # cid, name, type, notnull, default_value, pk + return [{ + 'name': field[1], + 'type': field[2], + 'size': get_field_size(field[2]), + 'null_ok': not field[3], + 'default': field[4], + 'pk': field[5], # undocumented + } for field in cursor.fetchall()] def get_constraints(self, cursor, table_name): """ diff --git a/docs/releases/1.9.txt b/docs/releases/1.9.txt index ce317ffeb7..8c7aff8fe1 100644 --- a/docs/releases/1.9.txt +++ b/docs/releases/1.9.txt @@ -165,6 +165,15 @@ Backwards incompatible changes in 1.9 deprecation timeline for a given feature, its removal may appear as a backwards incompatible change. +Database backend API +~~~~~~~~~~~~~~~~~~~~ + +* A couple of new tests rely on the ability of the backend to introspect column + defaults (returning the result as ``Field.default``). You can set the + ``can_introspect_default`` database feature to ``False`` if your backend + doesn't implement this. You may want to review the implementation on the + backends that Django includes for reference (:ticket:`24245`). + Miscellaneous ~~~~~~~~~~~~~ diff --git a/tests/schema/tests.py b/tests/schema/tests.py index 2dd56d7248..12fac76579 100644 --- a/tests/schema/tests.py +++ b/tests/schema/tests.py @@ -1348,3 +1348,53 @@ class SchemaTests(TransactionTestCase): finally: # Cleanup model states AuthorWithM2M._meta.local_many_to_many.remove(new_field) + + def test_add_field_default_dropped(self): + # Create the table + with connection.schema_editor() as editor: + editor.create_model(Author) + # Ensure there's no surname field + columns = self.column_classes(Author) + self.assertNotIn("surname", columns) + # Create a row + Author.objects.create(name='Anonymous1') + # Add new CharField with a default + new_field = CharField(max_length=15, blank=True, default='surname default') + new_field.set_attributes_from_name("surname") + with connection.schema_editor() as editor: + editor.add_field(Author, new_field) + # Ensure field was added with the right default + with connection.cursor() as cursor: + cursor.execute("SELECT surname FROM schema_author;") + item = cursor.fetchall()[0] + self.assertEqual(item[0], 'surname default') + # And that the default is no longer set in the database. + field = next( + f for f in connection.introspection.get_table_description(cursor, "schema_author") + if f.name == "surname" + ) + if connection.features.can_introspect_default: + self.assertIsNone(field.default) + + def test_alter_field_default_dropped(self): + # Create the table + with connection.schema_editor() as editor: + editor.create_model(Author) + # Create a row + Author.objects.create(name='Anonymous1') + self.assertEqual(Author.objects.get().height, None) + old_field = Author._meta.get_field('height') + # The default from the new field is used in updating existing rows. + new_field = IntegerField(blank=True, default=42) + new_field.set_attributes_from_name('height') + with connection.schema_editor() as editor: + editor.alter_field(Author, old_field, new_field) + self.assertEqual(Author.objects.get().height, 42) + # The database default should be removed. + with connection.cursor() as cursor: + field = next( + f for f in connection.introspection.get_table_description(cursor, "schema_author") + if f.name == "height" + ) + if connection.features.can_introspect_default: + self.assertIsNone(field.default)