Skip to content
Open
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
125 changes: 102 additions & 23 deletions packages/graphql/lib/services/resolvers-explorer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { Entrypoint } from '@nestjs/core/inspector/interfaces/entrypoint.interfa
import { SerializedGraph } from '@nestjs/core/inspector/serialized-graph';
import { REQUEST_CONTEXT_ID } from '@nestjs/core/router/request/request-constants';
import { GraphQLResolveInfo } from 'graphql';
import { head, identity } from 'lodash';
import { identity } from 'lodash';
import { SubscriptionOptions } from '../decorators/subscription.decorator';
import { AbstractGraphQLDriver } from '../drivers/abstract-graphql.driver';
import { GqlParamtype } from '../enums/gql-paramtype.enum';
Expand All @@ -43,13 +43,27 @@ import { extractMetadata } from '../utils/extract-metadata.util';
import { BaseExplorerService } from './base-explorer.service';
import { GqlContextType } from './gql-execution-context';

const ROOT_RESOLVER_TYPES = new Set<string>([
Resolver.MUTATION,
Resolver.QUERY,
Resolver.SUBSCRIPTION,
]);

@Injectable()
export class ResolversExplorerService extends BaseExplorerService {
private readonly logger = new Logger(ResolversExplorerService.name);
private readonly gqlParamsFactory = new GqlParamsFactory();
private readonly injector = new Injector();
private coreModuleRef: Module | null | undefined;

private fieldResolverEnhancersLookup: {
guards: boolean;
filters: boolean;
interceptors: boolean;
} | null = null;

private hasGlobalFieldMiddleware: boolean | null = null;

constructor(
private readonly modulesContainer: ModulesContainer,
private readonly metadataScanner: MetadataScanner,
Expand Down Expand Up @@ -92,9 +106,7 @@ export class ResolversExplorerService extends BaseExplorerService {
isUndefined(resolverType) ||
(!isReferenceResolver &&
!isPropertyResolver &&
![Resolver.MUTATION, Resolver.QUERY, Resolver.SUBSCRIPTION].some(
(type) => type === resolverType,
));
!ROOT_RESOLVER_TYPES.has(resolverType));

const resolvers = this.metadataScanner
.getAllMethodNames(prototype)
Expand Down Expand Up @@ -166,22 +178,21 @@ export class ResolversExplorerService extends BaseExplorerService {
transform: Function = identity,
) {
const paramsFactory = this.gqlParamsFactory;
const isPropertyResolver = ![
Resolver.MUTATION,
Resolver.QUERY,
Resolver.SUBSCRIPTION,
].some((type) => type === resolver.type);
const isPropertyResolver = !ROOT_RESOLVER_TYPES.has(resolver.type);

const fieldResolverEnhancers = this.gqlOptions.fieldResolverEnhancers || [];
if (this.fieldResolverEnhancersLookup === null) {
const enhancers = this.gqlOptions.fieldResolverEnhancers || [];
this.fieldResolverEnhancersLookup = {
guards: enhancers.includes('guards'),
filters: enhancers.includes('filters'),
interceptors: enhancers.includes('interceptors'),
};
}
const contextOptions =
resolver.methodName === FIELD_TYPENAME
? { guards: false, filters: false, interceptors: false }
: isPropertyResolver
? {
guards: fieldResolverEnhancers.includes('guards'),
filters: fieldResolverEnhancers.includes('filters'),
interceptors: fieldResolverEnhancers.includes('interceptors'),
}
? this.fieldResolverEnhancersLookup
: undefined;

if (isRequestScoped) {
Expand Down Expand Up @@ -220,6 +231,21 @@ export class ResolversExplorerService extends BaseExplorerService {
)
: resolverCallback;
}

if (
isPropertyResolver &&
this.canUseFastFieldResolver(
instance,
resolver.methodName,
contextOptions,
)
) {
const resolverFn = prototype[resolver.methodName];
if (typeof resolverFn === 'function') {
return resolverFn.bind(instance);
}
}

const resolverCallback = this.externalContextCreator.create<
Record<number, ParamMetadata>,
GqlContextType
Expand Down Expand Up @@ -300,14 +326,14 @@ export class ResolversExplorerService extends BaseExplorerService {

private registerContextProvider<T = any>(request: T, contextId: ContextId) {
if (this.coreModuleRef === undefined) {
const coreModuleArray = [...this.modulesContainer.entries()]
.filter(
([key, { metatype }]) =>
metatype && metatype.name === InternalCoreModule.name,
)
.map(([key, value]) => value);

this.coreModuleRef = head(coreModuleArray) ?? null;
let foundModule: Module | null = null;
for (const [, moduleRef] of this.modulesContainer.entries()) {
if (moduleRef.metatype?.name === InternalCoreModule.name) {
foundModule = moduleRef;
break;
}
}
this.coreModuleRef = foundModule;
}

if (!this.coreModuleRef) {
Expand Down Expand Up @@ -366,6 +392,59 @@ export class ResolversExplorerService extends BaseExplorerService {
return contextId;
}

/**
* Determines if a field resolver can use the fast-path that bypasses
* ExternalContextCreator overhead. This is possible when:
* - No guards/filters/interceptors are enabled for field resolvers
* - No field middleware is registered (global or method-level)
* - No parameter decorators (@Parent, @Args, etc.) are used on the method
*/
private canUseFastFieldResolver(
instance: object,
methodKey: string,
contextOptions?: {
guards: boolean;
filters: boolean;
interceptors: boolean;
},
): boolean {
if (
contextOptions?.guards ||
contextOptions?.filters ||
contextOptions?.interceptors
) {
return false;
}

const fieldMiddleware = Reflect.getMetadata(
FIELD_RESOLVER_MIDDLEWARE_METADATA,
instance[methodKey as keyof typeof instance],
);
if (fieldMiddleware?.length > 0) {
return false;
}

if (this.hasGlobalFieldMiddleware === null) {
const globalMiddleware =
this.gqlOptions?.buildSchemaOptions?.fieldMiddleware;
this.hasGlobalFieldMiddleware = (globalMiddleware?.length ?? 0) > 0;
}
if (this.hasGlobalFieldMiddleware) {
return false;
}

const paramMetadata = Reflect.getMetadata(
PARAM_ARGS_METADATA,
instance.constructor,
methodKey,
);
if (paramMetadata && Object.keys(paramMetadata).length > 0) {
return false;
}

return true;
}

private assignResolverConstructorUniqueId(
resolverConstructor: any,
moduleRef: Module,
Expand Down
18 changes: 10 additions & 8 deletions packages/graphql/lib/utils/normalize-resolver-args.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { isType } from 'graphql';

export function normalizeResolverArgs(args: any[]) {
const newArgs = [...args];
// Reference resolver args don't have args argument
const isReferenceResolver = newArgs.length === 3;
// Reference resolver args don't have args argument (3 args instead of 4)
const isReferenceResolver = args.length === 3;
// Resolve type args don't have args argument and the last argument is the parent object type
const isResolveType =
!isReferenceResolver && isType(newArgs[newArgs.length - 1]);
const isResolveType = !isReferenceResolver && isType(args[args.length - 1]);

// Add an undefined args argument
// Only create a new array when we need to insert undefined at position 1
// This avoids array allocation for the common 4-argument case
if (isReferenceResolver || isResolveType) {
newArgs.splice(1, 0, undefined);
// Insert undefined at position 1: [root, ctx, info] -> [root, undefined, ctx, info]
return [args[0], undefined, args[1], args[2], args[3]];
}
return newArgs;

// Return original array for the common case (no mutation needed)
return args;
}
64 changes: 64 additions & 0 deletions packages/graphql/tests/services/normalize-resolver-args.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { normalizeResolverArgs } from '../../lib/utils/normalize-resolver-args';

describe('normalizeResolverArgs', () => {
describe('standard resolver arguments (4 args)', () => {
it('should return the same array reference for 4-argument resolvers', () => {
const args = ['root', { id: 1 }, { user: {} }, { fieldName: 'test' }];
const result = normalizeResolverArgs(args);
expect(result).toBe(args);
});

it('should not modify the original array', () => {
const args = ['root', { id: 1 }, { user: {} }, { fieldName: 'test' }];
normalizeResolverArgs(args);
expect(args).toEqual([
'root',
{ id: 1 },
{ user: {} },
{ fieldName: 'test' },
]);
});
});

describe('reference resolver arguments (3 args)', () => {
it('should insert undefined at position 1 for 3-argument resolvers', () => {
const args = ['root', { user: {} }, { fieldName: 'test' }];
const result = normalizeResolverArgs(args);
expect(result).not.toBe(args);
expect(result).toEqual([
'root',
undefined,
{ user: {} },
{ fieldName: 'test' },
undefined,
]);
});

it('should not modify the original 3-arg array', () => {
const args = ['root', { user: {} }, { fieldName: 'test' }];
const originalArgs = [...args];
normalizeResolverArgs(args);
expect(args).toEqual(originalArgs);
});
});

describe('edge cases', () => {
it('should handle empty arrays', () => {
const args: any[] = [];
const result = normalizeResolverArgs(args);
expect(result).toBe(args);
});

it('should handle single element arrays', () => {
const args = ['root'];
const result = normalizeResolverArgs(args);
expect(result).toBe(args);
});

it('should handle 5+ argument arrays', () => {
const args = ['a', 'b', 'c', 'd', 'e'];
const result = normalizeResolverArgs(args);
expect(result).toBe(args);
});
});
});
Loading