Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dataloader auto resolve #4776

Draft
wants to merge 3 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,28 @@ export interface DataLoaderCreatorBaseOptions<TParent, TResult> {
*/
cache?: boolean;
/***
* What to return when resolving the unresolved result for a key.
* The default behaviour is to return an error - set to true to return NULL instead.
* This is useful when an a result is expected to be null and it's not an
* exceptional case.
* Overwrites the default behaviour of what to return when resolving the unresolved result for a key.
* This is useful when the result is expected to be null, and it's not an exceptional case.
* ---
* The default behaviour is determined by the Loader decorator on the dataloader`s creation.
* If the underlying graphql field, for which the dataloader is created, is nullable - the result can be resolved to null.
* <br/>
* Example:
* ```
* @ResolveField(() => ICallout, {
* nullable: true,
* description: 'The Callout that was published.',
* })
* public callout(
* @Parent() { payload }: InAppNotificationCalloutPublished,
* @Loader(CalloutLoaderCreator) loader: ILoader<ICallout | null>
* ) {
* return loader.load(payload.calloutID);
* }
* ```
* The `callout` is decorated as nullable, so the dataloader will auto-resolve to `null` if the result is not found.
* <br/>
* You can override this behaviour by setting the option to `false`. That way the problematic values will always be resolved to errors.
*/
resolveToNull?: boolean;
}
16 changes: 8 additions & 8 deletions src/core/dataloader/decorators/data.loader.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { isNonNullType } from 'graphql/type';
import { createParamDecorator, ExecutionContext, Type } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql';
import { DataLoaderInterceptorNotProvided } from '@common/exceptions/data-loader';
import { DATA_LOADER_CTX_INJECT_TOKEN } from '../data.loader.inject.token';
import { DataLoaderInterceptor } from '../interceptors';
import { DataLoaderCreator, DataLoaderCreatorOptions } from '../creators/base';

export function Loader<TParent, TReturn>(
creatorRef: Type<DataLoaderCreator<TReturn>>,
options?: DataLoaderCreatorOptions<TReturn, TParent>
options: DataLoaderCreatorOptions<TReturn, TParent> = {}
): ParameterDecorator {
return createParamDecorator(
(
Expand All @@ -17,11 +15,13 @@ export function Loader<TParent, TReturn>(
) => {
const ctx =
GqlExecutionContext.create(context).getContext<IGraphQLContext>();
// as the default behaviour we resolve to null if the field is nullable
if (options.resolveToNull === undefined) {
const info = context.getArgByIndex(3);
const fieldName = info.fieldName;
const field = info.parentType.getFields()[fieldName];

if (!ctx[DATA_LOADER_CTX_INJECT_TOKEN]) {
throw new DataLoaderInterceptorNotProvided(
`You must provide ${DataLoaderInterceptor.name} globally with the ${APP_INTERCEPTOR} injector token`
);
options.resolveToNull = !isNonNullType(field.type);
hero101 marked this conversation as resolved.
Show resolved Hide resolved
}

return ctx[DATA_LOADER_CTX_INJECT_TOKEN].get(innerCreatorRef, options);
Expand Down
4 changes: 2 additions & 2 deletions src/core/dataloader/utils/createTypedRelationLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export const createTypedRelationDataLoader = <
: fields
: { id: true };

return new DataLoader<string, TResult>(
return new DataLoader<string, TResult | null | EntityNotFoundException>(
keys =>
findByBatchIds<TParent, TResult>(
findByBatchIds<TParent, TResult | null | EntityNotFoundException>(
manager,
parentClassRef,
keys as string[],
Expand Down
2 changes: 1 addition & 1 deletion src/core/dataloader/utils/createTypedSimpleLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const createTypedSimpleDataLoader = <TResult extends { id: string }>(
: fields
: undefined;

return new DataLoader<string, TResult>(
return new DataLoader<string, TResult | null | EntityNotFoundException>(
keys =>
findByBatchIdsSimple(manager, classRef, keys as string[], {
...restOptions,
Expand Down
28 changes: 17 additions & 11 deletions src/core/dataloader/utils/findByBatchIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@ import {
import { Type } from '@nestjs/common';
import { EntityNotFoundException } from '@common/exceptions';
import { LogContext } from '@common/enums';
import { FindByBatchIdsOptions } from '../utils';
import { FindByBatchIdsOptions, sorOutputByKeys } from '../utils';

export const findByBatchIds = async <
TParent extends { id: string } & { [key: string]: any }, // todo better type
TResult
TResult,
>(
manager: EntityManager,
classRef: Type<TParent>,
ids: string[],
relations: FindOptionsRelations<TParent>,
options?: FindByBatchIdsOptions<TParent, TResult>
): Promise<(TResult | Error)[] | never> => {
): Promise<(TResult | null | EntityNotFoundException)[] | never> => {
if (!ids.length) {
return [];
}
Expand All @@ -33,31 +33,37 @@ export const findByBatchIds = async <

const { select, limit } = options ?? {};

const results = await manager.find<TParent>(classRef, {
const unsortedResults = await manager.find<TParent>(classRef, {
take: limit,
where: {
id: In(ids),
} as FindOptionsWhere<TParent>,
relations: relations,
select: select,
});
const sortedResults = sorOutputByKeys(unsortedResults, ids);

const topLevelRelation = relationKeys[0];

const getRelation = (result: TParent) =>
options?.getResult?.(result) ?? result[topLevelRelation];
// return empty object because DataLoader does not allow to return NULL values
// handle the value when the result is returned
const resolveUnresolvedForKey = options?.resolveToNull
? () => ({} as TResult)
: (key: string) =>
new EntityNotFoundException(
`Could not load relation '${topLevelRelation}' for ${classRef.name}: ${key}`,
LogContext.DATA_LOADER
const resolveUnresolvedForKey = (key: string) => {
return options?.resolveToNull
? null
: new EntityNotFoundException(
`Could not load relation '${topLevelRelation}' for ${classRef.name} for the given key`,
LogContext.DATA_LOADER,
{ id: key }
);
};

const resultsById = new Map<string, TResult>(
results.map<[string, TResult]>(result => [result.id, getRelation(result)])
sortedResults.map<[string, TResult]>(result => [
result.id,
getRelation(result),
])
);
// ensure the result length matches the input length
return ids.map(id => resultsById.get(id) ?? resolveUnresolvedForKey(id));
Expand Down
16 changes: 9 additions & 7 deletions src/core/dataloader/utils/findByBatchIdsSimple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const findByBatchIdsSimple = async <TResult extends { id: string }>(
classRef: Type<TResult>,
ids: string[],
options?: FindByBatchIdsOptions<TResult, TResult>
): Promise<(TResult | Error)[] | never> => {
): Promise<(TResult | null | EntityNotFoundException)[] | never> => {
if (!ids.length) {
return [];
}
Expand All @@ -25,13 +25,15 @@ export const findByBatchIdsSimple = async <TResult extends { id: string }>(
});
// return empty object because DataLoader does not allow to return NULL values
// handle the value when the result is returned
const resolveUnresolvedForKey = options?.resolveToNull
? () => ({} as TResult)
: (key: string) =>
new EntityNotFoundException(
`Could not load resource for '${key}'`,
LogContext.DATA_LOADER
const resolveUnresolvedForKey = (key: string) => {
return options?.resolveToNull
? null
: new EntityNotFoundException(
`Could not find ${classRef.name} for the given key`,
LogContext.DATA_LOADER,
{ id: key }
);
};

const resultsById = new Map<string, TResult>(
results.map<[string, TResult]>(result => [result.id, result])
Expand Down
1 change: 1 addition & 0 deletions src/core/dataloader/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './createTypedSimpleLoader';
export * from './createTypedBatchLoader';
export * from './selectOptionsFromFields';
export * from './find.by.batch.options';
export * from './sort.output.by.keys';
24 changes: 3 additions & 21 deletions src/domain/collaboration/callout/callout.resolver.fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export class CalloutResolverFields {
})
async publishedBy(
@Parent() callout: ICallout,
@Loader(UserLoaderCreator, { resolveToNull: true }) loader: ILoader<IUser>
@Loader(UserLoaderCreator) loader: ILoader<IUser | null>
): Promise<IUser | null> {
const publishedBy = callout.publishedBy;
if (!publishedBy) {
Expand Down Expand Up @@ -154,31 +154,13 @@ export class CalloutResolverFields {
})
async createdBy(
@Parent() callout: ICallout,
@Loader(UserLoaderCreator, { resolveToNull: true }) loader: ILoader<IUser>
@Loader(UserLoaderCreator) loader: ILoader<IUser | null>
): Promise<IUser | null> {
const createdBy = callout.createdBy;
if (!createdBy) {
return null;
}

try {
return await loader
.load(createdBy)
// empty object is result because DataLoader does not allow to return NULL values
// handle the value when the result is returned
.then(x => {
return !Object.keys(x).length ? null : x;
});
} catch (e: unknown) {
if (e instanceof EntityNotFoundException) {
this.logger?.warn(
`createdBy '${createdBy}' unable to be resolved when resolving callout '${callout.id}'`,
LogContext.COLLABORATION
);
return null;
} else {
throw e;
}
}
return loader.load(createdBy);
}
}
22 changes: 2 additions & 20 deletions src/domain/common/whiteboard/whiteboard.resolver.fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export class WhiteboardResolverFields {
})
async createdBy(
@Parent() whiteboard: IWhiteboard,
@Loader(UserLoaderCreator, { resolveToNull: true }) loader: ILoader<IUser>
@Loader(UserLoaderCreator) loader: ILoader<IUser | null>
): Promise<IUser | null> {
const createdBy = whiteboard.createdBy;
if (!createdBy) {
Expand All @@ -51,25 +51,7 @@ export class WhiteboardResolverFields {
return null;
}

try {
return await loader
.load(createdBy)
// empty object is result because DataLoader does not allow to return NULL values
// handle the value when the result is returned
.then(x => {
return !Object.keys(x).length ? null : x;
});
} catch (e: unknown) {
if (e instanceof EntityNotFoundException) {
this.logger?.warn(
`createdBy '${createdBy}' unable to be resolved when resolving whiteboard '${whiteboard.id}'`,
LogContext.COLLABORATION
);
return null;
} else {
throw e;
}
}
return loader.load(createdBy);
}

@UseGuards(GraphqlGuard)
Expand Down