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,
             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(
             '--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
             known_models = []
             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 not table_name_filter(table_name):
                         continue
@@ -160,7 +166,8 @@ class Command(BaseCommand):
                         field_desc += '  # ' + ' '.join(comment_notes)
                     yield '    %s' % field_desc
                 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
 
     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
 
-    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
         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]
                 if len(columns) > 1:
                     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 = ['']
         if has_unsupported_constraint:
             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'))
     supports_ignore_conflicts = property(operator.attrgetter('is_postgresql_9_5'))
     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):
         """Return a list of table and view names in the current database."""
         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
             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 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):
         """
@@ -73,7 +70,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
             JOIN pg_type t ON a.atttypid = t.oid
             JOIN pg_class c ON a.attrelid = c.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 n.nspname NOT IN ('pg_catalog', 'pg_toast')
                 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
 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.
 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 materialized views if
   :option:`--include-views` is used.
+* Models are created for partition tables if
+  :option:`--include-partitions` is used.
 
 .. versionchanged:: 2.2
 
@@ -413,6 +416,14 @@ PostgreSQL
 
 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
 
 .. 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
   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`
   for Oracle and PostgreSQL.
 

+ 34 - 0
tests/inspectdb/tests.py

@@ -334,6 +334,40 @@ class InspectDBTransactionalTests(TransactionTestCase):
             with connection.cursor() as cursor:
                 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')
     def test_foreign_data_wrapper(self):
         with connection.cursor() as cursor: