Browse Source

Refs #29722 -- Added introspection of partitions for PostgreSQL.

Nick Pope 6 years ago
parent
commit
ebd270627c

+ 20 - 8
django/core/management/commands/inspectdb.py

@@ -22,6 +22,9 @@ class Command(BaseCommand):
             '--database', default=DEFAULT_DB_ALIAS,
             '--database', default=DEFAULT_DB_ALIAS,
             help='Nominates a database to introspect. Defaults to using the "default" database.',
             help='Nominates a database to introspect. Defaults to using the "default" database.',
         )
         )
+        parser.add_argument(
+            '--include-partitions', action='store_true', help='Also output models for partition tables.',
+        )
         parser.add_argument(
         parser.add_argument(
             '--include-views', action='store_true', help='Also output models for database views.',
             '--include-views', action='store_true', help='Also output models for database views.',
         )
         )
@@ -55,12 +58,15 @@ class Command(BaseCommand):
             yield 'from %s import models' % self.db_module
             yield 'from %s import models' % self.db_module
             known_models = []
             known_models = []
             table_info = connection.introspection.get_table_list(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:
+            # Determine types of tables and/or views to be introspected.
+            types = {'t'}
+            if options['include_partitions']:
+                types.add('p')
+            if options['include_views']:
+                types.add('v')
+
+            for table_name in (options['table'] or sorted(info.name for info in table_info if info.type in types)):
                 if table_name_filter is not None and callable(table_name_filter):
                 if table_name_filter is not None and callable(table_name_filter):
                     if not table_name_filter(table_name):
                     if not table_name_filter(table_name):
                         continue
                         continue
@@ -160,7 +166,8 @@ class Command(BaseCommand):
                         field_desc += '  # ' + ' '.join(comment_notes)
                         field_desc += '  # ' + ' '.join(comment_notes)
                     yield '    %s' % field_desc
                     yield '    %s' % field_desc
                 is_view = any(info.name == table_name and info.type == 'v' for info in table_info)
                 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):
+                is_partition = any(info.name == table_name and info.type == 'p' for info in table_info)
+                for meta_line in self.get_meta(table_name, constraints, column_to_field_name, is_view, is_partition):
                     yield meta_line
                     yield meta_line
 
 
     def normalize_col_name(self, col_name, used_column_names, is_relation):
     def normalize_col_name(self, col_name, used_column_names, is_relation):
@@ -257,7 +264,7 @@ class Command(BaseCommand):
 
 
         return field_type, field_params, field_notes
         return field_type, field_params, field_notes
 
 
-    def get_meta(self, table_name, constraints, column_to_field_name, is_view):
+    def get_meta(self, table_name, constraints, column_to_field_name, is_view, is_partition):
         """
         """
         Return a sequence comprising the lines of code necessary
         Return a sequence comprising the lines of code necessary
         to construct the inner Meta class for the model corresponding
         to construct the inner Meta class for the model corresponding
@@ -273,7 +280,12 @@ class Command(BaseCommand):
                 columns = [x for x in columns if x is not None]
                 columns = [x for x in columns if x is not None]
                 if len(columns) > 1:
                 if len(columns) > 1:
                     unique_together.append(str(tuple(column_to_field_name[c] for c in columns)))
                     unique_together.append(str(tuple(column_to_field_name[c] for c in columns)))
-        managed_comment = "  # Created from a view. Don't remove." if is_view else ""
+        if is_view:
+            managed_comment = "  # Created from a view. Don't remove."
+        elif is_partition:
+            managed_comment = "  # Created from a partition. Don't remove."
+        else:
+            managed_comment = ''
         meta = ['']
         meta = ['']
         if has_unsupported_constraint:
         if has_unsupported_constraint:
             meta.append('    # A unique constraint could not be introspected.')
             meta.append('    # A unique constraint could not be introspected.')

+ 1 - 0
django/db/backends/postgresql/features.py

@@ -73,3 +73,4 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     has_gin_pending_list_limit = property(operator.attrgetter('is_postgresql_9_5'))
     has_gin_pending_list_limit = property(operator.attrgetter('is_postgresql_9_5'))
     supports_ignore_conflicts = property(operator.attrgetter('is_postgresql_9_5'))
     supports_ignore_conflicts = property(operator.attrgetter('is_postgresql_9_5'))
     has_phraseto_tsquery = property(operator.attrgetter('is_postgresql_9_6'))
     has_phraseto_tsquery = property(operator.attrgetter('is_postgresql_9_6'))
+    supports_table_partitions = property(operator.attrgetter('is_postgresql_10'))

+ 6 - 9
django/db/backends/postgresql/introspection.py

@@ -42,18 +42,15 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
     def get_table_list(self, cursor):
     def get_table_list(self, cursor):
         """Return a list of table and view names in the current database."""
         """Return a list of table and view names in the current database."""
         cursor.execute("""
         cursor.execute("""
-            SELECT c.relname, c.relkind
+            SELECT c.relname,
+            CASE WHEN {} THEN 'p' WHEN c.relkind IN ('m', 'v') THEN 'v' ELSE 't' END
             FROM pg_catalog.pg_class c
             FROM pg_catalog.pg_class c
             LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
             LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
-            WHERE c.relkind IN ('f', 'm', 'r', 'v')
+            WHERE c.relkind IN ('f', 'm', 'p', 'r', 'v')
                 AND n.nspname NOT IN ('pg_catalog', 'pg_toast')
                 AND n.nspname NOT IN ('pg_catalog', 'pg_toast')
                 AND pg_catalog.pg_table_is_visible(c.oid)
                 AND pg_catalog.pg_table_is_visible(c.oid)
-        """)
-        mapping = {'f': 't', 'm': 'v', 'r': 't', 'v': 'v'}
-        return [
-            TableInfo(row[0], mapping[row[1]])
-            for row in cursor.fetchall() if row[0] not in self.ignored_tables
-        ]
+        """.format('c.relispartition' if self.connection.features.supports_table_partitions else 'FALSE'))
+        return [TableInfo(*row) for row in cursor.fetchall() if row[0] not in self.ignored_tables]
 
 
     def get_table_description(self, cursor, table_name):
     def get_table_description(self, cursor, table_name):
         """
         """
@@ -73,7 +70,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
             JOIN pg_type t ON a.atttypid = t.oid
             JOIN pg_type t ON a.atttypid = t.oid
             JOIN pg_class c ON a.attrelid = c.oid
             JOIN pg_class c ON a.attrelid = c.oid
             JOIN pg_namespace n ON c.relnamespace = n.oid
             JOIN pg_namespace n ON c.relnamespace = n.oid
-            WHERE c.relkind IN ('f', 'm', 'r', 'v')
+            WHERE c.relkind IN ('f', 'm', 'p', 'r', 'v')
                 AND c.relname = %s
                 AND c.relname = %s
                 AND n.nspname NOT IN ('pg_catalog', 'pg_toast')
                 AND n.nspname NOT IN ('pg_catalog', 'pg_toast')
                 AND pg_catalog.pg_table_is_visible(c.oid)
                 AND pg_catalog.pg_table_is_visible(c.oid)

+ 12 - 1
docs/ref/django-admin.txt

@@ -352,7 +352,8 @@ file) to standard output.
 
 
 You may choose what tables or views to inspect by passing their names as
 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
 arguments. If no arguments are provided, models are created for views only if
-the :option:`--include-views` option is used.
+the :option:`--include-views` option is used. Models for partition tables are
+created on PostgreSQL if the :option:`--include-partitions` option is used.
 
 
 Use this if you have a legacy database with which you'd like to use Django.
 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
 The script will inspect the database and create a model for each table within
@@ -404,6 +405,8 @@ PostgreSQL
 * Models are created for foreign tables.
 * Models are created for foreign tables.
 * Models are created for materialized views if
 * Models are created for materialized views if
   :option:`--include-views` is used.
   :option:`--include-views` is used.
+* Models are created for partition tables if
+  :option:`--include-partitions` is used.
 
 
 .. versionchanged:: 2.2
 .. versionchanged:: 2.2
 
 
@@ -413,6 +416,14 @@ PostgreSQL
 
 
 Specifies the database to introspect. Defaults to ``default``.
 Specifies the database to introspect. Defaults to ``default``.
 
 
+.. django-admin-option:: --include-partitions
+
+.. versionadded:: 2.2
+
+If this option is provided, models are also created for partitions.
+
+Only support for PostgreSQL is implemented.
+
 .. django-admin-option:: --include-views
 .. django-admin-option:: --include-views
 
 
 .. versionadded:: 2.1
 .. versionadded:: 2.1

+ 4 - 0
docs/releases/2.2.txt

@@ -183,6 +183,10 @@ Management Commands
 * :option:`inspectdb --include-views` now creates models for materialized views
 * :option:`inspectdb --include-views` now creates models for materialized views
   on PostgreSQL.
   on PostgreSQL.
 
 
+* The new :option:`inspectdb --include-partitions` option allows creating
+  models for partition tables on PostgreSQL. In older versions, models are
+  created child tables instead the parent.
+
 * :djadmin:`inspectdb` now introspects :class:`~django.db.models.DurationField`
 * :djadmin:`inspectdb` now introspects :class:`~django.db.models.DurationField`
   for Oracle and PostgreSQL.
   for Oracle and PostgreSQL.
 
 

+ 34 - 0
tests/inspectdb/tests.py

@@ -334,6 +334,40 @@ class InspectDBTransactionalTests(TransactionTestCase):
             with connection.cursor() as cursor:
             with connection.cursor() as cursor:
                 cursor.execute('DROP MATERIALIZED VIEW IF EXISTS inspectdb_people_materialized_view')
                 cursor.execute('DROP MATERIALIZED VIEW IF EXISTS inspectdb_people_materialized_view')
 
 
+    @skipUnless(connection.vendor == 'postgresql', 'PostgreSQL specific SQL')
+    @skipUnlessDBFeature('supports_table_partitions')
+    def test_include_partitions(self):
+        """inspectdb --include-partitions creates models for partitions."""
+        with connection.cursor() as cursor:
+            cursor.execute('''\
+                CREATE TABLE inspectdb_partition_parent (name text not null)
+                PARTITION BY LIST (left(upper(name), 1))
+            ''')
+            cursor.execute('''\
+                CREATE TABLE inspectdb_partition_child
+                PARTITION OF inspectdb_partition_parent
+                FOR VALUES IN ('A', 'B', 'C')
+            ''')
+        out = StringIO()
+        partition_model_parent = 'class InspectdbPartitionParent(models.Model):'
+        partition_model_child = 'class InspectdbPartitionChild(models.Model):'
+        partition_managed = 'managed = False  # Created from a partition.'
+        try:
+            call_command('inspectdb', table_name_filter=inspectdb_tables_only, stdout=out)
+            no_partitions_output = out.getvalue()
+            self.assertIn(partition_model_parent, no_partitions_output)
+            self.assertNotIn(partition_model_child, no_partitions_output)
+            self.assertNotIn(partition_managed, no_partitions_output)
+            call_command('inspectdb', table_name_filter=inspectdb_tables_only, include_partitions=True, stdout=out)
+            with_partitions_output = out.getvalue()
+            self.assertIn(partition_model_parent, with_partitions_output)
+            self.assertIn(partition_model_child, with_partitions_output)
+            self.assertIn(partition_managed, with_partitions_output)
+        finally:
+            with connection.cursor() as cursor:
+                cursor.execute('DROP TABLE IF EXISTS inspectdb_partition_child')
+                cursor.execute('DROP TABLE IF EXISTS inspectdb_partition_parent')
+
     @skipUnless(connection.vendor == 'postgresql', 'PostgreSQL specific SQL')
     @skipUnless(connection.vendor == 'postgresql', 'PostgreSQL specific SQL')
     def test_foreign_data_wrapper(self):
     def test_foreign_data_wrapper(self):
         with connection.cursor() as cursor:
         with connection.cursor() as cursor: