summaryrefslogtreecommitdiff
path: root/django/db
diff options
context:
space:
mode:
Diffstat (limited to 'django/db')
-rw-r--r--django/db/backends/base/features.py5
-rw-r--r--django/db/backends/base/schema.py35
-rw-r--r--django/db/backends/postgresql/features.py7
-rw-r--r--django/db/models/base.py23
-rw-r--r--django/db/models/constraints.py29
5 files changed, 91 insertions, 8 deletions
diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py
index 11dd079110..79abad82cf 100644
--- a/django/db/backends/base/features.py
+++ b/django/db/backends/base/features.py
@@ -27,6 +27,11 @@ 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 supports specifying whether NULL values should be
+ # considered distinct in unique constraints?
+ supports_nulls_distinct_unique_constraints = False
+
# Does the backend support initially deferrable unique constraints?
supports_deferrable_unique_constraints = False
diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py
index 9329ee0971..5fe26d068f 100644
--- a/django/db/backends/base/schema.py
+++ b/django/db/backends/base/schema.py
@@ -129,7 +129,7 @@ class BaseDatabaseSchemaEditor:
)
sql_create_unique_index = (
"CREATE UNIQUE INDEX %(name)s ON %(table)s "
- "(%(columns)s)%(include)s%(condition)s"
+ "(%(columns)s)%(include)s%(condition)s%(nulls_distinct)s"
)
sql_rename_index = "ALTER INDEX %(old_name)s RENAME TO %(new_name)s"
sql_delete_index = "DROP INDEX %(name)s"
@@ -1675,12 +1675,20 @@ class BaseDatabaseSchemaEditor:
if deferrable == Deferrable.IMMEDIATE:
return " DEFERRABLE INITIALLY IMMEDIATE"
+ def _unique_index_nulls_distinct_sql(self, nulls_distinct):
+ if nulls_distinct is False:
+ return " NULLS NOT DISTINCT"
+ elif nulls_distinct is True:
+ return " NULLS DISTINCT"
+ return ""
+
def _unique_supported(
self,
condition=None,
deferrable=None,
include=None,
expressions=None,
+ nulls_distinct=None,
):
return (
(not condition or self.connection.features.supports_partial_indexes)
@@ -1692,6 +1700,10 @@ class BaseDatabaseSchemaEditor:
and (
not expressions or self.connection.features.supports_expression_indexes
)
+ and (
+ nulls_distinct is None
+ or self.connection.features.supports_nulls_distinct_unique_constraints
+ )
)
def _unique_sql(
@@ -1704,17 +1716,26 @@ class BaseDatabaseSchemaEditor:
include=None,
opclasses=None,
expressions=None,
+ nulls_distinct=None,
):
if not self._unique_supported(
condition=condition,
deferrable=deferrable,
include=include,
expressions=expressions,
+ nulls_distinct=nulls_distinct,
):
return None
- if condition or include or opclasses or expressions:
- # Databases support conditional, covering, and functional unique
- # constraints via a unique index.
+
+ if (
+ condition
+ or include
+ or opclasses
+ or expressions
+ or nulls_distinct is not None
+ ):
+ # Databases support conditional, covering, functional unique,
+ # and nulls distinct constraints via a unique index.
sql = self._create_unique_sql(
model,
fields,
@@ -1723,6 +1744,7 @@ class BaseDatabaseSchemaEditor:
include=include,
opclasses=opclasses,
expressions=expressions,
+ nulls_distinct=nulls_distinct,
)
if sql:
self.deferred_sql.append(sql)
@@ -1746,12 +1768,14 @@ class BaseDatabaseSchemaEditor:
include=None,
opclasses=None,
expressions=None,
+ nulls_distinct=None,
):
if not self._unique_supported(
condition=condition,
deferrable=deferrable,
include=include,
expressions=expressions,
+ nulls_distinct=nulls_distinct,
):
return None
@@ -1782,6 +1806,7 @@ class BaseDatabaseSchemaEditor:
condition=self._index_condition_sql(condition),
deferrable=self._deferrable_constraint_sql(deferrable),
include=self._index_include_sql(model, include),
+ nulls_distinct=self._unique_index_nulls_distinct_sql(nulls_distinct),
)
def _unique_constraint_name(self, table, columns, quote=True):
@@ -1804,12 +1829,14 @@ class BaseDatabaseSchemaEditor:
include=None,
opclasses=None,
expressions=None,
+ nulls_distinct=None,
):
if not self._unique_supported(
condition=condition,
deferrable=deferrable,
include=include,
expressions=expressions,
+ nulls_distinct=nulls_distinct,
):
return None
if condition or include or opclasses or expressions:
diff --git a/django/db/backends/postgresql/features.py b/django/db/backends/postgresql/features.py
index 29b6a4f6c5..12dbc71743 100644
--- a/django/db/backends/postgresql/features.py
+++ b/django/db/backends/postgresql/features.py
@@ -132,6 +132,13 @@ class DatabaseFeatures(BaseDatabaseFeatures):
def is_postgresql_14(self):
return self.connection.pg_version >= 140000
+ @cached_property
+ def is_postgresql_15(self):
+ return self.connection.pg_version >= 150000
+
has_bit_xor = property(operator.attrgetter("is_postgresql_14"))
supports_covering_spgist_indexes = property(operator.attrgetter("is_postgresql_14"))
supports_unlimited_charfield = True
+ supports_nulls_distinct_unique_constraints = property(
+ operator.attrgetter("is_postgresql_15")
+ )
diff --git a/django/db/models/base.py b/django/db/models/base.py
index 0711ec0d61..3e9b847b37 100644
--- a/django/db/models/base.py
+++ b/django/db/models/base.py
@@ -2442,6 +2442,29 @@ class Model(AltersData, metaclass=ModelBase):
id="models.W044",
)
)
+ if not (
+ connection.features.supports_nulls_distinct_unique_constraints
+ or (
+ "supports_nulls_distinct_unique_constraints"
+ in cls._meta.required_db_features
+ )
+ ) and any(
+ isinstance(constraint, UniqueConstraint)
+ and constraint.nulls_distinct is not None
+ for constraint in cls._meta.constraints
+ ):
+ errors.append(
+ checks.Warning(
+ "%s does not support unique constraints with "
+ "nulls distinct." % connection.display_name,
+ hint=(
+ "A constraint won't be created. Silence this "
+ "warning if you don't care about it."
+ ),
+ obj=cls,
+ id="models.W047",
+ )
+ )
fields = set(
chain.from_iterable(
(*constraint.fields, *constraint.include)
diff --git a/django/db/models/constraints.py b/django/db/models/constraints.py
index 0df0782b6f..e5136f89f5 100644
--- a/django/db/models/constraints.py
+++ b/django/db/models/constraints.py
@@ -186,6 +186,7 @@ class UniqueConstraint(BaseConstraint):
deferrable=None,
include=None,
opclasses=(),
+ nulls_distinct=None,
violation_error_code=None,
violation_error_message=None,
):
@@ -223,6 +224,8 @@ class UniqueConstraint(BaseConstraint):
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 not isinstance(nulls_distinct, (NoneType, bool)):
+ raise ValueError("UniqueConstraint.nulls_distinct must be a bool.")
if opclasses and len(fields) != len(opclasses):
raise ValueError(
"UniqueConstraint.fields and UniqueConstraint.opclasses must "
@@ -233,6 +236,7 @@ class UniqueConstraint(BaseConstraint):
self.deferrable = deferrable
self.include = tuple(include) if include else ()
self.opclasses = opclasses
+ self.nulls_distinct = nulls_distinct
self.expressions = tuple(
F(expression) if isinstance(expression, str) else expression
for expression in expressions
@@ -284,6 +288,7 @@ class UniqueConstraint(BaseConstraint):
include=include,
opclasses=self.opclasses,
expressions=expressions,
+ nulls_distinct=self.nulls_distinct,
)
def create_sql(self, model, schema_editor):
@@ -302,6 +307,7 @@ class UniqueConstraint(BaseConstraint):
include=include,
opclasses=self.opclasses,
expressions=expressions,
+ nulls_distinct=self.nulls_distinct,
)
def remove_sql(self, model, schema_editor):
@@ -318,10 +324,11 @@ class UniqueConstraint(BaseConstraint):
include=include,
opclasses=self.opclasses,
expressions=expressions,
+ nulls_distinct=self.nulls_distinct,
)
def __repr__(self):
- return "<%s:%s%s%s%s%s%s%s%s%s>" % (
+ return "<%s:%s%s%s%s%s%s%s%s%s%s>" % (
self.__class__.__qualname__,
"" if not self.fields else " fields=%s" % repr(self.fields),
"" if not self.expressions else " expressions=%s" % repr(self.expressions),
@@ -332,6 +339,11 @@ class UniqueConstraint(BaseConstraint):
"" if not self.opclasses else " opclasses=%s" % repr(self.opclasses),
(
""
+ if self.nulls_distinct is None
+ else " nulls_distinct=%r" % self.nulls_distinct
+ ),
+ (
+ ""
if self.violation_error_code is None
else " violation_error_code=%r" % self.violation_error_code
),
@@ -353,6 +365,7 @@ class UniqueConstraint(BaseConstraint):
and self.include == other.include
and self.opclasses == other.opclasses
and self.expressions == other.expressions
+ and self.nulls_distinct is other.nulls_distinct
and self.violation_error_code == other.violation_error_code
and self.violation_error_message == other.violation_error_message
)
@@ -370,6 +383,8 @@ class UniqueConstraint(BaseConstraint):
kwargs["include"] = self.include
if self.opclasses:
kwargs["opclasses"] = self.opclasses
+ if self.nulls_distinct is not None:
+ kwargs["nulls_distinct"] = self.nulls_distinct
return path, self.expressions, kwargs
def validate(self, model, instance, exclude=None, using=DEFAULT_DB_ALIAS):
@@ -381,9 +396,15 @@ class UniqueConstraint(BaseConstraint):
return
field = model._meta.get_field(field_name)
lookup_value = getattr(instance, field.attname)
- if lookup_value is None or (
- lookup_value == ""
- and connections[using].features.interprets_empty_strings_as_nulls
+ if (
+ self.nulls_distinct is not False
+ and lookup_value is None
+ or (
+ lookup_value == ""
+ and connections[
+ using
+ ].features.interprets_empty_strings_as_nulls
+ )
):
# A composite constraint containing NULL value cannot cause
# a violation since NULL != NULL in SQL.