From d1befa7e35fc1525b806bf9b8c2b9b7679fd4178 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:33:08 +0100 Subject: [PATCH] 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) --- .../common-base-query-runner.service.ts | 73 +++- ...common-delete-many-query-runner.service.ts | 4 +- ...ommon-destroy-many-query-runner.service.ts | 4 +- ...on-find-duplicates-query-runner.service.ts | 7 +- .../common-find-many-query-runner.service.ts | 7 +- .../common-find-one-query-runner.service.ts | 4 +- .../common-group-by-query-runner.service.ts | 19 +- .../common-merge-many-query-runner.service.ts | 6 +- ...ommon-restore-many-query-runner.service.ts | 4 +- ...common-update-many-query-runner.service.ts | 4 +- .../errors/common-query-runner.exception.ts | 1 + ...r-to-graphql-api-exception-handler.util.ts | 1 + ...nner-to-rest-api-exception-handler.util.ts | 1 + .../common-selected-fields-result.type.ts | 2 + .../graphql-config/graphql-config.service.ts | 13 +- ...graphql-selected-fields-relation.parser.ts | 21 +- .../graphql-selected-fields.parser.ts | 18 +- .../services/group-by-with-records.service.ts | 11 +- .../api/graphql/metadata.module-factory.ts | 9 +- .../handlers/rest-api-group-by.handler.ts | 12 +- ...date-graphql-query-complexity.hook.spec.ts | 342 ++++++++++++++++++ .../hooks/use-compute-complexity.hook.ts | 33 -- ...-validate-graphql-query-complexity.hook.ts | 255 +++++++++++++ .../twenty-config/config-variables.ts | 20 +- .../group-by-resolver.integration-spec.ts | 4 + ...-with-records-resolver.integration-spec.ts | 23 +- ...omplexity-failing.integration-spec.ts.snap | 7 + .../query-complexity.integration-spec.ts.snap | 7 + ...ery-complexity-failing.integration-spec.ts | 66 ++++ .../tooManyRelationQueryGqlFields.constant.ts | 94 +++++ ...anyRootResolversQueryGqlFields.constant.ts | 153 ++++++++ ...oNestedOneToManyQueryGqlFields.constant.ts | 25 ++ .../query-complexity.integration-spec.ts | 41 ++- ...rest-api-core-group-by.integration-spec.ts | 6 +- .../constants/QueryMaxRecordsFromRelation.ts | 1 + packages/twenty-shared/src/constants/index.ts | 1 + 36 files changed, 1208 insertions(+), 91 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/graphql/hooks/__tests__/use-validate-graphql-query-complexity.hook.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/graphql/hooks/use-compute-complexity.hook.ts create mode 100644 packages/twenty-server/src/engine/core-modules/graphql/hooks/use-validate-graphql-query-complexity.hook.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/query-complexity/__snapshots__/common-query-complexity-failing.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/graphql/suites/query-complexity/__snapshots__/query-complexity.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/graphql/suites/query-complexity/common-query-complexity-failing.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/query-complexity/constants/tooManyRelationQueryGqlFields.constant.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/query-complexity/constants/tooManyRootResolversQueryGqlFields.constant.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/query-complexity/constants/twoNestedOneToManyQueryGqlFields.constant.ts create mode 100644 packages/twenty-shared/src/constants/QueryMaxRecordsFromRelation.ts diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts index c3786a7fbf..77fad00f49 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts @@ -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; + return this.globalWorkspaceOrmManager.executeInWorkspaceContext( authContext, async () => @@ -166,16 +172,22 @@ export abstract class CommonBaseQueryRunnerService< authContext: WorkspaceAuthContext, ): Promise; + protected computeQueryComplexity( + selectedFieldsResult: CommonSelectedFieldsResult, + _args: CommonInput, + ): number { + const simpleFieldsComplexity = 1; + const selectedFieldsComplexity = + simpleFieldsComplexity + (selectedFieldsResult.relationFieldsCount ?? 0); + + return selectedFieldsComplexity; + } + private async processArgs( args: CommonInput, queryRunnerContext: CommonBaseQueryRunnerContext, operationName: CommonQueryNames, - commonQueryParser: GraphqlQueryParser, - ): Promise> { - const selectedFieldsResult = commonQueryParser.parseSelectedFields( - args.selectedFields, - ); - + ): Promise> { const { authContext, flatObjectMetadata } = queryRunnerContext; const computedArgs = await this.computeArgs(args, queryRunnerContext); @@ -188,10 +200,7 @@ export abstract class CommonBaseQueryRunnerService< computedArgs as WorkspacePreQueryHookPayload, )) as CommonInput; - return { - ...hookedArgs, - selectedFieldsResult, - }; + return hookedArgs; } private async executeQueryAndEnrichResults( @@ -388,4 +397,38 @@ export abstract class CommonBaseQueryRunnerService< throw error; } } + + private validateQueryComplexity( + selectedFieldsResult: CommonSelectedFieldsResult, + args: CommonInput, + ) { + 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}.`, + }, + ); + } + } } diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-delete-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-delete-many-query-runner.service.ts index f3a872e1fe..9c5b03b8be 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-delete-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-delete-many-query-runner.service.ts @@ -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 >, - limit: QUERY_MAX_RECORDS, + limit: QUERY_MAX_RECORDS_FROM_RELATION, authContext, workspaceDataSource, rolePermissionConfig, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-destroy-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-destroy-many-query-runner.service.ts index b90bb47e96..aa31065f3d 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-destroy-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-destroy-many-query-runner.service.ts @@ -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 >, - limit: QUERY_MAX_RECORDS, + limit: QUERY_MAX_RECORDS_FROM_RELATION, authContext, workspaceDataSource, rolePermissionConfig, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-duplicates-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-duplicates-query-runner.service.ts index 3973f8a594..d16d43ce15 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-duplicates-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-duplicates-query-runner.service.ts @@ -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 >, - limit: QUERY_MAX_RECORDS, + limit: QUERY_MAX_RECORDS_FROM_RELATION, authContext, workspaceDataSource, rolePermissionConfig, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts index 5105d9dd33..cc1b152913 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts @@ -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 >, aggregate: args.selectedFieldsResult.aggregate, - limit: QUERY_MAX_RECORDS, + limit: QUERY_MAX_RECORDS_FROM_RELATION, authContext, workspaceDataSource, rolePermissionConfig, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-one-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-one-query-runner.service.ts index c9aadc716f..be0b684055 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-one-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-one-query-runner.service.ts @@ -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 >, - limit: QUERY_MAX_RECORDS, + limit: QUERY_MAX_RECORDS_FROM_RELATION, authContext, workspaceDataSource, rolePermissionConfig, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts index 21747d7a03..3b22c12198 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts @@ -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; groupByDefinitions: GroupByDefinition[]; - selectedFieldsResult: GraphqlQuerySelectedFieldsResult; + selectedFieldsResult: CommonSelectedFieldsResult; groupLimit?: number; }): Promise { const effectiveGroupLimit = getGroupLimit(groupLimit); @@ -400,4 +400,19 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic ), }; } + + protected override computeQueryComplexity( + selectedFieldsResult: CommonSelectedFieldsResult, + args: CommonInput, + ): number { + const groupByQueryComplexity = 1; + const simpleFieldsComplexity = 1; + const selectedFieldsComplexity = + simpleFieldsComplexity + (selectedFieldsResult.relationFieldsCount ?? 0); + + return (args.includeRecords ?? false) + ? groupByQueryComplexity + + selectedFieldsComplexity * getGroupLimit(args.limit) + : groupByQueryComplexity; + } } diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-merge-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-merge-many-query-runner.service.ts index d8e7f2415f..7ecf40f7a5 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-merge-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-merge-many-query-runner.service.ts @@ -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 >, - 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 >, - limit: QUERY_MAX_RECORDS, + limit: QUERY_MAX_RECORDS_FROM_RELATION, authContext, workspaceDataSource, rolePermissionConfig, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-restore-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-restore-many-query-runner.service.ts index 444c76811e..197df7c50d 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-restore-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-restore-many-query-runner.service.ts @@ -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 >, - limit: QUERY_MAX_RECORDS, + limit: QUERY_MAX_RECORDS_FROM_RELATION, authContext, workspaceDataSource, rolePermissionConfig, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-update-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-update-many-query-runner.service.ts index 736c255abb..a1458b4714 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-update-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-update-many-query-runner.service.ts @@ -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 >, - limit: QUERY_MAX_RECORDS, + limit: QUERY_MAX_RECORDS_FROM_RELATION, authContext, workspaceDataSource, rolePermissionConfig, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts index 7ffc54a2f9..337dd6f980 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts @@ -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', } diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts index 78fb36e512..744fb25840 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts @@ -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); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts index 1419428005..2f897ddf57 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts @@ -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'); diff --git a/packages/twenty-server/src/engine/api/common/types/common-selected-fields-result.type.ts b/packages/twenty-server/src/engine/api/common/types/common-selected-fields-result.type.ts index f30795d4f6..3e0d2e4a05 100644 --- a/packages/twenty-server/src/engine/api/common/types/common-selected-fields-result.type.ts +++ b/packages/twenty-server/src/engine/api/common/types/common-selected-fields-result.type.ts @@ -8,4 +8,6 @@ export type CommonSelectedFieldsResult = { select: CommonSelectedFields; relations: CommonSelectedFields; aggregate: Record; + relationFieldsCount?: number; + hasAtLeastTwoNestedOneToManyRelations?: boolean; }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts index e8d0bc605d..1c130fa23d 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts @@ -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()) { diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields-relation.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields-relation.parser.ts index 2b2b14b91e..86f57c960f 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields-relation.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields-relation.parser.ts @@ -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 + | FlatFieldMetadata, 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; } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser.ts index 4f56619437..68832525e7 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser.ts @@ -21,6 +21,8 @@ export type GraphqlQuerySelectedFieldsResult = { relations: Record; // eslint-disable-next-line @typescript-eslint/no-explicit-any aggregate: Record; + 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>, 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>, 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>, 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( diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts index 75c0d3059b..578f67bd25 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts @@ -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; queryBuilderWithFiltersAndWithoutGroupBy: WorkspaceSelectQueryBuilder; 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 + >, aggregate: selectedFieldsResult.aggregate, limit: RELATIONS_PER_RECORD_LIMIT, authContext, diff --git a/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts b/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts index cc57b2d17e..16fabbc959 100644 --- a/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/metadata.module-factory.ts @@ -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: () => ({ diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts index 72af8d35e6..0848998636 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts @@ -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, }; } } diff --git a/packages/twenty-server/src/engine/core-modules/graphql/hooks/__tests__/use-validate-graphql-query-complexity.hook.spec.ts b/packages/twenty-server/src/engine/core-modules/graphql/hooks/__tests__/use-validate-graphql-query-complexity.hook.spec.ts new file mode 100644 index 0000000000..8947611d12 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/graphql/hooks/__tests__/use-validate-graphql-query-complexity.hook.spec.ts @@ -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[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'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-compute-complexity.hook.ts b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-compute-complexity.hook.ts deleted file mode 100644 index c0955a5ee4..0000000000 --- a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-compute-complexity.hook.ts +++ /dev/null @@ -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.`, - }, - ), - ); - } - }, - }, - }; - }); - }, -}); diff --git a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-validate-graphql-query-complexity.hook.ts b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-validate-graphql-query-complexity.hook.ts new file mode 100644 index 0000000000..edf4648ea8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-validate-graphql-query-complexity.hook.ts @@ -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; +}; + +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 => { + const fragmentMap = new Map(); + + 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, +): 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, + 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, + 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, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index daa2d370a4..1532c629aa 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -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, diff --git a/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts index 0fdf5e4629..9e64c769bb 100644 --- a/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts @@ -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, }), ); diff --git a/packages/twenty-server/test/integration/graphql/suites/group-by-with-records-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/group-by-with-records-resolver.integration-spec.ts index d0fb88ddb2..576677d372 100644 --- a/packages/twenty-server/test/integration/graphql/suites/group-by-with-records-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/group-by-with-records-resolver.integration-spec.ts @@ -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, }, }; }; diff --git a/packages/twenty-server/test/integration/graphql/suites/query-complexity/__snapshots__/common-query-complexity-failing.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/query-complexity/__snapshots__/common-query-complexity-failing.integration-spec.ts.snap new file mode 100644 index 0000000000..f0d273a891 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/query-complexity/__snapshots__/common-query-complexity-failing.integration-spec.ts.snap @@ -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."`; diff --git a/packages/twenty-server/test/integration/graphql/suites/query-complexity/__snapshots__/query-complexity.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/query-complexity/__snapshots__/query-complexity.integration-spec.ts.snap new file mode 100644 index 0000000000..83a1db100d --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/query-complexity/__snapshots__/query-complexity.integration-spec.ts.snap @@ -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"`; diff --git a/packages/twenty-server/test/integration/graphql/suites/query-complexity/common-query-complexity-failing.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/query-complexity/common-query-complexity-failing.integration-spec.ts new file mode 100644 index 0000000000..7ee0f9ae9a --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/query-complexity/common-query-complexity-failing.integration-spec.ts @@ -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(); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/query-complexity/constants/tooManyRelationQueryGqlFields.constant.ts b/packages/twenty-server/test/integration/graphql/suites/query-complexity/constants/tooManyRelationQueryGqlFields.constant.ts new file mode 100644 index 0000000000..5d444b5ea6 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/query-complexity/constants/tooManyRelationQueryGqlFields.constant.ts @@ -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 + } + } + } + } + } +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/query-complexity/constants/tooManyRootResolversQueryGqlFields.constant.ts b/packages/twenty-server/test/integration/graphql/suites/query-complexity/constants/tooManyRootResolversQueryGqlFields.constant.ts new file mode 100644 index 0000000000..ee947c3dc1 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/query-complexity/constants/tooManyRootResolversQueryGqlFields.constant.ts @@ -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 + } + } + } + } +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/query-complexity/constants/twoNestedOneToManyQueryGqlFields.constant.ts b/packages/twenty-server/test/integration/graphql/suites/query-complexity/constants/twoNestedOneToManyQueryGqlFields.constant.ts new file mode 100644 index 0000000000..053d3ef65e --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/query-complexity/constants/twoNestedOneToManyQueryGqlFields.constant.ts @@ -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 + } + } + } + } + } + } + } + } + } + } +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/query-complexity/query-complexity.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/query-complexity/query-complexity.integration-spec.ts index c2da49a3a1..950a33f970 100644 --- a/packages/twenty-server/test/integration/graphql/suites/query-complexity/query-complexity.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/query-complexity/query-complexity.integration-spec.ts @@ -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(); }); }); diff --git a/packages/twenty-server/test/integration/rest/suites/rest-api-core-group-by.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/rest-api-core-group-by.integration-spec.ts index 951fe9e674..da48a38299 100644 --- a/packages/twenty-server/test/integration/rest/suites/rest-api-core-group-by.integration-spec.ts +++ b/packages/twenty-server/test/integration/rest/suites/rest-api-core-group-by.integration-spec.ts @@ -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: {}, }; }; diff --git a/packages/twenty-shared/src/constants/QueryMaxRecordsFromRelation.ts b/packages/twenty-shared/src/constants/QueryMaxRecordsFromRelation.ts new file mode 100644 index 0000000000..d5ba769ee6 --- /dev/null +++ b/packages/twenty-shared/src/constants/QueryMaxRecordsFromRelation.ts @@ -0,0 +1 @@ +export const QUERY_MAX_RECORDS_FROM_RELATION = 60; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index b6304bb8a6..c2f9052d55 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -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';