Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add search operator #406

Merged
merged 1 commit into from
Sep 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions docs/general-usage/interfaces.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ The interface exposes the following fields, following the Wagtail Page model fie
showInMenus: Boolean
contentType: String
parent: PageInterface
children(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface]
siblings(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface]
nextSiblings(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface]
previousSiblings(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface]
descendants(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface]
ancestors(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, id: ID): [PageInterface]
children(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, searchOperator: SearchOperatorEnum, id: ID): [PageInterface]
siblings(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, searchOperator: SearchOperatorEnum, id: ID): [PageInterface]
nextSiblings(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, searchOperator: SearchOperatorEnum, id: ID): [PageInterface]
previousSiblings(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, searchOperator: SearchOperatorEnum, id: ID): [PageInterface]
descendants(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, searchOperator: SearchOperatorEnum, id: ID): [PageInterface]
ancestors(limit: PositiveInt, offset: PositiveInt, order: String, searchQuery: String, searchOperator: SearchOperatorEnum, id: ID): [PageInterface]


Any custom ``graphql_fields`` added to your specific Page models will be available here via the 'on' spread operator and
Expand Down Expand Up @@ -65,7 +65,8 @@ accepts the following arguments:
offset: PositiveInt
order: String
searchQuery: String
contentType: String # comma separated list of content types in app.Model notation
searchOperator: OR | AND # default is AND
contentType: String # comma separated list of content types in app.Model notation
inSite: Boolean
ancestor: PositiveInt # ID of ancestor page to restrict results to
parent: PositiveInt # ID of parent page to restrict results to
Expand Down
43 changes: 41 additions & 2 deletions grapple/types/structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ def parse_literal(ast, _variables=None):
return return_value


class SearchOperatorEnum(graphene.Enum):
"""
Enum for search operator.
"""

AND = "and"
OR = "or"

def __str__(self):
# the core search parser expects the operator to be a string.
# the default __str__ returns SearchOperatorEnum.AND/OR,
# this __str__ returns the value and/or for compatibility.
return self.value


class QuerySetList(graphene.List):
"""
List type with arguments used by Django's query sets.
Expand All @@ -32,6 +47,7 @@ class QuerySetList(graphene.List):
* ``limit``
* ``offset``
* ``search_query``
* ``search_operator``
* ``order``

:param enable_in_menu: Enable in_menu filter.
Expand All @@ -42,6 +58,8 @@ class QuerySetList(graphene.List):
:type enable_offset: bool
:param enable_search: Enable search query argument.
:type enable_search: bool
:param enable_search_operator: Enable search operator argument, enable_search must also be True
:type enable_search_operator: bool
:param enable_order: Enable ordering via query argument.
:type enable_order: bool
"""
Expand All @@ -50,8 +68,9 @@ def __init__(self, of_type, *args, **kwargs):
enable_in_menu = kwargs.pop("enable_in_menu", False)
enable_limit = kwargs.pop("enable_limit", True)
enable_offset = kwargs.pop("enable_offset", True)
enable_search = kwargs.pop("enable_search", True)
enable_order = kwargs.pop("enable_order", True)
enable_search = kwargs.pop("enable_search", True)
enable_search_operator = kwargs.pop("enable_search_operator", True)

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

if "id" not in kwargs:
kwargs["id"] = graphene.Argument(graphene.ID, description=_("Filter by ID"))
Expand Down Expand Up @@ -152,23 +179,27 @@ def PaginatedQuerySet(of_type, type_class, **kwargs):
"""
Paginated QuerySet type with arguments used by Django's query sets.

This type setts the following arguments on itself:
This type sets the following arguments on itself:

* ``id``
* ``in_menu``
* ``page``
* ``per_page``
* ``search_query``
* ``search_operator``
* ``order``

:param enable_search: Enable search query argument.
:type enable_search: bool
:param enable_search_operator: Enable search operator argument, enable_search must also be True
:type enable_search_operator: bool
:param enable_order: Enable ordering via query argument.
:type enable_order: bool
"""

enable_in_menu = kwargs.pop("enable_in_menu", False)
enable_search = kwargs.pop("enable_search", True)
enable_search_operator = kwargs.pop("enable_search_operator", True)
enable_order = kwargs.pop("enable_order", True)
required = kwargs.get("required", False)
type_name = type_class if isinstance(type_class, str) else type_class.__name__
Expand Down Expand Up @@ -225,6 +256,14 @@ def PaginatedQuerySet(of_type, type_class, **kwargs):
kwargs["search_query"] = graphene.Argument(
graphene.String, description=_("Filter the results using Wagtail's search.")
)
if enable_search_operator:
kwargs["search_operator"] = graphene.Argument(
SearchOperatorEnum,
description=_(
"Specify search operator (and/or), see: https://docs.wagtail.org/en/stable/topics/search/searching.html#search-operator"
),
default_value="and",
)

if "id" not in kwargs:
kwargs["id"] = graphene.Argument(graphene.ID, description=_("Filter by ID"))
Expand Down
44 changes: 36 additions & 8 deletions grapple/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from wagtail import VERSION as WAGTAIL_VERSION
from wagtail.models import Site
from wagtail.search.index import class_is_indexed
from wagtail.search.utils import parse_query_string

from .settings import grapple_settings
from .types.structures import BasePaginatedType, PaginationType
Expand Down Expand Up @@ -101,6 +102,7 @@ def resolve_queryset(
order=None,
collection=None,
in_menu=None,
search_operator="and",
**kwargs,
):
"""
Expand All @@ -122,6 +124,9 @@ def resolve_queryset(
:type order: str
:param collection: Use Wagtail's collection id to filter images or documents
:type collection: int
:param search_operator: The operator to use when combining search terms.
Defaults to "and".
:type search_operator: "and" | "or"
"""

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

qs = qs.search(search_query, order_by_relevance=order_by_relevance)
filters, parsed_query = parse_query_string(search_query, str(search_operator))

qs = qs.search(
parsed_query,
order_by_relevance=order_by_relevance,
operator=search_operator,
)
if connection.vendor != "sqlite":
qs = qs.annotate_score("search_score")

Expand Down Expand Up @@ -183,17 +194,25 @@ def get_paginated_result(qs, page, per_page):
count=len(page_obj.object_list),
per_page=per_page,
current_page=page_obj.number,
prev_page=page_obj.previous_page_number()
if page_obj.has_previous()
else None,
prev_page=(
page_obj.previous_page_number() if page_obj.has_previous() else None
),
next_page=page_obj.next_page_number() if page_obj.has_next() else None,
total_pages=paginator.num_pages,
),
)


def resolve_paginated_queryset(
qs, info, page=None, per_page=None, search_query=None, id=None, order=None, **kwargs
qs,
info,
page=None,
per_page=None,
id=None,
order=None,
search_query=None,
search_operator="and",
**kwargs,
):
"""
Add page, per_page and search capabilities to the query. This contains
Expand All @@ -207,11 +226,14 @@ def resolve_paginated_queryset(
:type id: int
:param per_page: The maximum number of items to include on a page.
:type per_page: int
:param order: Order the query set using the Django QuerySet order_by format.
:type order: str
:param search_query: Using Wagtail search, exclude objects that do not match
the search query.
:type search_query: str
:param order: Order the query set using the Django QuerySet order_by format.
:type order: str
:param search_operator: The operator to use when combining search terms.
Defaults to "and".
:type search_operator: "and" | "or"
"""
page = int(page or 1)
per_page = min(
Expand All @@ -236,7 +258,13 @@ def resolve_paginated_queryset(
query = Query.get(search_query)
query.add_hit()

qs = qs.search(search_query, order_by_relevance=order_by_relevance)
filters, parsed_query = parse_query_string(search_query, search_operator)

qs = qs.search(
parsed_query,
order_by_relevance=order_by_relevance,
operator=search_operator,
)
if connection.vendor != "sqlite":
qs = qs.annotate_score("search_score")

Expand Down
107 changes: 93 additions & 14 deletions tests/test_grapple.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,18 +474,41 @@ class PagesSearchTest(BaseGrappleTest):
@classmethod
def setUpTestData(cls):
cls.home = HomePage.objects.first()
BlogPageFactory(title="Alpha", parent=cls.home, show_in_menus=True)
BlogPageFactory(title="Alpha Alpha", parent=cls.home)
BlogPageFactory(title="Alpha Beta", parent=cls.home)
BlogPageFactory(title="Alpha Gamma", parent=cls.home)
BlogPageFactory(title="Beta", parent=cls.home)
BlogPageFactory(title="Beta Alpha", parent=cls.home)
BlogPageFactory(title="Beta Beta", parent=cls.home)
BlogPageFactory(title="Beta Gamma", parent=cls.home)
BlogPageFactory(title="Gamma", parent=cls.home)
BlogPageFactory(title="Gamma Alpha", parent=cls.home)
BlogPageFactory(title="Gamma Beta", parent=cls.home)
BlogPageFactory(title="Gamma Gamma", parent=cls.home)
BlogPageFactory(
title="Alpha",
body=[("heading", "Sigma")],
parent=cls.home,
show_in_menus=True,
)
BlogPageFactory(
title="Alpha Alpha", body=[("heading", "Sigma Sigma")], parent=cls.home
)
BlogPageFactory(
title="Alpha Beta", body=[("heading", "Sigma Theta")], parent=cls.home
)
BlogPageFactory(
title="Alpha Gamma", body=[("heading", "Sigma Delta")], parent=cls.home
)
BlogPageFactory(title="Beta", body=[("heading", "Theta")], parent=cls.home)
BlogPageFactory(
title="Beta Alpha", body=[("heading", "Theta Sigma")], parent=cls.home
)
BlogPageFactory(
title="Beta Beta", body=[("heading", "Theta Theta")], parent=cls.home
)
BlogPageFactory(
title="Beta Gamma", body=[("heading", "Theta Delta")], parent=cls.home
)
BlogPageFactory(title="Gamma", body=[("heading", "Delta")], parent=cls.home)
BlogPageFactory(
title="Gamma Alpha", body=[("heading", "Delta Sigma")], parent=cls.home
)
BlogPageFactory(
title="Gamma Beta", body=[("heading", "Delta Theta")], parent=cls.home
)
BlogPageFactory(
title="Gamma Gamma", body=[("heading", "Delta Delta")], parent=cls.home
)

@unittest.skipIf(
connection.vendor != "sqlite",
Expand Down Expand Up @@ -530,7 +553,6 @@ def test_searchQuery_order_by_relevance(self):
}
}
"""

executed = self.client.execute(query, variables={"searchQuery": "Alpha"})
page_data = executed["data"].get("pages")
self.assertEqual(len(page_data), 6)
Expand Down Expand Up @@ -559,7 +581,6 @@ def test_explicit_order(self):
query, variables={"searchQuery": "Gamma", "order": "-title"}
)
page_data = executed["data"].get("pages")

self.assertEqual(len(page_data), 6)
self.assertEqual(page_data[0]["title"], "Gamma Gamma")
self.assertEqual(page_data[1]["title"], "Gamma Beta")
Expand Down Expand Up @@ -593,6 +614,64 @@ def test_search_not_in_menus(self):
page_data = executed["data"].get("pages")
self.assertEqual(len(page_data), 12) # 11 blog pages + home page

def test_search_operator_default(self):
"""default operator is and"""
query = """
query($searchQuery: String) {
pages(searchQuery: $searchQuery) {
title
searchScore
}
}
"""
executed = self.client.execute(query, variables={"searchQuery": "Alpha Beta"})
page_data = executed["data"].get("pages")
self.assertEqual(len(page_data), 2)
self.assertEqual(page_data[0]["title"], "Alpha Beta")
self.assertEqual(page_data[1]["title"], "Beta Alpha")

def test_search_operator_and(self):
query = """
query($searchQuery: String, $searchOperator: SearchOperatorEnum) {
pages(searchQuery: $searchQuery, searchOperator: $searchOperator) {
title
searchScore
}
}
"""
executed = self.client.execute(
query, variables={"searchQuery": "Alpha Beta", "searchOperator": "AND"}
)
page_data = executed["data"].get("pages")
self.assertEqual(len(page_data), 2)
self.assertEqual(page_data[0]["title"], "Alpha Beta")
self.assertEqual(page_data[1]["title"], "Beta Alpha")

def test_search_operator_or(self):
query = """
query($searchQuery: String, $searchOperator: SearchOperatorEnum) {
pages(searchQuery: $searchQuery, searchOperator: $searchOperator) {
title
searchScore
}
}
"""
executed = self.client.execute(
query, variables={"searchQuery": "Alpha Beta", "searchOperator": "OR"}
)
page_data = executed["data"].get("pages")
self.assertEqual(len(page_data), 10)
self.assertEqual(page_data[0]["title"], "Alpha")
self.assertEqual(page_data[1]["title"], "Alpha Alpha")
self.assertEqual(page_data[2]["title"], "Alpha Beta")
self.assertEqual(page_data[3]["title"], "Alpha Gamma")
self.assertEqual(page_data[4]["title"], "Beta")
self.assertEqual(page_data[5]["title"], "Beta Alpha")
self.assertEqual(page_data[6]["title"], "Beta Beta")
self.assertEqual(page_data[7]["title"], "Beta Gamma")
self.assertEqual(page_data[8]["title"], "Gamma Alpha")
self.assertEqual(page_data[9]["title"], "Gamma Beta")


class PageUrlPathTest(BaseGrappleTest):
def _query_by_path(self, path, *, in_site=False):
Expand Down
Loading