Skip to content

Commit e383ebe

Browse files
docs: Description + example of GraphQL dataloader (#3290)
1 parent c1cfb73 commit e383ebe

File tree

2 files changed

+131
-0
lines changed

2 files changed

+131
-0
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
---
2+
title: "GraphQL Dataloaders"
3+
showtoc: true
4+
---
5+
6+
[Dataloaders](https://github.com/graphql/dataloader) are used in GraphQL to solve the so called N+1 problem.
7+
8+
## N+1 problem
9+
10+
Imagine a cart with 20 items. Your implementation requires you to perform an `async` calculation `isSubscription` for each cart item whih executes one or more queries each time it is called and it takes pretty long on each execution. It works fine for a cart with 1 or 2 items. But with more than 15 items, suddenly the cart takes a **lot** longer to load. Especially when the site is busy.
11+
12+
The reason: the N+1 problem. Your cart is firing of 20 or more queries almost at the same time, adding **significantly** to the GraphQL request. It's like going to the McDonald's drive-in to get 10 hamburgers and getting in line 10 times to get 1 hamburger at a time. It's not efficient.
13+
14+
## The solution: dataloaders
15+
16+
Dataloaders allow you to say: instead of loading each field in the `grapqhl` tree one at a time, aggregate all the `ids` you want to execute the `async` calculation for, and then execute this for all the `ids` in one efficient `request`.
17+
18+
Dataloaders are generally used on `fieldResolver`s. Often, you will need a specific dataloader for each field resolver.
19+
20+
A Dataloader can return anything: `boolean`, `ProductVariant`, `string`, etc
21+
22+
## Performance implications
23+
24+
Dataloaders can have a huge impact on performance. If your `fieldResolver` executes queries, and you log these queries, you should see a cascade of queries before the implementation of the dataloader, change to a single query using multiple `ids` after you implement it.
25+
26+
## Do I need this for `CustomField` relations?
27+
28+
No, not normally. `CustomField` relations are automatically added to the root query for the `entity` that they are part of. So, they are loaded as part of the query that loads that entity.
29+
30+
## Example
31+
32+
We will provide a complete example here for you to use as a starting point. The skeleton created can handle multiple dataloaders across multiple channels. We will implement a `fieldResolver` called `isSubscription` for an `OrderLine` that will return a `true/false` for each incoming `orderLine`, to indicate whether the `orderLine` represents a subscription.
33+
34+
35+
```ts title="src/plugins/my-plugin/api/api-extensions.ts"
36+
import gql from 'graphql-tag';
37+
38+
export const shopApiExtensions = gql`
39+
extend type OrderLine {
40+
isSubscription: Boolean!
41+
}
42+
`
43+
```
44+
45+
**Dataloader skeleton**
46+
47+
```ts title="src/plugins/my-plugin/api/datalaoder.ts"
48+
import DataLoader from 'dataloader'
49+
50+
const LoggerCtx = 'SubscriptionDataloaderService'
51+
52+
@Injectable({ scope: Scope.REQUEST }) // Important! Dataloaders live at the request level
53+
export class DataloaderService {
54+
55+
/**
56+
* first level is channel identifier, second level is dataloader key
57+
*/
58+
private loaders = new Map<string, Map<string, DataLoader<ID, any>>>()
59+
60+
constructor(private service: SubscriptionExtensionService) {}
61+
62+
getLoader(ctx: RequestContext, dataloaderKey: string) {
63+
const token = ctx.channel?.code ?? `${ctx.channelId}`
64+
65+
Logger.debug(`Dataloader retrieval: ${token}, ${dataloaderKey}`, LoggerCtx)
66+
67+
if (!this.loaders.has(token)) {
68+
this.loaders.set(token, new Map<string, DataLoader<ID, any>>())
69+
}
70+
71+
const channelLoaders = this.loaders.get(token)!
72+
if (!channelLoaders.get(dataloaderKey)) {
73+
let loader: DataLoader<ID, any>
74+
75+
switch (dataloaderKey) {
76+
case 'is-subscription':
77+
loader = new DataLoader<ID, any>((ids) =>
78+
this.batchLoadIsSubscription(ctx, ids as ID[]),
79+
)
80+
break
81+
// Implement cases for your other dataloaders here
82+
default:
83+
throw new Error(`Unknown dataloader key ${dataloaderKey}`)
84+
}
85+
86+
channelLoaders.set(dataloaderKey, loader)
87+
}
88+
return channelLoaders.get(dataloaderKey)!
89+
}
90+
91+
private async batchLoadIsSubscription(
92+
ctx: RequestContext,
93+
ids: ID[],
94+
): Promise<Boolean[]> {
95+
// Returns an array of ids that represent those input ids that are subscriptions
96+
// Remember: this array can be smaller than the input array
97+
const subscriptionIds = await this.service.whichSubscriptions(ctx, ids)
98+
99+
Logger.debug(`Dataloader is-subscription: ${ids}: ${subscriptionIds}`, LoggerCtx)
100+
101+
return ids.map((id) => subscriptionIds.includes(id)) // Important! preserve order and size of input ids array
102+
}
103+
}
104+
```
105+
106+
107+
```ts title="src/plugins/my-plugin/api/entity-resolver.ts"
108+
@Resolver(() => OrderLine)
109+
export class MyPluginOrderLineEntityResolver {
110+
constructor(
111+
private dataloaderService: DataloaderService,
112+
) {}
113+
114+
@ResolveField()
115+
isSubscription(@Ctx() ctx: RequestContext, @Parent() parent: OrderLine) {
116+
const loader = this.dataloaderService.getLoader(ctx, 'is-subscription')
117+
return loader.load(parent.id)
118+
}
119+
}
120+
```
121+
122+
To make it all work, ensure that the `DataLoaderService` is loaded in your `plugin` as a provider.
123+
124+
:::tip
125+
Dataloaders map the result in the same order as the `ids` you send to the dataloader.
126+
Dataloaders expect the same order and array size in the return result.
127+
128+
In other words: ensure that the order of your returned result is the same as the incoming `ids` and don't omit values!
129+
:::
130+

docs/sidebars.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ const sidebars = {
9292
className: 'sidebar-section-header',
9393
},
9494
'guides/developer-guide/cache/index',
95+
'guides/developer-guide/dataloaders/index',
9596
'guides/developer-guide/db-subscribers/index',
9697
'guides/developer-guide/importing-data/index',
9798
'guides/developer-guide/logging/index',

0 commit comments

Comments
 (0)