Skip to content

Commit 42f53cd

Browse files
antonyracdha
authored andcommitted
fix: contains filter, add endswith filter
* `__contains` now works in a more intuitive manner (the previous behaviour remains the default for `=` shortcut queries and can be requested explicitly with `__content`) * `__endswith` is now supported as the logical counterpart to `__startswith` Thanks to @antonyr for the patch and @sebslomski for code review and testing.
1 parent 01b6e67 commit 42f53cd

14 files changed

+77
-37
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,4 @@ Thanks to
113113
* Gilad Beeri (@giladbeeri) for adding retries when updating a backend
114114
* Arjen Verstoep (@terr) for a patch that allows attribute lookups through Django ManyToManyField relationships
115115
* Tim Babych (@tymofij) for enabling backend-specific parameters in ``.highlight()``
116+
* Antony Raj (@antonyr) for adding endswith input type and fixing contains input type

docs/searchqueryset_api.rst

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,7 @@ Field Lookups
817817

818818
The following lookup types are supported:
819819

820+
* content
820821
* contains
821822
* exact
822823
* gt
@@ -825,6 +826,7 @@ The following lookup types are supported:
825826
* lte
826827
* in
827828
* startswith
829+
* endswith
828830
* range
829831
* fuzzy
830832

@@ -843,10 +845,10 @@ The actual behavior of these lookups is backend-specific.
843845

844846
.. warning::
845847

846-
The ``contains`` filter became the new default filter as of Haystack v2.X
848+
The ``content`` filter became the new default filter as of Haystack v2.X
847849
(the default in Haystack v1.X was ``exact``). This changed because ``exact``
848850
caused problems and was unintuitive for new people trying to use Haystack.
849-
``contains`` is a much more natural usage.
851+
``content`` is a much more natural usage.
850852

851853
If you had an app built on Haystack v1.X & are upgrading, you'll need to
852854
sanity-check & possibly change any code that was relying on the default.
@@ -858,7 +860,7 @@ Example::
858860
SearchQuerySet().filter(content='foo')
859861

860862
# Identical to:
861-
SearchQuerySet().filter(content__contains='foo')
863+
SearchQuerySet().filter(content__content='foo')
862864

863865
# Phrase matching.
864866
SearchQuerySet().filter(content__exact='hello world')

haystack/backends/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,9 +401,8 @@ def split_expression(self, expression):
401401
"""Parses an expression and determines the field and filter type."""
402402
parts = expression.split(FILTER_SEPARATOR)
403403
field = parts[0]
404-
405404
if len(parts) == 1 or parts[-1] not in VALID_FILTERS:
406-
filter_type = 'contains'
405+
filter_type = 'content'
407406
else:
408407
filter_type = parts.pop()
409408

haystack/backends/elasticsearch_backend.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -802,7 +802,9 @@ def build_query_fragment(self, field, filter_type, value):
802802
index_fieldname = u'%s:' % connections[self._using].get_unified_index().get_index_fieldname(field)
803803

804804
filter_types = {
805-
'contains': u'%s',
805+
'content': u'%s',
806+
'contains': u'*%s*',
807+
'endswith': u'*%s',
806808
'startswith': u'%s*',
807809
'exact': u'%s',
808810
'gt': u'{%s TO *}',
@@ -815,7 +817,7 @@ def build_query_fragment(self, field, filter_type, value):
815817
if value.post_process is False:
816818
query_frag = prepared_value
817819
else:
818-
if filter_type in ['contains', 'startswith', 'fuzzy']:
820+
if filter_type in ['content', 'contains', 'startswith', 'endswith', 'fuzzy']:
819821
if value.input_type_name == 'exact':
820822
query_frag = prepared_value
821823
else:

haystack/backends/solr_backend.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,9 @@ def build_query_fragment(self, field, filter_type, value):
571571
index_fieldname = u'%s:' % connections[self._using].get_unified_index().get_index_fieldname(field)
572572

573573
filter_types = {
574-
'contains': u'%s',
574+
'content': u'%s',
575+
'contains': u'*%s*',
576+
'endswith': u'*%s',
575577
'startswith': u'%s*',
576578
'exact': u'%s',
577579
'gt': u'{%s TO *}',
@@ -584,7 +586,7 @@ def build_query_fragment(self, field, filter_type, value):
584586
if value.post_process is False:
585587
query_frag = prepared_value
586588
else:
587-
if filter_type in ['contains', 'startswith', 'fuzzy']:
589+
if filter_type in ['content', 'contains', 'startswith', 'endswith', 'fuzzy']:
588590
if value.input_type_name == 'exact':
589591
query_frag = prepared_value
590592
else:

haystack/backends/whoosh_backend.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -813,7 +813,9 @@ def build_query_fragment(self, field, filter_type, value):
813813
index_fieldname = u'%s:' % connections[self._using].get_unified_index().get_index_fieldname(field)
814814

815815
filter_types = {
816-
'contains': '%s',
816+
'content': '%s',
817+
'contains': '*%s*',
818+
'endswith': "*%s",
817819
'startswith': "%s*",
818820
'exact': '%s',
819821
'gt': "{%s to}",
@@ -826,7 +828,7 @@ def build_query_fragment(self, field, filter_type, value):
826828
if value.post_process is False:
827829
query_frag = prepared_value
828830
else:
829-
if filter_type in ['contains', 'startswith', 'fuzzy']:
831+
if filter_type in ['content', 'contains', 'startswith', 'endswith', 'fuzzy']:
830832
if value.input_type_name == 'exact':
831833
query_frag = prepared_value
832834
else:

haystack/constants.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
FUZZY_MAX_EXPANSIONS = getattr(settings, 'HAYSTACK_FUZZY_MAX_EXPANSIONS', 50)
2020

2121
# Valid expression extensions.
22-
VALID_FILTERS = set(['contains', 'exact', 'gt', 'gte', 'lt', 'lte', 'in', 'startswith', 'range', 'fuzzy'])
22+
VALID_FILTERS = set(['contains', 'exact', 'gt', 'gte', 'lt', 'lte', 'in', 'startswith', 'range', 'endswith', 'content', 'fuzzy'])
23+
2324
FILTER_SEPARATOR = '__'
2425

2526
# The maximum number of items to display in a SearchQuerySet.__repr__

test_haystack/elasticsearch_tests/test_elasticsearch_backend.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -851,7 +851,7 @@ def test_auto_query(self):
851851
# This will break horrifically if escaping isn't working.
852852
sqs = self.sqs.auto_query('"pants:rule"')
853853
self.assertTrue(isinstance(sqs, SearchQuerySet))
854-
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__contains="pants:rule">')
854+
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__content="pants:rule">')
855855
self.assertEqual(sqs.query.build_query(), u'("pants\\:rule")')
856856
self.assertEqual(len(sqs), 0)
857857

test_haystack/elasticsearch_tests/test_elasticsearch_query.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,16 @@ def test_build_query_fuzzy_filter_types(self):
122122
self.sq.add_filter(SQ(title__fuzzy='haystack'))
123123
self.assertEqual(self.sq.build_query(), u'((why) AND title:(haystack~))')
124124

125+
def test_build_query_with_contains(self):
126+
self.sq.add_filter(SQ(content='circular'))
127+
self.sq.add_filter(SQ(title__contains='haystack'))
128+
self.assertEqual(self.sq.build_query(), u'((circular) AND title:(*haystack*))')
129+
130+
def test_build_query_with_endswith(self):
131+
self.sq.add_filter(SQ(content='circular'))
132+
self.sq.add_filter(SQ(title__endswith='haystack'))
133+
self.assertEqual(self.sq.build_query(), u'((circular) AND title:(*haystack))')
134+
125135
def test_clean(self):
126136
self.assertEqual(self.sq.clean('hello world'), 'hello world')
127137
self.assertEqual(self.sq.clean('hello AND world'), 'hello and world')

test_haystack/solr_tests/test_solr_backend.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -973,7 +973,7 @@ def test_auto_query(self):
973973
# This will break horrifically if escaping isn't working.
974974
sqs = self.sqs.auto_query('"pants:rule"')
975975
self.assertTrue(isinstance(sqs, SearchQuerySet))
976-
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__contains="pants:rule">')
976+
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__content="pants:rule">')
977977
self.assertEqual(sqs.query.build_query(), u'("pants\\:rule")')
978978
self.assertEqual(len(sqs), 0)
979979

test_haystack/solr_tests/test_solr_query.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
from ..core.models import AnotherMockModel, MockModel
1515

16-
1716
class SolrSearchQueryTestCase(TestCase):
1817
fixtures = ['base_data']
1918

@@ -132,6 +131,16 @@ def test_build_query_in_with_set(self):
132131
else:
133132
self.assertTrue(u'title:("An Infamous Article" OR "A Famous Paper")' in query)
134133

134+
def test_build_query_with_contains(self):
135+
self.sq.add_filter(SQ(content='circular'))
136+
self.sq.add_filter(SQ(title__contains='haystack'))
137+
self.assertEqual(self.sq.build_query(), u'((circular) AND title:(*haystack*))')
138+
139+
def test_build_query_with_endswith(self):
140+
self.sq.add_filter(SQ(content='circular'))
141+
self.sq.add_filter(SQ(title__endswith='haystack'))
142+
self.assertEqual(self.sq.build_query(), u'((circular) AND title:(*haystack))')
143+
135144
def test_build_query_wildcard_filter_types(self):
136145
self.sq.add_filter(SQ(content='why'))
137146
self.sq.add_filter(SQ(title__startswith='haystack'))

test_haystack/test_managers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,12 @@ def test_load_all(self):
168168
def test_auto_query(self):
169169
sqs = self.search_index.objects.auto_query('test search -stuff')
170170
self.assertTrue(isinstance(sqs, SearchQuerySet))
171-
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__contains=test search -stuff>')
171+
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__content=test search -stuff>')
172172

173173
# With keyword argument
174174
sqs = self.search_index.objects.auto_query('test search -stuff', fieldname='title')
175175
self.assertTrue(isinstance(sqs, SearchQuerySet))
176-
self.assertEqual(repr(sqs.query.query_filter), "<SQ: AND title__contains=test search -stuff>")
176+
self.assertEqual(repr(sqs.query.query_filter), "<SQ: AND title__content=test search -stuff>")
177177

178178
def test_autocomplete(self):
179179
# Not implemented

test_haystack/test_query.py

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,40 +32,42 @@ class SQTestCase(TestCase):
3232
def test_split_expression(self):
3333
sq = SQ(foo='bar')
3434

35-
self.assertEqual(sq.split_expression('foo'), ('foo', 'contains'))
35+
self.assertEqual(sq.split_expression('foo'), ('foo', 'content'))
3636
self.assertEqual(sq.split_expression('foo__exact'), ('foo', 'exact'))
37+
self.assertEqual(sq.split_expression('foo__content'), ('foo', 'content'))
3738
self.assertEqual(sq.split_expression('foo__contains'), ('foo', 'contains'))
3839
self.assertEqual(sq.split_expression('foo__lt'), ('foo', 'lt'))
3940
self.assertEqual(sq.split_expression('foo__lte'), ('foo', 'lte'))
4041
self.assertEqual(sq.split_expression('foo__gt'), ('foo', 'gt'))
4142
self.assertEqual(sq.split_expression('foo__gte'), ('foo', 'gte'))
4243
self.assertEqual(sq.split_expression('foo__in'), ('foo', 'in'))
4344
self.assertEqual(sq.split_expression('foo__startswith'), ('foo', 'startswith'))
45+
self.assertEqual(sq.split_expression('foo__endswith'), ('foo', 'endswith'))
4446
self.assertEqual(sq.split_expression('foo__range'), ('foo', 'range'))
4547
self.assertEqual(sq.split_expression('foo__fuzzy'), ('foo', 'fuzzy'))
4648

4749
# Unrecognized filter. Fall back to exact.
48-
self.assertEqual(sq.split_expression('foo__moof'), ('foo', 'contains'))
50+
self.assertEqual(sq.split_expression('foo__moof'), ('foo', 'content'))
4951

5052
def test_repr(self):
51-
self.assertEqual(repr(SQ(foo='bar')), '<SQ: AND foo__contains=bar>')
52-
self.assertEqual(repr(SQ(foo=1)), '<SQ: AND foo__contains=1>')
53-
self.assertEqual(repr(SQ(foo=datetime.datetime(2009, 5, 12, 23, 17))), '<SQ: AND foo__contains=2009-05-12 23:17:00>')
53+
self.assertEqual(repr(SQ(foo='bar')), '<SQ: AND foo__content=bar>')
54+
self.assertEqual(repr(SQ(foo=1)), '<SQ: AND foo__content=1>')
55+
self.assertEqual(repr(SQ(foo=datetime.datetime(2009, 5, 12, 23, 17))), '<SQ: AND foo__content=2009-05-12 23:17:00>')
5456

5557
def test_simple_nesting(self):
5658
sq1 = SQ(foo='bar')
5759
sq2 = SQ(foo='bar')
5860
bigger_sq = SQ(sq1 & sq2)
59-
self.assertEqual(repr(bigger_sq), '<SQ: AND (foo__contains=bar AND foo__contains=bar)>')
61+
self.assertEqual(repr(bigger_sq), '<SQ: AND (foo__content=bar AND foo__content=bar)>')
6062

6163
another_bigger_sq = SQ(sq1 | sq2)
62-
self.assertEqual(repr(another_bigger_sq), '<SQ: AND (foo__contains=bar OR foo__contains=bar)>')
64+
self.assertEqual(repr(another_bigger_sq), '<SQ: AND (foo__content=bar OR foo__content=bar)>')
6365

6466
one_more_bigger_sq = SQ(sq1 & ~sq2)
65-
self.assertEqual(repr(one_more_bigger_sq), '<SQ: AND (foo__contains=bar AND NOT (foo__contains=bar))>')
67+
self.assertEqual(repr(one_more_bigger_sq), '<SQ: AND (foo__content=bar AND NOT (foo__content=bar))>')
6668

6769
mega_sq = SQ(bigger_sq & SQ(another_bigger_sq | ~one_more_bigger_sq))
68-
self.assertEqual(repr(mega_sq), '<SQ: AND ((foo__contains=bar AND foo__contains=bar) AND ((foo__contains=bar OR foo__contains=bar) OR NOT ((foo__contains=bar AND NOT (foo__contains=bar)))))>')
70+
self.assertEqual(repr(mega_sq), '<SQ: AND ((foo__content=bar AND foo__content=bar) AND ((foo__content=bar OR foo__content=bar) OR NOT ((foo__content=bar AND NOT (foo__content=bar)))))>')
6971

7072

7173
class BaseSearchQueryTestCase(TestCase):
@@ -95,15 +97,15 @@ def test_add_filter(self):
9597

9698
self.bsq.add_filter(SQ(claris='moof'), use_or=True)
9799

98-
self.assertEqual(repr(self.bsq.query_filter), '<SQ: OR ((foo__contains=bar AND foo__lt=10 AND NOT (claris__contains=moof)) OR claris__contains=moof)>')
100+
self.assertEqual(repr(self.bsq.query_filter), '<SQ: OR ((foo__content=bar AND foo__lt=10 AND NOT (claris__content=moof)) OR claris__content=moof)>')
99101

100102
self.bsq.add_filter(SQ(claris='moof'))
101103

102-
self.assertEqual(repr(self.bsq.query_filter), '<SQ: AND (((foo__contains=bar AND foo__lt=10 AND NOT (claris__contains=moof)) OR claris__contains=moof) AND claris__contains=moof)>')
104+
self.assertEqual(repr(self.bsq.query_filter), '<SQ: AND (((foo__content=bar AND foo__lt=10 AND NOT (claris__content=moof)) OR claris__content=moof) AND claris__content=moof)>')
103105

104106
self.bsq.add_filter(SQ(claris='wtf mate'))
105107

106-
self.assertEqual(repr(self.bsq.query_filter), '<SQ: AND (((foo__contains=bar AND foo__lt=10 AND NOT (claris__contains=moof)) OR claris__contains=moof) AND claris__contains=moof AND claris__contains=wtf mate)>')
108+
self.assertEqual(repr(self.bsq.query_filter), '<SQ: AND (((foo__content=bar AND foo__lt=10 AND NOT (claris__content=moof)) OR claris__content=moof) AND claris__content=moof AND claris__content=wtf mate)>')
107109

108110
def test_add_order_by(self):
109111
self.assertEqual(len(self.bsq.order_by), 0)
@@ -573,37 +575,37 @@ def test_load_all_read_queryset(self):
573575
def test_auto_query(self):
574576
sqs = self.msqs.auto_query('test search -stuff')
575577
self.assertTrue(isinstance(sqs, SearchQuerySet))
576-
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__contains=test search -stuff>')
578+
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__content=test search -stuff>')
577579

578580
sqs = self.msqs.auto_query('test "my thing" search -stuff')
579581
self.assertTrue(isinstance(sqs, SearchQuerySet))
580-
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__contains=test "my thing" search -stuff>')
582+
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__content=test "my thing" search -stuff>')
581583

582584
sqs = self.msqs.auto_query('test "my thing" search \'moar quotes\' -stuff')
583585
self.assertTrue(isinstance(sqs, SearchQuerySet))
584-
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__contains=test "my thing" search \'moar quotes\' -stuff>')
586+
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__content=test "my thing" search \'moar quotes\' -stuff>')
585587

586588
sqs = self.msqs.auto_query('test "my thing" search \'moar quotes\' "foo -stuff')
587589
self.assertTrue(isinstance(sqs, SearchQuerySet))
588-
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__contains=test "my thing" search \'moar quotes\' "foo -stuff>')
590+
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__content=test "my thing" search \'moar quotes\' "foo -stuff>')
589591

590592
sqs = self.msqs.auto_query('test - stuff')
591593
self.assertTrue(isinstance(sqs, SearchQuerySet))
592-
self.assertEqual(repr(sqs.query.query_filter), "<SQ: AND content__contains=test - stuff>")
594+
self.assertEqual(repr(sqs.query.query_filter), "<SQ: AND content__content=test - stuff>")
593595

594596
# Ensure bits in exact matches get escaped properly as well.
595597
sqs = self.msqs.auto_query('"pants:rule"')
596598
self.assertTrue(isinstance(sqs, SearchQuerySet))
597-
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__contains="pants:rule">')
599+
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND content__content="pants:rule">')
598600

599601
# Now with a different fieldname
600602
sqs = self.msqs.auto_query('test search -stuff', fieldname='title')
601603
self.assertTrue(isinstance(sqs, SearchQuerySet))
602-
self.assertEqual(repr(sqs.query.query_filter), "<SQ: AND title__contains=test search -stuff>")
604+
self.assertEqual(repr(sqs.query.query_filter), "<SQ: AND title__content=test search -stuff>")
603605

604606
sqs = self.msqs.auto_query('test "my thing" search -stuff', fieldname='title')
605607
self.assertTrue(isinstance(sqs, SearchQuerySet))
606-
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND title__contains=test "my thing" search -stuff>')
608+
self.assertEqual(repr(sqs.query.query_filter), '<SQ: AND title__content=test "my thing" search -stuff>')
607609

608610
def test_count(self):
609611
self.assertEqual(self.msqs.count(), 23)

test_haystack/whoosh_tests/test_whoosh_query.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,16 @@ def test_build_query_fuzzy_filter_types(self):
103103
self.sq.add_filter(SQ(title__fuzzy='haystack'))
104104
self.assertEqual(self.sq.build_query(), u'((why) AND title:(haystack~))')
105105

106+
def test_build_query_with_contains(self):
107+
self.sq.add_filter(SQ(content='circular'))
108+
self.sq.add_filter(SQ(title__contains='haystack'))
109+
self.assertEqual(self.sq.build_query(), u'((circular) AND title:(*haystack*))')
110+
111+
def test_build_query_with_endswith(self):
112+
self.sq.add_filter(SQ(content='circular'))
113+
self.sq.add_filter(SQ(title__endswith='haystack'))
114+
self.assertEqual(self.sq.build_query(), u'((circular) AND title:(*haystack))')
115+
106116
def test_clean(self):
107117
self.assertEqual(self.sq.clean('hello world'), 'hello world')
108118
self.assertEqual(self.sq.clean('hello AND world'), 'hello and world')

0 commit comments

Comments
 (0)