#34871 closed Bug (fixed)
Validation of UniqueConstraint with Case() crashes.
| Reported by: | Andrew Roberts | Owned by: | Simon Charette | 
|---|---|---|---|
| Component: | Database layer (models, ORM) | Version: | 4.2 | 
| Severity: | Normal | Keywords: | |
| Cc: | Gagaro, Simon Charette, Václav Řehák, Mark Gensler | Triage Stage: | Ready for checkin | 
| Has patch: | yes | Needs documentation: | no | 
| Needs tests: | no | Patch needs improvement: | no | 
| Easy pickings: | no | UI/UX: | no | 
Description
Consider the following model where we want to guarantee that there is only one active profile per email address:
class Profile(models.Model):
    active = models.BooleanField()
    email = models.CharField(max_length=255)
    class Meta:
        constraints = [
            models.UniqueConstraint(
                Case(When(active=True, then=F("email"))),
                name="unique_active_email",
            )
        ]
        app_label = "test"
Model validation indirectly calls constraint.validate(), but to simulate this we can execute:
constraint = Profile._meta.constraints[0] constraint.validate(Profile, Profile(active=True, email="test@example.com"))
This fails with:
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[21], line 1
----> 1 constraint.validate(Profile, Profile(active=True, email="test@example.com"))
File /usr/local/lib/python3.11/site-packages/django/db/models/constraints.py:346, in UniqueConstraint.validate(self, model, instance, exclude, using)
    344         if isinstance(expr, OrderBy):
    345             expr = expr.expression
--> 346         expressions.append(Exact(expr, expr.replace_expressions(replacements)))
    347     queryset = queryset.filter(*expressions)
    348 model_class_pk = instance._get_pk_val(model._meta)
File /usr/local/lib/python3.11/site-packages/django/db/models/expressions.py:401, in BaseExpression.replace_expressions(self, replacements)
    398 clone = self.copy()
    399 source_expressions = clone.get_source_expressions()
    400 clone.set_source_expressions(
--> 401     [
    402         expr.replace_expressions(replacements) if expr else None
    403         for expr in source_expressions
    404     ]
    405 )
    406 return clone
File /usr/local/lib/python3.11/site-packages/django/db/models/expressions.py:402, in <listcomp>(.0)
    398 clone = self.copy()
    399 source_expressions = clone.get_source_expressions()
    400 clone.set_source_expressions(
    401     [
--> 402         expr.replace_expressions(replacements) if expr else None
    403         for expr in source_expressions
    404     ]
    405 )
    406 return clone
File /usr/local/lib/python3.11/site-packages/django/db/models/expressions.py:401, in BaseExpression.replace_expressions(self, replacements)
    398 clone = self.copy()
    399 source_expressions = clone.get_source_expressions()
    400 clone.set_source_expressions(
--> 401     [
    402         expr.replace_expressions(replacements) if expr else None
    403         for expr in source_expressions
    404     ]
    405 )
    406 return clone
File /usr/local/lib/python3.11/site-packages/django/db/models/expressions.py:402, in <listcomp>(.0)
    398 clone = self.copy()
    399 source_expressions = clone.get_source_expressions()
    400 clone.set_source_expressions(
    401     [
--> 402         expr.replace_expressions(replacements) if expr else None
    403         for expr in source_expressions
    404     ]
    405 )
    406 return clone
AttributeError: 'Q' object has no attribute 'replace_expressions'
      Change History (21)
comment:1 by , 2 years ago
| Cc: | added | 
|---|---|
| Summary: | Validation fails for models with UniqueConstraints that include Case statements → Validation of UniqueConstraint with Case() crashes. | 
| Triage Stage: | Unreviewed → Accepted | 
comment:2 by , 2 years ago
| Owner: | removed | 
|---|---|
| Status: | new → assigned | 
comment:3 by , 2 years ago
| Owner: | removed | 
|---|---|
| Status: | new → assigned | 
comment:4 by , 2 years ago
| Owner: | set to | 
|---|
comment:5 by , 2 years ago
Hi Francesco,  please note that while you're free to assign yourself to the ticket, the design has not yet been discussed on what the best approach is.
comment:7 by , 2 years ago
Replying to David Sanders:
fyi for the uninitiated: #34805 is related to this
This situation is slightly different, as we have a Case() expression that returns a field, not a check for condition uniqueness.
comment:8 by , 21 months ago
Hey all, I'm running into this issue too. Is there any idea on how to work-a-round this issue?
comment:9 by , 21 months ago
I would suggest using a Func instead of Case / When or Q if you really need to achieve the exact SQL that CASE WHEN would generated
Something like
models.UniqueConstraint( Func(F("email")), template="CASE WHEN active = true THEN %(expressions)s END"), name="unique_active_email", )
Or to simply use UniqueConstraint.condition as documented if the backend you use support it which will also incur less storage use.
models.UniqueConstraint( fields=["email"], name="unique_active_email", condition=Q(active=True), )
I think we should spend the time to implement Q.replace_expressions though as it's pretty trivial to do so and would cover for the cases that cannot be expressed using .condition such as CASE WHEN last_name IS NULL THEN first_name ELSE first_name || last_name END.
comment:10 by , 21 months ago
| Cc: | added | 
|---|
Playing a bit with trying to implement Q.replace_expressions I have to retract myself; it's not trivial.
The only solution I can think of is have the validate logic pass a special type of dict as replacement to replace_expression that would default missing key lookups for tuples of the form ('active', True) to query.resolve_lookup_value so it gets turned into Exact(F("active"), True).replace_expression(replacements).
It's doable but certainly not trivial as this branch demonstrate.
I think this has some ties into #24267 where the idea would be that we'd introduce an UnresolvedLookup(BaseExpression) class initialized from __init__(self, field, **lookup) and that would store F(field) in its source expressions and thus allow replace_expressions to take place normally as Q would be storing UnresolvedLookup(key_parts[0], key_parts[1:]) instead of tuple of the form tuple[str, Any] as children.
All UnresolvedLookup would do in its resolve_expressions is what can be found in the trailing part of Query.build_filter today that calls solve_lookup_type, resolve_lookup_value, and build_lookup but it would behave like a normal expression and avoids all the hacks we have in place today that special case Q objects.
Until work towards  can be made the best solution here is to use lookups directly
from django.db.models.lookups import Exact models.UniqueConstraint( Case(When(Exact(F("active", True), then=F("email"))), name="unique_active_email", )
comment:11 by , 21 months ago
I was almost going to ask whether turning Q object into expressions might be a path here, because it seems to borrow a lot from Expression already being "combinable" and implementing a few expression-like attributes. Then I saw your comment about implementing replace_expressions() being non-trivial.
comment:12 by , 21 months ago
Making Q more Expression-like is definitely the way to approach this problem so you're right. The way to make it more like so proposed above would be to have it store Expression-like objects (UnresolvedLookup instances) instead of tuple[str, Any] it its children AKA source expressions.
In other words, having Q(name__lower__exact="david") turned into Q(UnresolvedLookup(F("name"), "lower__exact", "david")) internally and having UnresolvedLookup.resolve_expressions do what Query.build_filter does when dealing with ("name__lower__exact", "david") would make Q more Expression-like and allow methods such as replace_expressions.
comment:13 by , 17 months ago
| Cc: | added | 
|---|
comment:14 by , 16 months ago
| Cc: | added | 
|---|
comment:15 by , 8 months ago
| Has patch: | set | 
|---|---|
| Owner: | changed from to | 
Working on a solution for #36198 paved the way for a less involved than expected solution here.
Both patches can be found in this PR.
comment:16 by , 8 months ago
Marking as needs improvement as I'd like to find a way to avoid duplicating sql.Query.build_lookup logic.
comment:17 by , 8 months ago
| Patch needs improvement: | set | 
|---|
comment:18 by , 3 months ago
| Patch needs improvement: | unset | 
|---|
comment:19 by , 3 months ago
| Triage Stage: | Accepted → Ready for checkin | 
|---|
Thanks for the report.