Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions django/db/backends/base/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class BaseDatabaseFeatures:
# Does the backend allow inserting duplicate rows when a unique_together
# constraint exists and some fields are nullable but not all of them?
supports_partially_nullable_unique_constraints = True
# Does the backend support initially deferrable unique constraints?
supports_deferrable_unique_constraints = False

can_use_chunked_reads = True
can_return_columns_from_insert = False
Expand Down
38 changes: 32 additions & 6 deletions django/db/backends/base/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Columns, ForeignKeyName, IndexName, Statement, Table,
)
from django.db.backends.utils import names_digest, split_identifier
from django.db.models import Index
from django.db.models import Deferrable, Index
from django.db.transaction import TransactionManagementError, atomic
from django.utils import timezone

Expand Down Expand Up @@ -65,15 +65,15 @@ class BaseDatabaseSchemaEditor:
sql_rename_column = "ALTER TABLE %(table)s RENAME COLUMN %(old_column)s TO %(new_column)s"
sql_update_with_default = "UPDATE %(table)s SET %(column)s = %(default)s WHERE %(column)s IS NULL"

sql_unique_constraint = "UNIQUE (%(columns)s)"
sql_unique_constraint = "UNIQUE (%(columns)s)%(deferrable)s"
sql_check_constraint = "CHECK (%(check)s)"
sql_delete_constraint = "ALTER TABLE %(table)s DROP CONSTRAINT %(name)s"
sql_constraint = "CONSTRAINT %(name)s %(constraint)s"

sql_create_check = "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s CHECK (%(check)s)"
sql_delete_check = sql_delete_constraint

sql_create_unique = "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s UNIQUE (%(columns)s)"
sql_create_unique = "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s UNIQUE (%(columns)s)%(deferrable)s"
sql_delete_unique = sql_delete_constraint

sql_create_fk = (
Expand Down Expand Up @@ -1075,7 +1075,20 @@ def create_fk_name(*args, **kwargs):
def _delete_fk_sql(self, model, name):
return self._delete_constraint_sql(self.sql_delete_fk, model, name)

def _unique_sql(self, model, fields, name, condition=None):
def _deferrable_constraint_sql(self, deferrable):
if deferrable is None:
return ''
if deferrable == Deferrable.DEFERRED:
return ' DEFERRABLE INITIALLY DEFERRED'
if deferrable == Deferrable.IMMEDIATE:
return ' DEFERRABLE INITIALLY IMMEDIATE'

def _unique_sql(self, model, fields, name, condition=None, deferrable=None):
if (
deferrable and
not self.connection.features.supports_deferrable_unique_constraints
):
return None
if condition:
# Databases support conditional unique constraints via a unique
# index.
Expand All @@ -1085,13 +1098,20 @@ def _unique_sql(self, model, fields, name, condition=None):
return None
constraint = self.sql_unique_constraint % {
'columns': ', '.join(map(self.quote_name, fields)),
'deferrable': self._deferrable_constraint_sql(deferrable),
}
return self.sql_constraint % {
'name': self.quote_name(name),
'constraint': constraint,
}

def _create_unique_sql(self, model, columns, name=None, condition=None):
def _create_unique_sql(self, model, columns, name=None, condition=None, deferrable=None):
if (
deferrable and
not self.connection.features.supports_deferrable_unique_constraints
):
return None

def create_unique_name(*args, **kwargs):
return self.quote_name(self._create_index_name(*args, **kwargs))

Expand All @@ -1113,9 +1133,15 @@ def create_unique_name(*args, **kwargs):
name=name,
columns=columns,
condition=self._index_condition_sql(condition),
deferrable=self._deferrable_constraint_sql(deferrable),
)

def _delete_unique_sql(self, model, name, condition=None):
def _delete_unique_sql(self, model, name, condition=None, deferrable=None):
if (
deferrable and
not self.connection.features.supports_deferrable_unique_constraints
):
return None
if condition:
return (
self._delete_constraint_sql(self.sql_delete_index, model, name)
Expand Down
10 changes: 9 additions & 1 deletion django/db/backends/oracle/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,17 @@ def wrap_oracle_errors():
# message = 'ORA-02091: transaction rolled back
# 'ORA-02291: integrity constraint (TEST_DJANGOTEST.SYS
# _C00102056) violated - parent key not found'
# or:
# 'ORA-00001: unique constraint (DJANGOTEST.DEFERRABLE_
# PINK_CONSTRAINT) violated
# Convert that case to Django's IntegrityError exception.
x = e.args[0]
if hasattr(x, 'code') and hasattr(x, 'message') and x.code == 2091 and 'ORA-02291' in x.message:
if (
hasattr(x, 'code') and
hasattr(x, 'message') and
x.code == 2091 and
('ORA-02291' in x.message or 'ORA-00001' in x.message)
):
raise IntegrityError(*tuple(e.args))
raise

Expand Down
1 change: 1 addition & 0 deletions django/db/backends/oracle/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
has_native_duration_field = True
can_defer_constraint_checks = True
supports_partially_nullable_unique_constraints = False
supports_deferrable_unique_constraints = True
truncates_names = True
supports_tablespaces = True
supports_sequence_reset = False
Expand Down
1 change: 1 addition & 0 deletions django/db/backends/postgresql/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_aggregate_filter_clause = True
supported_explain_formats = {'JSON', 'TEXT', 'XML', 'YAML'}
validates_explain_options = False # A query will error on invalid options.
supports_deferrable_unique_constraints = True

@cached_property
def is_postgresql_9_6(self):
Expand Down
19 changes: 19 additions & 0 deletions django/db/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1904,6 +1904,25 @@ def _check_constraints(cls, databases):
id='models.W036',
)
)
if not (
connection.features.supports_deferrable_unique_constraints or
'supports_deferrable_unique_constraints' in cls._meta.required_db_features
) and any(
isinstance(constraint, UniqueConstraint) and constraint.deferrable is not None
for constraint in cls._meta.constraints
):
errors.append(
checks.Warning(
'%s does not support deferrable unique constraints.'
% connection.display_name,
hint=(
"A constraint won't be created. Silence this "
"warning if you don't care about it."
),
obj=cls,
id='models.W038',
)
)
return errors


Expand Down
42 changes: 35 additions & 7 deletions django/db/models/constraints.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from enum import Enum

from django.db.models.query_utils import Q
from django.db.models.sql.query import Query

__all__ = ['CheckConstraint', 'UniqueConstraint']
__all__ = ['CheckConstraint', 'Deferrable', 'UniqueConstraint']


class BaseConstraint:
Expand Down Expand Up @@ -69,14 +71,28 @@ def deconstruct(self):
return path, args, kwargs


class Deferrable(Enum):
DEFERRED = 'deferred'
IMMEDIATE = 'immediate'


class UniqueConstraint(BaseConstraint):
def __init__(self, *, fields, name, condition=None):
def __init__(self, *, fields, name, condition=None, deferrable=None):
if not fields:
raise ValueError('At least one field is required to define a unique constraint.')
if not isinstance(condition, (type(None), Q)):
raise ValueError('UniqueConstraint.condition must be a Q instance.')
if condition and deferrable:
raise ValueError(
'UniqueConstraint with conditions cannot be deferred.'
)
if not isinstance(deferrable, (type(None), Deferrable)):
raise ValueError(
'UniqueConstraint.deferrable must be a Deferrable instance.'
)
self.fields = tuple(fields)
self.condition = condition
self.deferrable = deferrable
super().__init__(name)

def _get_condition_sql(self, model, schema_editor):
Expand All @@ -91,29 +107,39 @@ def _get_condition_sql(self, model, schema_editor):
def constraint_sql(self, model, schema_editor):
fields = [model._meta.get_field(field_name).column for field_name in self.fields]
condition = self._get_condition_sql(model, schema_editor)
return schema_editor._unique_sql(model, fields, self.name, condition=condition)
return schema_editor._unique_sql(
model, fields, self.name, condition=condition,
deferrable=self.deferrable,
)

def create_sql(self, model, schema_editor):
fields = [model._meta.get_field(field_name).column for field_name in self.fields]
condition = self._get_condition_sql(model, schema_editor)
return schema_editor._create_unique_sql(model, fields, self.name, condition=condition)
return schema_editor._create_unique_sql(
model, fields, self.name, condition=condition,
deferrable=self.deferrable,
)

def remove_sql(self, model, schema_editor):
condition = self._get_condition_sql(model, schema_editor)
return schema_editor._delete_unique_sql(model, self.name, condition=condition)
return schema_editor._delete_unique_sql(
model, self.name, condition=condition, deferrable=self.deferrable,
)

def __repr__(self):
return '<%s: fields=%r name=%r%s>' % (
return '<%s: fields=%r name=%r%s%s>' % (
self.__class__.__name__, self.fields, self.name,
'' if self.condition is None else ' condition=%s' % self.condition,
'' if self.deferrable is None else ' deferrable=%s' % self.deferrable,
)

def __eq__(self, other):
if isinstance(other, UniqueConstraint):
return (
self.name == other.name and
self.fields == other.fields and
self.condition == other.condition
self.condition == other.condition and
self.deferrable == other.deferrable
)
return super().__eq__(other)

Expand All @@ -122,4 +148,6 @@ def deconstruct(self):
kwargs['fields'] = self.fields
if self.condition:
kwargs['condition'] = self.condition
if self.deferrable:
kwargs['deferrable'] = self.deferrable
return path, args, kwargs
2 changes: 2 additions & 0 deletions docs/ref/checks.txt
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,8 @@ Models
* **models.W036**: ``<database>`` does not support unique constraints with
conditions.
* **models.W037**: ``<database>`` does not support indexes with conditions.
* **models.W038**: ``<database>`` does not support deferrable unique
constraints.

Security
--------
Expand Down
34 changes: 33 additions & 1 deletion docs/ref/models/constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ The name of the constraint.
``UniqueConstraint``
====================

.. class:: UniqueConstraint(*, fields, name, condition=None)
.. class:: UniqueConstraint(*, fields, name, condition=None, deferrable=None)

Creates a unique constraint in the database.

Expand Down Expand Up @@ -119,3 +119,35 @@ ensures that each user only has one draft.

These conditions have the same database restrictions as
:attr:`Index.condition`.

``deferrable``
--------------

.. attribute:: UniqueConstraint.deferrable

.. versionadded:: 3.1

Set this parameter to create a deferrable unique constraint. Accepted values
are ``Deferrable.DEFERRED`` or ``Deferrable.IMMEDIATE``. For example::

from django.db.models import Deferrable, UniqueConstraint

UniqueConstraint(
name='unique_order',
fields=['order'],
deferrable=Deferrable.DEFERRED,
)

By default constraints are not deferred. A deferred constraint will not be
enforced until the end of the transaction. An immediate constraint will be
enforced immediately after every command.

.. admonition:: MySQL, MariaDB, and SQLite.

Deferrable unique constraints are ignored on MySQL, MariaDB, and SQLite as
neither supports them.

.. warning::

Deferred unique constraints may lead to a `performance penalty
<https://www.postgresql.org/docs/current/sql-createtable.html#id-1.9.3.85.9.4>`_.
3 changes: 3 additions & 0 deletions docs/releases/3.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,9 @@ Models
<sqlite3.Connection.create_function>` on Python 3.8+. This allows using them
in check constraints and partial indexes.

* The new :attr:`.UniqueConstraint.deferrable` attribute allows creating
deferrable unique constraints.

Pagination
~~~~~~~~~~

Expand Down
22 changes: 22 additions & 0 deletions tests/constraints/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ class Meta:
]


class UniqueConstraintDeferrable(models.Model):
name = models.CharField(max_length=255)
shelf = models.CharField(max_length=31)

class Meta:
required_db_features = {
'supports_deferrable_unique_constraints',
}
constraints = [
models.UniqueConstraint(
fields=['name'],
name='name_init_deferred_uniq',
deferrable=models.Deferrable.DEFERRED,
),
models.UniqueConstraint(
fields=['shelf'],
name='sheld_init_immediate_uniq',
deferrable=models.Deferrable.IMMEDIATE,
),
]


class AbstractModel(models.Model):
age = models.IntegerField()

Expand Down
Loading