1
0
mirror of https://github.com/django/django.git synced 2025-01-03 06:55:47 +00:00

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> Graham Carlyle <graham.carlyle@maplecroft.net>
Grant Jenks <contact@grantjenks.com> Grant Jenks <contact@grantjenks.com>
Greg Chapple <gregchapple1@gmail.com> Greg Chapple <gregchapple1@gmail.com>
Gregor Allensworth <greg.allensworth@gmail.com>
Gregor Müllegger <gregor@muellegger.de> Gregor Müllegger <gregor@muellegger.de>
Grigory Fateyev <greg@dial.com.ru> Grigory Fateyev <greg@dial.com.ru>
Grzegorz Ślusarek <grzegorz.slusarek@gmail.com> Grzegorz Ślusarek <grzegorz.slusarek@gmail.com>

View File

@ -123,10 +123,18 @@ class SearchQueryCombinable:
class SearchQuery(SearchQueryCombinable, Value): class SearchQuery(SearchQueryCombinable, Value):
output_field = SearchQueryField() 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.config = config
self.invert = invert 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) super().__init__(value, output_field=output_field)
def resolve_expression(self, query=None, allow_joins=True, reuse=None, summarize=False, for_save=False): 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): def as_sql(self, compiler, connection):
params = [self.value] params = [self.value]
function = self.SEARCH_TYPES[self.search_type]
if self.config: if self.config:
config_sql, config_params = compiler.compile(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] params = config_params + [self.value]
else: else:
template = 'plainto_tsquery(%s)' template = '{}(%s)'.format(function)
if self.invert: if self.invert:
template = '!!({})'.format(template) template = '!!({})'.format(template)
return template, params return template, params

View File

@ -57,6 +57,10 @@ class DatabaseFeatures(BaseDatabaseFeatures):
def is_postgresql_9_5(self): def is_postgresql_9_5(self):
return self.connection.pg_version >= 90500 return self.connection.pg_version >= 90500
@cached_property
def is_postgresql_9_6(self):
return self.connection.pg_version >= 90600
@cached_property @cached_property
def is_postgresql_10(self): def is_postgresql_10(self):
return self.connection.pg_version >= 100000 return self.connection.pg_version >= 100000
@ -67,3 +71,4 @@ class DatabaseFeatures(BaseDatabaseFeatures):
has_brin_autosummarize = is_postgresql_10 has_brin_autosummarize = is_postgresql_10
has_gin_pending_list_limit = is_postgresql_9_5 has_gin_pending_list_limit = is_postgresql_9_5
supports_ignore_conflicts = 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`` ``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 ``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 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 the user provides are passed through the stemming algorithms, and then it
looks for matches for all of the resulting terms. 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:: ``SearchQuery`` terms can be combined logically to provide more flexibility::
>>> from django.contrib.postgres.search import SearchQuery >>> 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 See :ref:`postgresql-fts-search-configuration` for an explanation of the
``config`` parameter. ``config`` parameter.
.. versionadded:: 2.2
The `search_type` parameter was added.
``SearchRank`` ``SearchRank``
============== ==============

View File

@ -90,6 +90,10 @@ Minor features
* :class:`~django.contrib.postgres.indexes.BrinIndex` now has the * :class:`~django.contrib.postgres.indexes.BrinIndex` now has the
``autosummarize`` parameter. ``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` :mod:`django.contrib.redirects`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -9,7 +9,7 @@ from django.contrib.postgres.search import (
SearchQuery, SearchRank, SearchVector, SearchQuery, SearchRank, SearchVector,
) )
from django.db.models import F 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 . import PostgreSQLTestCase
from .models import Character, Line, Scene from .models import Character, Line, Scene
@ -75,7 +75,7 @@ class GrailTestData:
cls.french = Line.objects.create( cls.french = Line.objects.create(
scene=trojan_rabbit, scene=trojan_rabbit,
character=guards, character=guards,
dialogue='Oh. Un cadeau. Oui oui.', dialogue='Oh. Un beau cadeau. Oui oui.',
dialogue_config='french', dialogue_config='french',
) )
@ -161,6 +161,46 @@ class MultipleFieldsTest(GrailTestData, PostgreSQLTestCase):
).filter(search=str(self.crowd.id)) ).filter(search=str(self.crowd.id))
self.assertSequenceEqual(searched, [self.crowd]) 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): def test_config_query_explicit(self):
searched = Line.objects.annotate( searched = Line.objects.annotate(
search=SearchVector('scene__setting', 'dialogue', config='french'), search=SearchVector('scene__setting', 'dialogue', config='french'),