Query complexity validation (#16274)
Validations : - relations count (in common api) - oneToMany relation nested count (in common) - requested fields count (in gql) - root resolver count (in gql) - root resolver duplicates (in gql) - specific complexity for metadata / nesting count (in gql)
This commit is contained in:
+58
-15
@@ -1,5 +1,6 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Omit } from 'zod/v4/core/util.cjs';
|
||||
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
CommonQueryNames,
|
||||
} from 'src/engine/api/common/types/common-query-args.type';
|
||||
import { CommonQueryResult } from 'src/engine/api/common/types/common-query-result.type';
|
||||
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
|
||||
import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util';
|
||||
import { OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS } from 'src/engine/api/graphql/graphql-query-runner/constants/objects-with-settings-permissions-requirements';
|
||||
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
|
||||
@@ -124,13 +126,17 @@ export abstract class CommonBaseQueryRunnerService<
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
const processedArgs = await this.processArgs(
|
||||
args,
|
||||
queryRunnerContext,
|
||||
this.operationName,
|
||||
commonQueryParser,
|
||||
const selectedFieldsResult = commonQueryParser.parseSelectedFields(
|
||||
args.selectedFields,
|
||||
);
|
||||
|
||||
this.validateQueryComplexity(selectedFieldsResult, args);
|
||||
|
||||
const processedArgs = {
|
||||
...(await this.processArgs(args, queryRunnerContext, this.operationName)),
|
||||
selectedFieldsResult,
|
||||
} as CommonExtendedInput<Args>;
|
||||
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () =>
|
||||
@@ -166,16 +172,22 @@ export abstract class CommonBaseQueryRunnerService<
|
||||
authContext: WorkspaceAuthContext,
|
||||
): Promise<Output>;
|
||||
|
||||
protected computeQueryComplexity(
|
||||
selectedFieldsResult: CommonSelectedFieldsResult,
|
||||
_args: CommonInput<Args>,
|
||||
): number {
|
||||
const simpleFieldsComplexity = 1;
|
||||
const selectedFieldsComplexity =
|
||||
simpleFieldsComplexity + (selectedFieldsResult.relationFieldsCount ?? 0);
|
||||
|
||||
return selectedFieldsComplexity;
|
||||
}
|
||||
|
||||
private async processArgs(
|
||||
args: CommonInput<Args>,
|
||||
queryRunnerContext: CommonBaseQueryRunnerContext,
|
||||
operationName: CommonQueryNames,
|
||||
commonQueryParser: GraphqlQueryParser,
|
||||
): Promise<CommonExtendedInput<Args>> {
|
||||
const selectedFieldsResult = commonQueryParser.parseSelectedFields(
|
||||
args.selectedFields,
|
||||
);
|
||||
|
||||
): Promise<CommonInput<Args>> {
|
||||
const { authContext, flatObjectMetadata } = queryRunnerContext;
|
||||
|
||||
const computedArgs = await this.computeArgs(args, queryRunnerContext);
|
||||
@@ -188,10 +200,7 @@ export abstract class CommonBaseQueryRunnerService<
|
||||
computedArgs as WorkspacePreQueryHookPayload<CommonQueryNames>,
|
||||
)) as CommonInput<Args>;
|
||||
|
||||
return {
|
||||
...hookedArgs,
|
||||
selectedFieldsResult,
|
||||
};
|
||||
return hookedArgs;
|
||||
}
|
||||
|
||||
private async executeQueryAndEnrichResults(
|
||||
@@ -388,4 +397,38 @@ export abstract class CommonBaseQueryRunnerService<
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateQueryComplexity(
|
||||
selectedFieldsResult: CommonSelectedFieldsResult,
|
||||
args: CommonInput<Args>,
|
||||
) {
|
||||
const maximumComplexity = this.twentyConfigService.get(
|
||||
'COMMON_QUERY_COMPLEXITY_LIMIT',
|
||||
);
|
||||
|
||||
if (selectedFieldsResult.hasAtLeastTwoNestedOneToManyRelations) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Query complexity is too high. One-to-Many relation cannot be nested in another One-to-Many relation.`,
|
||||
CommonQueryRunnerExceptionCode.TOO_COMPLEX_QUERY,
|
||||
{
|
||||
userFriendlyMessage: msg`Query complexity is too high. One-to-Many relation cannot be nested in another One-to-Many relation.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const queryComplexity = this.computeQueryComplexity(
|
||||
selectedFieldsResult,
|
||||
args,
|
||||
);
|
||||
|
||||
if (queryComplexity > maximumComplexity) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Query complexity is too high. Please, reduce the amount of relation fields requested. Query complexity: ${queryComplexity}. Maximum complexity: ${maximumComplexity}.`,
|
||||
CommonQueryRunnerExceptionCode.TOO_COMPLEX_QUERY,
|
||||
{
|
||||
userFriendlyMessage: msg`Query complexity is too high. Please, reduce the amount of relation fields requested. Query complexity: ${queryComplexity}. Maximum complexity: ${maximumComplexity}.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
|
||||
import { QUERY_MAX_RECORDS_FROM_RELATION } from 'twenty-shared/constants';
|
||||
import { ObjectRecord } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
|
||||
@@ -84,7 +84,7 @@ export class CommonDeleteManyQueryRunnerService extends CommonBaseQueryRunnerSer
|
||||
string,
|
||||
FindOptionsRelations<ObjectLiteral>
|
||||
>,
|
||||
limit: QUERY_MAX_RECORDS,
|
||||
limit: QUERY_MAX_RECORDS_FROM_RELATION,
|
||||
authContext,
|
||||
workspaceDataSource,
|
||||
rolePermissionConfig,
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
|
||||
import { QUERY_MAX_RECORDS_FROM_RELATION } from 'twenty-shared/constants';
|
||||
import { ObjectRecord } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
|
||||
@@ -85,7 +85,7 @@ export class CommonDestroyManyQueryRunnerService extends CommonBaseQueryRunnerSe
|
||||
string,
|
||||
FindOptionsRelations<ObjectLiteral>
|
||||
>,
|
||||
limit: QUERY_MAX_RECORDS,
|
||||
limit: QUERY_MAX_RECORDS_FROM_RELATION,
|
||||
authContext,
|
||||
workspaceDataSource,
|
||||
rolePermissionConfig,
|
||||
|
||||
+5
-2
@@ -1,7 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
|
||||
import {
|
||||
QUERY_MAX_RECORDS,
|
||||
QUERY_MAX_RECORDS_FROM_RELATION,
|
||||
} from 'twenty-shared/constants';
|
||||
import { ObjectRecord, OrderByDirection } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FindOptionsRelations, In, ObjectLiteral } from 'typeorm';
|
||||
@@ -150,7 +153,7 @@ export class CommonFindDuplicatesQueryRunnerService extends CommonBaseQueryRunne
|
||||
string,
|
||||
FindOptionsRelations<ObjectLiteral>
|
||||
>,
|
||||
limit: QUERY_MAX_RECORDS,
|
||||
limit: QUERY_MAX_RECORDS_FROM_RELATION,
|
||||
authContext,
|
||||
workspaceDataSource,
|
||||
rolePermissionConfig,
|
||||
|
||||
+5
-2
@@ -1,7 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'class-validator';
|
||||
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
|
||||
import {
|
||||
QUERY_MAX_RECORDS,
|
||||
QUERY_MAX_RECORDS_FROM_RELATION,
|
||||
} from 'twenty-shared/constants';
|
||||
import { ObjectRecord, OrderByDirection } from 'twenty-shared/types';
|
||||
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
|
||||
|
||||
@@ -168,7 +171,7 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
|
||||
FindOptionsRelations<ObjectLiteral>
|
||||
>,
|
||||
aggregate: args.selectedFieldsResult.aggregate,
|
||||
limit: QUERY_MAX_RECORDS,
|
||||
limit: QUERY_MAX_RECORDS_FROM_RELATION,
|
||||
authContext,
|
||||
workspaceDataSource,
|
||||
rolePermissionConfig,
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
|
||||
import { QUERY_MAX_RECORDS_FROM_RELATION } from 'twenty-shared/constants';
|
||||
import { ObjectRecord } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
|
||||
@@ -96,7 +96,7 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
string,
|
||||
FindOptionsRelations<ObjectLiteral>
|
||||
>,
|
||||
limit: QUERY_MAX_RECORDS,
|
||||
limit: QUERY_MAX_RECORDS_FROM_RELATION,
|
||||
authContext,
|
||||
workspaceDataSource,
|
||||
rolePermissionConfig,
|
||||
|
||||
+17
-2
@@ -34,7 +34,7 @@ import {
|
||||
CommonQueryNames,
|
||||
GroupByQueryArgs,
|
||||
} from 'src/engine/api/common/types/common-query-args.type';
|
||||
import { GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
|
||||
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
|
||||
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
|
||||
import { GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
|
||||
import { GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
|
||||
@@ -322,7 +322,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
}: {
|
||||
queryBuilder: WorkspaceSelectQueryBuilder<ObjectLiteral>;
|
||||
groupByDefinitions: GroupByDefinition[];
|
||||
selectedFieldsResult: GraphqlQuerySelectedFieldsResult;
|
||||
selectedFieldsResult: CommonSelectedFieldsResult;
|
||||
groupLimit?: number;
|
||||
}): Promise<CommonGroupByOutputItem[]> {
|
||||
const effectiveGroupLimit = getGroupLimit(groupLimit);
|
||||
@@ -400,4 +400,19 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
protected override computeQueryComplexity(
|
||||
selectedFieldsResult: CommonSelectedFieldsResult,
|
||||
args: CommonInput<GroupByQueryArgs>,
|
||||
): number {
|
||||
const groupByQueryComplexity = 1;
|
||||
const simpleFieldsComplexity = 1;
|
||||
const selectedFieldsComplexity =
|
||||
simpleFieldsComplexity + (selectedFieldsResult.relationFieldsCount ?? 0);
|
||||
|
||||
return (args.includeRecords ?? false)
|
||||
? groupByQueryComplexity +
|
||||
selectedFieldsComplexity * getGroupLimit(args.limit)
|
||||
: groupByQueryComplexity;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
MUTATION_MAX_MERGE_RECORDS,
|
||||
QUERY_MAX_RECORDS,
|
||||
QUERY_MAX_RECORDS_FROM_RELATION,
|
||||
} from 'twenty-shared/constants';
|
||||
import {
|
||||
FieldMetadataRelationSettings,
|
||||
@@ -157,7 +157,7 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
string,
|
||||
FindOptionsRelations<ObjectLiteral>
|
||||
>,
|
||||
limit: QUERY_MAX_RECORDS,
|
||||
limit: QUERY_MAX_RECORDS_FROM_RELATION,
|
||||
authContext: context.authContext,
|
||||
workspaceDataSource: context.workspaceDataSource,
|
||||
rolePermissionConfig: context.rolePermissionConfig,
|
||||
@@ -444,7 +444,7 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
|
||||
string,
|
||||
FindOptionsRelations<ObjectLiteral>
|
||||
>,
|
||||
limit: QUERY_MAX_RECORDS,
|
||||
limit: QUERY_MAX_RECORDS_FROM_RELATION,
|
||||
authContext,
|
||||
workspaceDataSource,
|
||||
rolePermissionConfig,
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
|
||||
import { QUERY_MAX_RECORDS_FROM_RELATION } from 'twenty-shared/constants';
|
||||
import { ObjectRecord } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
|
||||
@@ -85,7 +85,7 @@ export class CommonRestoreManyQueryRunnerService extends CommonBaseQueryRunnerSe
|
||||
string,
|
||||
FindOptionsRelations<ObjectLiteral>
|
||||
>,
|
||||
limit: QUERY_MAX_RECORDS,
|
||||
limit: QUERY_MAX_RECORDS_FROM_RELATION,
|
||||
authContext,
|
||||
workspaceDataSource,
|
||||
rolePermissionConfig,
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'class-validator';
|
||||
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
|
||||
import { QUERY_MAX_RECORDS_FROM_RELATION } from 'twenty-shared/constants';
|
||||
import { ObjectRecord } from 'twenty-shared/types';
|
||||
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
|
||||
|
||||
@@ -85,7 +85,7 @@ export class CommonUpdateManyQueryRunnerService extends CommonBaseQueryRunnerSer
|
||||
string,
|
||||
FindOptionsRelations<ObjectLiteral>
|
||||
>,
|
||||
limit: QUERY_MAX_RECORDS,
|
||||
limit: QUERY_MAX_RECORDS_FROM_RELATION,
|
||||
authContext,
|
||||
workspaceDataSource,
|
||||
rolePermissionConfig,
|
||||
|
||||
+1
@@ -16,4 +16,5 @@ export enum CommonQueryRunnerExceptionCode {
|
||||
TOO_MANY_RECORDS_TO_UPDATE = 'TOO_MANY_RECORDS_TO_UPDATE',
|
||||
BAD_REQUEST = 'BAD_REQUEST',
|
||||
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||
TOO_COMPLEX_QUERY = 'TOO_COMPLEX_QUERY',
|
||||
}
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = (
|
||||
case CommonQueryRunnerExceptionCode.INVALID_CURSOR:
|
||||
case CommonQueryRunnerExceptionCode.TOO_MANY_RECORDS_TO_UPDATE:
|
||||
case CommonQueryRunnerExceptionCode.BAD_REQUEST:
|
||||
case CommonQueryRunnerExceptionCode.TOO_COMPLEX_QUERY:
|
||||
throw new UserInputError(error);
|
||||
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
|
||||
throw new AuthenticationError(error);
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ export const commonQueryRunnerToRestApiExceptionHandler = (
|
||||
case CommonQueryRunnerExceptionCode.INVALID_CURSOR:
|
||||
case CommonQueryRunnerExceptionCode.TOO_MANY_RECORDS_TO_UPDATE:
|
||||
case CommonQueryRunnerExceptionCode.BAD_REQUEST:
|
||||
case CommonQueryRunnerExceptionCode.TOO_COMPLEX_QUERY:
|
||||
throw new BadRequestException(error.message);
|
||||
case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND:
|
||||
throw new NotFoundException('Record not found');
|
||||
|
||||
+2
@@ -8,4 +8,6 @@ export type CommonSelectedFieldsResult = {
|
||||
select: CommonSelectedFields;
|
||||
relations: CommonSelectedFields;
|
||||
aggregate: Record<string, AggregationField>;
|
||||
relationFieldsCount?: number;
|
||||
hasAtLeastTwoNestedOneToManyRelations?: boolean;
|
||||
};
|
||||
|
||||
+9
-4
@@ -33,9 +33,9 @@ import {
|
||||
import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { useSentryTracing } from 'src/engine/core-modules/exception-handler/hooks/use-sentry-tracing';
|
||||
import { useComputeComplexity } from 'src/engine/core-modules/graphql/hooks/use-compute-complexity.hook';
|
||||
import { useDisableIntrospectionAndSuggestionsForUnauthenticatedUsers } from 'src/engine/core-modules/graphql/hooks/use-disable-introspection-and-suggestions-for-unauthenticated-users.hook';
|
||||
import { useGraphQLErrorHandlerHook } from 'src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook';
|
||||
import { useValidateGraphqlQueryComplexity } from 'src/engine/core-modules/graphql/hooks/use-validate-graphql-query-complexity.hook';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
@@ -76,9 +76,14 @@ export class GraphQLConfigService
|
||||
useDisableIntrospectionAndSuggestionsForUnauthenticatedUsers(
|
||||
this.twentyConfigService.get('NODE_ENV') === NodeEnvironment.PRODUCTION,
|
||||
),
|
||||
useComputeComplexity(
|
||||
this.twentyConfigService.get('GRAPHQL_MAX_COMPLEXITY'),
|
||||
),
|
||||
useValidateGraphqlQueryComplexity({
|
||||
maximumAllowedFields:
|
||||
this.twentyConfigService.get('GRAPHQL_MAX_FIELDS'),
|
||||
maximumAllowedRootResolvers: this.twentyConfigService.get(
|
||||
'GRAPHQL_MAX_ROOT_RESOLVERS',
|
||||
),
|
||||
checkDuplicateRootResolvers: true,
|
||||
}),
|
||||
];
|
||||
|
||||
if (Sentry.isInitialized()) {
|
||||
|
||||
+20
-1
@@ -1,3 +1,4 @@
|
||||
import { RelationType, type FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
@@ -22,16 +23,26 @@ export class GraphqlQuerySelectedFieldsRelationParser {
|
||||
}
|
||||
|
||||
parseRelationField(
|
||||
fieldMetadata: FlatFieldMetadata,
|
||||
fieldMetadata:
|
||||
| FlatFieldMetadata<FieldMetadataType.RELATION>
|
||||
| FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>,
|
||||
fieldKey: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fieldValue: any,
|
||||
accumulator: GraphqlQuerySelectedFieldsResult,
|
||||
isFromOneToManyRelation?: boolean,
|
||||
): void {
|
||||
if (!fieldValue || typeof fieldValue !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isOneToManyRelation =
|
||||
fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY;
|
||||
|
||||
if (isFromOneToManyRelation && isOneToManyRelation) {
|
||||
accumulator.hasAtLeastTwoNestedOneToManyRelations = true;
|
||||
}
|
||||
|
||||
accumulator.relations[fieldKey] = true;
|
||||
|
||||
if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) {
|
||||
@@ -52,6 +63,7 @@ export class GraphqlQuerySelectedFieldsRelationParser {
|
||||
const relationAccumulator = fieldParser.parse(
|
||||
fieldValue,
|
||||
targetObjectMetadata,
|
||||
isFromOneToManyRelation || isOneToManyRelation,
|
||||
);
|
||||
|
||||
accumulator.select[fieldKey] = {
|
||||
@@ -60,5 +72,12 @@ export class GraphqlQuerySelectedFieldsRelationParser {
|
||||
};
|
||||
accumulator.relations[fieldKey] = relationAccumulator.relations;
|
||||
accumulator.aggregate[fieldKey] = relationAccumulator.aggregate;
|
||||
accumulator.relationFieldsCount =
|
||||
accumulator.relationFieldsCount +
|
||||
relationAccumulator.relationFieldsCount +
|
||||
1;
|
||||
accumulator.hasAtLeastTwoNestedOneToManyRelations =
|
||||
accumulator.hasAtLeastTwoNestedOneToManyRelations ||
|
||||
relationAccumulator.hasAtLeastTwoNestedOneToManyRelations;
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -21,6 +21,8 @@ export type GraphqlQuerySelectedFieldsResult = {
|
||||
relations: Record<string, any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aggregate: Record<string, any>;
|
||||
relationFieldsCount: number;
|
||||
hasAtLeastTwoNestedOneToManyRelations: boolean;
|
||||
};
|
||||
|
||||
export class GraphqlQuerySelectedFieldsParser {
|
||||
@@ -47,11 +49,14 @@ export class GraphqlQuerySelectedFieldsParser {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
graphqlSelectedFields: Partial<Record<string, any>>,
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
isFromOneToManyRelation?: boolean,
|
||||
): GraphqlQuerySelectedFieldsResult {
|
||||
const accumulator: GraphqlQuerySelectedFieldsResult = {
|
||||
select: {},
|
||||
relations: {},
|
||||
aggregate: {},
|
||||
relationFieldsCount: 0,
|
||||
hasAtLeastTwoNestedOneToManyRelations: false,
|
||||
};
|
||||
|
||||
if (this.isRootConnection(graphqlSelectedFields)) {
|
||||
@@ -59,6 +64,7 @@ export class GraphqlQuerySelectedFieldsParser {
|
||||
graphqlSelectedFields,
|
||||
flatObjectMetadata,
|
||||
accumulator,
|
||||
isFromOneToManyRelation,
|
||||
);
|
||||
|
||||
return accumulator;
|
||||
@@ -75,6 +81,7 @@ export class GraphqlQuerySelectedFieldsParser {
|
||||
graphqlSelectedFields,
|
||||
flatObjectMetadata,
|
||||
accumulator,
|
||||
isFromOneToManyRelation,
|
||||
);
|
||||
|
||||
return accumulator;
|
||||
@@ -85,6 +92,7 @@ export class GraphqlQuerySelectedFieldsParser {
|
||||
graphqlSelectedFields: Partial<Record<string, any>>,
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
accumulator: GraphqlQuerySelectedFieldsResult,
|
||||
isFromOneToManyRelation?: boolean,
|
||||
): void {
|
||||
for (const fieldMetadataId of flatObjectMetadata.fieldMetadataIds) {
|
||||
const fieldMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
@@ -116,6 +124,7 @@ export class GraphqlQuerySelectedFieldsParser {
|
||||
fieldMetadata.name,
|
||||
graphqlSelectedFieldValue,
|
||||
accumulator,
|
||||
isFromOneToManyRelation,
|
||||
);
|
||||
|
||||
continue;
|
||||
@@ -160,6 +169,7 @@ export class GraphqlQuerySelectedFieldsParser {
|
||||
fieldMetadata.name,
|
||||
graphqlSelectedFieldValue,
|
||||
accumulator,
|
||||
isFromOneToManyRelation,
|
||||
);
|
||||
|
||||
continue;
|
||||
@@ -197,6 +207,7 @@ export class GraphqlQuerySelectedFieldsParser {
|
||||
graphqlSelectedFields: Partial<Record<string, any>>,
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
accumulator: GraphqlQuerySelectedFieldsResult,
|
||||
isFromOneToManyRelation?: boolean,
|
||||
): void {
|
||||
this.aggregateParser.parse(
|
||||
graphqlSelectedFields,
|
||||
@@ -207,7 +218,12 @@ export class GraphqlQuerySelectedFieldsParser {
|
||||
|
||||
const node = graphqlSelectedFields.edges.node;
|
||||
|
||||
this.parseRecordFields(node, flatObjectMetadata, accumulator);
|
||||
this.parseRecordFields(
|
||||
node,
|
||||
flatObjectMetadata,
|
||||
accumulator,
|
||||
isFromOneToManyRelation,
|
||||
);
|
||||
}
|
||||
|
||||
private isRootConnection(
|
||||
|
||||
+7
-4
@@ -4,7 +4,7 @@ import { isNonEmptyString } from '@sniptt/guards';
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { ObjectRecord } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type ObjectLiteral } from 'typeorm';
|
||||
import { FindOptionsRelations, type ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils
|
||||
import { CommonResultGettersService } from 'src/engine/api/common/common-result-getters/common-result-getters.service';
|
||||
import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type';
|
||||
import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
|
||||
import { type GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
|
||||
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
|
||||
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
|
||||
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
|
||||
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
|
||||
@@ -50,7 +50,7 @@ export class GroupByWithRecordsService {
|
||||
queryBuilderWithGroupBy: WorkspaceSelectQueryBuilder<ObjectLiteral>;
|
||||
queryBuilderWithFiltersAndWithoutGroupBy: WorkspaceSelectQueryBuilder<ObjectLiteral>;
|
||||
groupByDefinitions: GroupByDefinition[];
|
||||
selectedFieldsResult: GraphqlQuerySelectedFieldsResult;
|
||||
selectedFieldsResult: CommonSelectedFieldsResult;
|
||||
queryRunnerContext: CommonExtendedQueryRunnerContext;
|
||||
orderByForRecords: ObjectRecordOrderBy;
|
||||
groupLimit?: number;
|
||||
@@ -110,7 +110,10 @@ export class GroupByWithRecordsService {
|
||||
parentObjectMetadataItem: flatObjectMetadata,
|
||||
parentObjectRecords: allRecords,
|
||||
parentObjectRecordsAggregatedValues: {},
|
||||
relations: selectedFieldsResult.relations,
|
||||
relations: selectedFieldsResult.relations as Record<
|
||||
string,
|
||||
FindOptionsRelations<ObjectLiteral>
|
||||
>,
|
||||
aggregate: selectedFieldsResult.aggregate,
|
||||
limit: RELATIONS_PER_RECORD_LIMIT,
|
||||
authContext,
|
||||
|
||||
@@ -7,9 +7,9 @@ import { useCachedMetadata } from 'src/engine/api/graphql/graphql-config/hooks/u
|
||||
import { MetadataGraphQLApiModule } from 'src/engine/api/graphql/metadata-graphql-api.module';
|
||||
import { type CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { type ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { useComputeComplexity } from 'src/engine/core-modules/graphql/hooks/use-compute-complexity.hook';
|
||||
import { useDisableIntrospectionAndSuggestionsForUnauthenticatedUsers } from 'src/engine/core-modules/graphql/hooks/use-disable-introspection-and-suggestions-for-unauthenticated-users.hook';
|
||||
import { useGraphQLErrorHandlerHook } from 'src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook';
|
||||
import { useValidateGraphqlQueryComplexity } from 'src/engine/core-modules/graphql/hooks/use-validate-graphql-query-complexity.hook';
|
||||
import { type I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { type MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
@@ -46,7 +46,12 @@ export const metadataModuleFactory = async (
|
||||
useDisableIntrospectionAndSuggestionsForUnauthenticatedUsers(
|
||||
twentyConfigService.get('NODE_ENV') === NodeEnvironment.PRODUCTION,
|
||||
),
|
||||
useComputeComplexity(twentyConfigService.get('GRAPHQL_MAX_COMPLEXITY')),
|
||||
useValidateGraphqlQueryComplexity({
|
||||
maximumAllowedFields: twentyConfigService.get('GRAPHQL_MAX_FIELDS'),
|
||||
maximumAllowedRootResolvers: 10,
|
||||
maximumAllowedNestedFields: 7,
|
||||
checkDuplicateRootResolvers: true,
|
||||
}),
|
||||
],
|
||||
path: '/metadata',
|
||||
context: () => ({
|
||||
|
||||
+11
-1
@@ -1,11 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
|
||||
import { DEFAULT_NUMBER_OF_GROUPS_LIMIT } from 'twenty-shared/constants';
|
||||
|
||||
import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service';
|
||||
import { RestApiBaseHandler } from 'src/engine/api/rest/core/handlers/rest-api-base.handler';
|
||||
import { parseAggregateFieldsRestRequest } from 'src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util';
|
||||
import { parseFilterRestRequest } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-rest-request.util';
|
||||
import { parseGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/group-by-parser-utils/parse-group-by-rest-request.util';
|
||||
import { parseIncludeRecordsSampleRestRequest } from 'src/engine/api/rest/input-request-parsers/group-by-with-records/parse-include-records-sample-rest-request.util';
|
||||
import { parseLimitRestRequest } from 'src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util';
|
||||
import { parseOrderByForRecordsWithGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-for-records-rest-request.util';
|
||||
import { parseOrderByWithGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-with-group-by-rest-request.util';
|
||||
import { parseViewIdRestRequest } from 'src/engine/api/rest/input-request-parsers/view-id-parser-utils/parse-view-id-rest-request.util';
|
||||
@@ -35,6 +38,7 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
|
||||
objectIdByNameSingular,
|
||||
includeRecords,
|
||||
orderByForRecords,
|
||||
limit,
|
||||
} = await this.parseRequestArgs(request);
|
||||
|
||||
return await this.commonGroupByQueryRunnerService.execute(
|
||||
@@ -44,6 +48,7 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
|
||||
viewId,
|
||||
groupBy,
|
||||
selectedFields,
|
||||
limit,
|
||||
includeRecords,
|
||||
orderByForRecords,
|
||||
},
|
||||
@@ -77,6 +82,10 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
|
||||
const groupBy = parseGroupByRestRequest(request);
|
||||
const includeRecords = parseIncludeRecordsSampleRestRequest(request);
|
||||
const aggregateFields = parseAggregateFieldsRestRequest(request);
|
||||
const limit = parseLimitRestRequest(
|
||||
request,
|
||||
DEFAULT_NUMBER_OF_GROUPS_LIMIT,
|
||||
);
|
||||
let selectedFields = { ...aggregateFields, groupByDimensionValues: true };
|
||||
|
||||
if (includeRecords) {
|
||||
@@ -104,6 +113,7 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
|
||||
groupBy,
|
||||
selectedFields,
|
||||
includeRecords,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
import { parse } from 'graphql';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { useValidateGraphqlQueryComplexity } from 'src/engine/core-modules/graphql/hooks/use-validate-graphql-query-complexity.hook';
|
||||
|
||||
describe('useValidateGraphqlQueryComplexity', () => {
|
||||
const validateQuery = (
|
||||
query: string,
|
||||
options: Parameters<typeof useValidateGraphqlQueryComplexity>[0],
|
||||
): Error | null => {
|
||||
const plugin = useValidateGraphqlQueryComplexity(options);
|
||||
|
||||
if (!isDefined(plugin.onParse)) {
|
||||
throw new Error('onParse hook not found');
|
||||
}
|
||||
|
||||
const document = parse(query);
|
||||
|
||||
const onParseResult = plugin.onParse({
|
||||
context: {},
|
||||
params: { source: query },
|
||||
parseFn: parse,
|
||||
setParseFn: () => {},
|
||||
setParsedDocument: () => {},
|
||||
extendContext: () => {},
|
||||
} as any);
|
||||
|
||||
if (typeof onParseResult !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
onParseResult({
|
||||
result: document,
|
||||
replaceParseResult: () => {},
|
||||
} as any);
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error as Error;
|
||||
}
|
||||
};
|
||||
|
||||
describe('maximumAllowedFields', () => {
|
||||
it('should pass when fields count is within limit', () => {
|
||||
const query = `
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const error = validateQuery(query, {
|
||||
maximumAllowedFields: 10,
|
||||
});
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('should fail when fields count exceeds limit', () => {
|
||||
const query = `
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const error = validateQuery(query, {
|
||||
maximumAllowedFields: 3,
|
||||
});
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toContain('Too many fields requested');
|
||||
expect(error?.message).toContain('Maximum allowed fields: 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maximumAllowedRootResolvers', () => {
|
||||
it('should pass when root resolvers count is within limit', () => {
|
||||
const query = `
|
||||
query {
|
||||
user {
|
||||
id
|
||||
}
|
||||
posts {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const error = validateQuery(query, {
|
||||
maximumAllowedRootResolvers: 3,
|
||||
});
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('should fail when root resolvers count exceeds limit', () => {
|
||||
const query = `
|
||||
query {
|
||||
user {
|
||||
id
|
||||
}
|
||||
posts {
|
||||
id
|
||||
}
|
||||
comments {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const error = validateQuery(query, {
|
||||
maximumAllowedRootResolvers: 2,
|
||||
});
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toContain('Too many root resolvers requested');
|
||||
expect(error?.message).toContain('Maximum allowed root resolvers: 2');
|
||||
});
|
||||
|
||||
it('should fail when root resolvers count exceeds limit - multiple queries', () => {
|
||||
const query = `
|
||||
query {
|
||||
user {
|
||||
id
|
||||
}
|
||||
posts {
|
||||
id
|
||||
}
|
||||
comments {
|
||||
id
|
||||
}
|
||||
}
|
||||
query {
|
||||
user {
|
||||
id
|
||||
}
|
||||
posts {
|
||||
id
|
||||
}
|
||||
comments {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const error = validateQuery(query, {
|
||||
maximumAllowedRootResolvers: 4,
|
||||
});
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toContain(
|
||||
'Query too complex - Too many root resolvers requested: 6 - Maximum allowed root resolvers: 4',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail when root resolvers count exceeds limit - fragment', () => {
|
||||
const query = `
|
||||
fragment UserFields on User {
|
||||
id
|
||||
}
|
||||
|
||||
fragment PostFields on Post {
|
||||
id
|
||||
}
|
||||
|
||||
fragment CommentFields on Comment {
|
||||
id
|
||||
}
|
||||
|
||||
query {
|
||||
...UserFields
|
||||
...PostFields
|
||||
...CommentFields
|
||||
}
|
||||
|
||||
query {
|
||||
...UserFields
|
||||
...PostFields
|
||||
...CommentFields
|
||||
}
|
||||
`;
|
||||
|
||||
const error = validateQuery(query, {
|
||||
maximumAllowedRootResolvers: 4,
|
||||
});
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toContain(
|
||||
'Query too complex - Too many root resolvers requested: 6 - Maximum allowed root resolvers: 4',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maximumAllowedNestedFields', () => {
|
||||
it('should pass when depth is within limit', () => {
|
||||
const query = `
|
||||
query {
|
||||
user {
|
||||
profile {
|
||||
bio
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const error = validateQuery(query, {
|
||||
maximumAllowedNestedFields: 5,
|
||||
checkDuplicateRootResolvers: false,
|
||||
});
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('should fail when depth exceeds limit', () => {
|
||||
const query = `
|
||||
query {
|
||||
user {
|
||||
profile {
|
||||
settings {
|
||||
notifications {
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const error = validateQuery(query, {
|
||||
maximumAllowedNestedFields: 3,
|
||||
checkDuplicateRootResolvers: false,
|
||||
});
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toContain('Too many nested fields requested');
|
||||
expect(error?.message).toContain('Maximum allowed nested fields: 3');
|
||||
});
|
||||
|
||||
it('should fail when depth exceeds limit - fragment', () => {
|
||||
const query = `
|
||||
fragment UserFields on User {
|
||||
profile {
|
||||
settings {
|
||||
notifications {
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query {
|
||||
user {
|
||||
nested {
|
||||
...UserFields
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const error = validateQuery(query, { maximumAllowedNestedFields: 1 });
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toContain(
|
||||
'Query too complex - Too many nested fields requested: 6 - Maximum allowed nested fields: 1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkDuplicateRootResolvers', () => {
|
||||
it('should fail when duplicate root resolvers are detected', () => {
|
||||
const query = `
|
||||
query {
|
||||
user {
|
||||
id
|
||||
}
|
||||
user {
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const error = validateQuery(query, {
|
||||
checkDuplicateRootResolvers: true,
|
||||
});
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toContain('Duplicate root resolver');
|
||||
expect(error?.message).toContain('user');
|
||||
});
|
||||
|
||||
it('should fail when duplicate root resolvers are detected - even when the field is aliased', () => {
|
||||
const query = `
|
||||
query {
|
||||
user {
|
||||
id
|
||||
}
|
||||
alias: user {
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const error = validateQuery(query, {
|
||||
checkDuplicateRootResolvers: true,
|
||||
});
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toContain('Duplicate root resolver');
|
||||
expect(error?.message).toContain('user');
|
||||
});
|
||||
|
||||
it('should fail when duplicate root resolvers are detected - multiple queries', () => {
|
||||
const query = `
|
||||
query {
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
query {
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const error = validateQuery(query, {
|
||||
checkDuplicateRootResolvers: true,
|
||||
});
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.message).toContain('Duplicate root resolver');
|
||||
expect(error?.message).toContain('user');
|
||||
});
|
||||
});
|
||||
});
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { type ValidationContext } from 'graphql';
|
||||
import { type Plugin } from 'graphql-yoga';
|
||||
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
export const useComputeComplexity = (maximumComplexity: number): Plugin => ({
|
||||
onValidate: ({ addValidationRule }) => {
|
||||
addValidationRule((context: ValidationContext) => {
|
||||
let complexity = 0;
|
||||
|
||||
return {
|
||||
Field() {
|
||||
complexity++;
|
||||
},
|
||||
Document: {
|
||||
leave() {
|
||||
if (complexity > maximumComplexity) {
|
||||
context.reportError(
|
||||
new UserInputError(
|
||||
`Query complexity is too high: ${complexity} - Too many fields requested`,
|
||||
{
|
||||
userFriendlyMessage: msg`The request is too complex to process. Please try reducing the amount of data requested.`,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import {
|
||||
type DocumentNode,
|
||||
type FieldNode,
|
||||
type FragmentDefinitionNode,
|
||||
type SelectionNode,
|
||||
Kind,
|
||||
} from 'graphql';
|
||||
import { type Plugin } from 'graphql-yoga';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
type FragmentMetadata = {
|
||||
fieldsCount: number;
|
||||
depth: number;
|
||||
rootFieldNames: Set<string>;
|
||||
};
|
||||
|
||||
type AnalysisResult = {
|
||||
requestedFieldsCount: number;
|
||||
requestedRootResolversCount: number;
|
||||
maxNestedDepth: number;
|
||||
rootResolverNames: string[];
|
||||
};
|
||||
|
||||
export const useValidateGraphqlQueryComplexity = ({
|
||||
maximumAllowedFields,
|
||||
maximumAllowedRootResolvers,
|
||||
maximumAllowedNestedFields,
|
||||
checkDuplicateRootResolvers = false,
|
||||
}: {
|
||||
maximumAllowedFields?: number;
|
||||
maximumAllowedRootResolvers?: number;
|
||||
maximumAllowedNestedFields?: number;
|
||||
checkDuplicateRootResolvers?: boolean;
|
||||
}): Plugin => ({
|
||||
onParse: () => {
|
||||
return ({ result }) => {
|
||||
if (!result || !('kind' in result) || result.kind !== Kind.DOCUMENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = result as DocumentNode;
|
||||
const fragmentMap = buildFragmentMap(document);
|
||||
|
||||
const analysis = analyzeDocument(
|
||||
document,
|
||||
fragmentMap,
|
||||
checkDuplicateRootResolvers,
|
||||
);
|
||||
|
||||
if (
|
||||
isDefined(maximumAllowedNestedFields) &&
|
||||
analysis.maxNestedDepth > maximumAllowedNestedFields
|
||||
) {
|
||||
throw new UserInputError(
|
||||
`Query too complex - Too many nested fields requested: ${analysis.maxNestedDepth} - Maximum allowed nested fields: ${maximumAllowedNestedFields}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(maximumAllowedFields) &&
|
||||
analysis.requestedFieldsCount > maximumAllowedFields
|
||||
) {
|
||||
throw new UserInputError(
|
||||
`Query too complex - Too many fields requested: ${analysis.requestedFieldsCount} - Maximum allowed fields: ${maximumAllowedFields}`,
|
||||
{
|
||||
userFriendlyMessage: msg`The request is too complex to process. Please try reducing the amount of requested fields.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(maximumAllowedRootResolvers) &&
|
||||
analysis.requestedRootResolversCount > maximumAllowedRootResolvers
|
||||
) {
|
||||
throw new UserInputError(
|
||||
`Query too complex - Too many root resolvers requested: ${analysis.requestedRootResolversCount} - Maximum allowed root resolvers: ${maximumAllowedRootResolvers}`,
|
||||
{
|
||||
userFriendlyMessage: msg`The request is too complex to process. Please try reducing the amount of requested root resolvers.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const buildFragmentMap = (
|
||||
document: DocumentNode,
|
||||
): Map<string, FragmentDefinitionNode> => {
|
||||
const fragmentMap = new Map<string, FragmentDefinitionNode>();
|
||||
|
||||
for (const definition of document.definitions) {
|
||||
if (definition.kind === Kind.FRAGMENT_DEFINITION) {
|
||||
fragmentMap.set(definition.name.value, definition);
|
||||
}
|
||||
}
|
||||
|
||||
return fragmentMap;
|
||||
};
|
||||
|
||||
const resolveFragmentMetadata = (
|
||||
fragmentName: string,
|
||||
fragmentMap: Map<string, FragmentDefinitionNode>,
|
||||
): FragmentMetadata | undefined => {
|
||||
const fragment = fragmentMap.get(fragmentName);
|
||||
|
||||
if (!isDefined(fragment)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const metadata = analyzeSelectionSet(
|
||||
fragment.selectionSet.selections,
|
||||
fragmentMap,
|
||||
0,
|
||||
);
|
||||
|
||||
const result: FragmentMetadata = {
|
||||
fieldsCount: metadata.fieldsCount,
|
||||
depth: metadata.maxDepth,
|
||||
rootFieldNames: new Set(metadata.rootFieldNames),
|
||||
};
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const analyzeSelectionSet = (
|
||||
selections: readonly SelectionNode[],
|
||||
fragmentMap: Map<string, FragmentDefinitionNode>,
|
||||
currentDepth: number,
|
||||
): {
|
||||
fieldsCount: number;
|
||||
maxDepth: number;
|
||||
rootFieldNames: string[];
|
||||
} => {
|
||||
let fieldsCount = 0;
|
||||
let maxDepth = currentDepth;
|
||||
const rootFieldNames: string[] = [];
|
||||
|
||||
for (const selection of selections) {
|
||||
switch (selection.kind) {
|
||||
case Kind.FIELD: {
|
||||
const fieldNode = selection as FieldNode;
|
||||
|
||||
// Skip introspection fields
|
||||
if (fieldNode.name.value.startsWith('__')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fieldsCount++;
|
||||
const fieldDepth = currentDepth + 1;
|
||||
|
||||
maxDepth = Math.max(maxDepth, fieldDepth);
|
||||
|
||||
if (currentDepth === 0) {
|
||||
rootFieldNames.push(fieldNode.name.value);
|
||||
}
|
||||
|
||||
if (fieldNode.selectionSet) {
|
||||
const nestedResult = analyzeSelectionSet(
|
||||
fieldNode.selectionSet.selections,
|
||||
fragmentMap,
|
||||
fieldDepth,
|
||||
);
|
||||
|
||||
fieldsCount += nestedResult.fieldsCount;
|
||||
maxDepth = Math.max(maxDepth, nestedResult.maxDepth);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Kind.INLINE_FRAGMENT: {
|
||||
if (selection.selectionSet) {
|
||||
const nestedResult = analyzeSelectionSet(
|
||||
selection.selectionSet.selections,
|
||||
fragmentMap,
|
||||
currentDepth,
|
||||
);
|
||||
|
||||
fieldsCount += nestedResult.fieldsCount;
|
||||
maxDepth = Math.max(maxDepth, nestedResult.maxDepth);
|
||||
|
||||
for (const name of nestedResult.rootFieldNames) {
|
||||
rootFieldNames.push(name);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Kind.FRAGMENT_SPREAD: {
|
||||
const fragmentName = selection.name.value;
|
||||
const metadata = resolveFragmentMetadata(fragmentName, fragmentMap);
|
||||
|
||||
if (isDefined(metadata)) {
|
||||
fieldsCount += metadata.fieldsCount;
|
||||
maxDepth = Math.max(maxDepth, currentDepth + metadata.depth);
|
||||
|
||||
if (currentDepth === 0) {
|
||||
for (const name of metadata.rootFieldNames) {
|
||||
rootFieldNames.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { fieldsCount, maxDepth, rootFieldNames };
|
||||
};
|
||||
|
||||
const analyzeDocument = (
|
||||
document: DocumentNode,
|
||||
fragmentMap: Map<string, FragmentDefinitionNode>,
|
||||
checkDuplicateRootResolvers: boolean,
|
||||
): AnalysisResult => {
|
||||
let requestedFieldsCount = 0;
|
||||
let requestedRootResolversCount = 0;
|
||||
let maxNestedDepth = 0;
|
||||
const rootResolverNames: string[] = [];
|
||||
|
||||
for (const definition of document.definitions) {
|
||||
if (
|
||||
definition.kind === Kind.OPERATION_DEFINITION &&
|
||||
definition.selectionSet
|
||||
) {
|
||||
const result = analyzeSelectionSet(
|
||||
definition.selectionSet.selections,
|
||||
fragmentMap,
|
||||
0,
|
||||
);
|
||||
|
||||
requestedFieldsCount += result.fieldsCount;
|
||||
maxNestedDepth = Math.max(maxNestedDepth, result.maxDepth);
|
||||
|
||||
for (const name of result.rootFieldNames) {
|
||||
requestedRootResolversCount++;
|
||||
if (checkDuplicateRootResolvers && rootResolverNames.includes(name)) {
|
||||
throw new UserInputError(`Duplicate root resolver: "${name}"`, {
|
||||
userFriendlyMessage: msg`Duplicate root resolver found. Each root resolver can only be called once per document.`,
|
||||
});
|
||||
}
|
||||
rootResolverNames.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestedFieldsCount,
|
||||
requestedRootResolversCount,
|
||||
maxNestedDepth,
|
||||
rootResolverNames,
|
||||
};
|
||||
};
|
||||
@@ -989,10 +989,26 @@ export class ConfigVariables {
|
||||
@CastToPositiveNumber()
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.RATE_LIMITING,
|
||||
description: 'Maximum complexity allowed for GQL queries',
|
||||
description: 'Maximum fields allowed for GQL queries',
|
||||
type: ConfigVariableType.NUMBER,
|
||||
})
|
||||
GRAPHQL_MAX_COMPLEXITY = 2000;
|
||||
GRAPHQL_MAX_FIELDS = 2000;
|
||||
|
||||
@CastToPositiveNumber()
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.RATE_LIMITING,
|
||||
description: 'Maximum root resolvers allowed for GQL queries',
|
||||
type: ConfigVariableType.NUMBER,
|
||||
})
|
||||
GRAPHQL_MAX_ROOT_RESOLVERS = 20;
|
||||
|
||||
@CastToPositiveNumber()
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.RATE_LIMITING,
|
||||
description: 'Maximum complexity allowed for Common API queries',
|
||||
type: ConfigVariableType.NUMBER,
|
||||
})
|
||||
COMMON_QUERY_COMPLEXITY_LIMIT = 50;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.RATE_LIMITING,
|
||||
|
||||
+4
@@ -402,6 +402,7 @@ describe('group-by resolver (integration)', () => {
|
||||
}
|
||||
`,
|
||||
filter: filter2025,
|
||||
limit: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -481,6 +482,7 @@ describe('group-by resolver (integration)', () => {
|
||||
}
|
||||
`,
|
||||
filter: filter2025,
|
||||
limit: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -564,6 +566,7 @@ describe('group-by resolver (integration)', () => {
|
||||
}
|
||||
`,
|
||||
filter: filter2025,
|
||||
limit: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1146,6 +1149,7 @@ describe('group-by resolver (integration)', () => {
|
||||
},
|
||||
],
|
||||
filter: filter2025,
|
||||
limit: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
+20
-3
@@ -172,8 +172,13 @@ describe('basic group-by with records', () => {
|
||||
query OpportunitiesGroupBy(
|
||||
$groupBy: [OpportunityGroupByInput!]!
|
||||
$filter: OpportunityFilterInput
|
||||
$limit: Int
|
||||
) {
|
||||
opportunitiesGroupBy(groupBy: $groupBy, filter: $filter) {
|
||||
opportunitiesGroupBy(
|
||||
groupBy: $groupBy
|
||||
filter: $filter
|
||||
limit: $limit
|
||||
) {
|
||||
minCloseDate
|
||||
groupByDimensionValues
|
||||
sumAmountAmountMicros
|
||||
@@ -210,6 +215,7 @@ describe('basic group-by with records', () => {
|
||||
name: 'AscNullsFirst',
|
||||
},
|
||||
filter: FILTER_2020,
|
||||
limit: 3,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -295,8 +301,13 @@ describe('basic group-by with records', () => {
|
||||
query OpportunitiesGroupBy(
|
||||
$groupBy: [OpportunityGroupByInput!]!
|
||||
$filter: OpportunityFilterInput
|
||||
$limit: Int
|
||||
) {
|
||||
opportunitiesGroupBy(groupBy: $groupBy, filter: $filter) {
|
||||
opportunitiesGroupBy(
|
||||
groupBy: $groupBy
|
||||
filter: $filter
|
||||
limit: $limit
|
||||
) {
|
||||
minCloseDate
|
||||
groupByDimensionValues
|
||||
sumAmountAmountMicros
|
||||
@@ -334,6 +345,7 @@ describe('basic group-by with records', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
limit: 2,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -376,8 +388,9 @@ describe('basic group-by with records', () => {
|
||||
query CompaniesGroupBy(
|
||||
$groupBy: [CompanyGroupByInput!]!
|
||||
$filter: CompanyFilterInput
|
||||
$limit: Int
|
||||
) {
|
||||
companiesGroupBy(groupBy: $groupBy, filter: $filter) {
|
||||
companiesGroupBy(groupBy: $groupBy, filter: $filter, limit: $limit) {
|
||||
groupByDimensionValues
|
||||
__typename
|
||||
edges {
|
||||
@@ -403,6 +416,7 @@ describe('basic group-by with records', () => {
|
||||
},
|
||||
],
|
||||
filter: FILTER_2020,
|
||||
limit: 2,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -467,11 +481,13 @@ describe('basic group-by with records', () => {
|
||||
$groupBy: [OpportunityGroupByInput!]!
|
||||
$filter: OpportunityFilterInput
|
||||
$orderByForRecords: [OpportunityOrderByInput!]
|
||||
$limit: Int
|
||||
) {
|
||||
opportunitiesGroupBy(
|
||||
groupBy: $groupBy
|
||||
filter: $filter
|
||||
orderByForRecords: $orderByForRecords
|
||||
limit: $limit
|
||||
) {
|
||||
groupByDimensionValues
|
||||
__typename
|
||||
@@ -501,6 +517,7 @@ describe('basic group-by with records', () => {
|
||||
},
|
||||
],
|
||||
filter: FILTER_2020,
|
||||
limit: 20,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Query Complexity - Failing Scenarios should fail findMany query with too many relation fields 1`] = `"Query complexity is too high. Please, reduce the amount of relation fields requested. Query complexity: 21. Maximum complexity: 10."`;
|
||||
|
||||
exports[`Query Complexity - Failing Scenarios should fail findMany query with two nested one to many relations 1`] = `"Query complexity is too high. One-to-Many relation cannot be nested in another One-to-Many relation."`;
|
||||
|
||||
exports[`Query Complexity - Failing Scenarios should fail groupBy query with too many relation fields 1`] = `"Query complexity is too high. Please, reduce the amount of relation fields requested. Query complexity: 23. Maximum complexity: 10."`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Query Complexity should fail to execute a query with duplicate root resolvers 1`] = `"Duplicate root resolver: "people""`;
|
||||
|
||||
exports[`Query Complexity should fail to execute a query with too many fields 1`] = `"Query too complex - Too many fields requested : 2010 - Maximum allowed fields: 2000"`;
|
||||
|
||||
exports[`Query Complexity should fail to execute a query with too many root resolvers 1`] = `"Query too complex - Too many root resolvers requested: 21 - Maximum allowed root resolvers: 20"`;
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { TOO_MANY_RELATION_QUERY_GQL_FIELDS } from 'test/integration/graphql/suites/query-complexity/constants/tooManyRelationQueryGqlFields.constant';
|
||||
import { TWO_NESTED_ONE_TO_MANY_QUERY_GQL_FIELDS } from 'test/integration/graphql/suites/query-complexity/constants/twoNestedOneToManyQueryGqlFields.constant';
|
||||
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
|
||||
import { groupByOperationFactory } from 'test/integration/graphql/utils/group-by-operation-factory.util';
|
||||
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
||||
import { createConfigVariable } from 'test/integration/twenty-config/utils/create-config-variable.util';
|
||||
import { deleteConfigVariable } from 'test/integration/twenty-config/utils/delete-config-variable.util';
|
||||
|
||||
describe('Query Complexity - Failing Scenarios', () => {
|
||||
beforeAll(async () => {
|
||||
await createConfigVariable({
|
||||
input: {
|
||||
key: 'COMMON_QUERY_COMPLEXITY_LIMIT',
|
||||
value: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteConfigVariable({
|
||||
input: { key: 'COMMON_QUERY_COMPLEXITY_LIMIT' },
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
it('should fail findMany query with two nested one to many relations', async () => {
|
||||
const findManyPeopleOperation = findManyOperationFactory({
|
||||
objectMetadataSingularName: 'person',
|
||||
objectMetadataPluralName: 'people',
|
||||
gqlFields: TWO_NESTED_ONE_TO_MANY_QUERY_GQL_FIELDS,
|
||||
first: 200,
|
||||
});
|
||||
|
||||
const response = await makeGraphqlAPIRequest(findManyPeopleOperation);
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should fail findMany query with too many relation fields', async () => {
|
||||
const findManyPeopleOperation = findManyOperationFactory({
|
||||
objectMetadataSingularName: 'person',
|
||||
objectMetadataPluralName: 'people',
|
||||
gqlFields: TOO_MANY_RELATION_QUERY_GQL_FIELDS,
|
||||
});
|
||||
|
||||
const response = await makeGraphqlAPIRequest(findManyPeopleOperation);
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should fail groupBy query with too many relation fields', async () => {
|
||||
const groupByOperation = groupByOperationFactory({
|
||||
objectMetadataSingularName: 'person',
|
||||
objectMetadataPluralName: 'people',
|
||||
groupBy: [{ city: true }],
|
||||
gqlFields: `edges { node { id company { id } } }`,
|
||||
limit: 11,
|
||||
});
|
||||
|
||||
const response = await makeGraphqlAPIRequest(groupByOperation);
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
export const TOO_MANY_RELATION_QUERY_GQL_FIELDS = `
|
||||
id
|
||||
city
|
||||
jobTitle
|
||||
avatarUrl
|
||||
intro
|
||||
searchVector
|
||||
pointOfContactForOpportunities {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
company {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
favorites {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
company {
|
||||
id
|
||||
}
|
||||
person {
|
||||
id
|
||||
company {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
noteTargets {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
company {
|
||||
id
|
||||
}
|
||||
note {
|
||||
id
|
||||
}
|
||||
person {
|
||||
id
|
||||
company {
|
||||
id
|
||||
}
|
||||
}
|
||||
company {
|
||||
id
|
||||
}
|
||||
opportunity {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
taskTargets {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
company {
|
||||
id
|
||||
}
|
||||
person {
|
||||
id
|
||||
company {
|
||||
id
|
||||
}
|
||||
}
|
||||
company {
|
||||
id
|
||||
}
|
||||
opportunity {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
company {
|
||||
id
|
||||
people {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
company {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const TOO_MANY_ROOT_RESOLVERS_QUERY_GQL_FIELDS = gql`
|
||||
query {
|
||||
people {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
companies {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
favorites {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
notes {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
attachments {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
noteTargets {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
taskTargets {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
opportunities {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
blocklists {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
calendarEvents {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
calendarEventParticipants {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
calendarChannels {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
calendarChannelEventAssociations {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
messageThreads {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
messageChannels {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
messageChannelMessageAssociations {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
timelineActivities {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
workflowRuns {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
workflowVersions {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
workflowAutomatedTriggers {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
export const TWO_NESTED_ONE_TO_MANY_QUERY_GQL_FIELDS = `
|
||||
id
|
||||
pointOfContactForOpportunities {
|
||||
edges {
|
||||
node {
|
||||
company {
|
||||
people {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
pointOfContactForOpportunities {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+38
-3
@@ -1,3 +1,5 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { TOO_MANY_ROOT_RESOLVERS_QUERY_GQL_FIELDS } from 'test/integration/graphql/suites/query-complexity/constants/tooManyRootResolversQueryGqlFields.constant';
|
||||
import { generateGqlFields } from 'test/integration/graphql/suites/query-complexity/generate-gql-fields.util';
|
||||
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
|
||||
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
||||
@@ -31,8 +33,41 @@ describe('Query Complexity', () => {
|
||||
const response = await makeGraphqlAPIRequest(findManyPeopleOperation);
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toContain(
|
||||
'Query complexity is too high',
|
||||
);
|
||||
expect(response.body.errors[0].message).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.only('should fail to execute a query with too many root resolvers', async () => {
|
||||
const response = await makeGraphqlAPIRequest({
|
||||
query: TOO_MANY_ROOT_RESOLVERS_QUERY_GQL_FIELDS,
|
||||
});
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should fail to execute a query with duplicate root resolvers', async () => {
|
||||
const response = await makeGraphqlAPIRequest({
|
||||
query: gql`
|
||||
query {
|
||||
people {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
people {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
+3
-3
@@ -171,7 +171,7 @@ describe('REST API Core Group By endpoint', () => {
|
||||
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/opportunities/groupBy?group_by=${encodeURIComponent(groupByQuery)}&aggregate=${encodeURIComponent(AGGREGATE_FIELDS)}&filter=${encodeURIComponent(FILTER_2020)}&include_records_sample=true`,
|
||||
path: `/opportunities/groupBy?group_by=${encodeURIComponent(groupByQuery)}&aggregate=${encodeURIComponent(AGGREGATE_FIELDS)}&filter=${encodeURIComponent(FILTER_2020)}&include_records_sample=true&limit=3`,
|
||||
body: {},
|
||||
});
|
||||
|
||||
@@ -258,7 +258,7 @@ describe('REST API Core Group By endpoint', () => {
|
||||
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/opportunities/groupBy?group_by=${encodeURIComponent(groupByQuery)}&aggregate=${encodeURIComponent(AGGREGATE_FIELDS)}&filter=${encodeURIComponent(filterQuery)}&include_records_sample=true`,
|
||||
path: `/opportunities/groupBy?group_by=${encodeURIComponent(groupByQuery)}&aggregate=${encodeURIComponent(AGGREGATE_FIELDS)}&filter=${encodeURIComponent(filterQuery)}&include_records_sample=true&limit=2`,
|
||||
body: {},
|
||||
});
|
||||
|
||||
@@ -309,7 +309,7 @@ describe('REST API Core Group By endpoint', () => {
|
||||
|
||||
return {
|
||||
method: 'get' as const,
|
||||
path: `/opportunities/groupBy?group_by=${encodeURIComponent(groupByQuery)}&filter=${encodeURIComponent(FILTER_2020)}&order_by_for_records=${encodeURIComponent(`name[${orderByForRecords}]`)}&include_records_sample=true`,
|
||||
path: `/opportunities/groupBy?group_by=${encodeURIComponent(groupByQuery)}&filter=${encodeURIComponent(FILTER_2020)}&order_by_for_records=${encodeURIComponent(`name[${orderByForRecords}]`)}&include_records_sample=true&limit=5`,
|
||||
body: {},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const QUERY_MAX_RECORDS_FROM_RELATION = 60;
|
||||
@@ -24,6 +24,7 @@ export { MUTATION_MAX_MERGE_RECORDS } from './MutationMaxMergeRecords';
|
||||
export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords';
|
||||
export { QUERY_DEFAULT_LIMIT_RECORDS } from './QueryDefaultLimitRecords';
|
||||
export { QUERY_MAX_RECORDS } from './QueryMaxRecords';
|
||||
export { QUERY_MAX_RECORDS_FROM_RELATION } from './QueryMaxRecordsFromRelation';
|
||||
export { QUOTED_STRING_REGEX } from './QuotedStringRegex';
|
||||
export { RATING_VALUES } from './RatingValues';
|
||||
export { RELATION_NESTED_QUERY_KEYWORDS } from './RelationNestedQueriesKeyword';
|
||||
|
||||
Reference in New Issue
Block a user