Commit 98bcdfa8 authored by Alex Hill's avatar Alex Hill Committed by Tim Graham
Browse files

Fixed #25064 -- Allowed empty join columns.

parent f9636fdf
Loading
Loading
Loading
Loading
+20 −10
Original line number Diff line number Diff line
@@ -66,30 +66,40 @@ class Join(object):
           LEFT OUTER JOIN sometable ON sometable.somecol = othertable.othercol, params
        clause for this join.
        """
        join_conditions = []
        params = []
        sql = []
        alias_str = '' if self.table_alias == self.table_name else (' %s' % self.table_alias)
        qn = compiler.quote_name_unless_alias
        qn2 = connection.ops.quote_name
        sql.append('%s %s%s ON (' % (self.join_type, qn(self.table_name), alias_str))

        # Add a join condition for each pair of joining columns.
        for index, (lhs_col, rhs_col) in enumerate(self.join_cols):
            if index != 0:
                sql.append(' AND ')
            sql.append('%s.%s = %s.%s' % (
            join_conditions.append('%s.%s = %s.%s' % (
                qn(self.parent_alias),
                qn2(lhs_col),
                qn(self.table_alias),
                qn2(rhs_col),
            ))

        # Add a single condition inside parentheses for whatever
        # get_extra_restriction() returns.
        extra_cond = self.join_field.get_extra_restriction(
            compiler.query.where_class, self.table_alias, self.parent_alias)
        if extra_cond:
            extra_sql, extra_params = compiler.compile(extra_cond)
            extra_sql = 'AND (%s)' % extra_sql
            join_conditions.append('(%s)' % extra_sql)
            params.extend(extra_params)
            sql.append('%s' % extra_sql)
        sql.append(')')
        return ' '.join(sql), params

        if not join_conditions:
            # This might be a rel on the other end of an actual declared field.
            declared_field = getattr(self.join_field, 'field', self.join_field)
            raise ValueError(
                "Join generated an empty ON clause. %s did not yield either "
                "joining columns or extra restrictions." % declared_field.__class__
            )
        on_clause_sql = ' AND '.join(join_conditions)
        alias_str = '' if self.table_alias == self.table_name else (' %s' % self.table_alias)
        sql = '%s %s%s ON (%s)' % (self.join_type, qn(self.table_name), alias_str, on_clause_sql)
        return sql, params

    def relabeled_clone(self, change_map):
        new_parent_alias = change_map.get(self.parent_alias, self.parent_alias)
+2 −1
Original line number Diff line number Diff line
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',
]
+100 −0
Original line number Diff line number Diff line
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
+47 −0
Original line number Diff line number Diff line
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))