diff --git a/django/contrib/postgres/search.py b/django/contrib/postgres/search.py index 4628f4cf19..fb2f41b524 100644 --- a/django/contrib/postgres/search.py +++ b/django/contrib/postgres/search.py @@ -92,14 +92,43 @@ class CombinedSearchVector(SearchVectorCombinable, CombinedExpression): super(CombinedSearchVector, self).__init__(lhs, connector, rhs, output_field) -class SearchQuery(Value): +class SearchQueryCombinable(object): + BITAND = '&&' + BITOR = '||' + + def _combine(self, other, connector, reversed, node=None): + if not isinstance(other, SearchQueryCombinable): + raise TypeError( + 'SearchQuery can only be combined with other SearchQuerys, ' + 'got {}.'.format(type(other)) + ) + if not self.config == other.config: + raise TypeError("SearchQuery configs don't match.") + if reversed: + return CombinedSearchQuery(other, connector, self, self.config) + return CombinedSearchQuery(self, connector, other, self.config) + + # On Combinable, these are not implemented to reduce confusion with Q. In + # this case we are actually (ab)using them to do logical combination so + # it's consistent with other usage in Django. + def __or__(self, other): + return self._combine(other, self.BITOR, False) + + def __ror__(self, other): + return self._combine(other, self.BITOR, True) + + def __and__(self, other): + return self._combine(other, self.BITAND, False) + + def __rand__(self, other): + return self._combine(other, self.BITAND, True) + + +class SearchQuery(SearchQueryCombinable, Value): invert = False _output_field = SearchQueryField() config = None - BITAND = '&&' - BITOR = '||' - def __init__(self, value, output_field=None, **extra): self.config = extra.pop('config', self.config) self.invert = extra.pop('invert', self.invert) @@ -131,21 +160,6 @@ class SearchQuery(Value): combined.output_field = SearchQueryField() return combined - # On Combinable, these are not implemented to reduce confusion with Q. In - # this case we are actually (ab)using them to do logical combination so - # it's consistent with other usage in Django. - def __or__(self, other): - return self._combine(other, self.BITOR, False) - - def __ror__(self, other): - return self._combine(other, self.BITOR, True) - - def __and__(self, other): - return self._combine(other, self.BITAND, False) - - def __rand__(self, other): - return self._combine(other, self.BITAND, True) - def __invert__(self): extra = { 'invert': not self.invert, @@ -154,6 +168,12 @@ class SearchQuery(Value): return type(self)(self.value, **extra) +class CombinedSearchQuery(SearchQueryCombinable, CombinedExpression): + def __init__(self, lhs, connector, rhs, config, output_field=None): + self.config = config + super(CombinedSearchQuery, self).__init__(lhs, connector, rhs, output_field) + + class SearchRank(Func): function = 'ts_rank' _output_field = FloatField() diff --git a/docs/releases/1.10.2.txt b/docs/releases/1.10.2.txt index f3c574d7d1..bc11980053 100644 --- a/docs/releases/1.10.2.txt +++ b/docs/releases/1.10.2.txt @@ -11,3 +11,6 @@ Bugfixes * Fixed a crash in MySQL database validation where ``SELECT @@sql_mode`` doesn't return a result (:ticket:`27180`). + +* Allowed combining ``contrib.postgres.search.SearchQuery`` with more than one + ``&`` or ``|`` operators (:ticket:`27143`). diff --git a/tests/postgres_tests/test_search.py b/tests/postgres_tests/test_search.py index 93d86bf275..0bf2df50f1 100644 --- a/tests/postgres_tests/test_search.py +++ b/tests/postgres_tests/test_search.py @@ -205,14 +205,46 @@ class TestCombinations(GrailTestData, PostgreSQLTestCase): ).filter(search=SearchQuery('bedemir') & SearchQuery('scales')) self.assertSequenceEqual(searched, [self.bedemir0]) + def test_query_multiple_and(self): + searched = Line.objects.annotate( + search=SearchVector('scene__setting', 'dialogue'), + ).filter(search=SearchQuery('bedemir') & SearchQuery('scales') & SearchQuery('nostrils')) + self.assertSequenceEqual(searched, []) + + searched = Line.objects.annotate( + search=SearchVector('scene__setting', 'dialogue'), + ).filter(search=SearchQuery('shall') & SearchQuery('use') & SearchQuery('larger')) + self.assertSequenceEqual(searched, [self.bedemir0]) + def test_query_or(self): searched = Line.objects.filter(dialogue__search=SearchQuery('kneecaps') | SearchQuery('nostrils')) self.assertSequenceEqual(set(searched), {self.verse1, self.verse2}) + def test_query_multiple_or(self): + searched = Line.objects.filter( + dialogue__search=SearchQuery('kneecaps') | SearchQuery('nostrils') | SearchQuery('Sir Robin') + ) + self.assertSequenceEqual(set(searched), {self.verse1, self.verse2, self.verse0}) + def test_query_invert(self): searched = Line.objects.filter(character=self.minstrel, dialogue__search=~SearchQuery('kneecaps')) self.assertEqual(set(searched), {self.verse0, self.verse2}) + def test_query_config_mismatch(self): + with self.assertRaisesMessage(TypeError, "SearchQuery configs don't match."): + Line.objects.filter( + dialogue__search=SearchQuery('kneecaps', config='german') | + SearchQuery('nostrils', config='english') + ) + + def test_query_combined_mismatch(self): + msg = "SearchQuery can only be combined with other SearchQuerys, got" + with self.assertRaisesMessage(TypeError, msg): + Line.objects.filter(dialogue__search=None | SearchQuery('kneecaps')) + + with self.assertRaisesMessage(TypeError, msg): + Line.objects.filter(dialogue__search=None & SearchQuery('kneecaps')) + @modify_settings(INSTALLED_APPS={'append': 'django.contrib.postgres'}) class TestRankingAndWeights(GrailTestData, PostgreSQLTestCase):