diff --git a/docs/docs/guides/developer-guide/dataloaders/index.md b/docs/docs/guides/developer-guide/dataloaders/index.md new file mode 100644 index 0000000000..2beabc2460 --- /dev/null +++ b/docs/docs/guides/developer-guide/dataloaders/index.md @@ -0,0 +1,130 @@ +--- +title: "GraphQL Dataloaders" +showtoc: true +--- + +[Dataloaders](https://github.com/graphql/dataloader) are used in GraphQL to solve the so called N+1 problem. + +## N+1 problem + +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. + +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. + +## The solution: dataloaders + +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`. + +Dataloaders are generally used on `fieldResolver`s. Often, you will need a specific dataloader for each field resolver. + +A Dataloader can return anything: `boolean`, `ProductVariant`, `string`, etc + +## Performance implications + +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. + +## Do I need this for `CustomField` relations? + +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. + +## Example + +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. + + +```ts title="src/plugins/my-plugin/api/api-extensions.ts" +import gql from 'graphql-tag'; + +export const shopApiExtensions = gql` + extend type OrderLine { + isSubscription: Boolean! + } +` +``` + +**Dataloader skeleton** + +```ts title="src/plugins/my-plugin/api/datalaoder.ts" +import DataLoader from 'dataloader' + +const LoggerCtx = 'SubscriptionDataloaderService' + +@Injectable({ scope: Scope.REQUEST }) // Important! Dataloaders live at the request level +export class DataloaderService { + + /** + * first level is channel identifier, second level is dataloader key + */ + private loaders = new Map>>() + + constructor(private service: SubscriptionExtensionService) {} + + getLoader(ctx: RequestContext, dataloaderKey: string) { + const token = ctx.channel?.code ?? `${ctx.channelId}` + + Logger.debug(`Dataloader retrieval: ${token}, ${dataloaderKey}`, LoggerCtx) + + if (!this.loaders.has(token)) { + this.loaders.set(token, new Map>()) + } + + const channelLoaders = this.loaders.get(token)! + if (!channelLoaders.get(dataloaderKey)) { + let loader: DataLoader + + switch (dataloaderKey) { + case 'is-subscription': + loader = new DataLoader((ids) => + this.batchLoadIsSubscription(ctx, ids as ID[]), + ) + break + // Implement cases for your other dataloaders here + default: + throw new Error(`Unknown dataloader key ${dataloaderKey}`) + } + + channelLoaders.set(dataloaderKey, loader) + } + return channelLoaders.get(dataloaderKey)! + } + + private async batchLoadIsSubscription( + ctx: RequestContext, + ids: ID[], + ): Promise { + // Returns an array of ids that represent those input ids that are subscriptions + // Remember: this array can be smaller than the input array + const subscriptionIds = await this.service.whichSubscriptions(ctx, ids) + + Logger.debug(`Dataloader is-subscription: ${ids}: ${subscriptionIds}`, LoggerCtx) + + return ids.map((id) => subscriptionIds.includes(id)) // Important! preserve order and size of input ids array + } +} +``` + + +```ts title="src/plugins/my-plugin/api/entity-resolver.ts" +@Resolver(() => OrderLine) +export class MyPluginOrderLineEntityResolver { + constructor( + private dataloaderService: DataloaderService, + ) {} + + @ResolveField() + isSubscription(@Ctx() ctx: RequestContext, @Parent() parent: OrderLine) { + const loader = this.dataloaderService.getLoader(ctx, 'is-subscription') + return loader.load(parent.id) + } +} +``` + +To make it all work, ensure that the `DataLoaderService` is loaded in your `plugin` as a provider. + +:::tip +Dataloaders map the result in the same order as the `ids` you send to the dataloader. +Dataloaders expect the same order and array size in the return result. + +In other words: ensure that the order of your returned result is the same as the incoming `ids` and don't omit values! +::: + diff --git a/docs/sidebars.js b/docs/sidebars.js index a365a8fcfb..6c429541ea 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -92,6 +92,7 @@ const sidebars = { className: 'sidebar-section-header', }, 'guides/developer-guide/cache/index', + 'guides/developer-guide/dataloaders/index', 'guides/developer-guide/db-subscribers/index', 'guides/developer-guide/importing-data/index', 'guides/developer-guide/logging/index',