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 #4775

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
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;
}
5 changes: 4 additions & 1 deletion src/core/dataloader/creators/base/data.loader.creator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ILoader } from '../../loader.interface';
import { DataLoaderCreatorOptions } from './data.loader.creator.options';
import { EntityNotFoundException } from '@common/exceptions';

export interface DataLoaderCreator<TReturn> {
create(options?: DataLoaderCreatorOptions<TReturn>): ILoader<TReturn>;
create(
options?: DataLoaderCreatorOptions<TReturn>
): ILoader<TReturn | null | EntityNotFoundException>;
}
1 change: 1 addition & 0 deletions src/core/dataloader/creators/base/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './data.loader.creator';

export * from './data.loader.creator.base.options';
export * from './data.loader.creator.options';
export * from './data.loader.creator.limit.options';
export * from './data.loader.creator.pagination.options';
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
import { EntityManager, In } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { DataLoaderCreator } from '@core/dataloader/creators/base';
import {
DataLoaderCreator,
DataLoaderCreatorBaseOptions,
} from '@core/dataloader/creators/base';
import { createBatchLoader } from '@core/dataloader/utils';
import { ILoader } from '@core/dataloader/loader.interface';
import { Callout, ICallout } from '@domain/collaboration/callout';
import { EntityNotFoundException } from '@common/exceptions';

@Injectable()
export class CalloutLoaderCreator implements DataLoaderCreator<ICallout> {
constructor(@InjectEntityManager() private manager: EntityManager) {}

public create(): ILoader<ICallout> {
return createBatchLoader(
this.constructor.name,
Callout.name,
this.calloutInBatch
);
public create(
options?: DataLoaderCreatorBaseOptions<any, any>
): ILoader<ICallout | null | EntityNotFoundException> {
return createBatchLoader(this.calloutInBatch, {
name: this.constructor.name,
loadedTypeName: Callout.name,
resolveToNull: options?.resolveToNull,
});
}

private calloutInBatch = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { EntityManager } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { CommunityContributorType } from '@common/enums/community.contributor.type';
import { DataLoaderCreator } from '@core/dataloader/creators/base';
import {
DataLoaderCreator,
DataLoaderCreatorBaseOptions,
} from '@core/dataloader/creators/base';
import { createBatchLoader } from '@core/dataloader/utils';
import { User } from '@domain/community/user/user.entity';
import { Organization } from '@domain/community/organization';
Expand All @@ -14,12 +17,12 @@ export class CommunityTypeLoaderCreator
{
constructor(@InjectEntityManager() private manager: EntityManager) {}

create() {
return createBatchLoader(
this.constructor.name,
'CommunityContributorType',
this.communityTypeInBatch
);
create(options?: DataLoaderCreatorBaseOptions<any, any>) {
return createBatchLoader(this.communityTypeInBatch, {
name: this.constructor.name,
loadedTypeName: 'CommunityContributorType',
resolveToNull: options?.resolveToNull,
});
}

private async communityTypeInBatch(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,33 @@
import { EntityManager, In } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { DataLoaderCreator } from '@core/dataloader/creators/base';
import {
DataLoaderCreator,
DataLoaderCreatorBaseOptions,
} from '@core/dataloader/creators/base';
import { createBatchLoader } from '@core/dataloader/utils';
import { ILoader } from '@core/dataloader/loader.interface';
import { IContributor } from '@domain/community/contributor/contributor.interface';
import { User } from '@domain/community/user/user.entity';
import { Organization } from '@domain/community/organization';
import { VirtualContributor } from '@domain/community/virtual-contributor/virtual.contributor.entity';
import { IContributorBase } from '@domain/community/contributor';
import { EntityNotFoundException } from '@common/exceptions';

@Injectable()
export class ContributorLoaderCreator
implements DataLoaderCreator<IContributorBase>
{
constructor(@InjectEntityManager() private manager: EntityManager) {}

public create(): ILoader<IContributor> {
return createBatchLoader(
this.constructor.name,
'Contributor',
this.contributorsInBatch
);
public create(
options?: DataLoaderCreatorBaseOptions<any, any>
): ILoader<IContributor | null | EntityNotFoundException> {
return createBatchLoader(this.contributorsInBatch, {
name: this.constructor.name,
loadedTypeName: 'Contributor',
resolveToNull: options?.resolveToNull,
});
}

private contributorsInBatch = async (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
import { EntityManager, In } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { DataLoaderCreator } from '@core/dataloader/creators/base';
import {
DataLoaderCreator,
DataLoaderCreatorBaseOptions,
} from '@core/dataloader/creators/base';
import { createBatchLoader } from '@core/dataloader/utils';
import { ILoader } from '@core/dataloader/loader.interface';
import { ISpace } from '@domain/space/space/space.interface';
import { Space } from '@domain/space/space/space.entity';
import { EntityNotFoundException } from '@common/exceptions';

@Injectable()
export class SpaceLoaderCreator implements DataLoaderCreator<ISpace> {
constructor(@InjectEntityManager() private manager: EntityManager) {}

public create(): ILoader<ISpace> {
return createBatchLoader(
this.constructor.name,
Space.name,
this.spaceInBatch
);
public create(
options?: DataLoaderCreatorBaseOptions<any, any>
): ILoader<ISpace | null | EntityNotFoundException> {
return createBatchLoader(this.spaceInBatch, {
name: this.constructor.name,
loadedTypeName: Space.name,
resolveToNull: options?.resolveToNull,
});
}

private spaceInBatch = (keys: ReadonlyArray<string>): Promise<Space[]> => {
Expand Down
17 changes: 9 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,14 @@ 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);
console.log(info.parentType.name, fieldName, options.resolveToNull);
}

return ctx[DATA_LOADER_CTX_INJECT_TOKEN].get(innerCreatorRef, options);
Expand Down
34 changes: 21 additions & 13 deletions src/core/dataloader/utils/createTypedBatchLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,22 @@ import { EntityNotFoundException } from '@common/exceptions';
import { LogContext } from '@common/enums';
import { ILoader } from '../loader.interface';
import { sorOutputByKeys } from '@core/dataloader/utils/sort.output.by.keys';
import { DataLoaderCreatorBaseOptions } from '@core/dataloader/creators/base/data.loader.creator.base.options';

export const createBatchLoader = <TResult extends { id: string }>(
name: string, // for debugging purposes
loadedTypeName: string, // for debugging purposes
batchLoadFn: (keys: ReadonlyArray<string>) => Promise<TResult[]>
): ILoader<TResult> => {
// the data loader returns an array the MUST match the input length
batchLoadFn: (keys: ReadonlyArray<string>) => Promise<TResult[]>,
options?: {
name: string; // for debugging purposes
loadedTypeName: string; // for debugging purposes
} & Pick<DataLoaderCreatorBaseOptions<any, any>, 'resolveToNull'>
): ILoader<TResult | null | EntityNotFoundException> => {
// the data loader returns an array the MUST match the input length AND input key order
// the provided batch function does not necessarily complete this requirement
// so we create a wrapper function that executes the batch function and ensure the output length
// so we create a wrapper function that executes the batch function and ensure the output length and order
// by either returning the original output (if the length matches) or filling the missing values with errors
const loadAndEnsureOutputLengthAndOrder = async (keys: readonly string[]) => {
const loadAndEnsureOutputLengthAndOrder = async (
keys: readonly string[]
): Promise<(TResult | null | Error)[]> => {
const unsortedOutput = await batchLoadFn(keys);
const sortedOutput = sorOutputByKeys(unsortedOutput, keys);
if (sortedOutput.length == keys.length) {
Expand All @@ -29,16 +34,19 @@ export const createBatchLoader = <TResult extends { id: string }>(
key => resultsById.get(key) ?? resolveUnresolvedForKey(key)
);
};
const { name, loadedTypeName, resolveToNull } = options ?? {};
// a function to resolve an unresolved entity for a given key (e.g. if not found, etc.)
const resolveUnresolvedForKey = (key: string) => {
return new EntityNotFoundException(
`Could not find ${loadedTypeName} for the given key`,
LogContext.DATA_LOADER,
{ id: key }
);
return resolveToNull
? null
: new EntityNotFoundException(
`Could not find ${loadedTypeName} for the given key`,
LogContext.DATA_LOADER,
{ id: key }
);
};

return new DataLoader<string, TResult>(
return new DataLoader<string, TResult | null>(
keys => loadAndEnsureOutputLengthAndOrder(keys),
{
cache: true,
Expand Down
8 changes: 4 additions & 4 deletions src/core/dataloader/utils/createTypedRelationLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ import { selectOptionsFromFields } from './selectOptionsFromFields';

export const createTypedRelationDataLoader = <
TParent extends { id: string } & { [key: string]: any }, // todo better type,
TResult
TResult,
>(
manager: EntityManager,
parentClassRef: Type<TParent>,
relations: FindOptionsRelations<TParent>,
name: string,
options?: DataLoaderCreatorOptions<TResult, TParent>
): ILoader<TResult> => {
): ILoader<TResult | null> => {
const { fields, ...restOptions } = options ?? {};

const topRelation = <keyof TResult>Object.keys(relations)[0];
Expand All @@ -33,9 +33,9 @@ export const createTypedRelationDataLoader = <
: fields
: { id: true };

return new DataLoader<string, TResult>(
return new DataLoader<string, TResult | null>(
keys =>
findByBatchIds<TParent, TResult>(
findByBatchIds<TParent, TResult | null>(
manager,
parentClassRef,
keys as string[],
Expand Down
4 changes: 2 additions & 2 deletions src/core/dataloader/utils/createTypedSimpleLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const createTypedSimpleDataLoader = <TResult extends { id: string }>(
classRef: Type<TResult>,
name: string,
options?: DataLoaderCreatorOptions<TResult, TResult>
): ILoader<TResult> => {
): ILoader<TResult | null> => {
const { fields, ...restOptions } = options ?? {};
// if fields ia specified, select specific fields, otherwise select all fields
const selectOptions = fields
Expand All @@ -23,7 +23,7 @@ export const createTypedSimpleDataLoader = <TResult extends { id: string }>(
: fields
: undefined;

return new DataLoader<string, TResult>(
return new DataLoader<string, TResult | null>(
keys =>
findByBatchIdsSimple(manager, classRef, keys as string[], {
...restOptions,
Expand Down
Loading