Skip to content

Commit 4b85da4

Browse files
committed
Integrate async data loaders for box product, size, and tags
- create loaders and run async query execution when dispatching GraphQL request - obtain loaders from GraphQL context in resolvers - split product resolver due to different authz enforcement cf. graphql-python/graphql-server#66 https://lightrun.com/answers/graphql-python-graphene-consider-supporting-promise-based-dataloaders-in-v3 graphql-python/graphql-core#71
1 parent e6c7657 commit 4b85da4

File tree

6 files changed

+84
-25
lines changed

6 files changed

+84
-25
lines changed

back/boxtribute_server/graph_ql/resolvers.py

+20-12
Original file line numberDiff line numberDiff line change
@@ -219,26 +219,34 @@ def resolve_qr_code(obj, _, qr_code=None):
219219

220220

221221
@box.field("tags")
222-
def resolve_box_tags(box_obj, _):
223-
return (
224-
Tag.select()
225-
.join(TagsRelation)
226-
.where(
227-
(TagsRelation.object_id == box_obj.id)
228-
& (TagsRelation.object_type == TaggableObjectType.Box)
229-
)
230-
)
222+
def resolve_box_tags(box_obj, info):
223+
authorize(permission="tag:read")
224+
return info.context["tags_for_box_loader"].load(box_obj.id)
231225

232226

233227
@query.field("product")
228+
def resolve_product(*_, id):
229+
product = Product.get_by_id(id)
230+
authorize(permission="product:read", base_id=product.base_id)
231+
return product
232+
233+
234234
@box.field("product")
235235
@unboxed_items_collection.field("product")
236-
def resolve_product(obj, _, id=None):
237-
product = obj.product if id is None else Product.get_by_id(id)
238-
authorize(permission="product:read", base_id=product.base_id)
236+
def resolve_box_product(obj, info):
237+
product = info.context["product_loader"].load(obj.product_id)
238+
# Base-specific authz can be omitted here since it was enforced in the box
239+
# parent-resolver. It's not possible that the box's product is assigned to a
240+
# different base than the box is in
241+
authorize(permission="product:read")
239242
return product
240243

241244

245+
@box.field("size")
246+
def resolve_size(box_obj, info):
247+
return info.context["size_loader"].load(box_obj.size_id)
248+
249+
242250
@query.field("box")
243251
@convert_kwargs_to_snake_case
244252
def resolve_box(*_, label_identifier):

back/boxtribute_server/loaders.py

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from collections import defaultdict
2+
3+
from aiodataloader import DataLoader
4+
5+
from .enums import TaggableObjectType
6+
from .models.definitions.product import Product
7+
from .models.definitions.size import Size
8+
from .models.definitions.tag import Tag
9+
from .models.definitions.tags_relation import TagsRelation
10+
11+
12+
class ProductLoader(DataLoader):
13+
async def batch_load_fn(self, keys):
14+
products = {p.id: p for p in Product.select().where(Product.id << keys)}
15+
return [products.get(i) for i in keys]
16+
17+
18+
class SizeLoader(DataLoader):
19+
async def batch_load_fn(self, keys):
20+
sizes = {s.id: s for s in Size.select()}
21+
return [sizes.get(i) for i in keys]
22+
23+
24+
class TagsForBoxLoader(DataLoader):
25+
async def batch_load_fn(self, keys):
26+
tags = defaultdict(list)
27+
# maybe need different join type
28+
for relation in (
29+
TagsRelation.select()
30+
.join(Tag)
31+
.where(TagsRelation.object_type == TaggableObjectType.Box)
32+
):
33+
tags[relation.object_id].append(relation.tag)
34+
35+
# keys are in fact box IDs. Return empty list if box has no tags assigned
36+
return [tags.get(i, []) for i in keys]

back/boxtribute_server/routes.py

+24-10
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
"""Construction of routes for web app and API"""
2+
import asyncio
23
import os
34

4-
from ariadne import graphql_sync
5+
from ariadne import graphql, graphql_sync
56
from ariadne.constants import PLAYGROUND_HTML
67
from flask import Blueprint, current_app, jsonify, request
78
from flask_cors import cross_origin
89

910
from .auth import request_jwt, requires_auth
1011
from .exceptions import AuthenticationFailed, format_database_errors
1112
from .graph_ql.schema import full_api_schema, query_api_schema
13+
from .loaders import ProductLoader, SizeLoader, TagsForBoxLoader
1214

1315
# Blueprint for query-only API. Deployed on the 'api*' subdomains
1416
api_bp = Blueprint("api_bp", __name__)
@@ -82,15 +84,27 @@ def graphql_playgroud():
8284
@cross_origin(origin="localhost", headers=["Content-Type", "Authorization"])
8385
@requires_auth
8486
def graphql_server():
85-
# Note: Passing the request to the context is optional.
86-
# In Flask, the current request is always accessible as flask.request
87-
success, result = graphql_sync(
88-
full_api_schema,
89-
data=request.get_json(),
90-
context_value=request,
91-
debug=current_app.debug,
92-
introspection=current_app.debug,
93-
error_formatter=format_database_errors,
87+
# Start async event loop, required for DataLoader construction, cf.
88+
# https://github.com/graphql-python/graphql-core/issues/71#issuecomment-620106364
89+
loop = asyncio.new_event_loop()
90+
asyncio.set_event_loop(loop)
91+
92+
# Create DataLoaders and persist them for the time of processing the request
93+
context = {
94+
"product_loader": ProductLoader(),
95+
"size_loader": SizeLoader(),
96+
"tags_for_box_loader": TagsForBoxLoader(),
97+
}
98+
99+
success, result = loop.run_until_complete(
100+
graphql(
101+
full_api_schema,
102+
data=request.get_json(),
103+
context_value=context,
104+
debug=current_app.debug,
105+
introspection=current_app.debug,
106+
error_formatter=format_database_errors,
107+
)
94108
)
95109

96110
status_code = 200 if success else 400

back/requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ peewee-moves==2.1.0
88
python-dateutil==2.8.2
99
python-dotenv==0.20.0
1010
python-jose==3.3.0
11+
aiodataloader==0.2.1
1112
gunicorn

back/scripts/load-test.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ const payload = JSON.stringify({
3434
// query: "query { beneficiaries { elements { firstName } } }",
3535

3636
// C) All boxes for base
37-
// query: "query { base(id: 1) { locations { name boxes { totalCount elements { labelIdentifier state size { id label } product { gender name } tags { name id } items } } } } }",
38-
query: "query { location(id: 1) { boxes { elements { product { gender name } } } } }",
37+
query: "query { base(id: 1) { locations { name boxes { totalCount elements { labelIdentifier state size { id label } product { gender name } tags { name id } numberOfItems } } } } }",
38+
// query: "query { location(id: 1) { boxes { elements { product { gender name } } } } }",
3939
});
4040

4141
export const options = {

back/test/endpoint_tests/test_permissions.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ def test_invalid_permission_for_shipment_base(read_only_client, mocker, field):
223223
assert_forbidden_request(read_only_client, query, value={field: None})
224224

225225

226-
@pytest.mark.parametrize("field", ["place", "product", "qrCode"])
226+
@pytest.mark.parametrize("field", ["place", "qrCode"])
227227
def test_invalid_permission_for_box_field(read_only_client, mocker, default_box, field):
228228
# verify missing field:read permission
229229
mocker.patch("jose.jwt.decode").return_value = create_jwt_payload(

0 commit comments

Comments
 (0)