We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN 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_
            

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `wagtail/search/utils.py`
Content:
```
1 import operator
2 import re
3 from functools import partial
4 
5 from django.apps import apps
6 from django.db import connections
7 
8 from wagtail.search.index import RelatedFields, SearchField
9 
10 from .query import MATCH_NONE, Phrase, PlainText
11 
12 NOT_SET = object()
13 
14 
15 def balanced_reduce(operator, seq, initializer=NOT_SET):
16     """
17     Has the same result as Python's reduce function, but performs the calculations in a different order.
18 
19     This is important when the operator is constructing data structures such as search query classes.
20     This method will make the resulting data structures flatter, so operations that need to traverse
21     them don't end up crashing with recursion errors.
22 
23     For example:
24 
25     Python's builtin reduce() function will do the following calculation:
26 
27     reduce(add, [1, 2, 3, 4, 5, 6, 7, 8])
28     (1 + (2 + (3 + (4 + (5 + (6 + (7 + 8)))))))
29 
30     When using this with query classes, it would create a large data structure with a depth of 7
31     Whereas balanced_reduce will execute this like so:
32 
33     balanced_reduce(add, [1, 2, 3, 4, 5, 6, 7, 8])
34     ((1 + 2) + (3 + 4)) + ((5 + 6) + (7 + 8))
35 
36     Which only has a depth of 2
37     """
38     # Casting all iterables to list makes the implementation simpler
39     if not isinstance(seq, list):
40         seq = list(seq)
41 
42     # Note, it needs to be possible to use None as an initial value
43     if initializer is not NOT_SET:
44         if len(seq) == 0:
45             return initializer
46         else:
47             return operator(initializer, balanced_reduce(operator, seq))
48 
49     if len(seq) == 0:
50         raise TypeError("reduce() of empty sequence with no initial value")
51     elif len(seq) == 1:
52         return seq[0]
53     else:
54         break_point = len(seq) // 2
55         first_set = balanced_reduce(operator, seq[:break_point])
56         second_set = balanced_reduce(operator, seq[break_point:])
57         return operator(first_set, second_set)
58 
59 
60 # Reduce any iterable to a single value using a logical OR e.g. (a | b | ...)
61 OR = partial(balanced_reduce, operator.or_)
62 # Reduce any iterable to a single value using a logical AND e.g. (a & b & ...)
63 AND = partial(balanced_reduce, operator.and_)
64 # Reduce any iterable to a single value using an addition
65 ADD = partial(balanced_reduce, operator.add)
66 # Reduce any iterable to a single value using a multiplication
67 MUL = partial(balanced_reduce, operator.mul)
68 
69 MAX_QUERY_STRING_LENGTH = 255
70 
71 
72 def normalise_query_string(query_string):
73     # Truncate query string
74     query_string = query_string[:MAX_QUERY_STRING_LENGTH]
75     # Convert query_string to lowercase
76     query_string = query_string.lower()
77 
78     # Remove leading, trailing and multiple spaces
79     query_string = re.sub(" +", " ", query_string).strip()
80 
81     return query_string
82 
83 
84 def separate_filters_from_query(query_string):
85     filters_regexp = r'(\w+):(\w+|"[^"]+")'
86 
87     filters = {}
88     for match_object in re.finditer(filters_regexp, query_string):
89         key, value = match_object.groups()
90         filters[key] = value.strip('"')
91 
92     query_string = re.sub(filters_regexp, "", query_string).strip()
93 
94     return filters, query_string
95 
96 
97 def parse_query_string(query_string, operator=None, zero_terms=MATCH_NONE):
98     """
99     This takes a query string typed in by a user and extracts the following:
100 
101      - Quoted terms (for phrase search)
102      - Filters
103 
104     For example, the following query:
105 
106       `hello "this is a phrase" live:true` would be parsed into:
107 
108     filters: {'live': 'true'}
109     tokens: And([PlainText('hello'), Phrase('this is a phrase')])
110     """
111     filters, query_string = separate_filters_from_query(query_string)
112 
113     is_phrase = False
114     tokens = []
115     for part in query_string.split('"'):
116         part = part.strip()
117 
118         if part:
119             if is_phrase:
120                 tokens.append(Phrase(part))
121             else:
122                 tokens.append(
123                     PlainText(part, operator=operator or PlainText.DEFAULT_OPERATOR)
124                 )
125 
126         is_phrase = not is_phrase
127 
128     if tokens:
129         if operator == "or":
130             search_query = OR(tokens)
131         else:
132             search_query = AND(tokens)
133     else:
134         search_query = zero_terms
135 
136     return filters, search_query
137 
138 
139 def get_descendant_models(model):
140     """
141     Returns all descendants of a model, including the model itself.
142     """
143     descendant_models = {
144         other_model
145         for other_model in apps.get_models()
146         if issubclass(other_model, model)
147     }
148     descendant_models.add(model)
149     return descendant_models
150 
151 
152 def get_content_type_pk(model):
153     # We import it locally because this file is loaded before apps are ready.
154     from django.contrib.contenttypes.models import ContentType
155 
156     return ContentType.objects.get_for_model(model).pk
157 
158 
159 def get_ancestors_content_types_pks(model):
160     """
161     Returns content types ids for the ancestors of this model, excluding it.
162     """
163     from django.contrib.contenttypes.models import ContentType
164 
165     return [
166         ct.pk
167         for ct in ContentType.objects.get_for_models(
168             *model._meta.get_parent_list()
169         ).values()
170     ]
171 
172 
173 def get_descendants_content_types_pks(model):
174     """
175     Returns content types ids for the descendants of this model, including it.
176     """
177     from django.contrib.contenttypes.models import ContentType
178 
179     return [
180         ct.pk
181         for ct in ContentType.objects.get_for_models(
182             *get_descendant_models(model)
183         ).values()
184     ]
185 
186 
187 def get_search_fields(search_fields):
188     for search_field in search_fields:
189         if isinstance(search_field, SearchField):
190             yield search_field
191         elif isinstance(search_field, RelatedFields):
192             for sub_field in get_search_fields(search_field.fields):
193                 yield sub_field
194 
195 
196 def get_postgresql_connections():
197     return [
198         connection
199         for connection in connections.all()
200         if connection.vendor == "postgresql"
201     ]
202
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

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

{"golden_diff": "diff --git a/wagtail/search/utils.py b/wagtail/search/utils.py\n--- a/wagtail/search/utils.py\n+++ b/wagtail/search/utils.py\n@@ -82,12 +82,14 @@\n \n \n def separate_filters_from_query(query_string):\n-    filters_regexp = r'(\\w+):(\\w+|\"[^\"]+\")'\n+    filters_regexp = r'(\\w+):(\\w+|\"[^\"]+\"|\\'[^\\']+\\')'\n \n     filters = {}\n     for match_object in re.finditer(filters_regexp, query_string):\n         key, value = match_object.groups()\n-        filters[key] = value.strip('\"')\n+        filters[key] = (\n+            value.strip('\"') if value.strip('\"') is not value else value.strip(\"'\")\n+        )\n \n     query_string = re.sub(filters_regexp, \"\", query_string).strip()\n \n@@ -112,7 +114,12 @@\n \n     is_phrase = False\n     tokens = []\n-    for part in query_string.split('\"'):\n+    if '\"' in query_string:\n+        parts = query_string.split('\"')\n+    else:\n+        parts = query_string.split(\"'\")\n+\n+    for part in parts:\n         part = part.strip()\n \n         if part:\n", "issue": "Ability to support outer double quotes in `wagtail.search.utils.parse_query_string`\n### Is your proposal related to a problem?\r\n\r\nIn #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.\r\n\r\nHowever, it was found that the support only works (originally, and with the changes) when the outer quotations are single quotation not double quotations.\r\n\r\n### Describe the solution you'd like\r\n\r\nAll 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\r\n\r\n```shell\r\n>>> from wagtail.search.utils import parse_query_string\r\n>>> parse_query_string('key1:\"hello key1\"')\r\n({'key1': 'hello key1'}, <Not <MatchAll>>)\r\n>>> parse_query_string('key1:\"hello key1\" key2:\"hello key2\"')\r\n({'key1': 'hello key1', 'key2': 'hello key2'}, <Not <MatchAll>>)\r\n>>> parse_query_string(\"key1:'hello key1'\")\r\n({}, <PlainText \"key1:'hello key1'\" operator='and' boost=1>)\r\n>>> parse_query_string(\"key1:'hello key1' key2:'hello key 2'\")\r\n({}, <PlainText \"key1:'hello key1' key2:'hello key 2'\" operator='and' boost=1>)\r\n```\r\n\r\nObserve above that the outer double quotations are treated a single string, but the outer single quotations are treated as a set of filters.\r\n\r\n### Describe alternatives you've considered\r\n\r\n* 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.\r\n\r\n\r\n### Additional info\r\n\r\n> @tomdyson should I raise a new issue for that additional edge case?\r\n\r\nI think so, yes please @lb-. Unless the quote type gets normalised further up the stack? I had a look but couldn't find anything.\r\n\r\n_Originally posted by @tomdyson in https://github.com/wagtail/wagtail/issues/9875#issuecomment-1396804696_\r\n            \n", "before_files": [{"content": "import operator\nimport re\nfrom functools import partial\n\nfrom django.apps import apps\nfrom django.db import connections\n\nfrom wagtail.search.index import RelatedFields, SearchField\n\nfrom .query import MATCH_NONE, Phrase, PlainText\n\nNOT_SET = object()\n\n\ndef balanced_reduce(operator, seq, initializer=NOT_SET):\n    \"\"\"\n    Has the same result as Python's reduce function, but performs the calculations in a different order.\n\n    This is important when the operator is constructing data structures such as search query classes.\n    This method will make the resulting data structures flatter, so operations that need to traverse\n    them don't end up crashing with recursion errors.\n\n    For example:\n\n    Python's builtin reduce() function will do the following calculation:\n\n    reduce(add, [1, 2, 3, 4, 5, 6, 7, 8])\n    (1 + (2 + (3 + (4 + (5 + (6 + (7 + 8)))))))\n\n    When using this with query classes, it would create a large data structure with a depth of 7\n    Whereas balanced_reduce will execute this like so:\n\n    balanced_reduce(add, [1, 2, 3, 4, 5, 6, 7, 8])\n    ((1 + 2) + (3 + 4)) + ((5 + 6) + (7 + 8))\n\n    Which only has a depth of 2\n    \"\"\"\n    # Casting all iterables to list makes the implementation simpler\n    if not isinstance(seq, list):\n        seq = list(seq)\n\n    # Note, it needs to be possible to use None as an initial value\n    if initializer is not NOT_SET:\n        if len(seq) == 0:\n            return initializer\n        else:\n            return operator(initializer, balanced_reduce(operator, seq))\n\n    if len(seq) == 0:\n        raise TypeError(\"reduce() of empty sequence with no initial value\")\n    elif len(seq) == 1:\n        return seq[0]\n    else:\n        break_point = len(seq) // 2\n        first_set = balanced_reduce(operator, seq[:break_point])\n        second_set = balanced_reduce(operator, seq[break_point:])\n        return operator(first_set, second_set)\n\n\n# Reduce any iterable to a single value using a logical OR e.g. (a | b | ...)\nOR = partial(balanced_reduce, operator.or_)\n# Reduce any iterable to a single value using a logical AND e.g. (a & b & ...)\nAND = partial(balanced_reduce, operator.and_)\n# Reduce any iterable to a single value using an addition\nADD = partial(balanced_reduce, operator.add)\n# Reduce any iterable to a single value using a multiplication\nMUL = partial(balanced_reduce, operator.mul)\n\nMAX_QUERY_STRING_LENGTH = 255\n\n\ndef normalise_query_string(query_string):\n    # Truncate query string\n    query_string = query_string[:MAX_QUERY_STRING_LENGTH]\n    # Convert query_string to lowercase\n    query_string = query_string.lower()\n\n    # Remove leading, trailing and multiple spaces\n    query_string = re.sub(\" +\", \" \", query_string).strip()\n\n    return query_string\n\n\ndef separate_filters_from_query(query_string):\n    filters_regexp = r'(\\w+):(\\w+|\"[^\"]+\")'\n\n    filters = {}\n    for match_object in re.finditer(filters_regexp, query_string):\n        key, value = match_object.groups()\n        filters[key] = value.strip('\"')\n\n    query_string = re.sub(filters_regexp, \"\", query_string).strip()\n\n    return filters, query_string\n\n\ndef parse_query_string(query_string, operator=None, zero_terms=MATCH_NONE):\n    \"\"\"\n    This takes a query string typed in by a user and extracts the following:\n\n     - Quoted terms (for phrase search)\n     - Filters\n\n    For example, the following query:\n\n      `hello \"this is a phrase\" live:true` would be parsed into:\n\n    filters: {'live': 'true'}\n    tokens: And([PlainText('hello'), Phrase('this is a phrase')])\n    \"\"\"\n    filters, query_string = separate_filters_from_query(query_string)\n\n    is_phrase = False\n    tokens = []\n    for part in query_string.split('\"'):\n        part = part.strip()\n\n        if part:\n            if is_phrase:\n                tokens.append(Phrase(part))\n            else:\n                tokens.append(\n                    PlainText(part, operator=operator or PlainText.DEFAULT_OPERATOR)\n                )\n\n        is_phrase = not is_phrase\n\n    if tokens:\n        if operator == \"or\":\n            search_query = OR(tokens)\n        else:\n            search_query = AND(tokens)\n    else:\n        search_query = zero_terms\n\n    return filters, search_query\n\n\ndef get_descendant_models(model):\n    \"\"\"\n    Returns all descendants of a model, including the model itself.\n    \"\"\"\n    descendant_models = {\n        other_model\n        for other_model in apps.get_models()\n        if issubclass(other_model, model)\n    }\n    descendant_models.add(model)\n    return descendant_models\n\n\ndef get_content_type_pk(model):\n    # We import it locally because this file is loaded before apps are ready.\n    from django.contrib.contenttypes.models import ContentType\n\n    return ContentType.objects.get_for_model(model).pk\n\n\ndef get_ancestors_content_types_pks(model):\n    \"\"\"\n    Returns content types ids for the ancestors of this model, excluding it.\n    \"\"\"\n    from django.contrib.contenttypes.models import ContentType\n\n    return [\n        ct.pk\n        for ct in ContentType.objects.get_for_models(\n            *model._meta.get_parent_list()\n        ).values()\n    ]\n\n\ndef get_descendants_content_types_pks(model):\n    \"\"\"\n    Returns content types ids for the descendants of this model, including it.\n    \"\"\"\n    from django.contrib.contenttypes.models import ContentType\n\n    return [\n        ct.pk\n        for ct in ContentType.objects.get_for_models(\n            *get_descendant_models(model)\n        ).values()\n    ]\n\n\ndef get_search_fields(search_fields):\n    for search_field in search_fields:\n        if isinstance(search_field, SearchField):\n            yield search_field\n        elif isinstance(search_field, RelatedFields):\n            for sub_field in get_search_fields(search_field.fields):\n                yield sub_field\n\n\ndef get_postgresql_connections():\n    return [\n        connection\n        for connection in connections.all()\n        if connection.vendor == \"postgresql\"\n    ]\n", "path": "wagtail/search/utils.py"}], "after_files": [{"content": "import operator\nimport re\nfrom functools import partial\n\nfrom django.apps import apps\nfrom django.db import connections\n\nfrom wagtail.search.index import RelatedFields, SearchField\n\nfrom .query import MATCH_NONE, Phrase, PlainText\n\nNOT_SET = object()\n\n\ndef balanced_reduce(operator, seq, initializer=NOT_SET):\n    \"\"\"\n    Has the same result as Python's reduce function, but performs the calculations in a different order.\n\n    This is important when the operator is constructing data structures such as search query classes.\n    This method will make the resulting data structures flatter, so operations that need to traverse\n    them don't end up crashing with recursion errors.\n\n    For example:\n\n    Python's builtin reduce() function will do the following calculation:\n\n    reduce(add, [1, 2, 3, 4, 5, 6, 7, 8])\n    (1 + (2 + (3 + (4 + (5 + (6 + (7 + 8)))))))\n\n    When using this with query classes, it would create a large data structure with a depth of 7\n    Whereas balanced_reduce will execute this like so:\n\n    balanced_reduce(add, [1, 2, 3, 4, 5, 6, 7, 8])\n    ((1 + 2) + (3 + 4)) + ((5 + 6) + (7 + 8))\n\n    Which only has a depth of 2\n    \"\"\"\n    # Casting all iterables to list makes the implementation simpler\n    if not isinstance(seq, list):\n        seq = list(seq)\n\n    # Note, it needs to be possible to use None as an initial value\n    if initializer is not NOT_SET:\n        if len(seq) == 0:\n            return initializer\n        else:\n            return operator(initializer, balanced_reduce(operator, seq))\n\n    if len(seq) == 0:\n        raise TypeError(\"reduce() of empty sequence with no initial value\")\n    elif len(seq) == 1:\n        return seq[0]\n    else:\n        break_point = len(seq) // 2\n        first_set = balanced_reduce(operator, seq[:break_point])\n        second_set = balanced_reduce(operator, seq[break_point:])\n        return operator(first_set, second_set)\n\n\n# Reduce any iterable to a single value using a logical OR e.g. (a | b | ...)\nOR = partial(balanced_reduce, operator.or_)\n# Reduce any iterable to a single value using a logical AND e.g. (a & b & ...)\nAND = partial(balanced_reduce, operator.and_)\n# Reduce any iterable to a single value using an addition\nADD = partial(balanced_reduce, operator.add)\n# Reduce any iterable to a single value using a multiplication\nMUL = partial(balanced_reduce, operator.mul)\n\nMAX_QUERY_STRING_LENGTH = 255\n\n\ndef normalise_query_string(query_string):\n    # Truncate query string\n    query_string = query_string[:MAX_QUERY_STRING_LENGTH]\n    # Convert query_string to lowercase\n    query_string = query_string.lower()\n\n    # Remove leading, trailing and multiple spaces\n    query_string = re.sub(\" +\", \" \", query_string).strip()\n\n    return query_string\n\n\ndef separate_filters_from_query(query_string):\n    filters_regexp = r'(\\w+):(\\w+|\"[^\"]+\"|\\'[^\\']+\\')'\n\n    filters = {}\n    for match_object in re.finditer(filters_regexp, query_string):\n        key, value = match_object.groups()\n        filters[key] = (\n            value.strip('\"') if value.strip('\"') is not value else value.strip(\"'\")\n        )\n\n    query_string = re.sub(filters_regexp, \"\", query_string).strip()\n\n    return filters, query_string\n\n\ndef parse_query_string(query_string, operator=None, zero_terms=MATCH_NONE):\n    \"\"\"\n    This takes a query string typed in by a user and extracts the following:\n\n     - Quoted terms (for phrase search)\n     - Filters\n\n    For example, the following query:\n\n      `hello \"this is a phrase\" live:true` would be parsed into:\n\n    filters: {'live': 'true'}\n    tokens: And([PlainText('hello'), Phrase('this is a phrase')])\n    \"\"\"\n    filters, query_string = separate_filters_from_query(query_string)\n\n    is_phrase = False\n    tokens = []\n    if '\"' in query_string:\n        parts = query_string.split('\"')\n    else:\n        parts = query_string.split(\"'\")\n\n    for part in parts:\n        part = part.strip()\n\n        if part:\n            if is_phrase:\n                tokens.append(Phrase(part))\n            else:\n                tokens.append(\n                    PlainText(part, operator=operator or PlainText.DEFAULT_OPERATOR)\n                )\n\n        is_phrase = not is_phrase\n\n    if tokens:\n        if operator == \"or\":\n            search_query = OR(tokens)\n        else:\n            search_query = AND(tokens)\n    else:\n        search_query = zero_terms\n\n    return filters, search_query\n\n\ndef get_descendant_models(model):\n    \"\"\"\n    Returns all descendants of a model, including the model itself.\n    \"\"\"\n    descendant_models = {\n        other_model\n        for other_model in apps.get_models()\n        if issubclass(other_model, model)\n    }\n    descendant_models.add(model)\n    return descendant_models\n\n\ndef get_content_type_pk(model):\n    # We import it locally because this file is loaded before apps are ready.\n    from django.contrib.contenttypes.models import ContentType\n\n    return ContentType.objects.get_for_model(model).pk\n\n\ndef get_ancestors_content_types_pks(model):\n    \"\"\"\n    Returns content types ids for the ancestors of this model, excluding it.\n    \"\"\"\n    from django.contrib.contenttypes.models import ContentType\n\n    return [\n        ct.pk\n        for ct in ContentType.objects.get_for_models(\n            *model._meta.get_parent_list()\n        ).values()\n    ]\n\n\ndef get_descendants_content_types_pks(model):\n    \"\"\"\n    Returns content types ids for the descendants of this model, including it.\n    \"\"\"\n    from django.contrib.contenttypes.models import ContentType\n\n    return [\n        ct.pk\n        for ct in ContentType.objects.get_for_models(\n            *get_descendant_models(model)\n        ).values()\n    ]\n\n\ndef get_search_fields(search_fields):\n    for search_field in search_fields:\n        if isinstance(search_field, SearchField):\n            yield search_field\n        elif isinstance(search_field, RelatedFields):\n            for sub_field in get_search_fields(search_field.fields):\n                yield sub_field\n\n\ndef get_postgresql_connections():\n    return [\n        connection\n        for connection in connections.all()\n        if connection.vendor == \"postgresql\"\n    ]\n", "path": "wagtail/search/utils.py"}]}