Fixed #30581 -- Added support for Meta.constraints validation.
Thanks Simon Charette, Keryn Knight, and Mariusz Felisiak for reviews.
This commit is contained in:
parent
441103a04d
commit
667105877e
|
@ -1,11 +1,13 @@
|
|||
import warnings
|
||||
|
||||
from django.contrib.postgres.indexes import OpClass
|
||||
from django.db import NotSupportedError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import DEFAULT_DB_ALIAS, NotSupportedError
|
||||
from django.db.backends.ddl_references import Expressions, Statement, Table
|
||||
from django.db.models import BaseConstraint, Deferrable, F, Q
|
||||
from django.db.models.expressions import ExpressionList
|
||||
from django.db.models.expressions import Exists, ExpressionList
|
||||
from django.db.models.indexes import IndexExpression
|
||||
from django.db.models.lookups import PostgresOperatorLookup
|
||||
from django.db.models.sql import Query
|
||||
from django.utils.deprecation import RemovedInDjango50Warning
|
||||
|
||||
|
@ -32,6 +34,7 @@ class ExclusionConstraint(BaseConstraint):
|
|||
deferrable=None,
|
||||
include=None,
|
||||
opclasses=(),
|
||||
violation_error_message=None,
|
||||
):
|
||||
if index_type and index_type.lower() not in {"gist", "spgist"}:
|
||||
raise ValueError(
|
||||
|
@ -78,7 +81,7 @@ class ExclusionConstraint(BaseConstraint):
|
|||
category=RemovedInDjango50Warning,
|
||||
stacklevel=2,
|
||||
)
|
||||
super().__init__(name=name)
|
||||
super().__init__(name=name, violation_error_message=violation_error_message)
|
||||
|
||||
def _get_expressions(self, schema_editor, query):
|
||||
expressions = []
|
||||
|
@ -197,3 +200,44 @@ class ExclusionConstraint(BaseConstraint):
|
|||
"" if not self.include else " include=%s" % repr(self.include),
|
||||
"" if not self.opclasses else " opclasses=%s" % repr(self.opclasses),
|
||||
)
|
||||
|
||||
def validate(self, model, instance, exclude=None, using=DEFAULT_DB_ALIAS):
|
||||
queryset = model._default_manager.using(using)
|
||||
replacement_map = instance._get_field_value_map(
|
||||
meta=model._meta, exclude=exclude
|
||||
)
|
||||
lookups = []
|
||||
for idx, (expression, operator) in enumerate(self.expressions):
|
||||
if isinstance(expression, str):
|
||||
expression = F(expression)
|
||||
if isinstance(expression, F):
|
||||
if exclude and expression.name in exclude:
|
||||
return
|
||||
rhs_expression = replacement_map.get(expression.name, expression)
|
||||
else:
|
||||
rhs_expression = expression.replace_references(replacement_map)
|
||||
if exclude:
|
||||
for expr in rhs_expression.flatten():
|
||||
if isinstance(expr, F) and expr.name in exclude:
|
||||
return
|
||||
# Remove OpClass because it only has sense during the constraint
|
||||
# creation.
|
||||
if isinstance(expression, OpClass):
|
||||
expression = expression.get_source_expressions()[0]
|
||||
if isinstance(rhs_expression, OpClass):
|
||||
rhs_expression = rhs_expression.get_source_expressions()[0]
|
||||
lookup = PostgresOperatorLookup(lhs=expression, rhs=rhs_expression)
|
||||
lookup.postgres_operator = operator
|
||||
lookups.append(lookup)
|
||||
queryset = queryset.filter(*lookups)
|
||||
model_class_pk = instance._get_pk_val(model._meta)
|
||||
if not instance._state.adding and model_class_pk is not None:
|
||||
queryset = queryset.exclude(pk=model_class_pk)
|
||||
if not self.condition:
|
||||
if queryset.exists():
|
||||
raise ValidationError(self.get_violation_error_message())
|
||||
else:
|
||||
if (self.condition & Exists(queryset.filter(self.condition))).check(
|
||||
replacement_map, using=using
|
||||
):
|
||||
raise ValidationError(self.get_violation_error_message())
|
||||
|
|
|
@ -28,6 +28,7 @@ from django.db.models import NOT_PROVIDED, ExpressionWrapper, IntegerField, Max,
|
|||
from django.db.models.constants import LOOKUP_SEP
|
||||
from django.db.models.constraints import CheckConstraint, UniqueConstraint
|
||||
from django.db.models.deletion import CASCADE, Collector
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.fields.related import (
|
||||
ForeignObjectRel,
|
||||
OneToOneField,
|
||||
|
@ -1189,6 +1190,16 @@ class Model(metaclass=ModelBase):
|
|||
setattr(self, cachename, obj)
|
||||
return getattr(self, cachename)
|
||||
|
||||
def _get_field_value_map(self, meta, exclude=None):
|
||||
if exclude is None:
|
||||
exclude = set()
|
||||
meta = meta or self._meta
|
||||
return {
|
||||
field.name: Value(getattr(self, field.attname), field)
|
||||
for field in meta.local_concrete_fields
|
||||
if field.name not in exclude
|
||||
}
|
||||
|
||||
def prepare_database_save(self, field):
|
||||
if self.pk is None:
|
||||
raise ValueError(
|
||||
|
@ -1221,7 +1232,7 @@ class Model(metaclass=ModelBase):
|
|||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
def _get_unique_checks(self, exclude=None):
|
||||
def _get_unique_checks(self, exclude=None, include_meta_constraints=False):
|
||||
"""
|
||||
Return a list of checks to perform. Since validate_unique() could be
|
||||
called from a ModelForm, some fields may have been excluded; we can't
|
||||
|
@ -1234,13 +1245,15 @@ class Model(metaclass=ModelBase):
|
|||
unique_checks = []
|
||||
|
||||
unique_togethers = [(self.__class__, self._meta.unique_together)]
|
||||
constraints = [(self.__class__, self._meta.total_unique_constraints)]
|
||||
constraints = []
|
||||
if include_meta_constraints:
|
||||
constraints = [(self.__class__, self._meta.total_unique_constraints)]
|
||||
for parent_class in self._meta.get_parent_list():
|
||||
if parent_class._meta.unique_together:
|
||||
unique_togethers.append(
|
||||
(parent_class, parent_class._meta.unique_together)
|
||||
)
|
||||
if parent_class._meta.total_unique_constraints:
|
||||
if include_meta_constraints and parent_class._meta.total_unique_constraints:
|
||||
constraints.append(
|
||||
(parent_class, parent_class._meta.total_unique_constraints)
|
||||
)
|
||||
|
@ -1251,10 +1264,11 @@ class Model(metaclass=ModelBase):
|
|||
# Add the check if the field isn't excluded.
|
||||
unique_checks.append((model_class, tuple(check)))
|
||||
|
||||
for model_class, model_constraints in constraints:
|
||||
for constraint in model_constraints:
|
||||
if not any(name in exclude for name in constraint.fields):
|
||||
unique_checks.append((model_class, constraint.fields))
|
||||
if include_meta_constraints:
|
||||
for model_class, model_constraints in constraints:
|
||||
for constraint in model_constraints:
|
||||
if not any(name in exclude for name in constraint.fields):
|
||||
unique_checks.append((model_class, constraint.fields))
|
||||
|
||||
# These are checks for the unique_for_<date/year/month>.
|
||||
date_checks = []
|
||||
|
@ -1410,10 +1424,35 @@ class Model(metaclass=ModelBase):
|
|||
params=params,
|
||||
)
|
||||
|
||||
def full_clean(self, exclude=None, validate_unique=True):
|
||||
def get_constraints(self):
|
||||
constraints = [(self.__class__, self._meta.constraints)]
|
||||
for parent_class in self._meta.get_parent_list():
|
||||
if parent_class._meta.constraints:
|
||||
constraints.append((parent_class, parent_class._meta.constraints))
|
||||
return constraints
|
||||
|
||||
def validate_constraints(self, exclude=None):
|
||||
constraints = self.get_constraints()
|
||||
using = router.db_for_write(self.__class__, instance=self)
|
||||
|
||||
errors = {}
|
||||
for model_class, model_constraints in constraints:
|
||||
for constraint in model_constraints:
|
||||
try:
|
||||
constraint.validate(model_class, self, exclude=exclude, using=using)
|
||||
except ValidationError as e:
|
||||
if e.code == "unique" and len(constraint.fields) == 1:
|
||||
errors.setdefault(constraint.fields[0], []).append(e)
|
||||
else:
|
||||
errors = e.update_error_dict(errors)
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
def full_clean(self, exclude=None, validate_unique=True, validate_constraints=True):
|
||||
"""
|
||||
Call clean_fields(), clean(), and validate_unique() on the model.
|
||||
Raise a ValidationError for any errors that occur.
|
||||
Call clean_fields(), clean(), validate_unique(), and
|
||||
validate_constraints() on the model. Raise a ValidationError for any
|
||||
errors that occur.
|
||||
"""
|
||||
errors = {}
|
||||
if exclude is None:
|
||||
|
@ -1443,6 +1482,16 @@ class Model(metaclass=ModelBase):
|
|||
except ValidationError as e:
|
||||
errors = e.update_error_dict(errors)
|
||||
|
||||
# Run constraints checks, but only for fields that passed validation.
|
||||
if validate_constraints:
|
||||
for name in errors:
|
||||
if name != NON_FIELD_ERRORS and name not in exclude:
|
||||
exclude.add(name)
|
||||
try:
|
||||
self.validate_constraints(exclude=exclude)
|
||||
except ValidationError as e:
|
||||
errors = e.update_error_dict(errors)
|
||||
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
|
@ -2339,8 +2388,28 @@ class Model(metaclass=ModelBase):
|
|||
connection.features.supports_table_check_constraints
|
||||
or "supports_table_check_constraints"
|
||||
not in cls._meta.required_db_features
|
||||
) and isinstance(constraint.check, Q):
|
||||
references.update(cls._get_expr_references(constraint.check))
|
||||
):
|
||||
if isinstance(constraint.check, Q):
|
||||
references.update(
|
||||
cls._get_expr_references(constraint.check)
|
||||
)
|
||||
if any(
|
||||
isinstance(expr, RawSQL)
|
||||
for expr in constraint.check.flatten()
|
||||
):
|
||||
errors.append(
|
||||
checks.Warning(
|
||||
f"Check constraint {constraint.name!r} contains "
|
||||
f"RawSQL() expression and won't be validated "
|
||||
f"during the model full_clean().",
|
||||
hint=(
|
||||
"Silence this warning if you don't care about "
|
||||
"it."
|
||||
),
|
||||
obj=cls,
|
||||
id="models.W045",
|
||||
),
|
||||
)
|
||||
for field_name, *lookups in references:
|
||||
# pk is an alias that won't be found by opts.get_field.
|
||||
if field_name != "pk":
|
||||
|
|
|
@ -1,16 +1,25 @@
|
|||
from enum import Enum
|
||||
|
||||
from django.db.models.expressions import ExpressionList, F
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
from django.db import connections
|
||||
from django.db.models.expressions import Exists, ExpressionList, F
|
||||
from django.db.models.indexes import IndexExpression
|
||||
from django.db.models.lookups import Exact
|
||||
from django.db.models.query_utils import Q
|
||||
from django.db.models.sql.query import Query
|
||||
from django.db.utils import DEFAULT_DB_ALIAS
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
__all__ = ["BaseConstraint", "CheckConstraint", "Deferrable", "UniqueConstraint"]
|
||||
|
||||
|
||||
class BaseConstraint:
|
||||
def __init__(self, name):
|
||||
violation_error_message = _("Constraint “%(name)s” is violated.")
|
||||
|
||||
def __init__(self, name, violation_error_message=None):
|
||||
self.name = name
|
||||
if violation_error_message is not None:
|
||||
self.violation_error_message = violation_error_message
|
||||
|
||||
@property
|
||||
def contains_expressions(self):
|
||||
|
@ -25,6 +34,12 @@ class BaseConstraint:
|
|||
def remove_sql(self, model, schema_editor):
|
||||
raise NotImplementedError("This method must be implemented by a subclass.")
|
||||
|
||||
def validate(self, model, instance, exclude=None, using=DEFAULT_DB_ALIAS):
|
||||
raise NotImplementedError("This method must be implemented by a subclass.")
|
||||
|
||||
def get_violation_error_message(self):
|
||||
return self.violation_error_message % {"name": self.name}
|
||||
|
||||
def deconstruct(self):
|
||||
path = "%s.%s" % (self.__class__.__module__, self.__class__.__name__)
|
||||
path = path.replace("django.db.models.constraints", "django.db.models")
|
||||
|
@ -36,13 +51,13 @@ class BaseConstraint:
|
|||
|
||||
|
||||
class CheckConstraint(BaseConstraint):
|
||||
def __init__(self, *, check, name):
|
||||
def __init__(self, *, check, name, violation_error_message=None):
|
||||
self.check = check
|
||||
if not getattr(check, "conditional", False):
|
||||
raise TypeError(
|
||||
"CheckConstraint.check must be a Q instance or boolean expression."
|
||||
)
|
||||
super().__init__(name)
|
||||
super().__init__(name, violation_error_message=violation_error_message)
|
||||
|
||||
def _get_check_sql(self, model, schema_editor):
|
||||
query = Query(model=model, alias_cols=False)
|
||||
|
@ -62,6 +77,14 @@ class CheckConstraint(BaseConstraint):
|
|||
def remove_sql(self, model, schema_editor):
|
||||
return schema_editor._delete_check_sql(model, self.name)
|
||||
|
||||
def validate(self, model, instance, exclude=None, using=DEFAULT_DB_ALIAS):
|
||||
against = instance._get_field_value_map(meta=model._meta, exclude=exclude)
|
||||
try:
|
||||
if not Q(self.check).check(against, using=using):
|
||||
raise ValidationError(self.get_violation_error_message())
|
||||
except FieldError:
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s: check=%s name=%s>" % (
|
||||
self.__class__.__qualname__,
|
||||
|
@ -99,6 +122,7 @@ class UniqueConstraint(BaseConstraint):
|
|||
deferrable=None,
|
||||
include=None,
|
||||
opclasses=(),
|
||||
violation_error_message=None,
|
||||
):
|
||||
if not name:
|
||||
raise ValueError("A unique constraint must be named.")
|
||||
|
@ -148,7 +172,7 @@ class UniqueConstraint(BaseConstraint):
|
|||
F(expression) if isinstance(expression, str) else expression
|
||||
for expression in expressions
|
||||
)
|
||||
super().__init__(name)
|
||||
super().__init__(name, violation_error_message=violation_error_message)
|
||||
|
||||
@property
|
||||
def contains_expressions(self):
|
||||
|
@ -265,3 +289,61 @@ class UniqueConstraint(BaseConstraint):
|
|||
if self.opclasses:
|
||||
kwargs["opclasses"] = self.opclasses
|
||||
return path, self.expressions, kwargs
|
||||
|
||||
def validate(self, model, instance, exclude=None, using=DEFAULT_DB_ALIAS):
|
||||
queryset = model._default_manager.using(using)
|
||||
if self.fields:
|
||||
lookup_kwargs = {}
|
||||
for field_name in self.fields:
|
||||
if exclude and field_name in exclude:
|
||||
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
|
||||
):
|
||||
# A composite constraint containing NULL value cannot cause
|
||||
# a violation since NULL != NULL in SQL.
|
||||
return
|
||||
lookup_kwargs[field.name] = lookup_value
|
||||
queryset = queryset.filter(**lookup_kwargs)
|
||||
else:
|
||||
# Ignore constraints with excluded fields.
|
||||
if exclude:
|
||||
for expression in self.expressions:
|
||||
for expr in expression.flatten():
|
||||
if isinstance(expr, F) and expr.name in exclude:
|
||||
return
|
||||
replacement_map = instance._get_field_value_map(
|
||||
meta=model._meta, exclude=exclude
|
||||
)
|
||||
expressions = [
|
||||
Exact(expr, expr.replace_references(replacement_map))
|
||||
for expr in self.expressions
|
||||
]
|
||||
queryset = queryset.filter(*expressions)
|
||||
model_class_pk = instance._get_pk_val(model._meta)
|
||||
if not instance._state.adding and model_class_pk is not None:
|
||||
queryset = queryset.exclude(pk=model_class_pk)
|
||||
if not self.condition:
|
||||
if queryset.exists():
|
||||
if self.expressions:
|
||||
raise ValidationError(self.get_violation_error_message())
|
||||
# When fields are defined, use the unique_error_message() for
|
||||
# backward compatibility.
|
||||
for model, constraints in instance.get_constraints():
|
||||
for constraint in constraints:
|
||||
if constraint is self:
|
||||
raise ValidationError(
|
||||
instance.unique_error_message(model, self.fields)
|
||||
)
|
||||
else:
|
||||
against = instance._get_field_value_map(meta=model._meta, exclude=exclude)
|
||||
try:
|
||||
if (self.condition & Exists(queryset.filter(self.condition))).check(
|
||||
against, using=using
|
||||
):
|
||||
raise ValidationError(self.get_violation_error_message())
|
||||
except FieldError:
|
||||
pass
|
||||
|
|
|
@ -387,6 +387,18 @@ class BaseExpression:
|
|||
)
|
||||
return clone
|
||||
|
||||
def replace_references(self, references_map):
|
||||
clone = self.copy()
|
||||
clone.set_source_expressions(
|
||||
[
|
||||
references_map.get(expr.name, expr)
|
||||
if isinstance(expr, F)
|
||||
else expr.replace_references(references_map)
|
||||
for expr in self.get_source_expressions()
|
||||
]
|
||||
)
|
||||
return clone
|
||||
|
||||
def copy(self):
|
||||
return copy.copy(self)
|
||||
|
||||
|
|
|
@ -806,7 +806,8 @@ class BaseModelFormSet(BaseFormSet):
|
|||
for form in valid_forms:
|
||||
exclude = form._get_validation_exclusions()
|
||||
unique_checks, date_checks = form.instance._get_unique_checks(
|
||||
exclude=exclude
|
||||
exclude=exclude,
|
||||
include_meta_constraints=True,
|
||||
)
|
||||
all_unique_checks.update(unique_checks)
|
||||
all_date_checks.update(date_checks)
|
||||
|
|
|
@ -395,6 +395,8 @@ Models
|
|||
* **models.W043**: ``<database>`` does not support indexes on expressions.
|
||||
* **models.W044**: ``<database>`` does not support unique constraints on
|
||||
expressions.
|
||||
* **models.W045**: Check constraint ``<constraint>`` contains ``RawSQL()``
|
||||
expression and won't be validated during the model ``full_clean()``.
|
||||
|
||||
Security
|
||||
--------
|
||||
|
|
|
@ -12,7 +12,7 @@ PostgreSQL supports additional data integrity constraints available from the
|
|||
``ExclusionConstraint``
|
||||
=======================
|
||||
|
||||
.. class:: ExclusionConstraint(*, name, expressions, index_type=None, condition=None, deferrable=None, include=None, opclasses=())
|
||||
.. class:: ExclusionConstraint(*, name, expressions, index_type=None, condition=None, deferrable=None, include=None, opclasses=(), violation_error_message=None)
|
||||
|
||||
Creates an exclusion constraint in the database. Internally, PostgreSQL
|
||||
implements exclusion constraints using indexes. The default index type is
|
||||
|
@ -27,6 +27,14 @@ PostgreSQL supports additional data integrity constraints available from the
|
|||
:exc:`~django.db.IntegrityError` is raised. Similarly, when update
|
||||
conflicts with an existing row.
|
||||
|
||||
Exclusion constraints are checked during the :ref:`model validation
|
||||
<validating-objects>`.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
In older versions, exclusion constraints were not checked during model
|
||||
validation.
|
||||
|
||||
``name``
|
||||
--------
|
||||
|
||||
|
@ -165,6 +173,15 @@ creates an exclusion constraint on ``circle`` using ``circle_ops``.
|
|||
:class:`OpClass() <django.contrib.postgres.indexes.OpClass>` in
|
||||
:attr:`~ExclusionConstraint.expressions`.
|
||||
|
||||
``violation_error_message``
|
||||
---------------------------
|
||||
|
||||
.. versionadded:: 4.1
|
||||
|
||||
The error message used when ``ValidationError`` is raised during
|
||||
:ref:`model validation <validating-objects>`. Defaults to
|
||||
:attr:`.BaseConstraint.violation_error_message`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
|
|
|
@ -31,24 +31,21 @@ option.
|
|||
|
||||
.. admonition:: Validation of Constraints
|
||||
|
||||
In general constraints are **not** checked during ``full_clean()``, and do
|
||||
not raise ``ValidationError``\s. Rather you'll get a database integrity
|
||||
error on ``save()``. ``UniqueConstraint``\s without a
|
||||
:attr:`~UniqueConstraint.condition` (i.e. non-partial unique constraints)
|
||||
and :attr:`~UniqueConstraint.expressions` (i.e. non-functional unique
|
||||
constraints) are different in this regard, in that they leverage the
|
||||
existing ``validate_unique()`` logic, and thus enable two-stage validation.
|
||||
In addition to ``IntegrityError`` on ``save()``, ``ValidationError`` is
|
||||
also raised during model validation when the ``UniqueConstraint`` is
|
||||
violated.
|
||||
Constraints are checked during the :ref:`model validation
|
||||
<validating-objects>`.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
In older versions, constraints were not checked during model validation.
|
||||
|
||||
``BaseConstraint``
|
||||
==================
|
||||
|
||||
.. class:: BaseConstraint(name)
|
||||
.. class:: BaseConstraint(name, violation_error_message=None)
|
||||
|
||||
Base class for all constraints. Subclasses must implement
|
||||
``constraint_sql()``, ``create_sql()``, and ``remove_sql()`` methods.
|
||||
``constraint_sql()``, ``create_sql()``, ``remove_sql()`` and
|
||||
``validate()`` methods.
|
||||
|
||||
All constraints have the following parameters in common:
|
||||
|
||||
|
@ -60,10 +57,37 @@ All constraints have the following parameters in common:
|
|||
The name of the constraint. You must always specify a unique name for the
|
||||
constraint.
|
||||
|
||||
``violation_error_message``
|
||||
---------------------------
|
||||
|
||||
.. versionadded:: 4.1
|
||||
|
||||
.. attribute:: BaseConstraint.violation_error_message
|
||||
|
||||
The error message used when ``ValidationError`` is raised during
|
||||
:ref:`model validation <validating-objects>`. Defaults to
|
||||
``"Constraint “%(name)s” is violated."``.
|
||||
|
||||
``validate()``
|
||||
--------------
|
||||
|
||||
.. versionadded:: 4.1
|
||||
|
||||
.. method:: BaseConstraint.validate(model, instance, exclude=None, using=DEFAULT_DB_ALIAS)
|
||||
|
||||
Validates that the constraint, defined on ``model``, is respected on the
|
||||
``instance``. This will do a query on the database to ensure that the
|
||||
constraint is respected. If fields in the ``exclude`` list are needed to
|
||||
validate the constraint, the constraint is ignored.
|
||||
|
||||
Raise a ``ValidationError`` if the constraint is violated.
|
||||
|
||||
This method must be implemented by a subclass.
|
||||
|
||||
``CheckConstraint``
|
||||
===================
|
||||
|
||||
.. class:: CheckConstraint(*, check, name)
|
||||
.. class:: CheckConstraint(*, check, name, violation_error_message=None)
|
||||
|
||||
Creates a check constraint in the database.
|
||||
|
||||
|
@ -78,10 +102,14 @@ specifies the check you want the constraint to enforce.
|
|||
For example, ``CheckConstraint(check=Q(age__gte=18), name='age_gte_18')``
|
||||
ensures the age field is never less than 18.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``violation_error_message`` argument was added.
|
||||
|
||||
``UniqueConstraint``
|
||||
====================
|
||||
|
||||
.. class:: UniqueConstraint(*expressions, fields=(), name=None, condition=None, deferrable=None, include=None, opclasses=())
|
||||
.. class:: UniqueConstraint(*expressions, fields=(), name=None, condition=None, deferrable=None, include=None, opclasses=(), violation_error_message=None)
|
||||
|
||||
Creates a unique constraint in the database.
|
||||
|
||||
|
@ -203,3 +231,21 @@ For example::
|
|||
creates a unique index on ``username`` using ``varchar_pattern_ops``.
|
||||
|
||||
``opclasses`` are ignored for databases besides PostgreSQL.
|
||||
|
||||
``violation_error_message``
|
||||
---------------------------
|
||||
|
||||
.. versionadded:: 4.1
|
||||
|
||||
.. attribute:: UniqueConstraint.violation_error_message
|
||||
|
||||
The error message used when ``ValidationError`` is raised during
|
||||
:ref:`model validation <validating-objects>`. Defaults to
|
||||
:attr:`.BaseConstraint.violation_error_message`.
|
||||
|
||||
This message is *not used* for :class:`UniqueConstraint`\s with
|
||||
:attr:`~UniqueConstraint.fields` and without a
|
||||
:attr:`~UniqueConstraint.condition`. Such :class:`~UniqueConstraint`\s show the
|
||||
same message as constraints defined with
|
||||
:attr:`.Field.unique` or in
|
||||
:attr:`Meta.unique_together <django.db.models.Options.constraints>`.
|
||||
|
|
|
@ -198,9 +198,10 @@ There are three steps involved in validating a model:
|
|||
1. Validate the model fields - :meth:`Model.clean_fields()`
|
||||
2. Validate the model as a whole - :meth:`Model.clean()`
|
||||
3. Validate the field uniqueness - :meth:`Model.validate_unique()`
|
||||
4. Validate the constraints - :meth:`Model.validate_constraints`
|
||||
|
||||
All three steps are performed when you call a model's
|
||||
:meth:`~Model.full_clean()` method.
|
||||
All four steps are performed when you call a model's :meth:`~Model.full_clean`
|
||||
method.
|
||||
|
||||
When you use a :class:`~django.forms.ModelForm`, the call to
|
||||
:meth:`~django.forms.Form.is_valid()` will perform these validation steps for
|
||||
|
@ -210,12 +211,18 @@ need to call a model's :meth:`~Model.full_clean()` method if you plan to handle
|
|||
validation errors yourself, or if you have excluded fields from the
|
||||
:class:`~django.forms.ModelForm` that require validation.
|
||||
|
||||
.. method:: Model.full_clean(exclude=None, validate_unique=True)
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
This method calls :meth:`Model.clean_fields()`, :meth:`Model.clean()`, and
|
||||
:meth:`Model.validate_unique()` (if ``validate_unique`` is ``True``), in that
|
||||
order and raises a :exc:`~django.core.exceptions.ValidationError` that has a
|
||||
``message_dict`` attribute containing errors from all three stages.
|
||||
In older versions, constraints were not checked during the model
|
||||
validation.
|
||||
|
||||
.. method:: Model.full_clean(exclude=None, validate_unique=True, validate_constraints=True)
|
||||
|
||||
This method calls :meth:`Model.clean_fields()`, :meth:`Model.clean()`,
|
||||
:meth:`Model.validate_unique()` (if ``validate_unique`` is ``True``), and
|
||||
:meth:`Model.validate_constraints()` (if ``validate_constraints`` is ``True``)
|
||||
in that order and raises a :exc:`~django.core.exceptions.ValidationError` that
|
||||
has a ``message_dict`` attribute containing errors from all four stages.
|
||||
|
||||
The optional ``exclude`` argument can be used to provide a list of field names
|
||||
that can be excluded from validation and cleaning.
|
||||
|
@ -238,6 +245,10 @@ models. For example::
|
|||
|
||||
The first step ``full_clean()`` performs is to clean each individual field.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``validate_constraints`` argument was added.
|
||||
|
||||
.. method:: Model.clean_fields(exclude=None)
|
||||
|
||||
This method will validate all fields on your model. The optional ``exclude``
|
||||
|
@ -306,7 +317,7 @@ pass a dictionary mapping field names to errors::
|
|||
'pub_date': ValidationError(_('Invalid date.'), code='invalid'),
|
||||
})
|
||||
|
||||
Finally, ``full_clean()`` will check any unique constraints on your model.
|
||||
Then, ``full_clean()`` will check unique constraints on your model.
|
||||
|
||||
.. admonition:: How to raise field-specific validation errors if those fields don't appear in a ``ModelForm``
|
||||
|
||||
|
@ -339,16 +350,40 @@ Finally, ``full_clean()`` will check any unique constraints on your model.
|
|||
|
||||
.. method:: Model.validate_unique(exclude=None)
|
||||
|
||||
This method is similar to :meth:`~Model.clean_fields`, but validates all
|
||||
uniqueness constraints on your model instead of individual field values. The
|
||||
optional ``exclude`` argument allows you to provide a list of field names to
|
||||
exclude from validation. It will raise a
|
||||
This method is similar to :meth:`~Model.clean_fields`, but validates
|
||||
uniqueness constraints defined via :attr:`.Field.unique`,
|
||||
:attr:`.Field.unique_for_date`, :attr:`.Field.unique_for_month`,
|
||||
:attr:`.Field.unique_for_year`, or :attr:`Meta.unique_together
|
||||
<django.db.models.Options.unique_together>` on your model instead of individual
|
||||
field values. The optional ``exclude`` argument allows you to provide a list of
|
||||
field names to exclude from validation. It will raise a
|
||||
:exc:`~django.core.exceptions.ValidationError` if any fields fail validation.
|
||||
|
||||
:class:`~django.db.models.UniqueConstraint`\s defined in the
|
||||
:attr:`Meta.constraints <django.db.models.Options.constraints>` are validated
|
||||
by :meth:`Model.validate_constraints`.
|
||||
|
||||
Note that if you provide an ``exclude`` argument to ``validate_unique()``, any
|
||||
:attr:`~django.db.models.Options.unique_together` constraint involving one of
|
||||
the fields you provided will not be checked.
|
||||
|
||||
Finally, ``full_clean()`` will check any other constraints on your model.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
In older versions, :class:`~django.db.models.UniqueConstraint`\s were
|
||||
validated by ``validate_unique()``.
|
||||
|
||||
.. method:: Model.validate_constraints(exclude=None)
|
||||
|
||||
.. versionadded:: 4.1
|
||||
|
||||
This method validates all constraints defined in
|
||||
:attr:`Meta.constraints <django.db.models.Options.constraints>`. The
|
||||
optional ``exclude`` argument allows you to provide a list of field names to
|
||||
exclude from validation. It will raise a
|
||||
:exc:`~django.core.exceptions.ValidationError` if any constraints fail
|
||||
validation.
|
||||
|
||||
Saving objects
|
||||
==============
|
||||
|
|
|
@ -65,6 +65,15 @@ advantage of developments in the ORM's asynchronous support as it evolves.
|
|||
|
||||
See :ref:`async-queries` for details and limitations.
|
||||
|
||||
Validation of Constraints
|
||||
-------------------------
|
||||
|
||||
:class:`Check <django.db.models.CheckConstraint>`,
|
||||
:class:`unique <django.db.models.UniqueConstraint>`, and :class:`exclusion
|
||||
<django.contrib.postgres.constraints.ExclusionConstraint>` constraints defined
|
||||
in the :attr:`Meta.constraints <django.db.models.Options.constraints>` option
|
||||
are now checked during :ref:`model validation <validating-objects>`.
|
||||
|
||||
.. _csrf-cookie-masked-usage:
|
||||
|
||||
``CSRF_COOKIE_MASKED`` setting
|
||||
|
@ -551,6 +560,10 @@ Miscellaneous
|
|||
* The undocumented ``django.contrib.auth.views.SuccessURLAllowedHostsMixin``
|
||||
mixin is replaced by ``RedirectURLMixin``.
|
||||
|
||||
* :class:`~django.db.models.BaseConstraint` subclasses must implement
|
||||
:meth:`~django.db.models.BaseConstraint.validate` method to allow those
|
||||
constraints to be used for validation.
|
||||
|
||||
.. _deprecated-features-4.1:
|
||||
|
||||
Features deprecated in 4.1
|
||||
|
|
|
@ -38,6 +38,10 @@ class UniqueConstraintProduct(models.Model):
|
|||
]
|
||||
|
||||
|
||||
class ChildUniqueConstraintProduct(UniqueConstraintProduct):
|
||||
pass
|
||||
|
||||
|
||||
class UniqueConstraintConditionProduct(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
color = models.CharField(max_length=32, null=True)
|
||||
|
|
|
@ -10,6 +10,7 @@ from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature
|
|||
|
||||
from .models import (
|
||||
ChildModel,
|
||||
ChildUniqueConstraintProduct,
|
||||
Product,
|
||||
UniqueConstraintConditionProduct,
|
||||
UniqueConstraintDeferrable,
|
||||
|
@ -46,6 +47,24 @@ class BaseConstraintTests(SimpleTestCase):
|
|||
with self.assertRaisesMessage(NotImplementedError, msg):
|
||||
c.remove_sql(None, None)
|
||||
|
||||
def test_validate(self):
|
||||
c = BaseConstraint("name")
|
||||
msg = "This method must be implemented by a subclass."
|
||||
with self.assertRaisesMessage(NotImplementedError, msg):
|
||||
c.validate(None, None)
|
||||
|
||||
def test_default_violation_error_message(self):
|
||||
c = BaseConstraint("name")
|
||||
self.assertEqual(
|
||||
c.get_violation_error_message(), "Constraint “name” is violated."
|
||||
)
|
||||
|
||||
def test_custom_violation_error_message(self):
|
||||
c = BaseConstraint(
|
||||
"base_name", violation_error_message="custom %(name)s message"
|
||||
)
|
||||
self.assertEqual(c.get_violation_error_message(), "custom base_name message")
|
||||
|
||||
|
||||
class CheckConstraintTests(TestCase):
|
||||
def test_eq(self):
|
||||
|
@ -122,16 +141,60 @@ class CheckConstraintTests(TestCase):
|
|||
constraints = get_constraints(ChildModel._meta.db_table)
|
||||
self.assertIn("constraints_childmodel_adult", constraints)
|
||||
|
||||
def test_validate(self):
|
||||
check = models.Q(price__gt=models.F("discounted_price"))
|
||||
constraint = models.CheckConstraint(check=check, name="price")
|
||||
# Invalid product.
|
||||
invalid_product = Product(price=10, discounted_price=42)
|
||||
with self.assertRaises(ValidationError):
|
||||
constraint.validate(Product, invalid_product)
|
||||
with self.assertRaises(ValidationError):
|
||||
constraint.validate(Product, invalid_product, exclude={"unit"})
|
||||
# Fields used by the check constraint are excluded.
|
||||
constraint.validate(Product, invalid_product, exclude={"price"})
|
||||
constraint.validate(Product, invalid_product, exclude={"discounted_price"})
|
||||
constraint.validate(
|
||||
Product,
|
||||
invalid_product,
|
||||
exclude={"discounted_price", "price"},
|
||||
)
|
||||
# Valid product.
|
||||
constraint.validate(Product, Product(price=10, discounted_price=5))
|
||||
|
||||
def test_validate_boolean_expressions(self):
|
||||
constraint = models.CheckConstraint(
|
||||
check=models.expressions.ExpressionWrapper(
|
||||
models.Q(price__gt=500) | models.Q(price__lt=500),
|
||||
output_field=models.BooleanField(),
|
||||
),
|
||||
name="price_neq_500_wrap",
|
||||
)
|
||||
msg = f"Constraint “{constraint.name}” is violated."
|
||||
with self.assertRaisesMessage(ValidationError, msg):
|
||||
constraint.validate(Product, Product(price=500, discounted_price=5))
|
||||
constraint.validate(Product, Product(price=501, discounted_price=5))
|
||||
constraint.validate(Product, Product(price=499, discounted_price=5))
|
||||
|
||||
def test_validate_rawsql_expressions_noop(self):
|
||||
constraint = models.CheckConstraint(
|
||||
check=models.expressions.RawSQL(
|
||||
"price < %s OR price > %s",
|
||||
(500, 500),
|
||||
output_field=models.BooleanField(),
|
||||
),
|
||||
name="price_neq_500_raw",
|
||||
)
|
||||
# RawSQL can not be checked and is always considered valid.
|
||||
constraint.validate(Product, Product(price=500, discounted_price=5))
|
||||
constraint.validate(Product, Product(price=501, discounted_price=5))
|
||||
constraint.validate(Product, Product(price=499, discounted_price=5))
|
||||
|
||||
|
||||
class UniqueConstraintTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.p1, cls.p2 = UniqueConstraintProduct.objects.bulk_create(
|
||||
[
|
||||
UniqueConstraintProduct(name="p1", color="red"),
|
||||
UniqueConstraintProduct(name="p2"),
|
||||
]
|
||||
)
|
||||
cls.p1 = UniqueConstraintProduct.objects.create(name="p1", color="red")
|
||||
cls.p2 = UniqueConstraintProduct.objects.create(name="p2")
|
||||
|
||||
def test_eq(self):
|
||||
self.assertEqual(
|
||||
|
@ -415,15 +478,135 @@ class UniqueConstraintTests(TestCase):
|
|||
with self.assertRaisesMessage(ValidationError, msg):
|
||||
UniqueConstraintProduct(
|
||||
name=self.p1.name, color=self.p1.color
|
||||
).validate_unique()
|
||||
).validate_constraints()
|
||||
|
||||
@skipUnlessDBFeature("supports_partial_indexes")
|
||||
def test_model_validation_with_condition(self):
|
||||
"""Partial unique constraints are ignored by Model.validate_unique()."""
|
||||
"""
|
||||
Partial unique constraints are not ignored by
|
||||
Model.validate_constraints().
|
||||
"""
|
||||
obj1 = UniqueConstraintConditionProduct.objects.create(name="p1", color="red")
|
||||
obj2 = UniqueConstraintConditionProduct.objects.create(name="p2")
|
||||
UniqueConstraintConditionProduct(name=obj1.name, color="blue").validate_unique()
|
||||
UniqueConstraintConditionProduct(name=obj2.name).validate_unique()
|
||||
UniqueConstraintConditionProduct(
|
||||
name=obj1.name, color="blue"
|
||||
).validate_constraints()
|
||||
msg = "Constraint “name_without_color_uniq” is violated."
|
||||
with self.assertRaisesMessage(ValidationError, msg):
|
||||
UniqueConstraintConditionProduct(name=obj2.name).validate_constraints()
|
||||
|
||||
def test_validate(self):
|
||||
constraint = UniqueConstraintProduct._meta.constraints[0]
|
||||
msg = "Unique constraint product with this Name and Color already exists."
|
||||
non_unique_product = UniqueConstraintProduct(
|
||||
name=self.p1.name, color=self.p1.color
|
||||
)
|
||||
with self.assertRaisesMessage(ValidationError, msg):
|
||||
constraint.validate(UniqueConstraintProduct, non_unique_product)
|
||||
# Null values are ignored.
|
||||
constraint.validate(
|
||||
UniqueConstraintProduct,
|
||||
UniqueConstraintProduct(name=self.p2.name, color=None),
|
||||
)
|
||||
# Existing instances have their existing row excluded.
|
||||
constraint.validate(UniqueConstraintProduct, self.p1)
|
||||
# Unique fields are excluded.
|
||||
constraint.validate(
|
||||
UniqueConstraintProduct,
|
||||
non_unique_product,
|
||||
exclude={"name"},
|
||||
)
|
||||
constraint.validate(
|
||||
UniqueConstraintProduct,
|
||||
non_unique_product,
|
||||
exclude={"color"},
|
||||
)
|
||||
constraint.validate(
|
||||
UniqueConstraintProduct,
|
||||
non_unique_product,
|
||||
exclude={"name", "color"},
|
||||
)
|
||||
# Validation on a child instance.
|
||||
with self.assertRaisesMessage(ValidationError, msg):
|
||||
constraint.validate(
|
||||
UniqueConstraintProduct,
|
||||
ChildUniqueConstraintProduct(name=self.p1.name, color=self.p1.color),
|
||||
)
|
||||
|
||||
@skipUnlessDBFeature("supports_partial_indexes")
|
||||
def test_validate_condition(self):
|
||||
p1 = UniqueConstraintConditionProduct.objects.create(name="p1")
|
||||
constraint = UniqueConstraintConditionProduct._meta.constraints[0]
|
||||
msg = "Constraint “name_without_color_uniq” is violated."
|
||||
with self.assertRaisesMessage(ValidationError, msg):
|
||||
constraint.validate(
|
||||
UniqueConstraintConditionProduct,
|
||||
UniqueConstraintConditionProduct(name=p1.name, color=None),
|
||||
)
|
||||
# Values not matching condition are ignored.
|
||||
constraint.validate(
|
||||
UniqueConstraintConditionProduct,
|
||||
UniqueConstraintConditionProduct(name=p1.name, color="anything-but-none"),
|
||||
)
|
||||
# Existing instances have their existing row excluded.
|
||||
constraint.validate(UniqueConstraintConditionProduct, p1)
|
||||
# Unique field is excluded.
|
||||
constraint.validate(
|
||||
UniqueConstraintConditionProduct,
|
||||
UniqueConstraintConditionProduct(name=p1.name, color=None),
|
||||
exclude={"name"},
|
||||
)
|
||||
|
||||
def test_validate_expression(self):
|
||||
constraint = models.UniqueConstraint(Lower("name"), name="name_lower_uniq")
|
||||
msg = "Constraint “name_lower_uniq” is violated."
|
||||
with self.assertRaisesMessage(ValidationError, msg):
|
||||
constraint.validate(
|
||||
UniqueConstraintProduct,
|
||||
UniqueConstraintProduct(name=self.p1.name.upper()),
|
||||
)
|
||||
constraint.validate(
|
||||
UniqueConstraintProduct,
|
||||
UniqueConstraintProduct(name="another-name"),
|
||||
)
|
||||
# Existing instances have their existing row excluded.
|
||||
constraint.validate(UniqueConstraintProduct, self.p1)
|
||||
# Unique field is excluded.
|
||||
constraint.validate(
|
||||
UniqueConstraintProduct,
|
||||
UniqueConstraintProduct(name=self.p1.name.upper()),
|
||||
exclude={"name"},
|
||||
)
|
||||
|
||||
def test_validate_expression_condition(self):
|
||||
constraint = models.UniqueConstraint(
|
||||
Lower("name"),
|
||||
name="name_lower_without_color_uniq",
|
||||
condition=models.Q(color__isnull=True),
|
||||
)
|
||||
non_unique_product = UniqueConstraintProduct(name=self.p2.name.upper())
|
||||
msg = "Constraint “name_lower_without_color_uniq” is violated."
|
||||
with self.assertRaisesMessage(ValidationError, msg):
|
||||
constraint.validate(UniqueConstraintProduct, non_unique_product)
|
||||
# Values not matching condition are ignored.
|
||||
constraint.validate(
|
||||
UniqueConstraintProduct,
|
||||
UniqueConstraintProduct(name=self.p1.name, color=self.p1.color),
|
||||
)
|
||||
# Existing instances have their existing row excluded.
|
||||
constraint.validate(UniqueConstraintProduct, self.p2)
|
||||
# Unique field is excluded.
|
||||
constraint.validate(
|
||||
UniqueConstraintProduct,
|
||||
non_unique_product,
|
||||
exclude={"name"},
|
||||
)
|
||||
# Field from a condition is excluded.
|
||||
constraint.validate(
|
||||
UniqueConstraintProduct,
|
||||
non_unique_product,
|
||||
exclude={"color"},
|
||||
)
|
||||
|
||||
def test_name(self):
|
||||
constraints = get_constraints(UniqueConstraintProduct._meta.db_table)
|
||||
|
|
|
@ -2198,6 +2198,66 @@ class ConstraintsTests(TestCase):
|
|||
]
|
||||
self.assertCountEqual(errors, expected_errors)
|
||||
|
||||
def test_check_constraint_raw_sql_check(self):
|
||||
class Model(models.Model):
|
||||
class Meta:
|
||||
required_db_features = {"supports_table_check_constraints"}
|
||||
constraints = [
|
||||
models.CheckConstraint(check=models.Q(id__gt=0), name="q_check"),
|
||||
models.CheckConstraint(
|
||||
check=models.ExpressionWrapper(
|
||||
models.Q(price__gt=20),
|
||||
output_field=models.BooleanField(),
|
||||
),
|
||||
name="expression_wrapper_check",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=models.expressions.RawSQL(
|
||||
"id = 0",
|
||||
params=(),
|
||||
output_field=models.BooleanField(),
|
||||
),
|
||||
name="raw_sql_check",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=models.Q(
|
||||
models.ExpressionWrapper(
|
||||
models.Q(
|
||||
models.expressions.RawSQL(
|
||||
"id = 0",
|
||||
params=(),
|
||||
output_field=models.BooleanField(),
|
||||
)
|
||||
),
|
||||
output_field=models.BooleanField(),
|
||||
)
|
||||
),
|
||||
name="nested_raw_sql_check",
|
||||
),
|
||||
]
|
||||
|
||||
expected_warnings = (
|
||||
[
|
||||
Warning(
|
||||
"Check constraint 'raw_sql_check' contains RawSQL() expression and "
|
||||
"won't be validated during the model full_clean().",
|
||||
hint="Silence this warning if you don't care about it.",
|
||||
obj=Model,
|
||||
id="models.W045",
|
||||
),
|
||||
Warning(
|
||||
"Check constraint 'nested_raw_sql_check' contains RawSQL() "
|
||||
"expression and won't be validated during the model full_clean().",
|
||||
hint="Silence this warning if you don't care about it.",
|
||||
obj=Model,
|
||||
id="models.W045",
|
||||
),
|
||||
]
|
||||
if connection.features.supports_table_check_constraints
|
||||
else []
|
||||
)
|
||||
self.assertEqual(Model.check(databases=self.databases), expected_warnings)
|
||||
|
||||
def test_unique_constraint_with_condition(self):
|
||||
class Model(models.Model):
|
||||
age = models.IntegerField()
|
||||
|
|
|
@ -2,6 +2,7 @@ import datetime
|
|||
from unittest import mock
|
||||
|
||||
from django.contrib.postgres.indexes import OpClass
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError, NotSupportedError, connection, transaction
|
||||
from django.db.models import (
|
||||
CheckConstraint,
|
||||
|
@ -612,18 +613,26 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
|
|||
timezone.datetime(2018, 6, 28),
|
||||
timezone.datetime(2018, 6, 29),
|
||||
]
|
||||
HotelReservation.objects.create(
|
||||
reservation = HotelReservation.objects.create(
|
||||
datespan=DateRange(datetimes[0].date(), datetimes[1].date()),
|
||||
start=datetimes[0],
|
||||
end=datetimes[1],
|
||||
room=room102,
|
||||
)
|
||||
constraint.validate(HotelReservation, reservation)
|
||||
HotelReservation.objects.create(
|
||||
datespan=DateRange(datetimes[1].date(), datetimes[3].date()),
|
||||
start=datetimes[1],
|
||||
end=datetimes[3],
|
||||
room=room102,
|
||||
)
|
||||
HotelReservation.objects.create(
|
||||
datespan=DateRange(datetimes[3].date(), datetimes[4].date()),
|
||||
start=datetimes[3],
|
||||
end=datetimes[4],
|
||||
room=room102,
|
||||
cancelled=True,
|
||||
)
|
||||
# Overlap dates.
|
||||
with self.assertRaises(IntegrityError), transaction.atomic():
|
||||
reservation = HotelReservation(
|
||||
|
@ -632,33 +641,58 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
|
|||
end=datetimes[2],
|
||||
room=room102,
|
||||
)
|
||||
msg = f"Constraint “{constraint.name}” is violated."
|
||||
with self.assertRaisesMessage(ValidationError, msg):
|
||||
constraint.validate(HotelReservation, reservation)
|
||||
reservation.save()
|
||||
# Valid range.
|
||||
HotelReservation.objects.bulk_create(
|
||||
[
|
||||
# Other room.
|
||||
HotelReservation(
|
||||
datespan=(datetimes[1].date(), datetimes[2].date()),
|
||||
start=datetimes[1],
|
||||
end=datetimes[2],
|
||||
room=room101,
|
||||
),
|
||||
# Cancelled reservation.
|
||||
HotelReservation(
|
||||
datespan=(datetimes[1].date(), datetimes[1].date()),
|
||||
start=datetimes[1],
|
||||
end=datetimes[2],
|
||||
room=room102,
|
||||
cancelled=True,
|
||||
),
|
||||
# Other adjacent dates.
|
||||
HotelReservation(
|
||||
datespan=(datetimes[3].date(), datetimes[4].date()),
|
||||
start=datetimes[3],
|
||||
end=datetimes[4],
|
||||
room=room102,
|
||||
),
|
||||
]
|
||||
other_valid_reservations = [
|
||||
# Other room.
|
||||
HotelReservation(
|
||||
datespan=(datetimes[1].date(), datetimes[2].date()),
|
||||
start=datetimes[1],
|
||||
end=datetimes[2],
|
||||
room=room101,
|
||||
),
|
||||
# Cancelled reservation.
|
||||
HotelReservation(
|
||||
datespan=(datetimes[1].date(), datetimes[1].date()),
|
||||
start=datetimes[1],
|
||||
end=datetimes[2],
|
||||
room=room102,
|
||||
cancelled=True,
|
||||
),
|
||||
# Other adjacent dates.
|
||||
HotelReservation(
|
||||
datespan=(datetimes[3].date(), datetimes[4].date()),
|
||||
start=datetimes[3],
|
||||
end=datetimes[4],
|
||||
room=room102,
|
||||
),
|
||||
]
|
||||
for reservation in other_valid_reservations:
|
||||
constraint.validate(HotelReservation, reservation)
|
||||
HotelReservation.objects.bulk_create(other_valid_reservations)
|
||||
# Excluded fields.
|
||||
constraint.validate(
|
||||
HotelReservation,
|
||||
HotelReservation(
|
||||
datespan=(datetimes[1].date(), datetimes[2].date()),
|
||||
start=datetimes[1],
|
||||
end=datetimes[2],
|
||||
room=room102,
|
||||
),
|
||||
exclude={"room"},
|
||||
)
|
||||
constraint.validate(
|
||||
HotelReservation,
|
||||
HotelReservation(
|
||||
datespan=(datetimes[1].date(), datetimes[2].date()),
|
||||
start=datetimes[1],
|
||||
end=datetimes[2],
|
||||
room=room102,
|
||||
),
|
||||
exclude={"datespan", "start", "end", "room"},
|
||||
)
|
||||
|
||||
@ignore_warnings(category=RemovedInDjango50Warning)
|
||||
|
@ -731,6 +765,21 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
|
|||
constraint_name, self.get_constraints(RangesModel._meta.db_table)
|
||||
)
|
||||
|
||||
def test_validate_range_adjacent(self):
|
||||
constraint = ExclusionConstraint(
|
||||
name="ints_adjacent",
|
||||
expressions=[("ints", RangeOperators.ADJACENT_TO)],
|
||||
violation_error_message="Custom error message.",
|
||||
)
|
||||
range_obj = RangesModel.objects.create(ints=(20, 50))
|
||||
constraint.validate(RangesModel, range_obj)
|
||||
msg = "Custom error message."
|
||||
with self.assertRaisesMessage(ValidationError, msg):
|
||||
constraint.validate(RangesModel, RangesModel(ints=(10, 20)))
|
||||
constraint.validate(RangesModel, RangesModel(ints=(10, 19)))
|
||||
constraint.validate(RangesModel, RangesModel(ints=(51, 60)))
|
||||
constraint.validate(RangesModel, RangesModel(ints=(10, 20)), exclude={"ints"})
|
||||
|
||||
def test_expressions_with_params(self):
|
||||
constraint_name = "scene_left_equal"
|
||||
self.assertNotIn(constraint_name, self.get_constraints(Scene._meta.db_table))
|
||||
|
|
|
@ -161,3 +161,59 @@ class UniqueFuncConstraintModel(models.Model):
|
|||
constraints = [
|
||||
models.UniqueConstraint(Lower("field"), name="func_lower_field_uq"),
|
||||
]
|
||||
|
||||
|
||||
class Product(models.Model):
|
||||
price = models.IntegerField(null=True)
|
||||
discounted_price = models.IntegerField(null=True)
|
||||
|
||||
class Meta:
|
||||
required_db_features = {
|
||||
"supports_table_check_constraints",
|
||||
}
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=models.Q(price__gt=models.F("discounted_price")),
|
||||
name="price_gt_discounted_price_validation",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ChildProduct(Product):
|
||||
class Meta:
|
||||
required_db_features = {
|
||||
"supports_table_check_constraints",
|
||||
}
|
||||
|
||||
|
||||
class UniqueConstraintProduct(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
color = models.CharField(max_length=32)
|
||||
rank = models.IntegerField()
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "color"], name="name_color_uniq_validation"
|
||||
),
|
||||
models.UniqueConstraint(fields=["rank"], name="rank_uniq_validation"),
|
||||
]
|
||||
|
||||
|
||||
class ChildUniqueConstraintProduct(UniqueConstraintProduct):
|
||||
pass
|
||||
|
||||
|
||||
class UniqueConstraintConditionProduct(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
color = models.CharField(max_length=31, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
required_db_features = {"supports_partial_indexes"}
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name"],
|
||||
name="name_without_color_uniq_validation",
|
||||
condition=models.Q(color__isnull=True),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, skipUnlessDBFeature
|
||||
|
||||
from .models import (
|
||||
ChildProduct,
|
||||
ChildUniqueConstraintProduct,
|
||||
Product,
|
||||
UniqueConstraintConditionProduct,
|
||||
UniqueConstraintProduct,
|
||||
)
|
||||
|
||||
|
||||
class PerformConstraintChecksTest(TestCase):
|
||||
@skipUnlessDBFeature("supports_table_check_constraints")
|
||||
def test_full_clean_with_check_constraints(self):
|
||||
product = Product(price=10, discounted_price=15)
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
product.full_clean()
|
||||
self.assertEqual(
|
||||
cm.exception.message_dict,
|
||||
{
|
||||
"__all__": [
|
||||
"Constraint “price_gt_discounted_price_validation” is violated."
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@skipUnlessDBFeature("supports_table_check_constraints")
|
||||
def test_full_clean_with_check_constraints_on_child_model(self):
|
||||
product = ChildProduct(price=10, discounted_price=15)
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
product.full_clean()
|
||||
self.assertEqual(
|
||||
cm.exception.message_dict,
|
||||
{
|
||||
"__all__": [
|
||||
"Constraint “price_gt_discounted_price_validation” is violated."
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@skipUnlessDBFeature("supports_table_check_constraints")
|
||||
def test_full_clean_with_check_constraints_disabled(self):
|
||||
product = Product(price=10, discounted_price=15)
|
||||
product.full_clean(validate_constraints=False)
|
||||
|
||||
def test_full_clean_with_unique_constraints(self):
|
||||
UniqueConstraintProduct.objects.create(name="product", color="yellow", rank=1)
|
||||
tests = [
|
||||
UniqueConstraintProduct(name="product", color="yellow", rank=1),
|
||||
# Child model.
|
||||
ChildUniqueConstraintProduct(name="product", color="yellow", rank=1),
|
||||
]
|
||||
for product in tests:
|
||||
with self.subTest(model=product.__class__.__name__):
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
product.full_clean()
|
||||
self.assertEqual(
|
||||
cm.exception.message_dict,
|
||||
{
|
||||
"__all__": [
|
||||
"Unique constraint product with this Name and Color "
|
||||
"already exists."
|
||||
],
|
||||
"rank": [
|
||||
"Unique constraint product with this Rank already exists."
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def test_full_clean_with_unique_constraints_disabled(self):
|
||||
UniqueConstraintProduct.objects.create(name="product", color="yellow", rank=1)
|
||||
product = UniqueConstraintProduct(name="product", color="yellow", rank=1)
|
||||
product.full_clean(validate_constraints=False)
|
||||
|
||||
@skipUnlessDBFeature("supports_partial_indexes")
|
||||
def test_full_clean_with_partial_unique_constraints(self):
|
||||
UniqueConstraintConditionProduct.objects.create(name="product")
|
||||
product = UniqueConstraintConditionProduct(name="product")
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
product.full_clean()
|
||||
self.assertEqual(
|
||||
cm.exception.message_dict,
|
||||
{
|
||||
"__all__": [
|
||||
"Constraint “name_without_color_uniq_validation” is violated."
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@skipUnlessDBFeature("supports_partial_indexes")
|
||||
def test_full_clean_with_partial_unique_constraints_disabled(self):
|
||||
UniqueConstraintConditionProduct.objects.create(name="product")
|
||||
product = UniqueConstraintConditionProduct(name="product")
|
||||
product.full_clean(validate_constraints=False)
|
|
@ -146,10 +146,6 @@ class PerformUniqueChecksTest(TestCase):
|
|||
mtv = ModelToValidate(number=10, name="Some Name")
|
||||
mtv.full_clean()
|
||||
|
||||
def test_func_unique_check_not_performed(self):
|
||||
with self.assertNumQueries(0):
|
||||
UniqueFuncConstraintModel(field="some name").full_clean()
|
||||
|
||||
def test_unique_for_date(self):
|
||||
Post.objects.create(
|
||||
title="Django 1.0 is released",
|
||||
|
|
Loading…
Reference in New Issue