We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Search results page fails to render if search string includes special characters
<!-- What problem are we solving? What does the experience look like today? What are the symptoms? -->

### Evidence / Screenshot (if possible)
<img width="1050" alt="Screen Shot 2023-03-08 at 2 56 35 PM" src="https://user-images.githubusercontent.com/82178/223893720-68b534ce-5bf8-4b2c-b601-92ef76a4a2d4.png">

### Relevant url?

https://openlibrary.org/search?q=Cornell+%2777%3A+The+Music%2C+the+Myth+and+the+Magnificence+of+the+Grateful+Dead+Show+at+Barton+Hall&mode=everything

### Steps to Reproduce
<!-- What steps caused you to find the bug? -->
1. Go to ...
2. Do ...

<!-- What actually happened after these steps? What did you expect to happen? -->
* Actual:
* Expected:

### Details

- **Logged in (Y/N)?**
- **Browser type/version?**
- **Operating system?**
- **Environment (prod/dev/local)?** prod
<!-- If not sure, put prod -->

### Proposal & Constraints
<!-- What is the proposed solution / implementation? Is there a precedent of this approach succeeding elsewhere? -->

### Related files
<!-- Files related to this issue; this is super useful for new contributors who might want to help! If you're not sure, leave this blank; a maintainer will add them. -->

### Stakeholders
<!-- @ tag stakeholders of this bug -->


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `openlibrary/solr/query_utils.py`
Content:
```
1 from typing import Callable, Literal, Optional
2 from luqum.parser import parser
3 from luqum.tree import Item, SearchField, BaseOperation, Group, Word, Unary
4 import re
5 
6 
7 class EmptyTreeError(Exception):
8     pass
9 
10 
11 def luqum_remove_child(child: Item, parents: list[Item]):
12     """
13     Removes a child from a luqum parse tree. If the tree
14     ends up being empty, errors.
15 
16     :param child: Node to remove
17     :param parents: Path of parent nodes leading from the root of the tree
18     """
19     parent = parents[-1] if parents else None
20     if parent is None:
21         # We cannot remove the element if it is the root of the tree
22         raise EmptyTreeError()
23     elif isinstance(parent, (BaseOperation, Group, Unary)):
24         new_children = tuple(c for c in parent.children if c != child)
25         if not new_children:
26             # If we have deleted all the children, we need to delete the parent
27             # as well. And potentially recurse up the tree.
28             luqum_remove_child(parent, parents[:-1])
29         else:
30             parent.children = new_children
31     else:
32         raise ValueError("Not supported for generic class Item")
33 
34 
35 def luqum_replace_child(parent: Item, old_child: Item, new_child: Item):
36     """
37     Replaces a child in a luqum parse tree.
38     """
39     if isinstance(parent, (BaseOperation, Group, Unary)):
40         new_children = tuple(
41             new_child if c == old_child else c for c in parent.children
42         )
43         parent.children = new_children
44     else:
45         raise ValueError("Not supported for generic class Item")
46 
47 
48 def luqum_traverse(item: Item, _parents: list[Item] | None = None):
49     """
50     Traverses every node in the parse tree in depth-first order.
51 
52     Does not make any guarantees about what will happen if you
53     modify the tree while traversing it 😅 But we do it anyways.
54 
55     :param item: Node to traverse
56     :param _parents: Internal parameter for tracking parents
57     """
58     parents = _parents or []
59     yield item, parents
60     new_parents = [*parents, item]
61     for child in item.children:
62         yield from luqum_traverse(child, new_parents)
63 
64 
65 def escape_unknown_fields(
66     query: str,
67     is_valid_field: Callable[[str], bool],
68     lower=True,
69 ) -> str:
70     """
71     Escapes the colon of any search field that is not deemed valid by the
72     predicate function `is_valid_field`.
73 
74     :param query: Query to escape
75     :param is_valid_field: Predicate function that determines if a field is valid
76     :param lower: If true, the field will be lowercased before being checked
77 
78     >>> escape_unknown_fields('title:foo', lambda field: False)
79     'title\\\\:foo'
80     >>> escape_unknown_fields('title:foo bar   blah:bar baz:boo', lambda field: False)
81     'title\\\\:foo bar   blah\\\\:bar baz\\\\:boo'
82     >>> escape_unknown_fields('title:foo bar', {'title'}.__contains__)
83     'title:foo bar'
84     >>> escape_unknown_fields('title:foo bar baz:boo', {'title'}.__contains__)
85     'title:foo bar baz\\\\:boo'
86     >>> escape_unknown_fields('title:foo bar baz:boo', {'TITLE'}.__contains__, lower=False)
87     'title\\\\:foo bar baz\\\\:boo'
88     >>> escape_unknown_fields('hi', {'title'}.__contains__)
89     'hi'
90     >>> escape_unknown_fields('(title:foo) OR (blah:bah)', {'title'}.__contains__)
91     '(title:foo) OR (blah\\\\:bah)'
92     """
93     tree = parser.parse(query)
94     # Note we use the string of the tree, because it strips spaces
95     # like: "title : foo" -> "title:foo"
96     escaped_query = str(tree)
97     offset = 0
98     for sf, _ in luqum_traverse(tree):
99         if isinstance(sf, SearchField) and not is_valid_field(
100             sf.name.lower() if lower else sf.name
101         ):
102             field = sf.name + r'\:'
103             if hasattr(sf, 'head'):
104                 # head and tail are used for whitespace between fields;
105                 # copy it along to the write space to avoid things smashing
106                 # together
107                 field = sf.head + field
108 
109             # We will be moving left to right, so we need to adjust the offset
110             # to account for the characters we have already replaced
111             escaped_query = (
112                 escaped_query[: sf.pos + offset]
113                 + field
114                 + escaped_query[sf.pos + len(field) - 1 + offset :]
115             )
116             offset += 1
117     return escaped_query
118 
119 
120 def fully_escape_query(query: str) -> str:
121     """
122     Try to convert a query to basically a plain lucene string.
123 
124     >>> fully_escape_query('title:foo')
125     'title\\\\:foo'
126     >>> fully_escape_query('title:foo bar')
127     'title\\\\:foo bar'
128     >>> fully_escape_query('title:foo (bar baz:boo)')
129     'title\\\\:foo \\\\(bar baz\\\\:boo\\\\)'
130     >>> fully_escape_query('x:[A TO Z}')
131     'x\\\\:\\\\[A TO Z\\\\}'
132     >>> fully_escape_query('foo AND bar')
133     'foo and bar'
134     """
135     escaped = query
136     # Escape special characters
137     escaped = re.sub(r'[\[\]\(\)\{\}:"\-+?~^/\\,]', r'\\\g<0>', escaped)
138     # Remove boolean operators by making them lowercase
139     escaped = re.sub(r'AND|OR|NOT', lambda _1: _1.group(0).lower(), escaped)
140     return escaped
141 
142 
143 def luqum_parser(query: str) -> Item:
144     """
145     Parses a lucene-like query, with the special binding rules of Open Library.
146 
147     In our queries, unlike native solr/lucene, field names are greedy, and
148     affect the rest of the query until another field is hit.
149 
150     Here are some examples. The first query is the native solr/lucene
151     parsing. The second is the parsing we want.
152 
153     Query : title:foo bar
154     Lucene: (title:foo) bar
155     OL    : (title:foo bar)
156 
157     Query : title:foo OR bar AND author:blah
158     Lucene: (title:foo) OR (bar) AND (author:blah)
159     OL    : (title:foo OR bar) AND (author:blah)
160 
161     This requires an annoying amount of manipulation of the default
162     Luqum parser, unfortunately.
163 
164     Also, OL queries allow spaces after fields.
165     """
166     tree = parser.parse(query)
167 
168     def find_next_word(item: Item) -> Optional[tuple[Word, Optional[BaseOperation]]]:
169         if isinstance(item, Word):
170             return item, None
171         elif isinstance(item, BaseOperation) and isinstance(item.children[0], Word):
172             return item.children[0], item
173         else:
174             return None
175 
176     for node, parents in luqum_traverse(tree):
177         if isinstance(node, BaseOperation):
178             # if any of the children are SearchField followed by one or more words,
179             # we bundle them together
180             last_sf: SearchField = None
181             to_rem = []
182             for child in node.children:
183                 if isinstance(child, SearchField) and isinstance(child.expr, Word):
184                     last_sf = child
185                 elif last_sf and (next_word := find_next_word(child)):
186                     word, parent_op = next_word
187                     # Add it over
188                     if not isinstance(last_sf.expr, Group):
189                         last_sf.expr = Group(type(node)(last_sf.expr, word))
190                         last_sf.expr.tail = word.tail
191                         word.tail = ''
192                     else:
193                         last_sf.expr.expr.children[-1].tail = last_sf.expr.tail
194                         last_sf.expr.expr.children += (word,)
195                         last_sf.expr.tail = word.tail
196                         word.tail = ''
197                     if parent_op:
198                         # A query like: 'title:foo blah OR author:bar
199                         # Lucene parses as: (title:foo) ? (blah OR author:bar)
200                         # We want         : (title:foo ? blah) OR (author:bar)
201                         node.op = parent_op.op
202                         node.children += (*parent_op.children[1:],)
203                     to_rem.append(child)
204                 else:
205                     last_sf = None
206             if len(to_rem) == len(node.children) - 1:
207                 # We only have the searchfield left!
208                 if parents:
209                     # Move the head to the next element
210                     last_sf.head = node.head
211                     parents[-1].children = tuple(
212                         child if child is not node else last_sf
213                         for child in parents[-1].children
214                     )
215                 else:
216                     tree = last_sf
217                     break
218             else:
219                 node.children = tuple(
220                     child for child in node.children if child not in to_rem
221                 )
222 
223     # Remove spaces before field names
224     for node, parents in luqum_traverse(tree):
225         if isinstance(node, SearchField):
226             node.expr.head = ''
227 
228     return tree
229 
230 
231 def query_dict_to_str(
232     escaped: dict | None = None,
233     unescaped: dict | None = None,
234     op: Literal['AND', 'OR', ''] = '',
235     phrase: bool = False,
236 ) -> str:
237     """
238     Converts a query dict to a search query.
239 
240     >>> query_dict_to_str({'title': 'foo'})
241     'title:(foo)'
242     >>> query_dict_to_str({'title': 'foo bar', 'author': 'bar'})
243     'title:(foo bar)  author:(bar)'
244     >>> query_dict_to_str({'title': 'foo bar', 'author': 'bar'}, op='OR')
245     'title:(foo bar) OR author:(bar)'
246     >>> query_dict_to_str({'title': 'foo ? to escape'})
247     'title:(foo \\\\? to escape)'
248     >>> query_dict_to_str({'title': 'YES AND'})
249     'title:(YES and)'
250     >>> query_dict_to_str({'publisher_facet': 'Running Press'}, phrase=True)
251     'publisher_facet:"Running Press"'
252     """
253     result = ''
254     if escaped:
255         result += f' {op} '.join(
256             f'{k}:"{fully_escape_query(v)}"'
257             if phrase
258             else f'{k}:({fully_escape_query(v)})'
259             for k, v in escaped.items()
260         )
261     if unescaped:
262         if result:
263             result += f' {op} '
264         result += f' {op} '.join(f'{k}:{v}' for k, v in unescaped.items())
265     return result
266
```
--- 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/openlibrary/solr/query_utils.py b/openlibrary/solr/query_utils.py
--- a/openlibrary/solr/query_utils.py
+++ b/openlibrary/solr/query_utils.py
@@ -131,10 +131,12 @@
     'x\\\\:\\\\[A TO Z\\\\}'
     >>> fully_escape_query('foo AND bar')
     'foo and bar'
+    >>> fully_escape_query("foo's bar")
+    "foo\\\\'s bar"
     """
     escaped = query
     # Escape special characters
-    escaped = re.sub(r'[\[\]\(\)\{\}:"\-+?~^/\\,]', r'\\\g<0>', escaped)
+    escaped = re.sub(r'[\[\]\(\)\{\}:"\-+?~^/\\,\']', r'\\\g<0>', escaped)
     # Remove boolean operators by making them lowercase
     escaped = re.sub(r'AND|OR|NOT', lambda _1: _1.group(0).lower(), escaped)
     return escaped

{"golden_diff": "diff --git a/openlibrary/solr/query_utils.py b/openlibrary/solr/query_utils.py\n--- a/openlibrary/solr/query_utils.py\n+++ b/openlibrary/solr/query_utils.py\n@@ -131,10 +131,12 @@\n     'x\\\\\\\\:\\\\\\\\[A TO Z\\\\\\\\}'\n     >>> fully_escape_query('foo AND bar')\n     'foo and bar'\n+    >>> fully_escape_query(\"foo's bar\")\n+    \"foo\\\\\\\\'s bar\"\n     \"\"\"\n     escaped = query\n     # Escape special characters\n-    escaped = re.sub(r'[\\[\\]\\(\\)\\{\\}:\"\\-+?~^/\\\\,]', r'\\\\\\g<0>', escaped)\n+    escaped = re.sub(r'[\\[\\]\\(\\)\\{\\}:\"\\-+?~^/\\\\,\\']', r'\\\\\\g<0>', escaped)\n     # Remove boolean operators by making them lowercase\n     escaped = re.sub(r'AND|OR|NOT', lambda _1: _1.group(0).lower(), escaped)\n     return escaped\n", "issue": "Search results page fails to render if search string includes special characters\n<!-- What problem are we solving? What does the experience look like today? What are the symptoms? -->\r\n\r\n### Evidence / Screenshot (if possible)\r\n<img width=\"1050\" alt=\"Screen Shot 2023-03-08 at 2 56 35 PM\" src=\"https://user-images.githubusercontent.com/82178/223893720-68b534ce-5bf8-4b2c-b601-92ef76a4a2d4.png\">\r\n\r\n### Relevant url?\r\n\r\nhttps://openlibrary.org/search?q=Cornell+%2777%3A+The+Music%2C+the+Myth+and+the+Magnificence+of+the+Grateful+Dead+Show+at+Barton+Hall&mode=everything\r\n\r\n### Steps to Reproduce\r\n<!-- What steps caused you to find the bug? -->\r\n1. Go to ...\r\n2. Do ...\r\n\r\n<!-- What actually happened after these steps? What did you expect to happen? -->\r\n* Actual:\r\n* Expected:\r\n\r\n### Details\r\n\r\n- **Logged in (Y/N)?**\r\n- **Browser type/version?**\r\n- **Operating system?**\r\n- **Environment (prod/dev/local)?** prod\r\n<!-- If not sure, put prod -->\r\n\r\n### Proposal & Constraints\r\n<!-- What is the proposed solution / implementation? Is there a precedent of this approach succeeding elsewhere? -->\r\n\r\n### Related files\r\n<!-- Files related to this issue; this is super useful for new contributors who might want to help! If you're not sure, leave this blank; a maintainer will add them. -->\r\n\r\n### Stakeholders\r\n<!-- @ tag stakeholders of this bug -->\r\n\n", "before_files": [{"content": "from typing import Callable, Literal, Optional\nfrom luqum.parser import parser\nfrom luqum.tree import Item, SearchField, BaseOperation, Group, Word, Unary\nimport re\n\n\nclass EmptyTreeError(Exception):\n    pass\n\n\ndef luqum_remove_child(child: Item, parents: list[Item]):\n    \"\"\"\n    Removes a child from a luqum parse tree. If the tree\n    ends up being empty, errors.\n\n    :param child: Node to remove\n    :param parents: Path of parent nodes leading from the root of the tree\n    \"\"\"\n    parent = parents[-1] if parents else None\n    if parent is None:\n        # We cannot remove the element if it is the root of the tree\n        raise EmptyTreeError()\n    elif isinstance(parent, (BaseOperation, Group, Unary)):\n        new_children = tuple(c for c in parent.children if c != child)\n        if not new_children:\n            # If we have deleted all the children, we need to delete the parent\n            # as well. And potentially recurse up the tree.\n            luqum_remove_child(parent, parents[:-1])\n        else:\n            parent.children = new_children\n    else:\n        raise ValueError(\"Not supported for generic class Item\")\n\n\ndef luqum_replace_child(parent: Item, old_child: Item, new_child: Item):\n    \"\"\"\n    Replaces a child in a luqum parse tree.\n    \"\"\"\n    if isinstance(parent, (BaseOperation, Group, Unary)):\n        new_children = tuple(\n            new_child if c == old_child else c for c in parent.children\n        )\n        parent.children = new_children\n    else:\n        raise ValueError(\"Not supported for generic class Item\")\n\n\ndef luqum_traverse(item: Item, _parents: list[Item] | None = None):\n    \"\"\"\n    Traverses every node in the parse tree in depth-first order.\n\n    Does not make any guarantees about what will happen if you\n    modify the tree while traversing it \ud83d\ude05 But we do it anyways.\n\n    :param item: Node to traverse\n    :param _parents: Internal parameter for tracking parents\n    \"\"\"\n    parents = _parents or []\n    yield item, parents\n    new_parents = [*parents, item]\n    for child in item.children:\n        yield from luqum_traverse(child, new_parents)\n\n\ndef escape_unknown_fields(\n    query: str,\n    is_valid_field: Callable[[str], bool],\n    lower=True,\n) -> str:\n    \"\"\"\n    Escapes the colon of any search field that is not deemed valid by the\n    predicate function `is_valid_field`.\n\n    :param query: Query to escape\n    :param is_valid_field: Predicate function that determines if a field is valid\n    :param lower: If true, the field will be lowercased before being checked\n\n    >>> escape_unknown_fields('title:foo', lambda field: False)\n    'title\\\\\\\\:foo'\n    >>> escape_unknown_fields('title:foo bar   blah:bar baz:boo', lambda field: False)\n    'title\\\\\\\\:foo bar   blah\\\\\\\\:bar baz\\\\\\\\:boo'\n    >>> escape_unknown_fields('title:foo bar', {'title'}.__contains__)\n    'title:foo bar'\n    >>> escape_unknown_fields('title:foo bar baz:boo', {'title'}.__contains__)\n    'title:foo bar baz\\\\\\\\:boo'\n    >>> escape_unknown_fields('title:foo bar baz:boo', {'TITLE'}.__contains__, lower=False)\n    'title\\\\\\\\:foo bar baz\\\\\\\\:boo'\n    >>> escape_unknown_fields('hi', {'title'}.__contains__)\n    'hi'\n    >>> escape_unknown_fields('(title:foo) OR (blah:bah)', {'title'}.__contains__)\n    '(title:foo) OR (blah\\\\\\\\:bah)'\n    \"\"\"\n    tree = parser.parse(query)\n    # Note we use the string of the tree, because it strips spaces\n    # like: \"title : foo\" -> \"title:foo\"\n    escaped_query = str(tree)\n    offset = 0\n    for sf, _ in luqum_traverse(tree):\n        if isinstance(sf, SearchField) and not is_valid_field(\n            sf.name.lower() if lower else sf.name\n        ):\n            field = sf.name + r'\\:'\n            if hasattr(sf, 'head'):\n                # head and tail are used for whitespace between fields;\n                # copy it along to the write space to avoid things smashing\n                # together\n                field = sf.head + field\n\n            # We will be moving left to right, so we need to adjust the offset\n            # to account for the characters we have already replaced\n            escaped_query = (\n                escaped_query[: sf.pos + offset]\n                + field\n                + escaped_query[sf.pos + len(field) - 1 + offset :]\n            )\n            offset += 1\n    return escaped_query\n\n\ndef fully_escape_query(query: str) -> str:\n    \"\"\"\n    Try to convert a query to basically a plain lucene string.\n\n    >>> fully_escape_query('title:foo')\n    'title\\\\\\\\:foo'\n    >>> fully_escape_query('title:foo bar')\n    'title\\\\\\\\:foo bar'\n    >>> fully_escape_query('title:foo (bar baz:boo)')\n    'title\\\\\\\\:foo \\\\\\\\(bar baz\\\\\\\\:boo\\\\\\\\)'\n    >>> fully_escape_query('x:[A TO Z}')\n    'x\\\\\\\\:\\\\\\\\[A TO Z\\\\\\\\}'\n    >>> fully_escape_query('foo AND bar')\n    'foo and bar'\n    \"\"\"\n    escaped = query\n    # Escape special characters\n    escaped = re.sub(r'[\\[\\]\\(\\)\\{\\}:\"\\-+?~^/\\\\,]', r'\\\\\\g<0>', escaped)\n    # Remove boolean operators by making them lowercase\n    escaped = re.sub(r'AND|OR|NOT', lambda _1: _1.group(0).lower(), escaped)\n    return escaped\n\n\ndef luqum_parser(query: str) -> Item:\n    \"\"\"\n    Parses a lucene-like query, with the special binding rules of Open Library.\n\n    In our queries, unlike native solr/lucene, field names are greedy, and\n    affect the rest of the query until another field is hit.\n\n    Here are some examples. The first query is the native solr/lucene\n    parsing. The second is the parsing we want.\n\n    Query : title:foo bar\n    Lucene: (title:foo) bar\n    OL    : (title:foo bar)\n\n    Query : title:foo OR bar AND author:blah\n    Lucene: (title:foo) OR (bar) AND (author:blah)\n    OL    : (title:foo OR bar) AND (author:blah)\n\n    This requires an annoying amount of manipulation of the default\n    Luqum parser, unfortunately.\n\n    Also, OL queries allow spaces after fields.\n    \"\"\"\n    tree = parser.parse(query)\n\n    def find_next_word(item: Item) -> Optional[tuple[Word, Optional[BaseOperation]]]:\n        if isinstance(item, Word):\n            return item, None\n        elif isinstance(item, BaseOperation) and isinstance(item.children[0], Word):\n            return item.children[0], item\n        else:\n            return None\n\n    for node, parents in luqum_traverse(tree):\n        if isinstance(node, BaseOperation):\n            # if any of the children are SearchField followed by one or more words,\n            # we bundle them together\n            last_sf: SearchField = None\n            to_rem = []\n            for child in node.children:\n                if isinstance(child, SearchField) and isinstance(child.expr, Word):\n                    last_sf = child\n                elif last_sf and (next_word := find_next_word(child)):\n                    word, parent_op = next_word\n                    # Add it over\n                    if not isinstance(last_sf.expr, Group):\n                        last_sf.expr = Group(type(node)(last_sf.expr, word))\n                        last_sf.expr.tail = word.tail\n                        word.tail = ''\n                    else:\n                        last_sf.expr.expr.children[-1].tail = last_sf.expr.tail\n                        last_sf.expr.expr.children += (word,)\n                        last_sf.expr.tail = word.tail\n                        word.tail = ''\n                    if parent_op:\n                        # A query like: 'title:foo blah OR author:bar\n                        # Lucene parses as: (title:foo) ? (blah OR author:bar)\n                        # We want         : (title:foo ? blah) OR (author:bar)\n                        node.op = parent_op.op\n                        node.children += (*parent_op.children[1:],)\n                    to_rem.append(child)\n                else:\n                    last_sf = None\n            if len(to_rem) == len(node.children) - 1:\n                # We only have the searchfield left!\n                if parents:\n                    # Move the head to the next element\n                    last_sf.head = node.head\n                    parents[-1].children = tuple(\n                        child if child is not node else last_sf\n                        for child in parents[-1].children\n                    )\n                else:\n                    tree = last_sf\n                    break\n            else:\n                node.children = tuple(\n                    child for child in node.children if child not in to_rem\n                )\n\n    # Remove spaces before field names\n    for node, parents in luqum_traverse(tree):\n        if isinstance(node, SearchField):\n            node.expr.head = ''\n\n    return tree\n\n\ndef query_dict_to_str(\n    escaped: dict | None = None,\n    unescaped: dict | None = None,\n    op: Literal['AND', 'OR', ''] = '',\n    phrase: bool = False,\n) -> str:\n    \"\"\"\n    Converts a query dict to a search query.\n\n    >>> query_dict_to_str({'title': 'foo'})\n    'title:(foo)'\n    >>> query_dict_to_str({'title': 'foo bar', 'author': 'bar'})\n    'title:(foo bar)  author:(bar)'\n    >>> query_dict_to_str({'title': 'foo bar', 'author': 'bar'}, op='OR')\n    'title:(foo bar) OR author:(bar)'\n    >>> query_dict_to_str({'title': 'foo ? to escape'})\n    'title:(foo \\\\\\\\? to escape)'\n    >>> query_dict_to_str({'title': 'YES AND'})\n    'title:(YES and)'\n    >>> query_dict_to_str({'publisher_facet': 'Running Press'}, phrase=True)\n    'publisher_facet:\"Running Press\"'\n    \"\"\"\n    result = ''\n    if escaped:\n        result += f' {op} '.join(\n            f'{k}:\"{fully_escape_query(v)}\"'\n            if phrase\n            else f'{k}:({fully_escape_query(v)})'\n            for k, v in escaped.items()\n        )\n    if unescaped:\n        if result:\n            result += f' {op} '\n        result += f' {op} '.join(f'{k}:{v}' for k, v in unescaped.items())\n    return result\n", "path": "openlibrary/solr/query_utils.py"}], "after_files": [{"content": "from typing import Callable, Literal, Optional\nfrom luqum.parser import parser\nfrom luqum.tree import Item, SearchField, BaseOperation, Group, Word, Unary\nimport re\n\n\nclass EmptyTreeError(Exception):\n    pass\n\n\ndef luqum_remove_child(child: Item, parents: list[Item]):\n    \"\"\"\n    Removes a child from a luqum parse tree. If the tree\n    ends up being empty, errors.\n\n    :param child: Node to remove\n    :param parents: Path of parent nodes leading from the root of the tree\n    \"\"\"\n    parent = parents[-1] if parents else None\n    if parent is None:\n        # We cannot remove the element if it is the root of the tree\n        raise EmptyTreeError()\n    elif isinstance(parent, (BaseOperation, Group, Unary)):\n        new_children = tuple(c for c in parent.children if c != child)\n        if not new_children:\n            # If we have deleted all the children, we need to delete the parent\n            # as well. And potentially recurse up the tree.\n            luqum_remove_child(parent, parents[:-1])\n        else:\n            parent.children = new_children\n    else:\n        raise ValueError(\"Not supported for generic class Item\")\n\n\ndef luqum_replace_child(parent: Item, old_child: Item, new_child: Item):\n    \"\"\"\n    Replaces a child in a luqum parse tree.\n    \"\"\"\n    if isinstance(parent, (BaseOperation, Group, Unary)):\n        new_children = tuple(\n            new_child if c == old_child else c for c in parent.children\n        )\n        parent.children = new_children\n    else:\n        raise ValueError(\"Not supported for generic class Item\")\n\n\ndef luqum_traverse(item: Item, _parents: list[Item] | None = None):\n    \"\"\"\n    Traverses every node in the parse tree in depth-first order.\n\n    Does not make any guarantees about what will happen if you\n    modify the tree while traversing it \ud83d\ude05 But we do it anyways.\n\n    :param item: Node to traverse\n    :param _parents: Internal parameter for tracking parents\n    \"\"\"\n    parents = _parents or []\n    yield item, parents\n    new_parents = [*parents, item]\n    for child in item.children:\n        yield from luqum_traverse(child, new_parents)\n\n\ndef escape_unknown_fields(\n    query: str,\n    is_valid_field: Callable[[str], bool],\n    lower=True,\n) -> str:\n    \"\"\"\n    Escapes the colon of any search field that is not deemed valid by the\n    predicate function `is_valid_field`.\n\n    :param query: Query to escape\n    :param is_valid_field: Predicate function that determines if a field is valid\n    :param lower: If true, the field will be lowercased before being checked\n\n    >>> escape_unknown_fields('title:foo', lambda field: False)\n    'title\\\\\\\\:foo'\n    >>> escape_unknown_fields('title:foo bar   blah:bar baz:boo', lambda field: False)\n    'title\\\\\\\\:foo bar   blah\\\\\\\\:bar baz\\\\\\\\:boo'\n    >>> escape_unknown_fields('title:foo bar', {'title'}.__contains__)\n    'title:foo bar'\n    >>> escape_unknown_fields('title:foo bar baz:boo', {'title'}.__contains__)\n    'title:foo bar baz\\\\\\\\:boo'\n    >>> escape_unknown_fields('title:foo bar baz:boo', {'TITLE'}.__contains__, lower=False)\n    'title\\\\\\\\:foo bar baz\\\\\\\\:boo'\n    >>> escape_unknown_fields('hi', {'title'}.__contains__)\n    'hi'\n    >>> escape_unknown_fields('(title:foo) OR (blah:bah)', {'title'}.__contains__)\n    '(title:foo) OR (blah\\\\\\\\:bah)'\n    \"\"\"\n    tree = parser.parse(query)\n    # Note we use the string of the tree, because it strips spaces\n    # like: \"title : foo\" -> \"title:foo\"\n    escaped_query = str(tree)\n    offset = 0\n    for sf, _ in luqum_traverse(tree):\n        if isinstance(sf, SearchField) and not is_valid_field(\n            sf.name.lower() if lower else sf.name\n        ):\n            field = sf.name + r'\\:'\n            if hasattr(sf, 'head'):\n                # head and tail are used for whitespace between fields;\n                # copy it along to the write space to avoid things smashing\n                # together\n                field = sf.head + field\n\n            # We will be moving left to right, so we need to adjust the offset\n            # to account for the characters we have already replaced\n            escaped_query = (\n                escaped_query[: sf.pos + offset]\n                + field\n                + escaped_query[sf.pos + len(field) - 1 + offset :]\n            )\n            offset += 1\n    return escaped_query\n\n\ndef fully_escape_query(query: str) -> str:\n    \"\"\"\n    Try to convert a query to basically a plain lucene string.\n\n    >>> fully_escape_query('title:foo')\n    'title\\\\\\\\:foo'\n    >>> fully_escape_query('title:foo bar')\n    'title\\\\\\\\:foo bar'\n    >>> fully_escape_query('title:foo (bar baz:boo)')\n    'title\\\\\\\\:foo \\\\\\\\(bar baz\\\\\\\\:boo\\\\\\\\)'\n    >>> fully_escape_query('x:[A TO Z}')\n    'x\\\\\\\\:\\\\\\\\[A TO Z\\\\\\\\}'\n    >>> fully_escape_query('foo AND bar')\n    'foo and bar'\n    >>> fully_escape_query(\"foo's bar\")\n    \"foo\\\\\\\\'s bar\"\n    \"\"\"\n    escaped = query\n    # Escape special characters\n    escaped = re.sub(r'[\\[\\]\\(\\)\\{\\}:\"\\-+?~^/\\\\,\\']', r'\\\\\\g<0>', escaped)\n    # Remove boolean operators by making them lowercase\n    escaped = re.sub(r'AND|OR|NOT', lambda _1: _1.group(0).lower(), escaped)\n    return escaped\n\n\ndef luqum_parser(query: str) -> Item:\n    \"\"\"\n    Parses a lucene-like query, with the special binding rules of Open Library.\n\n    In our queries, unlike native solr/lucene, field names are greedy, and\n    affect the rest of the query until another field is hit.\n\n    Here are some examples. The first query is the native solr/lucene\n    parsing. The second is the parsing we want.\n\n    Query : title:foo bar\n    Lucene: (title:foo) bar\n    OL    : (title:foo bar)\n\n    Query : title:foo OR bar AND author:blah\n    Lucene: (title:foo) OR (bar) AND (author:blah)\n    OL    : (title:foo OR bar) AND (author:blah)\n\n    This requires an annoying amount of manipulation of the default\n    Luqum parser, unfortunately.\n\n    Also, OL queries allow spaces after fields.\n    \"\"\"\n    tree = parser.parse(query)\n\n    def find_next_word(item: Item) -> Optional[tuple[Word, Optional[BaseOperation]]]:\n        if isinstance(item, Word):\n            return item, None\n        elif isinstance(item, BaseOperation) and isinstance(item.children[0], Word):\n            return item.children[0], item\n        else:\n            return None\n\n    for node, parents in luqum_traverse(tree):\n        if isinstance(node, BaseOperation):\n            # if any of the children are SearchField followed by one or more words,\n            # we bundle them together\n            last_sf: SearchField = None\n            to_rem = []\n            for child in node.children:\n                if isinstance(child, SearchField) and isinstance(child.expr, Word):\n                    last_sf = child\n                elif last_sf and (next_word := find_next_word(child)):\n                    word, parent_op = next_word\n                    # Add it over\n                    if not isinstance(last_sf.expr, Group):\n                        last_sf.expr = Group(type(node)(last_sf.expr, word))\n                        last_sf.expr.tail = word.tail\n                        word.tail = ''\n                    else:\n                        last_sf.expr.expr.children[-1].tail = last_sf.expr.tail\n                        last_sf.expr.expr.children += (word,)\n                        last_sf.expr.tail = word.tail\n                        word.tail = ''\n                    if parent_op:\n                        # A query like: 'title:foo blah OR author:bar\n                        # Lucene parses as: (title:foo) ? (blah OR author:bar)\n                        # We want         : (title:foo ? blah) OR (author:bar)\n                        node.op = parent_op.op\n                        node.children += (*parent_op.children[1:],)\n                    to_rem.append(child)\n                else:\n                    last_sf = None\n            if len(to_rem) == len(node.children) - 1:\n                # We only have the searchfield left!\n                if parents:\n                    # Move the head to the next element\n                    last_sf.head = node.head\n                    parents[-1].children = tuple(\n                        child if child is not node else last_sf\n                        for child in parents[-1].children\n                    )\n                else:\n                    tree = last_sf\n                    break\n            else:\n                node.children = tuple(\n                    child for child in node.children if child not in to_rem\n                )\n\n    # Remove spaces before field names\n    for node, parents in luqum_traverse(tree):\n        if isinstance(node, SearchField):\n            node.expr.head = ''\n\n    return tree\n\n\ndef query_dict_to_str(\n    escaped: dict | None = None,\n    unescaped: dict | None = None,\n    op: Literal['AND', 'OR', ''] = '',\n    phrase: bool = False,\n) -> str:\n    \"\"\"\n    Converts a query dict to a search query.\n\n    >>> query_dict_to_str({'title': 'foo'})\n    'title:(foo)'\n    >>> query_dict_to_str({'title': 'foo bar', 'author': 'bar'})\n    'title:(foo bar)  author:(bar)'\n    >>> query_dict_to_str({'title': 'foo bar', 'author': 'bar'}, op='OR')\n    'title:(foo bar) OR author:(bar)'\n    >>> query_dict_to_str({'title': 'foo ? to escape'})\n    'title:(foo \\\\\\\\? to escape)'\n    >>> query_dict_to_str({'title': 'YES AND'})\n    'title:(YES and)'\n    >>> query_dict_to_str({'publisher_facet': 'Running Press'}, phrase=True)\n    'publisher_facet:\"Running Press\"'\n    \"\"\"\n    result = ''\n    if escaped:\n        result += f' {op} '.join(\n            f'{k}:\"{fully_escape_query(v)}\"'\n            if phrase\n            else f'{k}:({fully_escape_query(v)})'\n            for k, v in escaped.items()\n        )\n    if unescaped:\n        if result:\n            result += f' {op} '\n        result += f' {op} '.join(f'{k}:{v}' for k, v in unescaped.items())\n    return result\n", "path": "openlibrary/solr/query_utils.py"}]}