diff --git a/AUTHORS b/AUTHORS index 7365ff8788..dbc414f5a3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -470,6 +470,7 @@ answer newbie questions, and generally made Django that much better: Luke Plant Maciej Fijalkowski Maciej Wiśniowski + Mads Jensen Makoto Tsuyuki Malcolm Tredinnick Manuel Saelices diff --git a/django/contrib/postgres/indexes.py b/django/contrib/postgres/indexes.py index 5382c15e1a..a2bd3918c6 100644 --- a/django/contrib/postgres/indexes.py +++ b/django/contrib/postgres/indexes.py @@ -1,8 +1,40 @@ from __future__ import unicode_literals -from django.db.models import Index +from django.db.models.indexes import Index -__all__ = ['GinIndex'] +__all__ = ['BrinIndex', 'GinIndex'] + + +class BrinIndex(Index): + suffix = 'brin' + + def __init__(self, fields=[], name=None, pages_per_range=None): + if pages_per_range is not None and not (isinstance(pages_per_range, int) and pages_per_range > 0): + raise ValueError('pages_per_range must be None or a positive integer for BRIN indexes') + self.pages_per_range = pages_per_range + return super(BrinIndex, self).__init__(fields, name) + + def __repr__(self): + if self.pages_per_range is not None: + return '<%(name)s: fields=%(fields)s, pages_per_range=%(pages_per_range)s>' % { + 'name': self.__class__.__name__, + 'fields': "'{}'".format(', '.join(self.fields)), + 'pages_per_range': self.pages_per_range, + } + else: + return super(BrinIndex, self).__repr__() + + def deconstruct(self): + path, args, kwargs = super(BrinIndex, self).deconstruct() + kwargs['pages_per_range'] = self.pages_per_range + return path, args, kwargs + + def get_sql_create_template_values(self, model, schema_editor, using): + parameters = super(BrinIndex, self).get_sql_create_template_values(model, schema_editor, using=' USING brin') + if self.pages_per_range is not None: + parameters['extra'] = ' WITH (pages_per_range={})'.format( + schema_editor.quote_value(self.pages_per_range)) + parameters['extra'] + return parameters class GinIndex(Index): diff --git a/django/db/backends/postgresql/features.py b/django/db/backends/postgresql/features.py index d80393d399..44c48eb946 100644 --- a/django/db/backends/postgresql/features.py +++ b/django/db/backends/postgresql/features.py @@ -37,6 +37,10 @@ class DatabaseFeatures(BaseDatabaseFeatures): def has_select_for_update_skip_locked(self): return self.connection.pg_version >= 90500 + @cached_property + def has_brin_index_support(self): + return self.connection.pg_version >= 90500 + @cached_property def has_jsonb_datatype(self): return self.connection.pg_version >= 90400 diff --git a/django/db/backends/postgresql/introspection.py b/django/db/backends/postgresql/introspection.py index a5a593c7c9..e9d7d0225f 100644 --- a/django/db/backends/postgresql/introspection.py +++ b/django/db/backends/postgresql/introspection.py @@ -176,13 +176,14 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): (SELECT fkc.relname || '.' || fka.attname FROM pg_attribute AS fka JOIN pg_class AS fkc ON fka.attrelid = fkc.oid - WHERE fka.attrelid = c.confrelid AND fka.attnum = c.confkey[1]) + WHERE fka.attrelid = c.confrelid AND fka.attnum = c.confkey[1]), + cl.reloptions FROM pg_constraint AS c JOIN pg_class AS cl ON c.conrelid = cl.oid JOIN pg_namespace AS ns ON cl.relnamespace = ns.oid WHERE ns.nspname = %s AND cl.relname = %s """, ["public", table_name]) - for constraint, columns, kind, used_cols in cursor.fetchall(): + for constraint, columns, kind, used_cols, options in cursor.fetchall(): constraints[constraint] = { "columns": columns, "primary_key": kind == "p", @@ -191,12 +192,13 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): "check": kind == "c", "index": False, "definition": None, + "options": options, } # Now get indexes cursor.execute(""" SELECT indexname, array_agg(attname), indisunique, indisprimary, - array_agg(ordering), amname, exprdef + array_agg(ordering), amname, exprdef, s2.attoptions FROM ( SELECT c2.relname as indexname, idx.*, attr.attname, am.amname, @@ -209,7 +211,8 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): CASE (option & 1) WHEN 1 THEN 'DESC' ELSE 'ASC' END - END as ordering + END as ordering, + c2.reloptions as attoptions FROM ( SELECT *, unnest(i.indkey) as key, unnest(i.indoption) as option @@ -221,9 +224,9 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): LEFT JOIN pg_attribute attr ON attr.attrelid = c.oid AND attr.attnum = idx.key WHERE c.relname = %s ) s2 - GROUP BY indexname, indisunique, indisprimary, amname, exprdef; + GROUP BY indexname, indisunique, indisprimary, amname, exprdef, attoptions; """, [table_name]) - for index, columns, unique, primary, orders, type_, definition in cursor.fetchall(): + for index, columns, unique, primary, orders, type_, definition, options in cursor.fetchall(): if index not in constraints: constraints[index] = { "columns": columns if columns != [None] else [], @@ -235,5 +238,6 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): "index": True, "type": type_, "definition": definition, + "options": options, } return constraints diff --git a/django/db/models/indexes.py b/django/db/models/indexes.py index c1ce0b90e7..35f84f37de 100644 --- a/django/db/models/indexes.py +++ b/django/db/models/indexes.py @@ -44,7 +44,7 @@ class Index(object): self.name = 'D%s' % self.name[1:] return errors - def create_sql(self, model, schema_editor, using=''): + def get_sql_create_template_values(self, model, schema_editor, using): fields = [model._meta.get_field(field_name) for field_name, order in self.fields_orders] tablespace_sql = schema_editor._get_index_tablespace_sql(model, fields) quote_name = schema_editor.quote_name @@ -52,7 +52,7 @@ class Index(object): ('%s %s' % (quote_name(field.column), order)).strip() for field, (field_name, order) in zip(fields, self.fields_orders) ] - return schema_editor.sql_create_index % { + return { 'table': quote_name(model._meta.db_table), 'name': quote_name(self.name), 'columns': ', '.join(columns), @@ -60,6 +60,11 @@ class Index(object): 'extra': tablespace_sql, } + def create_sql(self, model, schema_editor, using='', parameters=None): + sql_create_index = schema_editor.sql_create_index + sql_parameters = parameters or self.get_sql_create_template_values(model, schema_editor, using) + return sql_create_index % sql_parameters + def remove_sql(self, model, schema_editor): quote_name = schema_editor.quote_name return schema_editor.sql_delete_index % { diff --git a/docs/ref/contrib/postgres/indexes.txt b/docs/ref/contrib/postgres/indexes.txt index 9b8ab5ffa2..0995d1b75e 100644 --- a/docs/ref/contrib/postgres/indexes.txt +++ b/docs/ref/contrib/postgres/indexes.txt @@ -9,6 +9,16 @@ PostgreSQL specific model indexes The following are PostgreSQL specific :doc:`indexes ` available from the ``django.contrib.postgres.indexes`` module. +``BrinIndex`` +============= + +.. class:: BrinIndex(pages_per_range=None) + + Creates a `BRIN index + `_. For + performance considerations and use cases of the index, please consult the + documentation. + ``GinIndex`` ============ diff --git a/docs/releases/1.11.txt b/docs/releases/1.11.txt index e5b0efffef..0e47203549 100644 --- a/docs/releases/1.11.txt +++ b/docs/releases/1.11.txt @@ -212,8 +212,9 @@ Minor features :class:`~django.contrib.postgres.aggregates.StringAgg` determines if concatenated values will be distinct. -* The new :class:`~django.contrib.postgres.indexes.GinIndex` class allows - creating gin indexes in the database. +* The new :class:`~django.contrib.postgres.indexes.GinIndex` and + :class:`~django.contrib.postgres.indexes.BrinIndex` classes allow + creating ``GIN`` and ``BRIN`` indexes in the database. * :class:`~django.contrib.postgres.fields.JSONField` accepts a new ``encoder`` parameter to specify a custom class to encode data types not supported by the diff --git a/tests/postgres_tests/test_indexes.py b/tests/postgres_tests/test_indexes.py index 235d72c532..48b08a2737 100644 --- a/tests/postgres_tests/test_indexes.py +++ b/tests/postgres_tests/test_indexes.py @@ -1,8 +1,45 @@ -from django.contrib.postgres.indexes import GinIndex +from django.contrib.postgres.indexes import BrinIndex, GinIndex from django.db import connection +from django.test import skipUnlessDBFeature from . import PostgreSQLTestCase -from .models import IntegerArrayModel +from .models import CharFieldModel, IntegerArrayModel + + +@skipUnlessDBFeature('has_brin_index_support') +class BrinIndexTests(PostgreSQLTestCase): + + def test_repr(self): + index = BrinIndex(fields=['title'], pages_per_range=4) + another_index = BrinIndex(fields=['title']) + self.assertEqual(repr(index), "") + self.assertEqual(repr(another_index), "") + + def test_not_eq(self): + index = BrinIndex(fields=['title']) + index_with_page_range = BrinIndex(fields=['title'], pages_per_range=16) + self.assertNotEqual(index, index_with_page_range) + + def test_deconstruction(self): + index = BrinIndex(fields=['title'], name='test_title_brin') + path, args, kwargs = index.deconstruct() + self.assertEqual(path, 'django.contrib.postgres.indexes.BrinIndex') + self.assertEqual(args, ()) + self.assertEqual(kwargs, {'fields': ['title'], 'name': 'test_title_brin', 'pages_per_range': None}) + + def test_deconstruction_with_pages_per_rank(self): + index = BrinIndex(fields=['title'], name='test_title_brin', pages_per_range=16) + path, args, kwargs = index.deconstruct() + self.assertEqual(path, 'django.contrib.postgres.indexes.BrinIndex') + self.assertEqual(args, ()) + self.assertEqual(kwargs, {'fields': ['title'], 'name': 'test_title_brin', 'pages_per_range': 16}) + + def test_invalid_pages_per_range(self): + with self.assertRaises(ValueError): + BrinIndex(fields=['title'], name='test_title_brin', pages_per_range='Charles Babbage') + + with self.assertRaises(ValueError): + BrinIndex(fields=['title'], name='test_title_brin', pages_per_range=0) class GinIndexTests(PostgreSQLTestCase): @@ -55,3 +92,16 @@ class SchemaTests(PostgreSQLTestCase): with connection.schema_editor() as editor: editor.remove_index(IntegerArrayModel, index) self.assertNotIn(index_name, self.get_constraints(IntegerArrayModel._meta.db_table)) + + @skipUnlessDBFeature('has_brin_index_support') + def test_brin_index(self): + index_name = 'char_field_model_field_brin' + index = BrinIndex(fields=['field'], name=index_name, pages_per_range=4) + with connection.schema_editor() as editor: + editor.add_index(CharFieldModel, index) + constraints = self.get_constraints(CharFieldModel._meta.db_table) + self.assertEqual(constraints[index_name]['type'], 'brin') + self.assertEqual(constraints[index_name]['options'], ['pages_per_range=4']) + with connection.schema_editor() as editor: + editor.remove_index(CharFieldModel, index) + self.assertNotIn(index_name, self.get_constraints(CharFieldModel._meta.db_table))