Browse Source

Fixed #27899 -- Added support for phrase/raw searching in SearchQuery.

Thanks Tim Graham, Nick Pope, and Claude Paroz for contribution and review.
Claude Paroz 6 years ago
parent
commit
f5e347a640

+ 1 - 0
AUTHORS

@@ -312,6 +312,7 @@ answer newbie questions, and generally made Django that much better:
     Graham Carlyle <graham.carlyle@maplecroft.net>
     Grant Jenks <contact@grantjenks.com>
     Greg Chapple <gregchapple1@gmail.com>
+    Gregor Allensworth <greg.allensworth@gmail.com>
     Gregor Müllegger <gregor@muellegger.de>
     Grigory Fateyev <greg@dial.com.ru>
     Grzegorz Ślusarek <grzegorz.slusarek@gmail.com>

+ 12 - 3
django/contrib/postgres/search.py

@@ -123,10 +123,18 @@ class SearchQueryCombinable:
 
 class SearchQuery(SearchQueryCombinable, Value):
     output_field = SearchQueryField()
+    SEARCH_TYPES = {
+        'plain': 'plainto_tsquery',
+        'phrase': 'phraseto_tsquery',
+        'raw': 'to_tsquery',
+    }
 
-    def __init__(self, value, output_field=None, *, config=None, invert=False):
+    def __init__(self, value, output_field=None, *, config=None, invert=False, search_type='plain'):
         self.config = config
         self.invert = invert
+        if search_type not in self.SEARCH_TYPES:
+            raise ValueError("Unknown search_type argument '%s'." % search_type)
+        self.search_type = search_type
         super().__init__(value, output_field=output_field)
 
     def resolve_expression(self, query=None, allow_joins=True, reuse=None, summarize=False, for_save=False):
@@ -140,12 +148,13 @@ class SearchQuery(SearchQueryCombinable, Value):
 
     def as_sql(self, compiler, connection):
         params = [self.value]
+        function = self.SEARCH_TYPES[self.search_type]
         if self.config:
             config_sql, config_params = compiler.compile(self.config)
-            template = 'plainto_tsquery({}::regconfig, %s)'.format(config_sql)
+            template = '{}({}::regconfig, %s)'.format(function, config_sql)
             params = config_params + [self.value]
         else:
-            template = 'plainto_tsquery(%s)'
+            template = '{}(%s)'.format(function)
         if self.invert:
             template = '!!({})'.format(template)
         return template, params

+ 5 - 0
django/db/backends/postgresql/features.py

@@ -57,6 +57,10 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     def is_postgresql_9_5(self):
         return self.connection.pg_version >= 90500
 
+    @cached_property
+    def is_postgresql_9_6(self):
+        return self.connection.pg_version >= 90600
+
     @cached_property
     def is_postgresql_10(self):
         return self.connection.pg_version >= 100000
@@ -67,3 +71,4 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     has_brin_autosummarize = is_postgresql_10
     has_gin_pending_list_limit = is_postgresql_9_5
     supports_ignore_conflicts = is_postgresql_9_5
+    has_phraseto_tsquery = is_postgresql_9_6

+ 20 - 1
docs/ref/contrib/postgres/search.txt

@@ -70,13 +70,28 @@ and ``weight`` parameters.
 ``SearchQuery``
 ===============
 
-.. class:: SearchQuery(value, config=None)
+.. class:: SearchQuery(value, config=None, search_type='plain')
 
 ``SearchQuery`` translates the terms the user provides into a search query
 object that the database compares to a search vector. By default, all the words
 the user provides are passed through the stemming algorithms, and then it
 looks for matches for all of the resulting terms.
 
+If ``search_type`` is ``'plain'``, which is the default, the terms are treated
+as separate keywords. If ``search_type`` is ``'phrase'``, the terms are treated
+as a single phrase. If ``search_type`` is ``'raw'``, then you can provide a
+formatted search query with terms and operators. Read PostgreSQL's `Full Text
+Search docs`_ to learn about differences and syntax. Examples:
+
+.. _Full Text Search docs: https://www.postgresql.org/docs/current/static/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES
+
+    >>> from django.contrib.postgres.search import SearchQuery
+    >>> SearchQuery('red tomato')  # two keywords
+    >>> SearchQuery('tomato red')  # same results as above
+    >>> SearchQuery('red tomato', search_type='phrase')  # a phrase
+    >>> SearchQuery('tomato red', search_type='phrase')  # a different phrase
+    >>> SearchQuery("'tomato' & ('red' | 'green')", search_type='raw')  # boolean operators
+
 ``SearchQuery`` terms can be combined logically to provide more flexibility::
 
     >>> from django.contrib.postgres.search import SearchQuery
@@ -87,6 +102,10 @@ looks for matches for all of the resulting terms.
 See :ref:`postgresql-fts-search-configuration` for an explanation of the
 ``config`` parameter.
 
+.. versionadded:: 2.2
+
+    The `search_type` parameter was added.
+
 ``SearchRank``
 ==============
 

+ 4 - 0
docs/releases/2.2.txt

@@ -90,6 +90,10 @@ Minor features
 * :class:`~django.contrib.postgres.indexes.BrinIndex` now has the
   ``autosummarize`` parameter.
 
+* The new ``search_type`` parameter of
+  :class:`~django.contrib.postgres.search.SearchQuery` allows searching for
+  a phrase or raw expression.
+
 :mod:`django.contrib.redirects`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 42 - 2
tests/postgres_tests/test_search.py

@@ -9,7 +9,7 @@ from django.contrib.postgres.search import (
     SearchQuery, SearchRank, SearchVector,
 )
 from django.db.models import F
-from django.test import SimpleTestCase, modify_settings
+from django.test import SimpleTestCase, modify_settings, skipUnlessDBFeature
 
 from . import PostgreSQLTestCase
 from .models import Character, Line, Scene
@@ -75,7 +75,7 @@ class GrailTestData:
         cls.french = Line.objects.create(
             scene=trojan_rabbit,
             character=guards,
-            dialogue='Oh. Un cadeau. Oui oui.',
+            dialogue='Oh. Un beau cadeau. Oui oui.',
             dialogue_config='french',
         )
 
@@ -161,6 +161,46 @@ class MultipleFieldsTest(GrailTestData, PostgreSQLTestCase):
         ).filter(search=str(self.crowd.id))
         self.assertSequenceEqual(searched, [self.crowd])
 
+    @skipUnlessDBFeature('has_phraseto_tsquery')
+    def test_phrase_search(self):
+        line_qs = Line.objects.annotate(search=SearchVector('dialogue'))
+        searched = line_qs.filter(search=SearchQuery('burned body his away', search_type='phrase'))
+        self.assertSequenceEqual(searched, [])
+        searched = line_qs.filter(search=SearchQuery('his body burned away', search_type='phrase'))
+        self.assertSequenceEqual(searched, [self.verse1])
+
+    @skipUnlessDBFeature('has_phraseto_tsquery')
+    def test_phrase_search_with_config(self):
+        line_qs = Line.objects.annotate(
+            search=SearchVector('scene__setting', 'dialogue', config='french'),
+        )
+        searched = line_qs.filter(
+            search=SearchQuery('cadeau beau un', search_type='phrase', config='french'),
+        )
+        self.assertSequenceEqual(searched, [])
+        searched = line_qs.filter(
+            search=SearchQuery('un beau cadeau', search_type='phrase', config='french'),
+        )
+        self.assertSequenceEqual(searched, [self.french])
+
+    def test_raw_search(self):
+        line_qs = Line.objects.annotate(search=SearchVector('dialogue'))
+        searched = line_qs.filter(search=SearchQuery('Robin', search_type='raw'))
+        self.assertEqual(set(searched), {self.verse0, self.verse1})
+        searched = line_qs.filter(search=SearchQuery("Robin & !'Camelot'", search_type='raw'))
+        self.assertSequenceEqual(searched, [self.verse1])
+
+    def test_raw_search_with_config(self):
+        line_qs = Line.objects.annotate(search=SearchVector('dialogue', config='french'))
+        searched = line_qs.filter(
+            search=SearchQuery("'cadeaux' & 'beaux'", search_type='raw', config='french'),
+        )
+        self.assertSequenceEqual(searched, [self.french])
+
+    def test_bad_search_type(self):
+        with self.assertRaisesMessage(ValueError, "Unknown search_type argument 'foo'."):
+            SearchQuery('kneecaps', search_type='foo')
+
     def test_config_query_explicit(self):
         searched = Line.objects.annotate(
             search=SearchVector('scene__setting', 'dialogue', config='french'),