Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fixed #25064 -- Allowed empty join columns.
- Loading branch information
Showing
4 changed files
with
169 additions
and
11 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,10 @@ | ||
from .article import ( | ||
Article, ArticleIdea, ArticleTag, ArticleTranslation, NewsArticle, | ||
) | ||
from .empty_join import SlugPage | ||
from .person import Country, Friendship, Group, Membership, Person | ||
|
||
__all__ = [ | ||
'Article', 'ArticleIdea', 'ArticleTag', 'ArticleTranslation', 'Country', | ||
'Friendship', 'Group', 'Membership', 'NewsArticle', 'Person', | ||
'Friendship', 'Group', 'Membership', 'NewsArticle', 'Person', 'SlugPage', | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
from django.db import models | ||
from django.db.models.fields.related import ( | ||
ForeignObjectRel, ForeignRelatedObjectsDescriptor, | ||
) | ||
from django.db.models.lookups import StartsWith | ||
from django.db.models.query_utils import PathInfo | ||
from django.utils.encoding import python_2_unicode_compatible | ||
|
||
|
||
class CustomForeignObjectRel(ForeignObjectRel): | ||
""" | ||
Define some extra Field methods so this Rel acts more like a Field, which | ||
lets us use ForeignRelatedObjectsDescriptor in both directions. | ||
""" | ||
@property | ||
def foreign_related_fields(self): | ||
return tuple(lhs_field for lhs_field, rhs_field in self.field.related_fields) | ||
|
||
def get_attname(self): | ||
return self.name | ||
|
||
|
||
class StartsWithRelation(models.ForeignObject): | ||
""" | ||
A ForeignObject that uses StartsWith operator in its joins instead of | ||
the default equality operator. This is logically a many-to-many relation | ||
and creates a ForeignRelatedObjectsDescriptor in both directions. | ||
""" | ||
auto_created = False | ||
|
||
many_to_many = False | ||
many_to_one = True | ||
one_to_many = False | ||
one_to_one = False | ||
|
||
rel_class = CustomForeignObjectRel | ||
|
||
def __init__(self, *args, **kwargs): | ||
kwargs['on_delete'] = models.DO_NOTHING | ||
super(StartsWithRelation, self).__init__(*args, **kwargs) | ||
|
||
@property | ||
def field(self): | ||
""" | ||
Makes ForeignRelatedObjectsDescriptor work in both directions. | ||
""" | ||
return self.remote_field | ||
|
||
def get_extra_restriction(self, where_class, alias, related_alias): | ||
to_field = self.remote_field.model._meta.get_field(self.to_fields[0]) | ||
from_field = self.model._meta.get_field(self.from_fields[0]) | ||
return StartsWith(to_field.get_col(alias), from_field.get_col(related_alias)) | ||
|
||
def get_joining_columns(self, reverse_join=False): | ||
return tuple() | ||
|
||
def get_path_info(self): | ||
to_opts = self.remote_field.model._meta | ||
from_opts = self.model._meta | ||
return [PathInfo(from_opts, to_opts, (to_opts.pk,), self, False, False)] | ||
|
||
def get_reverse_path_info(self): | ||
to_opts = self.model._meta | ||
from_opts = self.remote_field.model._meta | ||
return [PathInfo(from_opts, to_opts, (to_opts.pk,), self.remote_field, False, False)] | ||
|
||
def contribute_to_class(self, cls, name, virtual_only=False): | ||
super(StartsWithRelation, self).contribute_to_class(cls, name, virtual_only) | ||
setattr(cls, self.name, ForeignRelatedObjectsDescriptor(self)) | ||
|
||
|
||
class BrokenContainsRelation(StartsWithRelation): | ||
""" | ||
This model is designed to yield no join conditions and | ||
raise an exception in ``Join.as_sql()``. | ||
""" | ||
def get_extra_restriction(self, where_class, alias, related_alias): | ||
return None | ||
|
||
|
||
@python_2_unicode_compatible | ||
class SlugPage(models.Model): | ||
slug = models.CharField(max_length=20) | ||
descendants = StartsWithRelation( | ||
'self', | ||
from_fields=['slug'], | ||
to_fields=['slug'], | ||
related_name='ascendants', | ||
) | ||
containers = BrokenContainsRelation( | ||
'self', | ||
from_fields=['slug'], | ||
to_fields=['slug'], | ||
) | ||
|
||
class Meta: | ||
ordering = ['slug'] | ||
|
||
def __str__(self): | ||
return 'SlugPage %s' % self.slug |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
from django.test import TestCase | ||
|
||
from .models import SlugPage | ||
|
||
|
||
class RestrictedConditionsTests(TestCase): | ||
def setUp(self): | ||
slugs = [ | ||
'a', | ||
'a/a', | ||
'a/b', | ||
'a/b/a', | ||
'x', | ||
'x/y/z', | ||
] | ||
SlugPage.objects.bulk_create([SlugPage(slug=slug) for slug in slugs]) | ||
|
||
def test_restrictions_with_no_joining_columns(self): | ||
""" | ||
Test that it's possible to create a working related field that doesn't | ||
use any joining columns, as long as an extra restriction is supplied. | ||
""" | ||
a = SlugPage.objects.get(slug='a') | ||
self.assertListEqual( | ||
[p.slug for p in SlugPage.objects.filter(ascendants=a)], | ||
['a', 'a/a', 'a/b', 'a/b/a'], | ||
) | ||
self.assertEqual( | ||
[p.slug for p in a.descendants.all()], | ||
['a', 'a/a', 'a/b', 'a/b/a'], | ||
) | ||
|
||
aba = SlugPage.objects.get(slug='a/b/a') | ||
self.assertListEqual( | ||
[p.slug for p in SlugPage.objects.filter(descendants__in=[aba])], | ||
['a', 'a/b', 'a/b/a'], | ||
) | ||
self.assertListEqual( | ||
[p.slug for p in aba.ascendants.all()], | ||
['a', 'a/b', 'a/b/a'], | ||
) | ||
|
||
def test_empty_join_conditions(self): | ||
x = SlugPage.objects.get(slug='x') | ||
message = "Join generated an empty ON clause." | ||
with self.assertRaisesMessage(ValueError, message): | ||
list(SlugPage.objects.filter(containers=x)) |