diff options
Diffstat (limited to 'django/db')
| -rw-r--r-- | django/db/backends/base/features.py | 5 | ||||
| -rw-r--r-- | django/db/backends/base/schema.py | 35 | ||||
| -rw-r--r-- | django/db/backends/postgresql/features.py | 7 | ||||
| -rw-r--r-- | django/db/models/base.py | 23 | ||||
| -rw-r--r-- | django/db/models/constraints.py | 29 |
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. |
