diff --git a/AUTHORS b/AUTHORS index 2034ee4fcb..ef00671540 100644 --- a/AUTHORS +++ b/AUTHORS @@ -120,6 +120,7 @@ answer newbie questions, and generally made Django that much better: Brandon Chinn Brant Harris Brendan Hayward + Brendan Quinn Brenton Simpson Brett Cannon Brett Hoerner diff --git a/django/core/management/commands/inspectdb.py b/django/core/management/commands/inspectdb.py index c502b34b51..0834a17c19 100644 --- a/django/core/management/commands/inspectdb.py +++ b/django/core/management/commands/inspectdb.py @@ -22,6 +22,9 @@ class Command(BaseCommand): '--database', action='store', dest='database', default=DEFAULT_DB_ALIAS, help='Nominates a database to introspect. Defaults to using the "default" database.', ) + parser.add_argument( + '--include-views', action='store_true', help='Also output models for database views.', + ) def handle(self, **options): try: @@ -54,7 +57,11 @@ class Command(BaseCommand): yield "# Feel free to rename the models, but don't rename db_table values or field names." yield 'from %s import models' % self.db_module known_models = [] - tables_to_introspect = options['table'] or connection.introspection.table_names(cursor) + table_info = connection.introspection.get_table_list(cursor) + tables_to_introspect = ( + options['table'] or + sorted(info.name for info in table_info if options['include_views'] or info.type == 't') + ) for table_name in tables_to_introspect: if table_name_filter is not None and callable(table_name_filter): @@ -160,7 +167,8 @@ class Command(BaseCommand): if comment_notes: field_desc += ' # ' + ' '.join(comment_notes) yield ' %s' % field_desc - for meta_line in self.get_meta(table_name, constraints, column_to_field_name): + is_view = any(info.name == table_name and info.type == 'v' for info in table_info) + for meta_line in self.get_meta(table_name, constraints, column_to_field_name, is_view): yield meta_line def normalize_col_name(self, col_name, used_column_names, is_relation): @@ -257,7 +265,7 @@ class Command(BaseCommand): return field_type, field_params, field_notes - def get_meta(self, table_name, constraints, column_to_field_name): + def get_meta(self, table_name, constraints, column_to_field_name, is_view): """ Return a sequence comprising the lines of code necessary to construct the inner Meta class for the model corresponding @@ -272,9 +280,10 @@ class Command(BaseCommand): # so we build the string rather than interpolate the tuple tup = '(' + ', '.join("'%s'" % column_to_field_name[c] for c in columns) + ')' unique_together.append(tup) + managed_comment = " # Created from a view. Don't remove." if is_view else "" meta = ["", " class Meta:", - " managed = False", + " managed = False%s" % managed_comment, " db_table = '%s'" % table_name] if unique_together: tup = '(' + ', '.join(unique_together) + ',)' diff --git a/django/db/backends/sqlite3/introspection.py b/django/db/backends/sqlite3/introspection.py index f3a33a97f2..bb78e2533e 100644 --- a/django/db/backends/sqlite3/introspection.py +++ b/django/db/backends/sqlite3/introspection.py @@ -96,13 +96,16 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): relations = {} # Schema for this table - cursor.execute("SELECT sql FROM sqlite_master WHERE tbl_name = %s AND type = %s", [table_name, "table"]) - try: - results = cursor.fetchone()[0].strip() - except TypeError: + cursor.execute( + "SELECT sql, type FROM sqlite_master " + "WHERE tbl_name = %s AND type IN ('table', 'view')", + [table_name] + ) + create_sql, table_type = cursor.fetchone() + if table_type == 'view': # It might be a view, then no results will be returned return relations - results = results[results.index('(') + 1:results.rindex(')')] + results = create_sql[create_sql.index('(') + 1:create_sql.rindex(')')] # Walk through and look for references to other tables. SQLite doesn't # really have enforced references, but since it echoes out the SQL used @@ -174,13 +177,20 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): def get_primary_key_column(self, cursor, table_name): """Return the column name of the primary key for the given table.""" # Don't use PRAGMA because that causes issues with some transactions - cursor.execute("SELECT sql FROM sqlite_master WHERE tbl_name = %s AND type = %s", [table_name, "table"]) + cursor.execute( + "SELECT sql, type FROM sqlite_master " + "WHERE tbl_name = %s AND type IN ('table', 'view')", + [table_name] + ) row = cursor.fetchone() if row is None: raise ValueError("Table %s does not exist" % table_name) - results = row[0].strip() - results = results[results.index('(') + 1:results.rindex(')')] - for field_desc in results.split(','): + create_sql, table_type = row + if table_type == 'view': + # Views don't have a primary key. + return None + fields_sql = create_sql[create_sql.index('(') + 1:create_sql.rindex(')')] + for field_desc in fields_sql.split(','): field_desc = field_desc.strip() m = re.search('"(.*)".*PRIMARY KEY( AUTOINCREMENT)?', field_desc) if m: diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index 84ed59aa51..cf64fbfe0b 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -350,8 +350,11 @@ Specifies the database to flush. Defaults to ``default``. Introspects the database tables in the database pointed-to by the :setting:`NAME` setting and outputs a Django model module (a ``models.py`` -file) to standard output. You may choose what tables to inspect by passing -their names as arguments. +file) to standard output. + +You may choose what tables or views to inspect by passing their names as +arguments. If no arguments are provided, models are created for views only if +the :option:`--include-views` option is used. Use this if you have a legacy database with which you'd like to use Django. The script will inspect the database and create a model for each table within @@ -405,6 +408,12 @@ it because ``True`` is its default value). Specifies the database to introspect. Defaults to ``default``. +.. django-admin-option:: --include-views + +.. versionadded:: 2.1 + +If this option is provided, models are also created for database views. + ``loaddata`` ------------ diff --git a/docs/releases/2.1.txt b/docs/releases/2.1.txt index 323da06fd6..03d80115d3 100644 --- a/docs/releases/2.1.txt +++ b/docs/releases/2.1.txt @@ -155,7 +155,8 @@ Internationalization Management Commands ~~~~~~~~~~~~~~~~~~~ -* ... +* The new :option:`inspectdb --include-views` option allows creating models + for database views. Migrations ~~~~~~~~~~ diff --git a/tests/inspectdb/tests.py b/tests/inspectdb/tests.py index 88b828168b..ed55e31353 100644 --- a/tests/inspectdb/tests.py +++ b/tests/inspectdb/tests.py @@ -4,7 +4,8 @@ from unittest import mock, skipUnless from django.core.management import call_command from django.db import connection -from django.test import TestCase, skipUnlessDBFeature +from django.db.backends.base.introspection import TableInfo +from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature from .models import ColumnTypes @@ -260,10 +261,37 @@ class InspectDBTestCase(TestCase): be visible in the output. """ out = StringIO() - with mock.patch('django.db.backends.base.introspection.BaseDatabaseIntrospection.table_names', - return_value=['nonexistent']): + with mock.patch('django.db.connection.introspection.get_table_list', + return_value=[TableInfo(name='nonexistent', type='t')]): call_command('inspectdb', stdout=out) output = out.getvalue() self.assertIn("# Unable to inspect table 'nonexistent'", output) # The error message depends on the backend self.assertIn("# The error was:", output) + + +class InspectDBTransactionalTests(TransactionTestCase): + available_apps = None + + def test_include_views(self): + """inspectdb --include-views creates models for database views.""" + with connection.cursor() as cursor: + cursor.execute( + 'CREATE VIEW inspectdb_people_view AS ' + 'SELECT id, name FROM inspectdb_people' + ) + out = StringIO() + view_model = 'class InspectdbPeopleView(models.Model):' + view_managed = 'managed = False # Created from a view.' + try: + call_command('inspectdb', stdout=out) + no_views_output = out.getvalue() + self.assertNotIn(view_model, no_views_output) + self.assertNotIn(view_managed, no_views_output) + call_command('inspectdb', include_views=True, stdout=out) + with_views_output = out.getvalue() + self.assertIn(view_model, with_views_output) + self.assertIn(view_managed, with_views_output) + finally: + with connection.cursor() as cursor: + cursor.execute('DROP VIEW inspectdb_people_view') diff --git a/tests/introspection/tests.py b/tests/introspection/tests.py index a66ecaba03..ce102fcdad 100644 --- a/tests/introspection/tests.py +++ b/tests/introspection/tests.py @@ -151,7 +151,7 @@ class IntrospectionTests(TransactionTestCase): ] for statement in create_table_statements: with connection.cursor() as cursor: - cursor.fetchone = mock.Mock(return_value=[statement.format(Article._meta.db_table)]) + cursor.fetchone = mock.Mock(return_value=[statement.format(Article._meta.db_table), 'table']) relations = connection.introspection.get_relations(cursor, 'mocked_table') self.assertEqual(relations, {'art_id': ('id', Article._meta.db_table)})