Fixed #31702 -- Added support for PostgreSQL opclasses in UniqueConstraint.
This commit is contained in:
parent
69e0d9c553
commit
7edc6e53a7
|
@ -1092,13 +1092,16 @@ class BaseDatabaseSchemaEditor:
|
||||||
if deferrable == Deferrable.IMMEDIATE:
|
if deferrable == Deferrable.IMMEDIATE:
|
||||||
return ' DEFERRABLE INITIALLY IMMEDIATE'
|
return ' DEFERRABLE INITIALLY IMMEDIATE'
|
||||||
|
|
||||||
def _unique_sql(self, model, fields, name, condition=None, deferrable=None, include=None):
|
def _unique_sql(
|
||||||
|
self, model, fields, name, condition=None, deferrable=None,
|
||||||
|
include=None, opclasses=None,
|
||||||
|
):
|
||||||
if (
|
if (
|
||||||
deferrable and
|
deferrable and
|
||||||
not self.connection.features.supports_deferrable_unique_constraints
|
not self.connection.features.supports_deferrable_unique_constraints
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
if condition or include:
|
if condition or include or opclasses:
|
||||||
# Databases support conditional and covering unique constraints via
|
# Databases support conditional and covering unique constraints via
|
||||||
# a unique index.
|
# a unique index.
|
||||||
sql = self._create_unique_sql(
|
sql = self._create_unique_sql(
|
||||||
|
@ -1107,6 +1110,7 @@ class BaseDatabaseSchemaEditor:
|
||||||
name=name,
|
name=name,
|
||||||
condition=condition,
|
condition=condition,
|
||||||
include=include,
|
include=include,
|
||||||
|
opclasses=opclasses,
|
||||||
)
|
)
|
||||||
if sql:
|
if sql:
|
||||||
self.deferred_sql.append(sql)
|
self.deferred_sql.append(sql)
|
||||||
|
@ -1120,7 +1124,10 @@ class BaseDatabaseSchemaEditor:
|
||||||
'constraint': constraint,
|
'constraint': constraint,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _create_unique_sql(self, model, columns, name=None, condition=None, deferrable=None, include=None):
|
def _create_unique_sql(
|
||||||
|
self, model, columns, name=None, condition=None, deferrable=None,
|
||||||
|
include=None, opclasses=None,
|
||||||
|
):
|
||||||
if (
|
if (
|
||||||
(
|
(
|
||||||
deferrable and
|
deferrable and
|
||||||
|
@ -1139,8 +1146,8 @@ class BaseDatabaseSchemaEditor:
|
||||||
name = IndexName(model._meta.db_table, columns, '_uniq', create_unique_name)
|
name = IndexName(model._meta.db_table, columns, '_uniq', create_unique_name)
|
||||||
else:
|
else:
|
||||||
name = self.quote_name(name)
|
name = self.quote_name(name)
|
||||||
columns = Columns(table, columns, self.quote_name)
|
columns = self._index_columns(table, columns, col_suffixes=(), opclasses=opclasses)
|
||||||
if condition or include:
|
if condition or include or opclasses:
|
||||||
sql = self.sql_create_unique_index
|
sql = self.sql_create_unique_index
|
||||||
else:
|
else:
|
||||||
sql = self.sql_create_unique
|
sql = self.sql_create_unique
|
||||||
|
@ -1154,7 +1161,10 @@ class BaseDatabaseSchemaEditor:
|
||||||
include=self._index_include_sql(model, include),
|
include=self._index_include_sql(model, include),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _delete_unique_sql(self, model, name, condition=None, deferrable=None, include=None):
|
def _delete_unique_sql(
|
||||||
|
self, model, name, condition=None, deferrable=None, include=None,
|
||||||
|
opclasses=None,
|
||||||
|
):
|
||||||
if (
|
if (
|
||||||
(
|
(
|
||||||
deferrable and
|
deferrable and
|
||||||
|
@ -1164,7 +1174,7 @@ class BaseDatabaseSchemaEditor:
|
||||||
(include and not self.connection.features.supports_covering_indexes)
|
(include and not self.connection.features.supports_covering_indexes)
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
if condition or include:
|
if condition or include or opclasses:
|
||||||
sql = self.sql_delete_index
|
sql = self.sql_delete_index
|
||||||
else:
|
else:
|
||||||
sql = self.sql_delete_unique
|
sql = self.sql_delete_unique
|
||||||
|
|
|
@ -77,7 +77,16 @@ class Deferrable(Enum):
|
||||||
|
|
||||||
|
|
||||||
class UniqueConstraint(BaseConstraint):
|
class UniqueConstraint(BaseConstraint):
|
||||||
def __init__(self, *, fields, name, condition=None, deferrable=None, include=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
fields,
|
||||||
|
name,
|
||||||
|
condition=None,
|
||||||
|
deferrable=None,
|
||||||
|
include=None,
|
||||||
|
opclasses=(),
|
||||||
|
):
|
||||||
if not fields:
|
if not fields:
|
||||||
raise ValueError('At least one field is required to define a unique constraint.')
|
raise ValueError('At least one field is required to define a unique constraint.')
|
||||||
if not isinstance(condition, (type(None), Q)):
|
if not isinstance(condition, (type(None), Q)):
|
||||||
|
@ -92,10 +101,18 @@ class UniqueConstraint(BaseConstraint):
|
||||||
)
|
)
|
||||||
if not isinstance(include, (type(None), list, tuple)):
|
if not isinstance(include, (type(None), list, tuple)):
|
||||||
raise ValueError('UniqueConstraint.include must be a list or tuple.')
|
raise ValueError('UniqueConstraint.include must be a list or tuple.')
|
||||||
|
if not isinstance(opclasses, (list, tuple)):
|
||||||
|
raise ValueError('UniqueConstraint.opclasses must be a list or tuple.')
|
||||||
|
if opclasses and len(fields) != len(opclasses):
|
||||||
|
raise ValueError(
|
||||||
|
'UniqueConstraint.fields and UniqueConstraint.opclasses must '
|
||||||
|
'have the same number of elements.'
|
||||||
|
)
|
||||||
self.fields = tuple(fields)
|
self.fields = tuple(fields)
|
||||||
self.condition = condition
|
self.condition = condition
|
||||||
self.deferrable = deferrable
|
self.deferrable = deferrable
|
||||||
self.include = tuple(include) if include else ()
|
self.include = tuple(include) if include else ()
|
||||||
|
self.opclasses = opclasses
|
||||||
super().__init__(name)
|
super().__init__(name)
|
||||||
|
|
||||||
def _get_condition_sql(self, model, schema_editor):
|
def _get_condition_sql(self, model, schema_editor):
|
||||||
|
@ -114,6 +131,7 @@ class UniqueConstraint(BaseConstraint):
|
||||||
return schema_editor._unique_sql(
|
return schema_editor._unique_sql(
|
||||||
model, fields, self.name, condition=condition,
|
model, fields, self.name, condition=condition,
|
||||||
deferrable=self.deferrable, include=include,
|
deferrable=self.deferrable, include=include,
|
||||||
|
opclasses=self.opclasses,
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_sql(self, model, schema_editor):
|
def create_sql(self, model, schema_editor):
|
||||||
|
@ -123,6 +141,7 @@ class UniqueConstraint(BaseConstraint):
|
||||||
return schema_editor._create_unique_sql(
|
return schema_editor._create_unique_sql(
|
||||||
model, fields, self.name, condition=condition,
|
model, fields, self.name, condition=condition,
|
||||||
deferrable=self.deferrable, include=include,
|
deferrable=self.deferrable, include=include,
|
||||||
|
opclasses=self.opclasses,
|
||||||
)
|
)
|
||||||
|
|
||||||
def remove_sql(self, model, schema_editor):
|
def remove_sql(self, model, schema_editor):
|
||||||
|
@ -130,15 +149,16 @@ class UniqueConstraint(BaseConstraint):
|
||||||
include = [model._meta.get_field(field_name).column for field_name in self.include]
|
include = [model._meta.get_field(field_name).column for field_name in self.include]
|
||||||
return schema_editor._delete_unique_sql(
|
return schema_editor._delete_unique_sql(
|
||||||
model, self.name, condition=condition, deferrable=self.deferrable,
|
model, self.name, condition=condition, deferrable=self.deferrable,
|
||||||
include=include,
|
include=include, opclasses=self.opclasses,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<%s: fields=%r name=%r%s%s%s>' % (
|
return '<%s: fields=%r name=%r%s%s%s%s>' % (
|
||||||
self.__class__.__name__, self.fields, self.name,
|
self.__class__.__name__, self.fields, self.name,
|
||||||
'' if self.condition is None else ' condition=%s' % self.condition,
|
'' if self.condition is None else ' condition=%s' % self.condition,
|
||||||
'' if self.deferrable is None else ' deferrable=%s' % self.deferrable,
|
'' if self.deferrable is None else ' deferrable=%s' % self.deferrable,
|
||||||
'' if not self.include else ' include=%s' % repr(self.include),
|
'' if not self.include else ' include=%s' % repr(self.include),
|
||||||
|
'' if not self.opclasses else ' opclasses=%s' % repr(self.opclasses),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
|
@ -148,7 +168,8 @@ class UniqueConstraint(BaseConstraint):
|
||||||
self.fields == other.fields and
|
self.fields == other.fields and
|
||||||
self.condition == other.condition and
|
self.condition == other.condition and
|
||||||
self.deferrable == other.deferrable and
|
self.deferrable == other.deferrable and
|
||||||
self.include == other.include
|
self.include == other.include and
|
||||||
|
self.opclasses == other.opclasses
|
||||||
)
|
)
|
||||||
return super().__eq__(other)
|
return super().__eq__(other)
|
||||||
|
|
||||||
|
@ -161,4 +182,6 @@ class UniqueConstraint(BaseConstraint):
|
||||||
kwargs['deferrable'] = self.deferrable
|
kwargs['deferrable'] = self.deferrable
|
||||||
if self.include:
|
if self.include:
|
||||||
kwargs['include'] = self.include
|
kwargs['include'] = self.include
|
||||||
|
if self.opclasses:
|
||||||
|
kwargs['opclasses'] = self.opclasses
|
||||||
return path, args, kwargs
|
return path, args, kwargs
|
||||||
|
|
|
@ -73,7 +73,7 @@ constraint.
|
||||||
``UniqueConstraint``
|
``UniqueConstraint``
|
||||||
====================
|
====================
|
||||||
|
|
||||||
.. class:: UniqueConstraint(*, fields, name, condition=None, deferrable=None, include=None)
|
.. class:: UniqueConstraint(*, fields, name, condition=None, deferrable=None, include=None, opclasses=())
|
||||||
|
|
||||||
Creates a unique constraint in the database.
|
Creates a unique constraint in the database.
|
||||||
|
|
||||||
|
@ -168,3 +168,24 @@ while fetching data only from the index.
|
||||||
``include`` is supported only on PostgreSQL.
|
``include`` is supported only on PostgreSQL.
|
||||||
|
|
||||||
Non-key columns have the same database restrictions as :attr:`Index.include`.
|
Non-key columns have the same database restrictions as :attr:`Index.include`.
|
||||||
|
|
||||||
|
|
||||||
|
``opclasses``
|
||||||
|
-------------
|
||||||
|
|
||||||
|
.. attribute:: UniqueConstraint.opclasses
|
||||||
|
|
||||||
|
.. versionadded:: 3.2
|
||||||
|
|
||||||
|
The names of the `PostgreSQL operator classes
|
||||||
|
<https://www.postgresql.org/docs/current/indexes-opclass.html>`_ to use for
|
||||||
|
this unique index. If you require a custom operator class, you must provide one
|
||||||
|
for each field in the index.
|
||||||
|
|
||||||
|
For example::
|
||||||
|
|
||||||
|
UniqueConstraint(name='unique_username', fields=['username'], opclasses=['varchar_pattern_ops'])
|
||||||
|
|
||||||
|
creates a unique index on ``username`` using ``varchar_pattern_ops``.
|
||||||
|
|
||||||
|
``opclasses`` are ignored for databases besides PostgreSQL.
|
||||||
|
|
|
@ -196,6 +196,9 @@ Models
|
||||||
attributes allow creating covering indexes and covering unique constraints on
|
attributes allow creating covering indexes and covering unique constraints on
|
||||||
PostgreSQL 11+.
|
PostgreSQL 11+.
|
||||||
|
|
||||||
|
* The new :attr:`.UniqueConstraint.opclasses` attribute allows setting
|
||||||
|
PostgreSQL operator classes.
|
||||||
|
|
||||||
Requests and Responses
|
Requests and Responses
|
||||||
~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
|
|
@ -196,6 +196,20 @@ class UniqueConstraintTests(TestCase):
|
||||||
self.assertEqual(constraint_1, constraint_1)
|
self.assertEqual(constraint_1, constraint_1)
|
||||||
self.assertNotEqual(constraint_1, constraint_2)
|
self.assertNotEqual(constraint_1, constraint_2)
|
||||||
|
|
||||||
|
def test_eq_with_opclasses(self):
|
||||||
|
constraint_1 = models.UniqueConstraint(
|
||||||
|
fields=['foo', 'bar'],
|
||||||
|
name='opclasses',
|
||||||
|
opclasses=['text_pattern_ops', 'varchar_pattern_ops'],
|
||||||
|
)
|
||||||
|
constraint_2 = models.UniqueConstraint(
|
||||||
|
fields=['foo', 'bar'],
|
||||||
|
name='opclasses',
|
||||||
|
opclasses=['varchar_pattern_ops', 'text_pattern_ops'],
|
||||||
|
)
|
||||||
|
self.assertEqual(constraint_1, constraint_1)
|
||||||
|
self.assertNotEqual(constraint_1, constraint_2)
|
||||||
|
|
||||||
def test_repr(self):
|
def test_repr(self):
|
||||||
fields = ['foo', 'bar']
|
fields = ['foo', 'bar']
|
||||||
name = 'unique_fields'
|
name = 'unique_fields'
|
||||||
|
@ -241,6 +255,18 @@ class UniqueConstraintTests(TestCase):
|
||||||
"include=('baz_1', 'baz_2')>",
|
"include=('baz_1', 'baz_2')>",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_repr_with_opclasses(self):
|
||||||
|
constraint = models.UniqueConstraint(
|
||||||
|
fields=['foo', 'bar'],
|
||||||
|
name='opclasses_fields',
|
||||||
|
opclasses=['text_pattern_ops', 'varchar_pattern_ops'],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
repr(constraint),
|
||||||
|
"<UniqueConstraint: fields=('foo', 'bar') name='opclasses_fields' "
|
||||||
|
"opclasses=['text_pattern_ops', 'varchar_pattern_ops']>",
|
||||||
|
)
|
||||||
|
|
||||||
def test_deconstruction(self):
|
def test_deconstruction(self):
|
||||||
fields = ['foo', 'bar']
|
fields = ['foo', 'bar']
|
||||||
name = 'unique_fields'
|
name = 'unique_fields'
|
||||||
|
@ -291,6 +317,20 @@ class UniqueConstraintTests(TestCase):
|
||||||
'include': tuple(include),
|
'include': tuple(include),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def test_deconstruction_with_opclasses(self):
|
||||||
|
fields = ['foo', 'bar']
|
||||||
|
name = 'unique_fields'
|
||||||
|
opclasses = ['varchar_pattern_ops', 'text_pattern_ops']
|
||||||
|
constraint = models.UniqueConstraint(fields=fields, name=name, opclasses=opclasses)
|
||||||
|
path, args, kwargs = constraint.deconstruct()
|
||||||
|
self.assertEqual(path, 'django.db.models.UniqueConstraint')
|
||||||
|
self.assertEqual(args, ())
|
||||||
|
self.assertEqual(kwargs, {
|
||||||
|
'fields': tuple(fields),
|
||||||
|
'name': name,
|
||||||
|
'opclasses': opclasses,
|
||||||
|
})
|
||||||
|
|
||||||
def test_database_constraint(self):
|
def test_database_constraint(self):
|
||||||
with self.assertRaises(IntegrityError):
|
with self.assertRaises(IntegrityError):
|
||||||
UniqueConstraintProduct.objects.create(name=self.p1.name, color=self.p1.color)
|
UniqueConstraintProduct.objects.create(name=self.p1.name, color=self.p1.color)
|
||||||
|
@ -392,3 +432,24 @@ class UniqueConstraintTests(TestCase):
|
||||||
fields=['field'],
|
fields=['field'],
|
||||||
include='other',
|
include='other',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_invalid_opclasses_argument(self):
|
||||||
|
msg = 'UniqueConstraint.opclasses must be a list or tuple.'
|
||||||
|
with self.assertRaisesMessage(ValueError, msg):
|
||||||
|
models.UniqueConstraint(
|
||||||
|
name='uniq_opclasses',
|
||||||
|
fields=['field'],
|
||||||
|
opclasses='jsonb_path_ops',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_opclasses_and_fields_same_length(self):
|
||||||
|
msg = (
|
||||||
|
'UniqueConstraint.fields and UniqueConstraint.opclasses must have '
|
||||||
|
'the same number of elements.'
|
||||||
|
)
|
||||||
|
with self.assertRaisesMessage(ValueError, msg):
|
||||||
|
models.UniqueConstraint(
|
||||||
|
name='uniq_opclasses',
|
||||||
|
fields=['field'],
|
||||||
|
opclasses=['foo', 'bar'],
|
||||||
|
)
|
||||||
|
|
|
@ -4,12 +4,14 @@ from unittest import mock
|
||||||
from django.db import (
|
from django.db import (
|
||||||
IntegrityError, NotSupportedError, connection, transaction,
|
IntegrityError, NotSupportedError, connection, transaction,
|
||||||
)
|
)
|
||||||
from django.db.models import CheckConstraint, Deferrable, F, Func, Q
|
from django.db.models import (
|
||||||
|
CheckConstraint, Deferrable, F, Func, Q, UniqueConstraint,
|
||||||
|
)
|
||||||
from django.test import skipUnlessDBFeature
|
from django.test import skipUnlessDBFeature
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from . import PostgreSQLTestCase
|
from . import PostgreSQLTestCase
|
||||||
from .models import HotelReservation, RangesModel, Room
|
from .models import HotelReservation, RangesModel, Room, Scene
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from django.contrib.postgres.constraints import ExclusionConstraint
|
from django.contrib.postgres.constraints import ExclusionConstraint
|
||||||
|
@ -21,6 +23,13 @@ except ImportError:
|
||||||
|
|
||||||
|
|
||||||
class SchemaTests(PostgreSQLTestCase):
|
class SchemaTests(PostgreSQLTestCase):
|
||||||
|
get_opclass_query = '''
|
||||||
|
SELECT opcname, c.relname FROM pg_opclass AS oc
|
||||||
|
JOIN pg_index as i on oc.oid = ANY(i.indclass)
|
||||||
|
JOIN pg_class as c on c.oid = i.indexrelid
|
||||||
|
WHERE c.relname = %s
|
||||||
|
'''
|
||||||
|
|
||||||
def get_constraints(self, table):
|
def get_constraints(self, table):
|
||||||
"""Get the constraints on the table using a new cursor."""
|
"""Get the constraints on the table using a new cursor."""
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
|
@ -84,6 +93,75 @@ class SchemaTests(PostgreSQLTestCase):
|
||||||
timestamps_inner=(datetime_1, datetime_2),
|
timestamps_inner=(datetime_1, datetime_2),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_opclass(self):
|
||||||
|
constraint = UniqueConstraint(
|
||||||
|
name='test_opclass',
|
||||||
|
fields=['scene'],
|
||||||
|
opclasses=['varchar_pattern_ops'],
|
||||||
|
)
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.add_constraint(Scene, constraint)
|
||||||
|
self.assertIn(constraint.name, self.get_constraints(Scene._meta.db_table))
|
||||||
|
with editor.connection.cursor() as cursor:
|
||||||
|
cursor.execute(self.get_opclass_query, [constraint.name])
|
||||||
|
self.assertEqual(
|
||||||
|
cursor.fetchall(),
|
||||||
|
[('varchar_pattern_ops', constraint.name)],
|
||||||
|
)
|
||||||
|
# Drop the constraint.
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.remove_constraint(Scene, constraint)
|
||||||
|
self.assertNotIn(constraint.name, self.get_constraints(Scene._meta.db_table))
|
||||||
|
|
||||||
|
def test_opclass_multiple_columns(self):
|
||||||
|
constraint = UniqueConstraint(
|
||||||
|
name='test_opclass_multiple',
|
||||||
|
fields=['scene', 'setting'],
|
||||||
|
opclasses=['varchar_pattern_ops', 'text_pattern_ops'],
|
||||||
|
)
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.add_constraint(Scene, constraint)
|
||||||
|
with editor.connection.cursor() as cursor:
|
||||||
|
cursor.execute(self.get_opclass_query, [constraint.name])
|
||||||
|
expected_opclasses = (
|
||||||
|
('varchar_pattern_ops', constraint.name),
|
||||||
|
('text_pattern_ops', constraint.name),
|
||||||
|
)
|
||||||
|
self.assertCountEqual(cursor.fetchall(), expected_opclasses)
|
||||||
|
|
||||||
|
def test_opclass_partial(self):
|
||||||
|
constraint = UniqueConstraint(
|
||||||
|
name='test_opclass_partial',
|
||||||
|
fields=['scene'],
|
||||||
|
opclasses=['varchar_pattern_ops'],
|
||||||
|
condition=Q(setting__contains="Sir Bedemir's Castle"),
|
||||||
|
)
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.add_constraint(Scene, constraint)
|
||||||
|
with editor.connection.cursor() as cursor:
|
||||||
|
cursor.execute(self.get_opclass_query, [constraint.name])
|
||||||
|
self.assertCountEqual(
|
||||||
|
cursor.fetchall(),
|
||||||
|
[('varchar_pattern_ops', constraint.name)],
|
||||||
|
)
|
||||||
|
|
||||||
|
@skipUnlessDBFeature('supports_covering_indexes')
|
||||||
|
def test_opclass_include(self):
|
||||||
|
constraint = UniqueConstraint(
|
||||||
|
name='test_opclass_include',
|
||||||
|
fields=['scene'],
|
||||||
|
opclasses=['varchar_pattern_ops'],
|
||||||
|
include=['setting'],
|
||||||
|
)
|
||||||
|
with connection.schema_editor() as editor:
|
||||||
|
editor.add_constraint(Scene, constraint)
|
||||||
|
with editor.connection.cursor() as cursor:
|
||||||
|
cursor.execute(self.get_opclass_query, [constraint.name])
|
||||||
|
self.assertCountEqual(
|
||||||
|
cursor.fetchall(),
|
||||||
|
[('varchar_pattern_ops', constraint.name)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExclusionConstraintTests(PostgreSQLTestCase):
|
class ExclusionConstraintTests(PostgreSQLTestCase):
|
||||||
def get_constraints(self, table):
|
def get_constraints(self, table):
|
||||||
|
|
Loading…
Reference in New Issue