summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude Paroz <claude@2xlibre.net>2018-09-17 18:03:52 +0200
committerTim Graham <timograham@gmail.com>2018-09-17 12:03:52 -0400
commitf5e347a6402c1996a8f7063de4b314bae4a55683 (patch)
treec635bf758e0754f483448b2d272ee2f5bb5fd29f
parentc5e450ac950316068455e76d4a63b78110d7cde5 (diff)
Fixed #27899 -- Added support for phrase/raw searching in SearchQuery.
Thanks Tim Graham, Nick Pope, and Claude Paroz for contribution and review.
-rw-r--r--AUTHORS1
-rw-r--r--django/contrib/postgres/search.py15
-rw-r--r--django/db/backends/postgresql/features.py5
-rw-r--r--docs/ref/contrib/postgres/search.txt21
-rw-r--r--docs/releases/2.2.txt4
-rw-r--r--tests/postgres_tests/test_search.py44
6 files changed, 84 insertions, 6 deletions
diff --git a/AUTHORS b/AUTHORS
index 1cd9f75344..044de4836c 100644
--- a/AUTHORS
+++ b/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>
diff --git a/django/contrib/postgres/search.py b/django/contrib/postgres/search.py
index 63fa9116ec..635a715250 100644
--- a/django/contrib/postgres/search.py
+++ b/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
diff --git a/django/db/backends/postgresql/features.py b/django/db/backends/postgresql/features.py
index 7fbe4bae02..5d6ebc9d15 100644
--- a/django/db/backends/postgresql/features.py
+++ b/django/db/backends/postgresql/features.py
@@ -58,6 +58,10 @@ class DatabaseFeatures(BaseDatabaseFeatures):
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
diff --git a/docs/ref/contrib/postgres/search.txt b/docs/ref/contrib/postgres/search.txt
index 815b423c35..1ae6233abe 100644
--- a/docs/ref/contrib/postgres/search.txt
+++ b/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``
==============
diff --git a/docs/releases/2.2.txt b/docs/releases/2.2.txt
index bf5bc30cde..8a94ef5e98 100644
--- a/docs/releases/2.2.txt
+++ b/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`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/tests/postgres_tests/test_search.py b/tests/postgres_tests/test_search.py
index 5ab7609cb3..d53c15116f 100644
--- a/tests/postgres_tests/test_search.py
+++ b/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'),