Fixed #20564 -- Generic relations exclude() regression
The patch for #19385 caused a regression in certain generic relations .exclude() filters if a subquery was needed. The fix contains a refactoring to how Query.split_exclude() and Query.trim_start() interact. Thanks to Trac alias nferrari for the report.
This commit is contained in:
parent
8c5b805ca1
commit
31fd64ad8a
|
@ -1422,7 +1422,9 @@ class Query(object):
|
||||||
query.clear_ordering(True)
|
query.clear_ordering(True)
|
||||||
# Try to have as simple as possible subquery -> trim leading joins from
|
# Try to have as simple as possible subquery -> trim leading joins from
|
||||||
# the subquery.
|
# the subquery.
|
||||||
trimmed_joins = query.trim_start(names_with_path)
|
trimmed_prefix, contains_louter = query.trim_start(names_with_path)
|
||||||
|
query.remove_inherited_models()
|
||||||
|
|
||||||
# Add extra check to make sure the selected field will not be null
|
# Add extra check to make sure the selected field will not be null
|
||||||
# since we are adding a IN <subquery> clause. This prevents the
|
# since we are adding a IN <subquery> clause. This prevents the
|
||||||
# database from tripping over IN (...,NULL,...) selects and returning
|
# database from tripping over IN (...,NULL,...) selects and returning
|
||||||
|
@ -1431,38 +1433,20 @@ class Query(object):
|
||||||
alias, col = query.select[0].col
|
alias, col = query.select[0].col
|
||||||
query.where.add((Constraint(alias, col, query.select[0].field), 'isnull', False), AND)
|
query.where.add((Constraint(alias, col, query.select[0].field), 'isnull', False), AND)
|
||||||
|
|
||||||
# Still make sure that the trimmed parts in the inner query and
|
|
||||||
# trimmed prefix are in sync. So, use the trimmed_joins to make sure
|
|
||||||
# as many path elements are in the prefix as there were trimmed joins.
|
|
||||||
# In addition, convert the path elements back to names so that
|
|
||||||
# add_filter() can handle them.
|
|
||||||
trimmed_prefix = []
|
|
||||||
paths_in_prefix = trimmed_joins
|
|
||||||
for name, path in names_with_path:
|
|
||||||
if paths_in_prefix - len(path) < 0:
|
|
||||||
break
|
|
||||||
trimmed_prefix.append(name)
|
|
||||||
paths_in_prefix -= len(path)
|
|
||||||
join_field = path[paths_in_prefix].join_field
|
|
||||||
# TODO: This should be made properly multicolumn
|
|
||||||
# join aware. It is likely better to not use build_filter
|
|
||||||
# at all, instead construct joins up to the correct point,
|
|
||||||
# then construct the needed equality constraint manually,
|
|
||||||
# or maybe using SubqueryConstraint would work, too.
|
|
||||||
# The foreign_related_fields attribute is right here, we
|
|
||||||
# don't ever split joins for direct case.
|
|
||||||
trimmed_prefix.append(
|
|
||||||
join_field.field.foreign_related_fields[0].name)
|
|
||||||
trimmed_prefix = LOOKUP_SEP.join(trimmed_prefix)
|
|
||||||
condition = self.build_filter(
|
condition = self.build_filter(
|
||||||
('%s__in' % trimmed_prefix, query),
|
('%s__in' % trimmed_prefix, query),
|
||||||
current_negated=True, branch_negated=True, can_reuse=can_reuse)
|
current_negated=True, branch_negated=True, can_reuse=can_reuse)
|
||||||
# Intentionally leave the other alias as blank, if the condition
|
if contains_louter:
|
||||||
# refers it, things will break here.
|
or_null_condition = self.build_filter(
|
||||||
extra_restriction = join_field.get_extra_restriction(
|
('%s__isnull' % trimmed_prefix, True),
|
||||||
self.where_class, None, [t for t in query.tables if query.alias_refcount[t]][0])
|
current_negated=True, branch_negated=True, can_reuse=can_reuse)
|
||||||
if extra_restriction:
|
condition.add(or_null_condition, OR)
|
||||||
query.where.add(extra_restriction, 'AND')
|
# Note that the end result will be:
|
||||||
|
# (outercol NOT IN innerq AND outercol IS NOT NULL) OR outercol IS NULL.
|
||||||
|
# This might look crazy but due to how IN works, this seems to be
|
||||||
|
# correct. If the IS NOT NULL check is removed then outercol NOT
|
||||||
|
# IN will return UNKNOWN. If the IS NULL check is removed, then if
|
||||||
|
# outercol IS NULL we will not match the row.
|
||||||
return condition
|
return condition
|
||||||
|
|
||||||
def set_empty(self):
|
def set_empty(self):
|
||||||
|
@ -1821,35 +1805,58 @@ class Query(object):
|
||||||
def trim_start(self, names_with_path):
|
def trim_start(self, names_with_path):
|
||||||
"""
|
"""
|
||||||
Trims joins from the start of the join path. The candidates for trim
|
Trims joins from the start of the join path. The candidates for trim
|
||||||
are the PathInfos in names_with_path structure. Outer joins are not
|
are the PathInfos in names_with_path structure that are m2m joins.
|
||||||
eligible for removal. Also sets the select column so the start
|
|
||||||
matches the join.
|
|
||||||
|
|
||||||
This method is mostly useful for generating the subquery joins & col
|
Also sets the select column so the start matches the join.
|
||||||
in "WHERE somecol IN (subquery)". This construct is needed by
|
|
||||||
split_exclude().
|
This method is meant to be used for generating the subquery joins &
|
||||||
|
cols in split_exclude().
|
||||||
|
|
||||||
|
Returns a lookup usable for doing outerq.filter(lookup=self). Returns
|
||||||
|
also if the joins in the prefix contain a LEFT OUTER join.
|
||||||
_"""
|
_"""
|
||||||
all_paths = []
|
all_paths = []
|
||||||
for _, paths in names_with_path:
|
for _, paths in names_with_path:
|
||||||
all_paths.extend(paths)
|
all_paths.extend(paths)
|
||||||
direct_join = True
|
contains_louter = False
|
||||||
for pos, path in enumerate(all_paths):
|
for pos, path in enumerate(all_paths):
|
||||||
if self.alias_map[self.tables[pos + 1]].join_type == self.LOUTER:
|
if path.m2m:
|
||||||
direct_join = False
|
|
||||||
pos -= 1
|
|
||||||
break
|
break
|
||||||
|
if self.alias_map[self.tables[pos + 1]].join_type == self.LOUTER:
|
||||||
|
contains_louter = True
|
||||||
self.unref_alias(self.tables[pos])
|
self.unref_alias(self.tables[pos])
|
||||||
if path.direct:
|
# The path.join_field is a Rel, lets get the other side's field
|
||||||
direct_join = not direct_join
|
join_field = path.join_field.field
|
||||||
join_side = 0 if direct_join else 1
|
# Build the filter prefix.
|
||||||
select_alias = self.tables[pos + 1]
|
trimmed_prefix = []
|
||||||
join_field = path.join_field
|
paths_in_prefix = pos
|
||||||
if hasattr(join_field, 'field'):
|
for name, path in names_with_path:
|
||||||
join_field = join_field.field
|
if paths_in_prefix - len(path) < 0:
|
||||||
select_fields = [r[join_side] for r in join_field.related_fields]
|
break
|
||||||
|
trimmed_prefix.append(name)
|
||||||
|
paths_in_prefix -= len(path)
|
||||||
|
trimmed_prefix.append(
|
||||||
|
join_field.foreign_related_fields[0].name)
|
||||||
|
trimmed_prefix = LOOKUP_SEP.join(trimmed_prefix)
|
||||||
|
# Lets still see if we can trim the first join from the inner query
|
||||||
|
# (that is, self). We can't do this for LEFT JOINs because we would
|
||||||
|
# miss those rows that have nothing on the outer side.
|
||||||
|
if self.alias_map[self.tables[pos + 1]].join_type != self.LOUTER:
|
||||||
|
select_fields = [r[0] for r in join_field.related_fields]
|
||||||
|
select_alias = self.tables[pos + 1]
|
||||||
|
self.unref_alias(self.tables[pos])
|
||||||
|
extra_restriction = join_field.get_extra_restriction(
|
||||||
|
self.where_class, None, self.tables[pos + 1])
|
||||||
|
if extra_restriction:
|
||||||
|
self.where.add(extra_restriction, AND)
|
||||||
|
else:
|
||||||
|
# TODO: It might be possible to trim more joins from the start of the
|
||||||
|
# inner query if it happens to have a longer join chain containing the
|
||||||
|
# values in select_fields. Lets punt this one for now.
|
||||||
|
select_fields = [r[1] for r in join_field.related_fields]
|
||||||
|
select_alias = self.tables[pos]
|
||||||
self.select = [SelectInfo((select_alias, f.column), f) for f in select_fields]
|
self.select = [SelectInfo((select_alias, f.column), f) for f in select_fields]
|
||||||
self.remove_inherited_models()
|
return trimmed_prefix, contains_louter
|
||||||
return pos
|
|
||||||
|
|
||||||
def is_nullable(self, field):
|
def is_nullable(self, field):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -131,3 +131,27 @@ class HasLinks(models.Model):
|
||||||
|
|
||||||
class HasLinkThing(HasLinks):
|
class HasLinkThing(HasLinks):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class A(models.Model):
|
||||||
|
flag = models.NullBooleanField()
|
||||||
|
content_type = models.ForeignKey(ContentType)
|
||||||
|
object_id = models.PositiveIntegerField()
|
||||||
|
content_object = generic.GenericForeignKey('content_type', 'object_id')
|
||||||
|
|
||||||
|
class B(models.Model):
|
||||||
|
a = generic.GenericRelation(A)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('id',)
|
||||||
|
|
||||||
|
class C(models.Model):
|
||||||
|
b = models.ForeignKey(B)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('id',)
|
||||||
|
|
||||||
|
class D(models.Model):
|
||||||
|
b = models.ForeignKey(B, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('id',)
|
||||||
|
|
|
@ -5,7 +5,7 @@ from django.test import TestCase, skipIfDBFeature
|
||||||
from .models import (
|
from .models import (
|
||||||
Address, Place, Restaurant, Link, CharLink, TextLink,
|
Address, Place, Restaurant, Link, CharLink, TextLink,
|
||||||
Person, Contact, Note, Organization, OddRelation1, OddRelation2, Company,
|
Person, Contact, Note, Organization, OddRelation1, OddRelation2, Company,
|
||||||
Developer, Team, Guild, Tag, Board, HasLinkThing)
|
Developer, Team, Guild, Tag, Board, HasLinkThing, A, B, C, D)
|
||||||
|
|
||||||
|
|
||||||
class GenericRelationTests(TestCase):
|
class GenericRelationTests(TestCase):
|
||||||
|
@ -156,3 +156,59 @@ class GenericRelationTests(TestCase):
|
||||||
self.assertQuerysetEqual(
|
self.assertQuerysetEqual(
|
||||||
HasLinkThing.objects.exclude(links=l1),
|
HasLinkThing.objects.exclude(links=l1),
|
||||||
[hs2], lambda x: x)
|
[hs2], lambda x: x)
|
||||||
|
|
||||||
|
def test_ticket_20564(self):
|
||||||
|
b1 = B.objects.create()
|
||||||
|
b2 = B.objects.create()
|
||||||
|
b3 = B.objects.create()
|
||||||
|
c1 = C.objects.create(b=b1)
|
||||||
|
c2 = C.objects.create(b=b2)
|
||||||
|
c3 = C.objects.create(b=b3)
|
||||||
|
A.objects.create(flag=None, content_object=b1)
|
||||||
|
A.objects.create(flag=True, content_object=b2)
|
||||||
|
self.assertQuerysetEqual(
|
||||||
|
C.objects.filter(b__a__flag=None),
|
||||||
|
[c1, c3], lambda x: x
|
||||||
|
)
|
||||||
|
self.assertQuerysetEqual(
|
||||||
|
C.objects.exclude(b__a__flag=None),
|
||||||
|
[c2], lambda x: x
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ticket_20564_nullable_fk(self):
|
||||||
|
b1 = B.objects.create()
|
||||||
|
b2 = B.objects.create()
|
||||||
|
b3 = B.objects.create()
|
||||||
|
d1 = D.objects.create(b=b1)
|
||||||
|
d2 = D.objects.create(b=b2)
|
||||||
|
d3 = D.objects.create(b=b3)
|
||||||
|
d4 = D.objects.create()
|
||||||
|
A.objects.create(flag=None, content_object=b1)
|
||||||
|
A.objects.create(flag=True, content_object=b1)
|
||||||
|
A.objects.create(flag=True, content_object=b2)
|
||||||
|
self.assertQuerysetEqual(
|
||||||
|
D.objects.exclude(b__a__flag=None),
|
||||||
|
[d2], lambda x: x
|
||||||
|
)
|
||||||
|
self.assertQuerysetEqual(
|
||||||
|
D.objects.filter(b__a__flag=None),
|
||||||
|
[d1, d3, d4], lambda x: x
|
||||||
|
)
|
||||||
|
self.assertQuerysetEqual(
|
||||||
|
B.objects.filter(a__flag=None),
|
||||||
|
[b1, b3], lambda x: x
|
||||||
|
)
|
||||||
|
self.assertQuerysetEqual(
|
||||||
|
B.objects.exclude(a__flag=None),
|
||||||
|
[b2], lambda x: x
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_extra_join_condition(self):
|
||||||
|
# A crude check that content_type_id is taken in account in the
|
||||||
|
# join/subquery condition.
|
||||||
|
self.assertIn("content_type_id", str(B.objects.exclude(a__flag=None).query).lower())
|
||||||
|
# No need for any joins - the join from inner query can be trimmed in
|
||||||
|
# this case (but not in the above case as no a objects at all for given
|
||||||
|
# B would then fail).
|
||||||
|
self.assertNotIn(" join ", str(B.objects.exclude(a__flag=True).query).lower())
|
||||||
|
self.assertIn("content_type_id", str(B.objects.exclude(a__flag=True).query).lower())
|
||||||
|
|
Loading…
Reference in New Issue