Skip to content

Commit 7463002

Browse files
committed
feat: add search operator
allow for more specific search queries by specifying the search_operator
1 parent 48b983d commit 7463002

File tree

4 files changed

+178
-31
lines changed

4 files changed

+178
-31
lines changed

docs/general-usage/interfaces.rst

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,12 @@ The interface exposes the following fields, following the Wagtail Page model fie
2828
showInMenus: Boolean
2929
contentType: String
3030
parent: PageInterface
31-
children(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface]
32-
siblings(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface]
33-
nextSiblings(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface]
34-
previousSiblings(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface]
35-
descendants(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface]
36-
ancestors(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface]
31+
children(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, searchOperator: SearchOperatorEnum, id: ID): [PageInterface]
32+
siblings(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, searchOperator: SearchOperatorEnum, id: ID): [PageInterface]
33+
nextSiblings(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, searchOperator: SearchOperatorEnum, id: ID): [PageInterface]
34+
previousSiblings(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, searchOperator: SearchOperatorEnum, id: ID): [PageInterface]
35+
descendants(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, searchOperator: SearchOperatorEnum, id: ID): [PageInterface]
36+
ancestors(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, searchOperator: SearchOperatorEnum, id: ID): [PageInterface]
3737

3838

3939
Any custom ``graphql_fields`` added to your specific Page models will be available here via the 'on' spread operator and
@@ -65,7 +65,8 @@ accepts the following arguments:
6565
offset: PositiveInt
6666
order: String
6767
searchQuery: String
68-
contentType: String # comma separated list of content types in app.Model notation
68+
searchOperator: OR | AND # default is AND
69+
contentType: String # comma separated list of content types in app.Model notation
6970
inSite: Boolean
7071
ancestor: PositiveInt # ID of ancestor page to restrict results to
7172
parent: PositiveInt # ID of parent page to restrict results to

grapple/types/structures.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ def parse_literal(ast, _variables=None):
2121
return return_value
2222

2323

24+
class SearchOperatorEnum(graphene.Enum):
25+
"""
26+
Enum for search operator.
27+
"""
28+
29+
AND = "and"
30+
OR = "or"
31+
32+
def __str__(self):
33+
# the core search parser expects the operator to be a string.
34+
# the default __str__ returns SearchOperatorEnum.AND/OR,
35+
# this __str__ returns the value and/or for compatibility.
36+
return self.value
37+
38+
2439
class QuerySetList(graphene.List):
2540
"""
2641
List type with arguments used by Django's query sets.
@@ -32,6 +47,7 @@ class QuerySetList(graphene.List):
3247
* ``limit``
3348
* ``offset``
3449
* ``search_query``
50+
* ``search_operator``
3551
* ``order``
3652
3753
:param enable_in_menu: Enable in_menu filter.
@@ -42,6 +58,8 @@ class QuerySetList(graphene.List):
4258
:type enable_offset: bool
4359
:param enable_search: Enable search query argument.
4460
:type enable_search: bool
61+
:param enable_search_operator: Enable search operator argument, enable_search must also be True
62+
:type enable_search_operator: bool
4563
:param enable_order: Enable ordering via query argument.
4664
:type enable_order: bool
4765
"""
@@ -50,8 +68,9 @@ def __init__(self, of_type, *args, **kwargs):
5068
enable_in_menu = kwargs.pop("enable_in_menu", False)
5169
enable_limit = kwargs.pop("enable_limit", True)
5270
enable_offset = kwargs.pop("enable_offset", True)
53-
enable_search = kwargs.pop("enable_search", True)
5471
enable_order = kwargs.pop("enable_order", True)
72+
enable_search = kwargs.pop("enable_search", True)
73+
enable_search_operator = kwargs.pop("enable_search_operator", True)
5574

5675
# Check if the type is a Django model type. Do not perform the
5776
# check if value is lazy.
@@ -106,6 +125,14 @@ def __init__(self, of_type, *args, **kwargs):
106125
graphene.String,
107126
description=_("Filter the results using Wagtail's search."),
108127
)
128+
if enable_search_operator:
129+
kwargs["search_operator"] = graphene.Argument(
130+
SearchOperatorEnum,
131+
description=_(
132+
"Specify search operator (and/or), see: https://docs.wagtail.org/en/stable/topics/search/searching.html#search-operator"
133+
),
134+
default_value="and",
135+
)
109136

110137
if "id" not in kwargs:
111138
kwargs["id"] = graphene.Argument(graphene.ID, description=_("Filter by ID"))
@@ -152,23 +179,27 @@ def PaginatedQuerySet(of_type, type_class, **kwargs):
152179
"""
153180
Paginated QuerySet type with arguments used by Django's query sets.
154181
155-
This type setts the following arguments on itself:
182+
This type sets the following arguments on itself:
156183
157184
* ``id``
158185
* ``in_menu``
159186
* ``page``
160187
* ``per_page``
161188
* ``search_query``
189+
* ``search_operator``
162190
* ``order``
163191
164192
:param enable_search: Enable search query argument.
165193
:type enable_search: bool
194+
:param enable_search_operator: Enable search operator argument, enable_search must also be True
195+
:type enable_search_operator: bool
166196
:param enable_order: Enable ordering via query argument.
167197
:type enable_order: bool
168198
"""
169199

170200
enable_in_menu = kwargs.pop("enable_in_menu", False)
171201
enable_search = kwargs.pop("enable_search", True)
202+
enable_search_operator = kwargs.pop("enable_search_operator", True)
172203
enable_order = kwargs.pop("enable_order", True)
173204
required = kwargs.get("required", False)
174205
type_name = type_class if isinstance(type_class, str) else type_class.__name__
@@ -225,6 +256,14 @@ def PaginatedQuerySet(of_type, type_class, **kwargs):
225256
kwargs["search_query"] = graphene.Argument(
226257
graphene.String, description=_("Filter the results using Wagtail's search.")
227258
)
259+
if enable_search_operator:
260+
kwargs["search_operator"] = graphene.Argument(
261+
SearchOperatorEnum,
262+
description=_(
263+
"Specify search operator (and/or), see: https://docs.wagtail.org/en/stable/topics/search/searching.html#search-operator"
264+
),
265+
default_value="and",
266+
)
228267

229268
if "id" not in kwargs:
230269
kwargs["id"] = graphene.Argument(graphene.ID, description=_("Filter by ID"))

grapple/utils.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from wagtail import VERSION as WAGTAIL_VERSION
99
from wagtail.models import Site
1010
from wagtail.search.index import class_is_indexed
11+
from wagtail.search.utils import parse_query_string
1112

1213
from .settings import grapple_settings
1314
from .types.structures import BasePaginatedType, PaginationType
@@ -101,6 +102,7 @@ def resolve_queryset(
101102
order=None,
102103
collection=None,
103104
in_menu=None,
105+
search_operator="and",
104106
**kwargs,
105107
):
106108
"""
@@ -122,6 +124,9 @@ def resolve_queryset(
122124
:type order: str
123125
:param collection: Use Wagtail's collection id to filter images or documents
124126
:type collection: int
127+
:param search_operator: The operator to use when combining search terms.
128+
Defaults to "and".
129+
:type search_operator: "and" | "or"
125130
"""
126131

127132
qs = qs.all() if id is None else qs.filter(pk=id)
@@ -152,7 +157,13 @@ def resolve_queryset(
152157
query = Query.get(search_query)
153158
query.add_hit()
154159

155-
qs = qs.search(search_query, order_by_relevance=order_by_relevance)
160+
filters, parsed_query = parse_query_string(search_query, str(search_operator))
161+
162+
qs = qs.search(
163+
parsed_query,
164+
order_by_relevance=order_by_relevance,
165+
operator=search_operator,
166+
)
156167
if connection.vendor != "sqlite":
157168
qs = qs.annotate_score("search_score")
158169

@@ -183,17 +194,25 @@ def get_paginated_result(qs, page, per_page):
183194
count=len(page_obj.object_list),
184195
per_page=per_page,
185196
current_page=page_obj.number,
186-
prev_page=page_obj.previous_page_number()
187-
if page_obj.has_previous()
188-
else None,
197+
prev_page=(
198+
page_obj.previous_page_number() if page_obj.has_previous() else None
199+
),
189200
next_page=page_obj.next_page_number() if page_obj.has_next() else None,
190201
total_pages=paginator.num_pages,
191202
),
192203
)
193204

194205

195206
def resolve_paginated_queryset(
196-
qs, info, page=None, per_page=None, search_query=None, id=None, order=None, **kwargs
207+
qs,
208+
info,
209+
page=None,
210+
per_page=None,
211+
id=None,
212+
order=None,
213+
search_query=None,
214+
search_operator="and",
215+
**kwargs,
197216
):
198217
"""
199218
Add page, per_page and search capabilities to the query. This contains
@@ -207,11 +226,14 @@ def resolve_paginated_queryset(
207226
:type id: int
208227
:param per_page: The maximum number of items to include on a page.
209228
:type per_page: int
229+
:param order: Order the query set using the Django QuerySet order_by format.
230+
:type order: str
210231
:param search_query: Using Wagtail search, exclude objects that do not match
211232
the search query.
212233
:type search_query: str
213-
:param order: Order the query set using the Django QuerySet order_by format.
214-
:type order: str
234+
:param search_operator: The operator to use when combining search terms.
235+
Defaults to "and".
236+
:type search_operator: "and" | "or"
215237
"""
216238
page = int(page or 1)
217239
per_page = min(
@@ -236,7 +258,13 @@ def resolve_paginated_queryset(
236258
query = Query.get(search_query)
237259
query.add_hit()
238260

239-
qs = qs.search(search_query, order_by_relevance=order_by_relevance)
261+
filters, parsed_query = parse_query_string(search_query, search_operator)
262+
263+
qs = qs.search(
264+
parsed_query,
265+
order_by_relevance=order_by_relevance,
266+
operator=search_operator,
267+
)
240268
if connection.vendor != "sqlite":
241269
qs = qs.annotate_score("search_score")
242270

tests/test_grapple.py

Lines changed: 93 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -474,18 +474,41 @@ class PagesSearchTest(BaseGrappleTest):
474474
@classmethod
475475
def setUpTestData(cls):
476476
cls.home = HomePage.objects.first()
477-
BlogPageFactory(title="Alpha", parent=cls.home, show_in_menus=True)
478-
BlogPageFactory(title="Alpha Alpha", parent=cls.home)
479-
BlogPageFactory(title="Alpha Beta", parent=cls.home)
480-
BlogPageFactory(title="Alpha Gamma", parent=cls.home)
481-
BlogPageFactory(title="Beta", parent=cls.home)
482-
BlogPageFactory(title="Beta Alpha", parent=cls.home)
483-
BlogPageFactory(title="Beta Beta", parent=cls.home)
484-
BlogPageFactory(title="Beta Gamma", parent=cls.home)
485-
BlogPageFactory(title="Gamma", parent=cls.home)
486-
BlogPageFactory(title="Gamma Alpha", parent=cls.home)
487-
BlogPageFactory(title="Gamma Beta", parent=cls.home)
488-
BlogPageFactory(title="Gamma Gamma", parent=cls.home)
477+
BlogPageFactory(
478+
title="Alpha",
479+
body=[("heading", "Sigma")],
480+
parent=cls.home,
481+
show_in_menus=True,
482+
)
483+
BlogPageFactory(
484+
title="Alpha Alpha", body=[("heading", "Sigma Sigma")], parent=cls.home
485+
)
486+
BlogPageFactory(
487+
title="Alpha Beta", body=[("heading", "Sigma Theta")], parent=cls.home
488+
)
489+
BlogPageFactory(
490+
title="Alpha Gamma", body=[("heading", "Sigma Delta")], parent=cls.home
491+
)
492+
BlogPageFactory(title="Beta", body=[("heading", "Theta")], parent=cls.home)
493+
BlogPageFactory(
494+
title="Beta Alpha", body=[("heading", "Theta Sigma")], parent=cls.home
495+
)
496+
BlogPageFactory(
497+
title="Beta Beta", body=[("heading", "Theta Theta")], parent=cls.home
498+
)
499+
BlogPageFactory(
500+
title="Beta Gamma", body=[("heading", "Theta Delta")], parent=cls.home
501+
)
502+
BlogPageFactory(title="Gamma", body=[("heading", "Delta")], parent=cls.home)
503+
BlogPageFactory(
504+
title="Gamma Alpha", body=[("heading", "Delta Sigma")], parent=cls.home
505+
)
506+
BlogPageFactory(
507+
title="Gamma Beta", body=[("heading", "Delta Theta")], parent=cls.home
508+
)
509+
BlogPageFactory(
510+
title="Gamma Gamma", body=[("heading", "Delta Delta")], parent=cls.home
511+
)
489512

490513
@unittest.skipIf(
491514
connection.vendor != "sqlite",
@@ -530,7 +553,6 @@ def test_searchQuery_order_by_relevance(self):
530553
}
531554
}
532555
"""
533-
534556
executed = self.client.execute(query, variables={"searchQuery": "Alpha"})
535557
page_data = executed["data"].get("pages")
536558
self.assertEqual(len(page_data), 6)
@@ -559,7 +581,6 @@ def test_explicit_order(self):
559581
query, variables={"searchQuery": "Gamma", "order": "-title"}
560582
)
561583
page_data = executed["data"].get("pages")
562-
563584
self.assertEqual(len(page_data), 6)
564585
self.assertEqual(page_data[0]["title"], "Gamma Gamma")
565586
self.assertEqual(page_data[1]["title"], "Gamma Beta")
@@ -593,6 +614,64 @@ def test_search_not_in_menus(self):
593614
page_data = executed["data"].get("pages")
594615
self.assertEqual(len(page_data), 12) # 11 blog pages + home page
595616

617+
def test_search_operator_default(self):
618+
"""default operator is and"""
619+
query = """
620+
query($searchQuery: String) {
621+
pages(searchQuery: $searchQuery) {
622+
title
623+
searchScore
624+
}
625+
}
626+
"""
627+
executed = self.client.execute(query, variables={"searchQuery": "Alpha Beta"})
628+
page_data = executed["data"].get("pages")
629+
self.assertEqual(len(page_data), 2)
630+
self.assertEqual(page_data[0]["title"], "Alpha Beta")
631+
self.assertEqual(page_data[1]["title"], "Beta Alpha")
632+
633+
def test_search_operator_and(self):
634+
query = """
635+
query($searchQuery: String, $searchOperator: SearchOperatorEnum) {
636+
pages(searchQuery: $searchQuery, searchOperator: $searchOperator) {
637+
title
638+
searchScore
639+
}
640+
}
641+
"""
642+
executed = self.client.execute(
643+
query, variables={"searchQuery": "Alpha Beta", "searchOperator": "AND"}
644+
)
645+
page_data = executed["data"].get("pages")
646+
self.assertEqual(len(page_data), 2)
647+
self.assertEqual(page_data[0]["title"], "Alpha Beta")
648+
self.assertEqual(page_data[1]["title"], "Beta Alpha")
649+
650+
def test_search_operator_or(self):
651+
query = """
652+
query($searchQuery: String, $searchOperator: SearchOperatorEnum) {
653+
pages(searchQuery: $searchQuery, searchOperator: $searchOperator) {
654+
title
655+
searchScore
656+
}
657+
}
658+
"""
659+
executed = self.client.execute(
660+
query, variables={"searchQuery": "Alpha Beta", "searchOperator": "OR"}
661+
)
662+
page_data = executed["data"].get("pages")
663+
self.assertEqual(len(page_data), 10)
664+
self.assertEqual(page_data[0]["title"], "Alpha")
665+
self.assertEqual(page_data[1]["title"], "Alpha Alpha")
666+
self.assertEqual(page_data[2]["title"], "Alpha Beta")
667+
self.assertEqual(page_data[3]["title"], "Alpha Gamma")
668+
self.assertEqual(page_data[4]["title"], "Beta")
669+
self.assertEqual(page_data[5]["title"], "Beta Alpha")
670+
self.assertEqual(page_data[6]["title"], "Beta Beta")
671+
self.assertEqual(page_data[7]["title"], "Beta Gamma")
672+
self.assertEqual(page_data[8]["title"], "Gamma Alpha")
673+
self.assertEqual(page_data[9]["title"], "Gamma Beta")
674+
596675

597676
class PageUrlPathTest(BaseGrappleTest):
598677
def _query_by_path(self, path, *, in_site=False):

0 commit comments

Comments
 (0)