1
0
mirror of https://github.com/django/django.git synced 2024-12-22 17:16:24 +00:00

Fixed #35275 -- Fixed Meta.constraints validation crash on UniqueConstraint with OpClass().

This also introduces Expression.constraint_validation_compatible that
allows specifying that expression should be ignored during a constraint
validation.
This commit is contained in:
Mariusz Felisiak 2024-05-12 20:10:55 +02:00 committed by Sarah Boyce
parent ceaf1e2848
commit f030236a86
8 changed files with 61 additions and 11 deletions

View File

@ -1,6 +1,5 @@
from types import NoneType from types import NoneType
from django.contrib.postgres.indexes import OpClass
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import DEFAULT_DB_ALIAS, NotSupportedError from django.db import DEFAULT_DB_ALIAS, NotSupportedError
from django.db.backends.ddl_references import Expressions, Statement, Table from django.db.backends.ddl_references import Expressions, Statement, Table
@ -208,12 +207,10 @@ class ExclusionConstraint(BaseConstraint):
if isinstance(expr, F) and expr.name in exclude: if isinstance(expr, F) and expr.name in exclude:
return return
rhs_expression = expression.replace_expressions(replacements) rhs_expression = expression.replace_expressions(replacements)
# Remove OpClass because it only has sense during the constraint if hasattr(expression, "get_expression_for_validation"):
# creation. expression = expression.get_expression_for_validation()
if isinstance(expression, OpClass): if hasattr(rhs_expression, "get_expression_for_validation"):
expression = expression.get_source_expressions()[0] rhs_expression = rhs_expression.get_expression_for_validation()
if isinstance(rhs_expression, OpClass):
rhs_expression = rhs_expression.get_source_expressions()[0]
lookup = PostgresOperatorLookup(lhs=expression, rhs=rhs_expression) lookup = PostgresOperatorLookup(lhs=expression, rhs=rhs_expression)
lookup.postgres_operator = operator lookup.postgres_operator = operator
lookups.append(lookup) lookups.append(lookup)

View File

@ -244,6 +244,7 @@ class SpGistIndex(PostgresIndex):
class OpClass(Func): class OpClass(Func):
template = "%(expressions)s %(name)s" template = "%(expressions)s %(name)s"
constraint_validation_compatible = False
def __init__(self, expression, name): def __init__(self, expression, name):
super().__init__(expression, name=name) super().__init__(expression, name=name)

View File

@ -6,7 +6,7 @@ from django.core import checks
from django.core.exceptions import FieldDoesNotExist, FieldError, ValidationError from django.core.exceptions import FieldDoesNotExist, FieldError, ValidationError
from django.db import connections from django.db import connections
from django.db.models.constants import LOOKUP_SEP from django.db.models.constants import LOOKUP_SEP
from django.db.models.expressions import Exists, ExpressionList, F, OrderBy, RawSQL from django.db.models.expressions import Exists, ExpressionList, F, RawSQL
from django.db.models.indexes import IndexExpression from django.db.models.indexes import IndexExpression
from django.db.models.lookups import Exact from django.db.models.lookups import Exact
from django.db.models.query_utils import Q from django.db.models.query_utils import Q
@ -644,9 +644,8 @@ class UniqueConstraint(BaseConstraint):
} }
expressions = [] expressions = []
for expr in self.expressions: for expr in self.expressions:
# Ignore ordering. if hasattr(expr, "get_expression_for_validation"):
if isinstance(expr, OrderBy): expr = expr.get_expression_for_validation()
expr = expr.expression
expressions.append(Exact(expr, expr.replace_expressions(replacements))) expressions.append(Exact(expr, expr.replace_expressions(replacements)))
queryset = queryset.filter(*expressions) queryset = queryset.filter(*expressions)
model_class_pk = instance._get_pk_val(model._meta) model_class_pk = instance._get_pk_val(model._meta)

View File

@ -180,6 +180,8 @@ class BaseExpression:
window_compatible = False window_compatible = False
# Can the expression be used as a database default value? # Can the expression be used as a database default value?
allowed_default = False allowed_default = False
# Can the expression be used during a constraint validation?
constraint_validation_compatible = True
def __init__(self, output_field=None): def __init__(self, output_field=None):
if output_field is not None: if output_field is not None:
@ -484,6 +486,20 @@ class BaseExpression:
return self.output_field.select_format(compiler, sql, params) return self.output_field.select_format(compiler, sql, params)
return sql, params return sql, params
def get_expression_for_validation(self):
# Ignore expressions that cannot be used during a constraint validation.
if not getattr(self, "constraint_validation_compatible", True):
try:
(expression,) = self.get_source_expressions()
except ValueError as e:
raise ValueError(
"Expressions with constraint_validation_compatible set to False "
"must have only one source expression."
) from e
else:
return expression
return self
@deconstructible @deconstructible
class Expression(BaseExpression, Combinable): class Expression(BaseExpression, Combinable):
@ -1716,6 +1732,7 @@ class Exists(Subquery):
class OrderBy(Expression): class OrderBy(Expression):
template = "%(expression)s %(ordering)s" template = "%(expression)s %(ordering)s"
conditional = False conditional = False
constraint_validation_compatible = False
def __init__(self, expression, descending=False, nulls_first=None, nulls_last=None): def __init__(self, expression, descending=False, nulls_first=None, nulls_last=None):
if nulls_first and nulls_last: if nulls_first and nulls_last:

View File

@ -1058,6 +1058,15 @@ calling the appropriate methods on the wrapped expression.
Tells Django that this expression can be used in Tells Django that this expression can be used in
:attr:`Field.db_default`. Defaults to ``False``. :attr:`Field.db_default`. Defaults to ``False``.
.. attribute:: constraint_validation_compatible
.. versionadded:: 5.1
Tells Django that this expression can be used during a constraint
validation. Expressions with ``constraint_validation_compatible`` set
to ``False`` must have only one source expression. Defaults to
``True``.
.. attribute:: contains_aggregate .. attribute:: contains_aggregate
Tells Django that this expression contains an aggregate and that a Tells Django that this expression contains an aggregate and that a

View File

@ -281,6 +281,10 @@ Models
reload a model's value. This can be used to lock the row before reloading or reload a model's value. This can be used to lock the row before reloading or
to select related objects. to select related objects.
* The new :attr:`.Expression.constraint_validation_compatible` attribute allows
specifying that the expression should be ignored during a constraint
validation.
Requests and Responses Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~

View File

@ -1425,6 +1425,16 @@ class SimpleExpressionTests(SimpleTestCase):
hash(Expression(TestModel._meta.get_field("other_field"))), hash(Expression(TestModel._meta.get_field("other_field"))),
) )
def test_get_expression_for_validation_only_one_source_expression(self):
expression = Expression()
expression.constraint_validation_compatible = False
msg = (
"Expressions with constraint_validation_compatible set to False must have "
"only one source expression."
)
with self.assertRaisesMessage(ValueError, msg):
expression.get_expression_for_validation()
class ExpressionsNumericTests(TestCase): class ExpressionsNumericTests(TestCase):
@classmethod @classmethod

View File

@ -266,6 +266,19 @@ class SchemaTests(PostgreSQLTestCase):
self.assertNotIn(constraint.name, self.get_constraints(Scene._meta.db_table)) self.assertNotIn(constraint.name, self.get_constraints(Scene._meta.db_table))
Scene.objects.create(scene="ScEnE 10", setting="Sir Bedemir's Castle") Scene.objects.create(scene="ScEnE 10", setting="Sir Bedemir's Castle")
def test_opclass_func_validate_constraints(self):
constraint_name = "test_opclass_func_validate_constraints"
constraint = UniqueConstraint(
OpClass(Lower("scene"), name="text_pattern_ops"),
name="test_opclass_func_validate_constraints",
)
Scene.objects.create(scene="First scene")
# Non-unique scene.
msg = f"Constraint “{constraint_name}” is violated."
with self.assertRaisesMessage(ValidationError, msg):
constraint.validate(Scene, Scene(scene="first Scene"))
constraint.validate(Scene, Scene(scene="second Scene"))
class ExclusionConstraintTests(PostgreSQLTestCase): class ExclusionConstraintTests(PostgreSQLTestCase):
def get_constraints(self, table): def get_constraints(self, table):