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

Thanks Tim Graham, Nick Pope, and Claude Paroz for contribution and review.
This commit is contained in:
Claude Paroz 2018-09-17 18:03:52 +02:00 committed by Tim Graham
parent c5e450ac95
commit f5e347a640
6 changed files with 84 additions and 6 deletions

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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``
==============

View File

@ -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`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -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'),