|
| 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 | + |
0 commit comments