@@ -291,6 +291,109 @@ class SearchListView(generics.ListAPIView):
291291 ]
292292
293293
294+ @pytest .mark .requires_postgres
295+ class SearchFilterFullTextTests (TestCase ):
296+ @classmethod
297+ def setUpTestData (cls ):
298+ SearchFilterModel .objects .create (title = 'The quick brown fox' , text = 'jumps over the lazy dog' )
299+ SearchFilterModel .objects .create (title = 'The slow brown turtle' , text = 'crawls under the fence' )
300+ SearchFilterModel .objects .create (title = 'A bright sunny day' , text = 'in the park with friends' )
301+
302+ def test_full_text_search_single_term (self ):
303+ class SearchListView (generics .ListAPIView ):
304+ queryset = SearchFilterModel .objects .all ()
305+ serializer_class = SearchFilterSerializer
306+ filter_backends = (filters .SearchFilter ,)
307+ search_fields = ('@title' ,)
308+
309+ view = SearchListView .as_view ()
310+ request = factory .get ('/' , {'search' : 'fox' })
311+ response = view (request )
312+ assert len (response .data ) == 1
313+ assert response .data [0 ]['title' ] == 'The quick brown fox'
314+
315+ def test_full_text_search_multiple_results (self ):
316+ class SearchListView (generics .ListAPIView ):
317+ queryset = SearchFilterModel .objects .all ()
318+ serializer_class = SearchFilterSerializer
319+ filter_backends = (filters .SearchFilter ,)
320+ search_fields = ('@title' ,)
321+
322+ view = SearchListView .as_view ()
323+ request = factory .get ('/' , {'search' : 'brown' })
324+ response = view (request )
325+ assert len (response .data ) == 2
326+ titles = {item ['title' ] for item in response .data }
327+ assert titles == {'The quick brown fox' , 'The slow brown turtle' }
328+
329+ def test_full_text_search_no_results (self ):
330+ class SearchListView (generics .ListAPIView ):
331+ queryset = SearchFilterModel .objects .all ()
332+ serializer_class = SearchFilterSerializer
333+ filter_backends = (filters .SearchFilter ,)
334+ search_fields = ('@title' ,)
335+
336+ view = SearchListView .as_view ()
337+ request = factory .get ('/' , {'search' : 'elephant' })
338+ response = view (request )
339+ assert len (response .data ) == 0
340+
341+ def test_full_text_search_multiple_fields (self ):
342+ class SearchListView (generics .ListAPIView ):
343+ queryset = SearchFilterModel .objects .all ()
344+ serializer_class = SearchFilterSerializer
345+ filter_backends = (filters .SearchFilter ,)
346+ search_fields = ('@title' , '@text' )
347+
348+ view = SearchListView .as_view ()
349+ request = factory .get ('/' , {'search' : 'lazy' })
350+ response = view (request )
351+ assert len (response .data ) == 1
352+ assert response .data [0 ]['title' ] == 'The quick brown fox'
353+
354+ def test_full_text_search_stemming (self ):
355+ """Full text search should match stemmed words (e.g. 'jumping' matches 'jumps')."""
356+ class SearchListView (generics .ListAPIView ):
357+ queryset = SearchFilterModel .objects .all ()
358+ serializer_class = SearchFilterSerializer
359+ filter_backends = (filters .SearchFilter ,)
360+ search_fields = ('@text' ,)
361+
362+ view = SearchListView .as_view ()
363+ request = factory .get ('/' , {'search' : 'jumping' })
364+ response = view (request )
365+ assert len (response .data ) == 1
366+ assert response .data [0 ]['text' ] == 'jumps over the lazy dog'
367+
368+ def test_full_text_search_multiple_terms (self ):
369+ """Each search term must match (AND semantics across terms)."""
370+ class SearchListView (generics .ListAPIView ):
371+ queryset = SearchFilterModel .objects .all ()
372+ serializer_class = SearchFilterSerializer
373+ filter_backends = (filters .SearchFilter ,)
374+ search_fields = ('@title' , '@text' )
375+
376+ view = SearchListView .as_view ()
377+ request = factory .get ('/' , {'search' : 'brown lazy' })
378+ response = view (request )
379+ assert len (response .data ) == 1
380+ assert response .data [0 ]['title' ] == 'The quick brown fox'
381+
382+ def test_full_text_search_mixed_with_icontains (self ):
383+ """Full text search fields can be mixed with regular icontains fields."""
384+ class SearchListView (generics .ListAPIView ):
385+ queryset = SearchFilterModel .objects .all ()
386+ serializer_class = SearchFilterSerializer
387+ filter_backends = (filters .SearchFilter ,)
388+ search_fields = ('@title' , 'text' )
389+
390+ view = SearchListView .as_view ()
391+ request = factory .get ('/' , {'search' : 'park' })
392+ response = view (request )
393+ assert len (response .data ) == 1
394+ assert response .data [0 ]['title' ] == 'A bright sunny day'
395+
396+
294397class AttributeModel (models .Model ):
295398 label = models .CharField (max_length = 32 )
296399
@@ -339,6 +442,10 @@ def test_custom_lookup_to_related_model(self):
339442 assert 'attribute__label__icontains' == filter_ .construct_search ('attribute__label' , SearchFilterModelFk ._meta )
340443 assert 'attribute__label__iendswith' == filter_ .construct_search ('attribute__label__iendswith' , SearchFilterModelFk ._meta )
341444
445+ def test_construct_search_with_at_prefix (self ):
446+ filter_ = filters .SearchFilter ()
447+ assert 'title__search' == filter_ .construct_search ('@title' , SearchFilterModelFk ._meta )
448+
342449
343450class SearchFilterModelM2M (models .Model ):
344451 title = models .CharField (max_length = 20 )
0 commit comments