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 Grant Jenks Greg Chapple + Gregor Allensworth Gregor Müllegger Grigory Fateyev Grzegorz Ślusarek 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 @@ -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 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'),