diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 7993c0dd32..b607b0a2d4 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -19,6 +19,7 @@ Changelog * Switch lock/unlock side panel toggle to a switch, with more appropriate confirmation message status (Sage Abdullah) * Ensure that changed or cleared selection from choosers will dispatch a DOM `change` event (George Sakkis) * Add the ability to disable model indexing by setting `search_fields = []` (Daniel Kirkham) + * Enhance `wagtail.search.utils.parse_query_string` to allow inner single quotes for key/value parsing (Aman Pandey) * Fix: Ensure `label_format` on StructBlock gracefully handles missing variables (Aadi jindal) * Fix: Adopt a no-JavaScript and more accessible solution for the 'Reset to default' switch to Gravatar when editing user profile (Loveth Omokaro) * Fix: Ensure `Site.get_site_root_paths` works on cache backends that do not preserve Python objects (Jaap Roes) diff --git a/docs/releases/5.0.md b/docs/releases/5.0.md index 3fe39098ad..dd55422c89 100644 --- a/docs/releases/5.0.md +++ b/docs/releases/5.0.md @@ -31,6 +31,7 @@ Support for adding custom validation logic to StreamField blocks has been formal * Switch lock/unlock side panel toggle to a switch, with more appropriate confirmation message status (Sage Abdullah) * Ensure that changed or cleared selection from choosers will dispatch a DOM `change` event (George Sakkis) * Add the ability to [disable model indexing](wagtailsearch_disable_indexing) by setting `search_fields = []` (Daniel Kirkham) + * Enhance `wagtail.search.utils.parse_query_string` to allow inner single quotes for key/value parsing (Aman Pandey) ### Bug fixes diff --git a/docs/topics/search/searching.md b/docs/topics/search/searching.md index 5483941ed1..b63348c021 100644 --- a/docs/topics/search/searching.md +++ b/docs/topics/search/searching.md @@ -272,11 +272,14 @@ For example: ```python >>> from wagtail.search.utils import parse_query_string ->>> filters, query = parse_query_string('my query string "this is a phrase" this-is-a:filter', operator='and') +>>> filters, query = parse_query_string('my query string "this is a phrase" this_is_a:filter', operator='and') + +# Alternatively.. +# filters, query = parse_query_string("my query string 'this is a phrase' this_is_a:filter", operator='and') >>> filters { - 'this-is-a': 'filter', + 'this_is_a': 'filter', } >>> query diff --git a/wagtail/search/tests/test_queries.py b/wagtail/search/tests/test_queries.py index 8586c7773f..3a52f90a57 100644 --- a/wagtail/search/tests/test_queries.py +++ b/wagtail/search/tests/test_queries.py @@ -235,6 +235,13 @@ class TestSeparateFiltersFromQuery(SimpleTestCase): self.assertDictEqual(filters, {"author": "foo bar", "bar": "two beers"}) self.assertEqual(query, "hello world") + filters, query = separate_filters_from_query( + "author:'foo bar' hello world bar:'two beers'" + ) + + self.assertDictEqual(filters, {"author": "foo bar", "bar": "two beers"}) + self.assertEqual(query, "hello world") + class TestParseQueryString(SimpleTestCase): def test_simple_query(self): @@ -249,6 +256,11 @@ class TestParseQueryString(SimpleTestCase): self.assertDictEqual(filters, {}) self.assertEqual(repr(query), repr(Phrase("hello world"))) + filters, query = parse_query_string("'hello world'") + + self.assertDictEqual(filters, {}) + self.assertEqual(repr(query), repr(Phrase("hello world"))) + def test_with_simple_and_phrase(self): filters, query = parse_query_string('this is simple "hello world"') @@ -257,6 +269,13 @@ class TestParseQueryString(SimpleTestCase): repr(query), repr(And([PlainText("this is simple"), Phrase("hello world")])) ) + filters, query = parse_query_string("this is simple 'hello world'") + + self.assertDictEqual(filters, {}) + self.assertEqual( + repr(query), repr(And([PlainText("this is simple"), Phrase("hello world")])) + ) + def test_operator(self): filters, query = parse_query_string( 'this is simple "hello world"', operator="or" @@ -270,18 +289,40 @@ class TestParseQueryString(SimpleTestCase): ), ) + filters, query = parse_query_string( + "this is simple 'hello world'", operator="or" + ) + + self.assertDictEqual(filters, {}) + self.assertEqual( + repr(query), + repr( + Or([PlainText("this is simple", operator="or"), Phrase("hello world")]) + ), + ) + def test_with_phrase_unclosed(self): filters, query = parse_query_string('"hello world') self.assertDictEqual(filters, {}) self.assertEqual(repr(query), repr(Phrase("hello world"))) + filters, query = parse_query_string("'hello world") + + self.assertDictEqual(filters, {}) + self.assertEqual(repr(query), repr(Phrase("hello world"))) + def test_phrase_with_filter(self): filters, query = parse_query_string('"hello world" author:"foo bar" bar:beer') self.assertDictEqual(filters, {"author": "foo bar", "bar": "beer"}) self.assertEqual(repr(query), repr(Phrase("hello world"))) + filters, query = parse_query_string("'hello world' author:'foo bar' bar:beer") + + self.assertDictEqual(filters, {"author": "foo bar", "bar": "beer"}) + self.assertEqual(repr(query), repr(Phrase("hello world"))) + def test_multiple_phrases(self): filters, query = parse_query_string('"hello world" "hi earth"') @@ -289,6 +330,23 @@ class TestParseQueryString(SimpleTestCase): repr(query), repr(And([Phrase("hello world"), Phrase("hi earth")])) ) + filters, query = parse_query_string("'hello world' 'hi earth'") + + self.assertEqual( + repr(query), repr(And([Phrase("hello world"), Phrase("hi earth")])) + ) + + def test_mixed_phrases_with_filters(self): + filters, query = parse_query_string( + """"lord of the rings" army_1:"elves" army_2:'humans'""" + ) + + self.assertDictEqual(filters, {"army_1": "elves", "army_2": "humans"}) + self.assertEqual( + repr(query), + repr(Phrase("lord of the rings")), + ) + class TestBalancedReduce(SimpleTestCase): # For simple values, this should behave exactly the same as Pythons reduce() diff --git a/wagtail/search/utils.py b/wagtail/search/utils.py index 3e20b3dd5d..cdd44e2f53 100644 --- a/wagtail/search/utils.py +++ b/wagtail/search/utils.py @@ -82,12 +82,14 @@ def normalise_query_string(query_string): def separate_filters_from_query(query_string): - filters_regexp = r'(\w+):(\w+|"[^"]+")' + filters_regexp = r'(\w+):(\w+|"[^"]+"|\'[^\']+\')' filters = {} for match_object in re.finditer(filters_regexp, query_string): key, value = match_object.groups() - filters[key] = value.strip('"') + filters[key] = ( + value.strip('"') if value.strip('"') is not value else value.strip("'") + ) query_string = re.sub(filters_regexp, "", query_string).strip() @@ -112,7 +114,12 @@ def parse_query_string(query_string, operator=None, zero_terms=MATCH_NONE): is_phrase = False tokens = [] - for part in query_string.split('"'): + if '"' in query_string: + parts = query_string.split('"') + else: + parts = query_string.split("'") + + for part in parts: part = part.strip() if part: