Skip to content

Commit 9a5c835

Browse files
authored
fix(query-graphql): use DataLoader in ReferenceResolver to prevent N+1 queries (#389) (#391)
closes #389
2 parents 7d4f482 + e4a05d1 commit 9a5c835

File tree

4 files changed

+92
-19
lines changed

4 files changed

+92
-19
lines changed

packages/query-graphql/__tests__/resolvers/reference.resolver.spec.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { ExecutionContext } from '@nestjs/common'
12
import { Query, Resolver } from '@nestjs/graphql'
23
import { ReferenceResolver, ReferenceResolverOpts } from '@ptc-org/nestjs-query-graphql'
3-
import { when } from 'ts-mockito'
4+
import { anything, when } from 'ts-mockito'
45

56
import { createResolverFromNest, generateSchema, TestResolverDTO, TestService } from '../__fixtures__'
67

@@ -33,33 +34,48 @@ describe('ReferenceResolver', () => {
3334
})
3435

3536
describe('#resolveReference', () => {
36-
it('should call the service getById with the provided input', async () => {
37+
const createContext = (): ExecutionContext => ({}) as unknown as ExecutionContext
38+
39+
it('should use DataLoader to resolve reference', async () => {
3740
const { resolver, mockService } = await createResolverFromNest(TestResolver)
41+
const context = createContext()
3842
const id = 'id-1'
3943
const output: TestResolverDTO = {
4044
id,
4145
stringField: 'foo'
4246
}
43-
when(mockService.getById(id)).thenResolve(output)
47+
48+
when(mockService.query(anything())).thenResolve([output])
49+
4450
// @ts-ignore
4551
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/naming-convention
46-
const result = await resolver.resolveReference({ __type: 'TestReference', id })
52+
const result = await resolver.resolveReference({ __type: 'TestReference', id }, context)
4753
return expect(result).toEqual(output)
4854
})
4955

5056
it('should reject if the id is not found', async () => {
51-
const { resolver, mockService } = await createResolverFromNest(TestResolver)
52-
const id = 'id-1'
53-
const output: TestResolverDTO = {
54-
id,
55-
stringField: 'foo'
56-
}
57-
when(mockService.getById(id)).thenResolve(output)
57+
const { resolver } = await createResolverFromNest(TestResolver)
58+
const context = createContext()
59+
5860
// @ts-ignore
5961
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/naming-convention
60-
return expect(resolver.resolveReference({ __type: 'TestReference' })).rejects.toThrow(
62+
return expect(resolver.resolveReference({ __type: 'TestReference' }, context)).rejects.toThrow(
6163
'Unable to resolve reference, missing required key id for TestResolverDTO'
6264
)
6365
})
66+
67+
it('should reject if entity is not found', async () => {
68+
const { resolver, mockService } = await createResolverFromNest(TestResolver)
69+
const context = createContext()
70+
const id = 'id-not-found'
71+
72+
when(mockService.query(anything())).thenResolve([])
73+
74+
// @ts-ignore
75+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/naming-convention
76+
return expect(resolver.resolveReference({ __type: 'TestReference', id }, context)).rejects.toThrow(
77+
'Unable to find TestResolverDTO with id: id-not-found'
78+
)
79+
})
6480
})
6581
})

packages/query-graphql/src/loader/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './count-relations.loader'
33
export * from './dataloader.factory'
44
export * from './find-relations.loader'
55
export * from './query-relations.loader'
6+
export * from './reference.loader'
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Class, Filter, QueryService } from '@ptc-org/nestjs-query-core'
2+
3+
import { NestjsQueryDataloader } from './relations.loader'
4+
5+
export type ReferenceArgs = {
6+
id: string | number
7+
}
8+
9+
export class ReferenceLoader<DTO> implements NestjsQueryDataloader<DTO, ReferenceArgs, DTO | undefined | Error> {
10+
constructor(readonly DTOClass: Class<DTO>) {}
11+
12+
public createLoader(service: QueryService<DTO, unknown, unknown>) {
13+
return async (args: ReadonlyArray<ReferenceArgs>): Promise<(DTO | undefined | Error)[]> => {
14+
// Extract all unique IDs from the batch
15+
const ids = args.map((arg) => arg.id)
16+
17+
// Use batch query to fetch all entities at once
18+
const filter = { id: { in: ids } } as unknown as Filter<DTO>
19+
const entities = await service.query({ filter })
20+
21+
// Create a map for fast lookup by ID
22+
const entityMap = new Map<string | number, DTO>()
23+
if (entities) {
24+
entities.forEach((entity) => {
25+
const id = (entity as Record<string, unknown>).id as string | number
26+
entityMap.set(id, entity)
27+
})
28+
}
29+
30+
// Return results in the same order as requested
31+
return args.map((arg) => entityMap.get(arg.id) || undefined)
32+
}
33+
}
34+
}

packages/query-graphql/src/resolvers/reference.resolver.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import { BadRequestException } from '@nestjs/common'
2-
import { Resolver, ResolveReference } from '@nestjs/graphql'
1+
import { BadRequestException, ExecutionContext } from '@nestjs/common'
2+
import { Context, Resolver, ResolveReference } from '@nestjs/graphql'
33
import { Class, QueryService } from '@ptc-org/nestjs-query-core'
44

55
import { getDTONames } from '../common'
6+
import { InjectDataLoaderConfig } from '../decorators/inject-dataloader-config.decorator'
67
import { RepresentationType } from '../federation'
8+
import { DataLoaderFactory, ReferenceLoader } from '../loader'
9+
import { DataLoaderOptions } from '../pipes/inject-data-loader-config.pipe'
710
import { BaseServiceResolver, ResolverClass, ServiceResolver } from './resolver.interface'
811

912
export interface ReferenceResolverOpts {
@@ -21,18 +24,37 @@ export const Referenceable =
2124
return BaseClass
2225
}
2326
const { key } = opts
27+
const { baseName } = getDTONames(DTOClass)
28+
const loaderName = `loadReference${baseName}`
29+
const referenceLoader = new ReferenceLoader<DTO>(DTOClass)
2430

2531
@Resolver(() => DTOClass, { isAbstract: true })
2632
class ResolveReferenceResolverBase extends BaseClass {
2733
@ResolveReference()
28-
async resolveReference(representation: RepresentationType): Promise<DTO> {
34+
async resolveReference(
35+
representation: RepresentationType,
36+
@Context() context: ExecutionContext,
37+
@InjectDataLoaderConfig()
38+
dataLoaderConfig?: DataLoaderOptions
39+
): Promise<DTO> {
2940
const id = representation[key]
3041
if (id === undefined) {
31-
throw new BadRequestException(
32-
`Unable to resolve reference, missing required key ${key} for ${getDTONames(DTOClass).baseName}`
33-
)
42+
throw new BadRequestException(`Unable to resolve reference, missing required key ${key} for ${baseName}`)
3443
}
35-
return this.service.getById(representation[key] as string | number)
44+
45+
const loader = DataLoaderFactory.getOrCreateLoader(
46+
context,
47+
loaderName,
48+
() => referenceLoader.createLoader(this.service),
49+
dataLoaderConfig
50+
)
51+
52+
const result = await loader.load({ id: id as string | number })
53+
if (!result) {
54+
throw new BadRequestException(`Unable to find ${baseName} with ${key}: ${String(id)}`)
55+
}
56+
57+
return result
3658
}
3759
}
3860

0 commit comments

Comments
 (0)