Repository: wagtail/wagtail

Issue:
Ability to support outer double quotes in `wagtail.search.utils.parse_query_string`
### Is your proposal related to a problem?

In #7961 (PR #9875) the util `wagtail.search.utils.parse_query_string` was updated to support more than one key/value syntax as a string being parsed into an Object for the query.

However, it was found that the support only works (originally, and with the changes) when the outer quotations are single quotation not double quotations.

### Describe the solution you'd like

All the following combinations of single/multi key value pairs and usage of outer double or single quotations should work the same. This support should also be documented here https://docs.wagtail.org/en/stable/topics/search/searching.html#query-string-parsing

```shell
>>> from wagtail.search.utils import parse_query_string
>>> parse_query_string('key1:"hello key1"')
({'key1': 'hello key1'}, <Not <MatchAll>>)
>>> parse_query_string('key1:"hello key1" key2:"hello key2"')
({'key1': 'hello key1', 'key2': 'hello key2'}, <Not <MatchAll>>)
>>> parse_query_string("key1:'hello key1'")
({}, <PlainText "key1:'hello key1'" operator='and' boost=1>)
>>> parse_query_string("key1:'hello key1' key2:'hello key 2'")
({}, <PlainText "key1:'hello key1' key2:'hello key 2'" operator='and' boost=1>)
```

Observe above that the outer double quotations are treated a single string, but the outer single quotations are treated as a set of filters.

### Describe alternatives you've considered

* We could close this issue as not needing to be implemented and/or document that only one form of quotations are supported and provide an example of escaping instead.


### Additional info

> @tomdyson should I raise a new issue for that additional edge case?

I think so, yes please @lb-. Unless the quote type gets normalised further up the stack? I had a look but couldn't find anything.

_Originally posted by @tomdyson in https://github.com/wagtail/wagtail/issues/9875#issuecomment-1396804696_
            


Discussion:
adding few additional regEX check would be good to go
![image](https://user-images.githubusercontent.com/74553951/214525392-c5def778-685c-4c07-87bc-3bce150c5608.png)
this is working perfect gonna test a little, then make a PR

additionally eg in the docs is wrong as \w does not count `-` it only accounts for `_` 
![image](https://user-images.githubusercontent.com/74553951/214531682-462efe58-85b5-4758-8e20-a428d5aa49b6.png)
![image](https://user-images.githubusercontent.com/74553951/214531743-5516fa4b-ef46-489f-bbbe-5d3e77c7b553.png)


Resolving patch:
diff --git a/wagtail/search/utils.py b/wagtail/search/utils.py
--- 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:


Test patch:
diff --git a/wagtail/search/tests/test_queries.py b/wagtail/search/tests/test_queries.py
--- a/wagtail/search/tests/test_queries.py
+++ b/wagtail/search/tests/test_queries.py
@@ -235,6 +235,13 @@ def test_two_filters_with_quotation_marks_and_query(self):
         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 @@ def test_with_phrase(self):
         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 @@ def test_with_simple_and_phrase(self):
             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 @@ def test_operator(self):
             ),
         )
 
+        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 @@ def test_multiple_phrases(self):
             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()