Skip to content

Commit

Permalink
feat: implement DataloaderModule.forFeature() 💪
Browse files Browse the repository at this point in the history
  • Loading branch information
robertrossmann committed Jul 25, 2024
1 parent d74f04c commit 21c6985
Show file tree
Hide file tree
Showing 9 changed files with 129 additions and 52 deletions.
25 changes: 23 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,6 @@ export {

A Factory is responsible for creating new instances of Dataloader. Each factory creates only one type of Dataloader so for each relation you will need to define a Factory. You define a Factory by subclassing the provided `DataloaderFactory` and implemneting `load()` and `id()` methods on it, at minimum.

> ⚠️ Each `DataloaderFactory` implementation must be added to your module's `providers: []` and `exports: []` sections in order to make it available to other parts of your application.
> Each Factory can be considered global in the dependency graph, you do not need to import the module that provides the Factory in order to use it elsewhere in your application.
```ts
Expand Down Expand Up @@ -124,10 +122,33 @@ export {
}
```

### Export the factory

Each Dataloader factory you create must be added to Nest.js DI container via `DataloaderModule.forFeature()`. Don't forget to also export the `DataloaderModule` to make the Dataloader factory available to other modules.

```ts
// authors.module.ts
import { Module } from '@nestjs/common'
import { DataloaderModule } from '@strv/nestjs-dataloader'
import { BooksService } from './books.service.js'
import { AuthorBooksLoaderFactory } from './AuthorBooksLoader.factory.js'

@Module({
imports:[
DataloaderModule.forFeature([AuthorBooksLoaderFactory]),
],
providers: [BooksService],
exports: [DataloaderModule],
})
class AuthorsModule {}
```

### Inject a Dataloader

Now that we have a Dataloader factory defined and available in the DI container, it's time to put it to some use! To obtain a Dataloader instance, you can use the provided `@Loader()` param decorator in your GraphQL resolvers.

> 💡 It's possible to use the `@Loader()` param decorator also in REST controllers although the benefits of using Dataloaders in REST APIs are not that tangible as in GraphQL. However, if your app provides both GraphQL and REST interfaces this might be a good way to share some logic between the two.
```ts
// author.resolver.ts
import { Resolver, ResolveField } from '@nestjs/graphql'
Expand Down
38 changes: 14 additions & 24 deletions src/Dataloader.module.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,33 @@
import { DynamicModule, type FactoryProvider, Module } from '@nestjs/common'
import { APP_INTERCEPTOR } from '@nestjs/core'
import { DataloaderInterceptor } from './Dataloader.interceptor.js'
import { OPTIONS_TOKEN } from './internal.js'
import { type DataloaderOptions } from './types.js'
import { type DynamicModule, Module } from '@nestjs/common'
import { type DataloaderModuleOptions, type Factory, type DataloaderOptions } from './types.js'
import { DataloaderCoreModule } from './DataloaderCore.module.js'

@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: DataloaderInterceptor },
],
})
@Module({})
class DataloaderModule {
static forRoot(options?: DataloaderOptions): DynamicModule {
return {
module: DataloaderModule,
providers: [{
provide: OPTIONS_TOKEN,
useValue: options,
}],
imports: [DataloaderCoreModule.forRoot(options)],
}
}

static forRootAsync(options: DataloaderModuleOptions): DynamicModule {
return {
module: DataloaderModule,
imports: options.imports ?? [],
providers: [{
provide: OPTIONS_TOKEN,
inject: options.inject ?? [],
useFactory: options.useFactory,
}],
imports: [DataloaderCoreModule.forRootAsync(options)],
}
}
}

/** Dataloader module options for async configuration */
type DataloaderModuleOptions = Omit<FactoryProvider<DataloaderOptions>, 'provide'> & Pick<DynamicModule, 'imports'>
static forFeature(loaders: Factory[]): DynamicModule {
return {
module: DataloaderModule,
providers: loaders,
exports: loaders,
}
}
}


export {
DataloaderModule,
DataloaderModuleOptions,
}
44 changes: 44 additions & 0 deletions src/DataloaderCore.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { type Provider, type DynamicModule, Module } from '@nestjs/common'
import { APP_INTERCEPTOR } from '@nestjs/core'
import { DataloaderInterceptor } from './Dataloader.interceptor.js'
import { OPTIONS_TOKEN } from './internal.js'
import { type DataloaderModuleOptions, type DataloaderOptions } from './types.js'

/** @private */
const interceptor: Provider = { provide: APP_INTERCEPTOR, useClass: DataloaderInterceptor }

/** @private */
@Module({})
class DataloaderCoreModule {
static forRoot(options?: DataloaderOptions): DynamicModule {
return {
module: DataloaderCoreModule,
providers: [
interceptor,
{
provide: OPTIONS_TOKEN,
useValue: options,
},
],
}
}

static forRootAsync(options: DataloaderModuleOptions): DynamicModule {
return {
module: DataloaderCoreModule,
imports: options.imports ?? [],
providers: [
interceptor,
{
provide: OPTIONS_TOKEN,
inject: options.inject ?? [],
useFactory: options.useFactory,
},
],
}
}
}

export {
DataloaderCoreModule,
}
4 changes: 2 additions & 2 deletions src/Loader.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createParamDecorator, type ExecutionContext } from '@nestjs/common'
import { lifetimeKey, store, type Factory } from './internal.js'
import { type LifetimeKeyFn } from './types.js'
import { lifetimeKey, store } from './internal.js'
import { type Factory, type LifetimeKeyFn } from './types.js'
import { DataloaderException } from './DataloaderException.js'

/**
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { LifetimeKeyFn, DataloaderOptions } from './types.js'
export { LifetimeKeyFn, DataloaderOptions, DataloaderModuleOptions } from './types.js'
export { DataloaderException } from './DataloaderException.js'
export { Loader, createLoaderDecorator } from './Loader.decorator.js'
export { DataloaderFactory, LoaderFrom, Aggregated } from './Dataloader.factory.js'
export { DataloaderModule, DataloaderModuleOptions } from './Dataloader.module.js'
export { DataloaderModule } from './Dataloader.module.js'
12 changes: 2 additions & 10 deletions src/internal.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import { type ExecutionContext, type Type } from '@nestjs/common'
import { type ExecutionContext } from '@nestjs/common'
import { type ModuleRef } from '@nestjs/core'
import { GqlExecutionContext, type GqlContextType } from '@nestjs/graphql'
import type DataLoader from 'dataloader'
import { type DataloaderFactory } from './Dataloader.factory.js'
import { type LifetimeKeyFn } from './types.js'
import { type Factory, type LifetimeKeyFn } from './types.js'
import { DataloaderException } from './DataloaderException.js'

/** @private */
const OPTIONS_TOKEN = Symbol('DataloaderModuleOptions')

/**
* DataloaderFactory constructor type
* @private
*/
type Factory = Type<DataloaderFactory<unknown, unknown>>

/** @private */
interface StoreItem {
/** ModuleRef is used by the `@Loader()` decorator to pull the Factory instance from Nest's DI container */
Expand Down Expand Up @@ -48,6 +41,5 @@ const lifetimeKey: LifetimeKeyFn = (context: ExecutionContext) => {
export {
OPTIONS_TOKEN,
store,
Factory,
lifetimeKey,
}
11 changes: 10 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { type ExecutionContext } from '@nestjs/common'
import { type Type, type ExecutionContext, type FactoryProvider, type DynamicModule } from '@nestjs/common'
import { type DataloaderFactory } from './Dataloader.factory.js'

/** DataloaderFactory constructor type */
type Factory = Type<DataloaderFactory<unknown, unknown>>

/**
* Given an execution context, extract a value out of it that is
Expand All @@ -22,7 +26,12 @@ interface DataloaderOptions {
lifetime?: LifetimeKeyFn
}

/** Dataloader module options for async configuration */
type DataloaderModuleOptions = Omit<FactoryProvider<DataloaderOptions>, 'provide'> & Pick<DynamicModule, 'imports'>

export {
Factory,
LifetimeKeyFn,
DataloaderOptions,
DataloaderModuleOptions,
}
26 changes: 25 additions & 1 deletion test/DataloaderModule.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe } from 'vitest'
import { Test, TestingModule } from '@nestjs/testing'
import { DataloaderModule } from '@strv/nestjs-dataloader'
import { Injectable } from '@nestjs/common'
import { DataloaderFactory, DataloaderModule } from '@strv/nestjs-dataloader'

describe('DataloaderModule', it => {
it('exists', t => {
Expand All @@ -26,4 +27,27 @@ describe('DataloaderModule', it => {

t.expect(app).toBeInstanceOf(TestingModule)
})

it('.forFeatre()', async t => {
@Injectable()
class SampleLoaderFactory extends DataloaderFactory<unknown, unknown> {
load = async (keys: unknown[]) => await Promise.resolve(keys)
id = (key: unknown) => key
}

const provider = DataloaderModule.forFeature([SampleLoaderFactory])
const module = Test.createTestingModule({ imports: [
DataloaderModule.forRoot(),
provider,
] })
const app = await module.compile()
t.onTestFinished(async () => await app.close())

t.expect(app).toBeInstanceOf(TestingModule)

t.expect(provider).toBeDefined()
t.expect(provider.module).toBe(DataloaderModule)
t.expect(provider.providers).toEqual([SampleLoaderFactory])
t.expect(provider.exports).toEqual([SampleLoaderFactory])
})
})
17 changes: 7 additions & 10 deletions test/Loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,8 @@ describe('@Loader()', it => {
it('injects the dataloader instance into the request handler', async t => {
@Injectable()
class SampleLoaderFactory extends DataloaderFactory<unknown, unknown> {
async load(keys: unknown[]) {
return await Promise.resolve(keys)
}

id(key: unknown) {
return key
}
load = async (keys: unknown[]) => await Promise.resolve(keys)
id = (key: unknown) => key
}

@Controller()
Expand All @@ -28,10 +23,12 @@ describe('@Loader()', it => {
}

const module = await Test.createTestingModule({
imports: [DataloaderModule.forRoot()],
imports: [
DataloaderModule.forRoot(),
DataloaderModule.forFeature([SampleLoaderFactory]),
],
controllers: [TestController],
providers: [SampleLoaderFactory],
exports: [SampleLoaderFactory],
exports: [DataloaderModule],
}).compile()
const app = await module.createNestApplication<NestExpressApplication>().init()
t.onTestFinished(async () => await app.close())
Expand Down

0 comments on commit 21c6985

Please sign in to comment.