Opened 5 years ago
Closed 5 years ago
#31639 closed New feature (fixed)
Allow using transforms in F() and OuterRef().
| Reported by: | Baptiste Mispelon | Owned by: | Ian Foote |
|---|---|---|---|
| Component: | Database layer (models, ORM) | Version: | dev |
| Severity: | Normal | Keywords: | |
| Cc: | Baptiste Mispelon, Gordon Wrigley | 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 two models Client and Bill defined like so:
from django.db import models class Client(models.Model): name = models.CharField(max_length=50) class Bill(models.Model): client = models.ForeignKey('Client', on_delete=models.CASCADE, related_name='bills') issued_on = models.DateField()
I was trying to get a Bill queryset where each bill is annotated with the year of the previous bill (same client) and came up with the following code which gave me incorrect results:
from django.db.models import F, Max, Q from django.db.models.functions import ExtractYear filter_Q = Q(client__bills__issued_on__year__lt=F('issued_on__year')) annotation = Max(ExtractYear('client__bills__issued_on'), filter=filter_Q) # Returns wrong annotations (as if the filter=... was not there) Bill.objects.annotate(previous_year=annotation)
However if I use ExtractYear instead of the __year lookup then I get the correct results:
filter_Q = Q(client__bills__issued_on__year__lt=ExtractYear('issued_on'))
I would assume that F('issued_on__year') should be strictly equivalent to ExtractYear('issued_on') but somehow it's not the case here. I believe that's a bug.
Here's a small testcase that summarizes the issue:
from django.db.models import F, Max, Q from django.db.models.functions import ExtractYear from django.test import TestCase from .models import Bill, Client class ReproductionTestCase(TestCase): @classmethod def setUpTestData(cls): c = Client.objects.create(name="Baptiste") c.bills.create(issued_on='2020-01-01') c.bills.create(issued_on='2019-01-01') c.bills.create(issued_on='2019-07-01') c.bills.create(issued_on='2018-01-01') def test_extractyear(self): filter_Q = Q(client__bills__issued_on__year__lt=ExtractYear('issued_on')) annotation = Max(ExtractYear('client__bills__issued_on'), filter=filter_Q) queryset = Bill.objects.annotate(previous_year=annotation).order_by('issued_on') expected = [None, 2018, 2018, 2019] self.assertQuerysetEqual(queryset, expected, transform=lambda bill: bill.previous_year) def test_f_object_and_lookup(self): filter_Q = Q(client__bills__issued_on__year__lt=F('issued_on__year')) annotation = Max(ExtractYear('client__bills__issued_on'), filter=filter_Q) queryset = Bill.objects.annotate(previous_year=annotation).order_by('issued_on') expected = [None, 2018, 2018, 2019] self.assertQuerysetEqual(queryset, expected, transform=lambda bill: bill.previous_year)
Not sure if it's related but while debugging this I came across #29396. Before that change, evaluating the queryset raised an IndexError (both when using ExtractYear and with the __year lookup).
Change History (17)
comment:1 by , 5 years ago
| Summary: | Incorrect result when using __year lookup in aggregation filter → F objects do not error when referencing transforms. |
|---|---|
| Triage Stage: | Unreviewed → Accepted |
comment:2 by , 5 years ago
I personally don't have strong feelings on whether F objects should accept transforms or not.
Raising an error instead of silently dropping them seems like a good fix to me.
I wonder if this relates to #25534 somehow.
comment:3 by , 5 years ago
Yes it's related. Supporting transforms/lookups in the F() expression will automatically fix #25534 (see BaseExpression._parse_expressions()).
comment:4 by , 5 years ago
| Owner: | changed from to |
|---|---|
| Status: | new → assigned |
comment:6 by , 5 years ago
Hey guys - Here is an outline of a brute force solution for raising a ValueError if a lookup is passed into F().
Thought Process
- We init the F() object with a parameter
name - If a lookup value is found in
name, raise ValueError - Otherwise, continue instantiating the F() object as normal
So the next step is figuring out how to check if a lookup value is contained in name.
- Lookup expressions follow the format of
<fieldname>__<lookup> - First, I wanted to raise the error if
namecontained two underscores__but I realized we use double underscores for referencing foreign keys as shown in our example ofclient__bills__issued_on. - Per docs linked below, lookups are ALWAYS evaluated last. So if we split the
namestring on character__, the last portion of that split would be a potential lookup expression. Check if that potential lookup expression exists in Django's actual lookup expressions. I could follow this process to check for transforms withinname
Conclusion
So this technically works but it seems hacky because it won't take into account situations where users define their own custom lookups.
Let me know if I am missing something obvious here. Happy to take hints/direction to help explore the right places.
Thanks!
References
Lookup Positioning Docs https://docs.djangoproject.com/en/3.0/howto/custom-lookups/#how-django-determines-the-lookups-and-transforms-which-are-used
List of All Django Field Lookups https://docs.djangoproject.com/en/3.0/ref/models/querysets/#field-lookups
Code for F() https://github.com/django/django/blob/58a336a674a658c1cda6707fe1cacb56aaed3008/django/db/models/expressions.py#L560
class F(Combinable):
def __init__(self, name):
lookups = {'exact', 'iexact', 'contains', 'icontains', 'in', 'gt',
'gte', 'lt', 'lte', 'startswith', 'istartswith', 'endswith',
'iendswith', 'range', 'date', 'year', 'iso_year', 'month', 'day',
'week', 'week_day', 'quarter', 'time', 'hour', 'minute', 'second',
'isnull', 'regex', 'iregex'}
if name.split("__")[-1] in lookups:
raise ValueError("F() objects do not support lookups")
self.name = name
comment:7 by , 5 years ago
| Summary: | F objects do not error when referencing transforms. → F() and OuterRef() objects do not error when referencing transforms. |
|---|
#31977 is a duplicate for OuterRef() (which is a subclass of F).
comment:8 by , 5 years ago
| Cc: | added |
|---|
comment:9 by , 5 years ago
As a user who has run into this several times in different contexts it really does seem intuitive and expected that transforms and lookups would work in F expressions and the work arounds for them not working are quite clunky.
The current behaviour of silently not working is the worst of all options and I'd take an error over the current behaviour, but having them work would be better.
comment:10 by , 5 years ago
| Has patch: | set |
|---|
While fixing #25534, I've implemented support for transforms in expressions, fixing this too. https://github.com/django/django/pull/13685
comment:11 by , 5 years ago
| Has patch: | unset |
|---|---|
| Summary: | F() and OuterRef() objects do not error when referencing transforms. → Allow using lookup and transforms in F() and OuterRef(). |
| Type: | Bug → New feature |
comment:12 by , 5 years ago
| Has patch: | set |
|---|---|
| Needs documentation: | set |
| Needs tests: | set |
comment:13 by , 5 years ago
| Owner: | changed from to |
|---|
comment:14 by , 5 years ago
| Needs documentation: | unset |
|---|---|
| Needs tests: | unset |
comment:15 by , 5 years ago
| Summary: | Allow using lookup and transforms in F() and OuterRef(). → Allow using transforms in F() and OuterRef(). |
|---|
comment:16 by , 5 years ago
| Triage Stage: | Accepted → Ready for checkin |
|---|
I think the underlying issue here is that
Fobjects simply don't support transforms but silently drops them when resolving toCol.For example, given the models you've provided
Results in the following on SQLite
Notice how the right hand side of the filter
__yeartransform doesn't result in wrapping what comes after the=.It's not an issue with
filter's compilation,.annotateexhibit the same behaviourIn the end, references to transforms via
Fis not supported and whether or not it should be discussed on the mailing list. Accepting the ticket on the basis that we should at least error out in this case.